ts-video-player 0.1.8 → 0.1.10

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.
@@ -16,7 +16,7 @@ import {
16
16
  detectPipAvailability2,
17
17
  enterPiP2,
18
18
  exitPiP2
19
- } from "./chunk-rhykj0qy.js";
19
+ } from "./chunk-pvq6e7gz.js";
20
20
 
21
21
  // src/core/media-session.ts
22
22
  function isMediaSessionSupported2() {
@@ -148,6 +148,8 @@ class BaseProvider2 {
148
148
  }
149
149
  destroy() {
150
150
  this._ready = false;
151
+ this.stopEmbedFullscreen?.();
152
+ this.stopEmbedFullscreen = null;
151
153
  this.cleanup();
152
154
  this.events.removeAllListeners();
153
155
  }
@@ -167,6 +169,25 @@ class BaseProvider2 {
167
169
  return [];
168
170
  }
169
171
  setAudioTrack(_trackId) {}
172
+ stopEmbedFullscreen = null;
173
+ announceEmbedFeatures() {
174
+ if (typeof document === "undefined" || typeof HTMLMediaElement === "undefined")
175
+ return;
176
+ const fullscreenAvailability = this.getFeatureAvailability("fullscreen");
177
+ const pipAvailability = this.getFeatureAvailability("pip");
178
+ const volumeAvailability = this.getFeatureAvailability("volume");
179
+ this.events.emit("statechange", {
180
+ canFullscreen: fullscreenAvailability === "available",
181
+ canPictureInPicture: pipAvailability === "available",
182
+ fullscreenAvailability,
183
+ pipAvailability,
184
+ volumeAvailability
185
+ });
186
+ this.events.emit("availabilitychange", "fullscreen", fullscreenAvailability);
187
+ this.events.emit("availabilitychange", "pip", pipAvailability);
188
+ this.events.emit("availabilitychange", "volume", volumeAvailability);
189
+ this.stopEmbedFullscreen ??= onFullscreenChange2((fullscreen) => this.events.emit("fullscreenchange", fullscreen));
190
+ }
170
191
  getFeatureAvailability(feature) {
171
192
  const el = this.mediaElement;
172
193
  const media = el instanceof HTMLMediaElement ? el : null;
@@ -267,6 +288,8 @@ class HTML5Provider2 extends BaseProvider2 {
267
288
  this.isAudio = this.options.mediaType === "audio";
268
289
  this.media = document.createElement(this.isAudio ? "audio" : "video");
269
290
  this.media.className = "ts-video-player__media";
291
+ if (!this.isAudio)
292
+ this.media.style.cssText = "display:block;width:100%;height:100%;object-fit:contain;background:#000";
270
293
  if (this.options.autoplay)
271
294
  this.media.autoplay = true;
272
295
  if (this.options.loop)
@@ -715,6 +738,7 @@ class YouTubeProvider2 extends BaseProvider2 {
715
738
  }, 250);
716
739
  this.events.emit("loadedmetadata");
717
740
  this.events.emit("canplay");
741
+ this.announceEmbedFeatures();
718
742
  if (this.pendingVideoId) {
719
743
  const videoId = this.pendingVideoId;
720
744
  this.pendingVideoId = null;
@@ -1066,10 +1090,9 @@ class VimeoProvider2 extends BaseProvider2 {
1066
1090
  this.events.emit("statechange", {
1067
1091
  videoWidth: width,
1068
1092
  videoHeight: height,
1069
- aspectRatio: width / height || 16 / 9,
1070
- canFullscreen: true,
1071
- canPictureInPicture: false
1093
+ aspectRatio: width / height || 16 / 9
1072
1094
  });
1095
+ this.announceEmbedFeatures();
1073
1096
  }
1074
1097
  attachVimeoEvents() {
1075
1098
  if (!this.player)
@@ -1279,6 +1302,7 @@ class HLSProvider2 extends BaseProvider2 {
1279
1302
  }
1280
1303
  this.video = document.createElement("video");
1281
1304
  this.video.className = "ts-video-player__media";
1305
+ this.video.style.cssText = "display:block;width:100%;height:100%;object-fit:contain;background:#000";
1282
1306
  if (this.options.autoplay)
1283
1307
  this.video.autoplay = true;
1284
1308
  if (this.options.loop)
@@ -2848,6 +2872,8 @@ class Player2 {
2848
2872
  toggleCaptions: () => this.toggleCaptions(),
2849
2873
  seekBackward: () => this.seekBy(-5),
2850
2874
  seekForward: () => this.seekBy(5),
2875
+ seekBackwardLong: () => this.seekBy(-10),
2876
+ seekForwardLong: () => this.seekBy(10),
2851
2877
  volumeUp: () => this.setVolume(Math.min(1, this.state.volume + 0.1)),
2852
2878
  volumeDown: () => this.setVolume(Math.max(0, this.state.volume - 0.1)),
2853
2879
  speedUp: () => this.setPlaybackRate(Math.min(4, this.state.playbackRate + 0.25)),
@@ -3088,7 +3114,7 @@ class Player2 {
3088
3114
  this.announce(label(this._el, "playing"));
3089
3115
  });
3090
3116
  provider.on("pause", () => {
3091
- this._store.set("paused", true);
3117
+ this._store.batch({ paused: true, playing: false, playbackState: "paused" });
3092
3118
  this._events.emit("pause");
3093
3119
  this.announce(label(this._el, "paused"));
3094
3120
  });
@@ -3239,7 +3265,8 @@ class Player2 {
3239
3265
  if (this.state.pictureInPicture) {
3240
3266
  await this.exitPiP();
3241
3267
  }
3242
- await this._provider?.enterFullscreen();
3268
+ const media = this._provider?.mediaElement;
3269
+ await enterFullscreen2(this._el, media instanceof HTMLMediaElement ? media : null);
3243
3270
  if (this._options.fullscreenOrientationLock !== false) {
3244
3271
  lockOrientation2("landscape");
3245
3272
  }
@@ -3357,7 +3384,7 @@ class VideoPlayerElement2 extends HTMLElementBase {
3357
3384
  this._disconnectId++;
3358
3385
  if (this._player)
3359
3386
  return;
3360
- const shadow = this.shadowRoot ?? this.attachShadow({ mode: "open" });
3387
+ const shadow = this.shadowRoot ?? this.attachShadow({ mode: "open", delegatesFocus: true });
3361
3388
  this._container = document.createElement("div");
3362
3389
  this._container.setAttribute("part", "container");
3363
3390
  this._container.style.cssText = "position:relative;width:100%;height:100%;";
@@ -3643,6 +3670,8 @@ class MediaPlayButton2 extends HTMLElementBase {
3643
3670
  background: transparent; border: none; color: inherit; cursor: pointer;
3644
3671
  }
3645
3672
  button:hover { opacity: 0.8; }
3673
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
3674
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
3646
3675
  </style>
3647
3676
  <button part="button" type="button" aria-label="${label(this, "play")}">
3648
3677
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
@@ -3704,6 +3733,8 @@ class MediaMuteButton2 extends HTMLElementBase {
3704
3733
  background: transparent; border: none; color: inherit; cursor: pointer;
3705
3734
  }
3706
3735
  button:hover { opacity: 0.8; }
3736
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
3737
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
3707
3738
  </style>
3708
3739
  <button part="button" type="button" aria-label="${label(this, "mute")}">
3709
3740
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
@@ -3753,6 +3784,8 @@ class MediaFullscreenButton2 extends HTMLElementBase {
3753
3784
  }
3754
3785
  button:hover { opacity: 0.8; }
3755
3786
  button:disabled { opacity: 0.4; cursor: not-allowed; }
3787
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
3788
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
3756
3789
  </style>
3757
3790
  <button part="button" type="button" aria-label="${label(this, "enterFullscreen")}">
3758
3791
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
@@ -3808,6 +3841,8 @@ class MediaPipButton2 extends HTMLElementBase {
3808
3841
  }
3809
3842
  button:hover { opacity: 0.8; }
3810
3843
  button:disabled { opacity: 0.4; cursor: not-allowed; }
3844
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
3845
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
3811
3846
  </style>
3812
3847
  <button part="button" type="button" aria-label="${label(this, "enterPiP")}">
3813
3848
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
@@ -4068,6 +4103,8 @@ class MediaProgressBar2 extends HTMLElementBase {
4068
4103
  border-top-color: rgba(0,0,0,0.85);
4069
4104
  }
4070
4105
  .container:hover .tooltip { opacity: 1; }
4106
+ /* A finger needs more than the 20px a mouse does. */
4107
+ @media (pointer: coarse) { .container { height: 36px; } .thumb { width: 18px; height: 18px; } }
4071
4108
  </style>
4072
4109
  <div class="container" part="container" role="slider"
4073
4110
  aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
@@ -4216,6 +4253,8 @@ class MediaVolumeSlider2 extends HTMLElementBase {
4216
4253
  transition: transform 0.1s;
4217
4254
  }
4218
4255
  .container:hover .thumb { transform: translate(-50%, -50%) scale(1.2); }
4256
+ /* A finger needs more than the 20px a mouse does. */
4257
+ @media (pointer: coarse) { .container { height: 36px; } .thumb { width: 18px; height: 18px; } }
4219
4258
  </style>
4220
4259
  <div class="container" part="container" role="slider"
4221
4260
  aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"
@@ -4412,6 +4451,8 @@ class MediaSettingsMenu2 extends HTMLElementBase {
4412
4451
  .item[aria-checked="true"]::before { content: '\\2713'; }
4413
4452
  .item[aria-checked="false"]::before { content: ''; min-width: 12px; display: inline-block; }
4414
4453
  .back { font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.2); }
4454
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
4455
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
4415
4456
  </style>
4416
4457
  <button class="trigger" part="trigger" type="button"
4417
4458
  aria-label="${label(this, "settings")}" aria-haspopup="true" aria-expanded="false">
@@ -4641,6 +4682,8 @@ class MediaSeekButton2 extends HTMLElementBase {
4641
4682
  button:hover { opacity: .8; }
4642
4683
  button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
4643
4684
  button:disabled { cursor: default; opacity: .45; }
4685
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
4686
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
4644
4687
  </style>
4645
4688
  <button part="button" type="button"></button>
4646
4689
  `;
@@ -4686,6 +4729,8 @@ class MediaPlaybackRateButton2 extends HTMLElementBase {
4686
4729
  button { min-width: 42px; height: 40px; padding: 0 8px; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-variant-numeric: tabular-nums; }
4687
4730
  button:hover { opacity: .8; }
4688
4731
  button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
4732
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
4733
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
4689
4734
  </style>
4690
4735
  <button part="button" type="button" aria-label="${label(this, "speed")}"></button>
4691
4736
  `;
@@ -4736,6 +4781,8 @@ class MediaAirplayButton2 extends HTMLElementBase {
4736
4781
  button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
4737
4782
  button:hover { opacity: 0.8; }
4738
4783
  :host([connected]) button { color: var(--media-airplay-active-color, #38bdf8); }
4784
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
4785
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
4739
4786
  </style>
4740
4787
  <button part="button" type="button" aria-label="${label(this, "airPlayPlay")}">
4741
4788
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${AIRPLAY_ICON}"/></svg>
@@ -4795,6 +4842,8 @@ class MediaLiveButton2 extends HTMLElementBase {
4795
4842
  button { display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; font-size: 12px; font-weight: 700; }
4796
4843
  i { width: 8px; height: 8px; border-radius: 9999px; background: #ef4444; }
4797
4844
  :host(:not([at-live-edge])) button { opacity: 0.72; }
4845
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
4846
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
4798
4847
  </style>
4799
4848
  <button part="button" type="button" aria-label="${label(this, "seekToLive")}"><i aria-hidden="true"></i><span>${label(this, "live")}</span></button>
4800
4849
  `;
@@ -4891,6 +4940,8 @@ class MediaRemotePlaybackButton2 extends HTMLElementBase {
4891
4940
  button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
4892
4941
  button:hover { opacity: 0.8; }
4893
4942
  :host([connected]) button { color: var(--media-remote-active-color, #38bdf8); }
4943
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
4944
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
4894
4945
  </style>
4895
4946
  <button part="button" type="button" aria-label="${label(this, "remotePlayback")}">
4896
4947
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${REMOTE_ICON}"/></svg>
@@ -8,7 +8,7 @@ import {
8
8
  probeVolumeAvailability2,
9
9
  detectFullscreenAvailability2,
10
10
  detectPipAvailability2
11
- } from "./chunk-rhykj0qy.js";
11
+ } from "./chunk-pvq6e7gz.js";
12
12
 
13
13
  // src/features/playback.ts
14
14
  var playback2 = {
@@ -383,7 +383,9 @@ class MediaEventsNormalizer2 {
383
383
  }
384
384
  function onFullscreenChange2(handler) {
385
385
  const events = ["fullscreenchange", "webkitfullscreenchange", "mozfullscreenchange", "MSFullscreenChange"];
386
- const listener = () => {
386
+ const listener = (event) => {
387
+ if (event && typeof CustomEvent !== "undefined" && event instanceof CustomEvent)
388
+ return;
387
389
  const fullscreen = !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement);
388
390
  handler(fullscreen);
389
391
  };
@@ -411,8 +413,8 @@ var DEFAULT_KEYBOARD_SHORTCUTS2 = {
411
413
  c: "toggleCaptions",
412
414
  ArrowLeft: "seekBackward",
413
415
  ArrowRight: "seekForward",
414
- j: "seekBackward",
415
- l: "seekForward",
416
+ j: "seekBackwardLong",
417
+ l: "seekForwardLong",
416
418
  ArrowUp: "volumeUp",
417
419
  ArrowDown: "volumeDown",
418
420
  ">": "speedUp",
@@ -463,6 +465,18 @@ function createKeyboardHandler2(actions, options = {}) {
463
465
  case "seekForward":
464
466
  actions.seekForward();
465
467
  break;
468
+ case "seekBackwardLong":
469
+ if (actions.seekBackwardLong)
470
+ actions.seekBackwardLong();
471
+ else
472
+ actions.seekBackward();
473
+ break;
474
+ case "seekForwardLong":
475
+ if (actions.seekForwardLong)
476
+ actions.seekForwardLong();
477
+ else
478
+ actions.seekForward();
479
+ break;
466
480
  case "volumeUp":
467
481
  actions.volumeUp();
468
482
  break;
@@ -78,6 +78,9 @@ export interface KeyboardActions {
78
78
  toggleCaptions(): void;
79
79
  seekBackward(): void;
80
80
  seekForward(): void;
81
+ /** J and L: ten seconds, as YouTube's keys go (the arrows are five). Falls back to seekBackward/seekForward. */
82
+ seekBackwardLong?(): void;
83
+ seekForwardLong?(): void;
81
84
  volumeUp(): void;
82
85
  volumeDown(): void;
83
86
  speedUp(): void;
@@ -23,7 +23,7 @@ import {
23
23
  MediaErrorDisplay2,
24
24
  MediaRemotePlaybackButton2,
25
25
  registerElements2
26
- } from "../chunk-hvjcmbfn.js";
26
+ } from "../chunk-cay3ahqr.js";
27
27
  export {
28
28
  MediaAirplayButton2 as MediaAirplayButton,
29
29
  MediaErrorDisplay2 as MediaErrorDisplay,
@@ -11,7 +11,7 @@ import {
11
11
  videoFeatures2,
12
12
  audioFeatures2,
13
13
  minimalFeatures2
14
- } from "../chunk-ds1nj487.js";
14
+ } from "../chunk-ndbfytd6.js";
15
15
  export {
16
16
  audioFeatures2 as audioFeatures,
17
17
  captions2 as captions,
package/dist/index.js CHANGED
@@ -63,7 +63,7 @@ import {
63
63
  applyPosition2,
64
64
  MediaSettingsMenu2,
65
65
  registerElements2
66
- } from "./chunk-hvjcmbfn.js";
66
+ } from "./chunk-cay3ahqr.js";
67
67
  import {
68
68
  playback2,
69
69
  volume2,
@@ -77,7 +77,7 @@ import {
77
77
  videoFeatures2,
78
78
  audioFeatures2,
79
79
  minimalFeatures2
80
- } from "./chunk-ds1nj487.js";
80
+ } from "./chunk-ndbfytd6.js";
81
81
  import {
82
82
  shallowEqual2,
83
83
  createSignal2,
@@ -118,7 +118,7 @@ import {
118
118
  enterPiP2,
119
119
  exitPiP2,
120
120
  detectAllFeatures2
121
- } from "./chunk-rhykj0qy.js";
121
+ } from "./chunk-pvq6e7gz.js";
122
122
  // src/core/audio-gain.ts
123
123
  class AudioGain {
124
124
  media;
@@ -43,6 +43,18 @@ export declare abstract class BaseProvider implements Provider {
43
43
  setTextTrackMode(_trackId: string, _mode: 'disabled' | 'hidden' | 'showing'): void;
44
44
  getAudioTracks(): AudioTrack[];
45
45
  setAudioTrack(_trackId: string): void;
46
+ private stopEmbedFullscreen;
47
+ /**
48
+ * What an embedded player (YouTube, Vimeo) can do, said once it is ready.
49
+ *
50
+ * The native-media providers say it on loadedmetadata; the embeds said
51
+ * nothing, so fullscreen stayed "unavailable" and its button disabled,
52
+ * though the player can fill the screen like any other. Fullscreen is the
53
+ * container's own (the player's controls come along); picture in picture
54
+ * is the embed's business. Also follows the document into and out of
55
+ * fullscreen, which only the native providers did.
56
+ */
57
+ protected announceEmbedFeatures(): void;
46
58
  getFeatureAvailability(feature: 'volume' | 'fullscreen' | 'pip'): FeatureAvailability;
47
59
  enterFullscreen(): Promise<void>;
48
60
  exitFullscreen(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-video-player",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "A modern, framework-agnostic video player with deep stx integration",
5
5
  "author": "Chris Breuer <chris@stacksjs.org>",
6
6
  "license": "MIT",
@@ -208,7 +208,12 @@ export class MediaEventsNormalizer {
208
208
  export function onFullscreenChange(handler: (fullscreen: boolean) => void): () => void {
209
209
  const events = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
210
210
 
211
- const listener = () => {
211
+ const listener = (event?: Event) => {
212
+ // The browser's own change only. <video-player> re-dispatches the player's
213
+ // events on itself, bubbling and composed, under the same names; its
214
+ // "fullscreenchange" reached this document listener, which announced it
215
+ // again, and the two fed each other until the stack overflowed.
216
+ if (event && typeof CustomEvent !== 'undefined' && event instanceof CustomEvent) return
212
217
  const fullscreen = !!(
213
218
  document.fullscreenElement ||
214
219
  (document as any).webkitFullscreenElement ||
@@ -261,8 +266,8 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: Record<string, string> = {
261
266
  c: 'toggleCaptions',
262
267
  ArrowLeft: 'seekBackward',
263
268
  ArrowRight: 'seekForward',
264
- j: 'seekBackward',
265
- l: 'seekForward',
269
+ j: 'seekBackwardLong',
270
+ l: 'seekForwardLong',
266
271
  ArrowUp: 'volumeUp',
267
272
  ArrowDown: 'volumeDown',
268
273
  '>': 'speedUp',
@@ -292,6 +297,9 @@ export interface KeyboardActions {
292
297
  toggleCaptions(): void
293
298
  seekBackward(): void
294
299
  seekForward(): void
300
+ /** J and L: ten seconds, as YouTube's keys go (the arrows are five). Falls back to seekBackward/seekForward. */
301
+ seekBackwardLong?(): void
302
+ seekForwardLong?(): void
295
303
  volumeUp(): void
296
304
  volumeDown(): void
297
305
  speedUp(): void
@@ -351,6 +359,14 @@ export function createKeyboardHandler(
351
359
  case 'seekForward':
352
360
  actions.seekForward()
353
361
  break
362
+ case 'seekBackwardLong':
363
+ if (actions.seekBackwardLong) actions.seekBackwardLong()
364
+ else actions.seekBackward()
365
+ break
366
+ case 'seekForwardLong':
367
+ if (actions.seekForwardLong) actions.seekForwardLong()
368
+ else actions.seekForward()
369
+ break
354
370
  case 'volumeUp':
355
371
  actions.volumeUp()
356
372
  break
@@ -27,6 +27,8 @@ export class MediaAirplayButton extends HTMLElementBase {
27
27
  button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
28
28
  button:hover { opacity: 0.8; }
29
29
  :host([connected]) button { color: var(--media-airplay-active-color, #38bdf8); }
30
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
31
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
30
32
  </style>
31
33
  <button part="button" type="button" aria-label="${label(this, 'airPlayPlay')}">
32
34
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${AIRPLAY_ICON}"/></svg>
@@ -28,6 +28,8 @@ export class MediaFullscreenButton extends HTMLElementBase {
28
28
  }
29
29
  button:hover { opacity: 0.8; }
30
30
  button:disabled { opacity: 0.4; cursor: not-allowed; }
31
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
32
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
31
33
  </style>
32
34
  <button part="button" type="button" aria-label="${label(this, 'enterFullscreen')}">
33
35
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
@@ -20,6 +20,8 @@ export class MediaLiveButton extends HTMLElementBase {
20
20
  button { display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; font-size: 12px; font-weight: 700; }
21
21
  i { width: 8px; height: 8px; border-radius: 9999px; background: #ef4444; }
22
22
  :host(:not([at-live-edge])) button { opacity: 0.72; }
23
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
24
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
23
25
  </style>
24
26
  <button part="button" type="button" aria-label="${label(this, 'seekToLive')}"><i aria-hidden="true"></i><span>${label(this, 'live')}</span></button>
25
27
  `
@@ -33,6 +33,8 @@ export class MediaMuteButton extends HTMLElementBase {
33
33
  background: transparent; border: none; color: inherit; cursor: pointer;
34
34
  }
35
35
  button:hover { opacity: 0.8; }
36
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
37
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
36
38
  </style>
37
39
  <button part="button" type="button" aria-label="${label(this, 'mute')}">
38
40
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
@@ -27,6 +27,8 @@ export class MediaPipButton extends HTMLElementBase {
27
27
  }
28
28
  button:hover { opacity: 0.8; }
29
29
  button:disabled { opacity: 0.4; cursor: not-allowed; }
30
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
31
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
30
32
  </style>
31
33
  <button part="button" type="button" aria-label="${label(this, 'enterPiP')}">
32
34
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
@@ -27,6 +27,8 @@ export class MediaPlayButton extends HTMLElementBase {
27
27
  background: transparent; border: none; color: inherit; cursor: pointer;
28
28
  }
29
29
  button:hover { opacity: 0.8; }
30
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
31
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
30
32
  </style>
31
33
  <button part="button" type="button" aria-label="${label(this, 'play')}">
32
34
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
@@ -18,6 +18,8 @@ export class MediaPlaybackRateButton extends HTMLElementBase {
18
18
  button { min-width: 42px; height: 40px; padding: 0 8px; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-variant-numeric: tabular-nums; }
19
19
  button:hover { opacity: .8; }
20
20
  button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
21
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
22
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
21
23
  </style>
22
24
  <button part="button" type="button" aria-label="${label(this, 'speed')}"></button>
23
25
  `
@@ -65,6 +65,8 @@ export class MediaProgressBar extends HTMLElementBase {
65
65
  border-top-color: rgba(0,0,0,0.85);
66
66
  }
67
67
  .container:hover .tooltip { opacity: 1; }
68
+ /* A finger needs more than the 20px a mouse does. */
69
+ @media (pointer: coarse) { .container { height: 36px; } .thumb { width: 18px; height: 18px; } }
68
70
  </style>
69
71
  <div class="container" part="container" role="slider"
70
72
  aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
@@ -32,6 +32,8 @@ export class MediaRemotePlaybackButton extends HTMLElementBase {
32
32
  button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
33
33
  button:hover { opacity: 0.8; }
34
34
  :host([connected]) button { color: var(--media-remote-active-color, #38bdf8); }
35
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
36
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
35
37
  </style>
36
38
  <button part="button" type="button" aria-label="${label(this, 'remotePlayback')}">
37
39
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${REMOTE_ICON}"/></svg>
@@ -17,6 +17,8 @@ export class MediaSeekButton extends HTMLElementBase {
17
17
  button:hover { opacity: .8; }
18
18
  button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
19
19
  button:disabled { cursor: default; opacity: .45; }
20
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
21
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
20
22
  </style>
21
23
  <button part="button" type="button"></button>
22
24
  `
@@ -54,6 +54,8 @@ export class MediaSettingsMenu extends HTMLElementBase {
54
54
  .item[aria-checked="true"]::before { content: '\\2713'; }
55
55
  .item[aria-checked="false"]::before { content: ''; min-width: 12px; display: inline-block; }
56
56
  .back { font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.2); }
57
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
58
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
57
59
  </style>
58
60
  <button class="trigger" part="trigger" type="button"
59
61
  aria-label="${say(this, 'settings')}" aria-haspopup="true" aria-expanded="false">
@@ -42,6 +42,8 @@ export class MediaVolumeSlider extends HTMLElementBase {
42
42
  transition: transform 0.1s;
43
43
  }
44
44
  .container:hover .thumb { transform: translate(-50%, -50%) scale(1.2); }
45
+ /* A finger needs more than the 20px a mouse does. */
46
+ @media (pointer: coarse) { .container { height: 36px; } .thumb { width: 18px; height: 18px; } }
45
47
  </style>
46
48
  <div class="container" part="container" role="slider"
47
49
  aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"
@@ -31,7 +31,9 @@ export class VideoPlayerElement extends HTMLElementBase {
31
31
  if (this._player) return
32
32
 
33
33
  // Create shadow DOM
34
- const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' })
34
+ // delegatesFocus: focusing <video-player> (a dialog's first focus, an
35
+ // `autofocus`) puts focus on the player itself, where its keys work.
36
+ const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open', delegatesFocus: true })
35
37
 
36
38
  // Container
37
39
  this._container = document.createElement('div')
package/src/player.ts CHANGED
@@ -22,6 +22,7 @@ import { createMediaSession, type MediaSessionOptions } from './core/media-sessi
22
22
  import { lockOrientation, unlockOrientation } from './core/orientation'
23
23
  import { defaultLoaders, findSourceCandidates } from './providers'
24
24
  import { embedCover } from './core/cover'
25
+ import { enterFullscreen as enterFullscreenElement } from './core/features'
25
26
  import { label } from './elements/utils'
26
27
 
27
28
  // =============================================================================
@@ -263,6 +264,8 @@ export class Player implements IPlayer {
263
264
  toggleCaptions: () => this.toggleCaptions(),
264
265
  seekBackward: () => this.seekBy(-5),
265
266
  seekForward: () => this.seekBy(5),
267
+ seekBackwardLong: () => this.seekBy(-10),
268
+ seekForwardLong: () => this.seekBy(10),
266
269
  volumeUp: () => this.setVolume(Math.min(1, this.state.volume + 0.1)),
267
270
  volumeDown: () => this.setVolume(Math.max(0, this.state.volume - 0.1)),
268
271
  speedUp: () => this.setPlaybackRate(Math.min(4, this.state.playbackRate + 0.25)),
@@ -599,7 +602,10 @@ export class Player implements IPlayer {
599
602
  })
600
603
 
601
604
  provider.on('pause', () => {
602
- this._store.set('paused', true)
605
+ // Not playing any more either: the controls hide only while playing,
606
+ // and a paused player that still said `playing` hid them over a still
607
+ // frame, and told everyone reading state.playing it was still going.
608
+ this._store.batch({ paused: true, playing: false, playbackState: 'paused' })
603
609
  this._events.emit('pause')
604
610
  this.announce(label(this._el, 'paused'))
605
611
  })
@@ -794,7 +800,12 @@ export class Player implements IPlayer {
794
800
  if (this.state.pictureInPicture) {
795
801
  await this.exitPiP()
796
802
  }
797
- await this._provider?.enterFullscreen()
803
+ // The whole player, controls included: the provider's own container holds
804
+ // only the media, so a fullscreen video had no controls to pause or seek
805
+ // with. A browser that can only take the <video> itself fullscreen (iOS
806
+ // Safari) still gets it, with the system's controls.
807
+ const media = this._provider?.mediaElement
808
+ await enterFullscreenElement(this._el, media instanceof HTMLMediaElement ? media : null)
798
809
  if (this._options.fullscreenOrientationLock !== false) {
799
810
  lockOrientation('landscape')
800
811
  }
@@ -18,7 +18,7 @@ import type {
18
18
  MediaError,
19
19
  FeatureAvailability,
20
20
  } from '../types'
21
- import { EventEmitter } from '../core/events'
21
+ import { EventEmitter, onFullscreenChange } from '../core/events'
22
22
  import {
23
23
  detectVolumeAvailability,
24
24
  detectFullscreenAvailability,
@@ -63,6 +63,8 @@ export abstract class BaseProvider implements Provider {
63
63
 
64
64
  destroy(): void {
65
65
  this._ready = false
66
+ this.stopEmbedFullscreen?.()
67
+ this.stopEmbedFullscreen = null
66
68
  this.cleanup()
67
69
  this.events.removeAllListeners()
68
70
  }
@@ -132,6 +134,37 @@ export abstract class BaseProvider implements Provider {
132
134
 
133
135
  // === Feature Availability ===
134
136
 
137
+ private stopEmbedFullscreen: (() => void) | null = null
138
+
139
+ /**
140
+ * What an embedded player (YouTube, Vimeo) can do, said once it is ready.
141
+ *
142
+ * The native-media providers say it on loadedmetadata; the embeds said
143
+ * nothing, so fullscreen stayed "unavailable" and its button disabled,
144
+ * though the player can fill the screen like any other. Fullscreen is the
145
+ * container's own (the player's controls come along); picture in picture
146
+ * is the embed's business. Also follows the document into and out of
147
+ * fullscreen, which only the native providers did.
148
+ */
149
+ protected announceEmbedFeatures(): void {
150
+ // Nothing to measure without a page (server rendering, tests).
151
+ if (typeof document === 'undefined' || typeof HTMLMediaElement === 'undefined') return
152
+ const fullscreenAvailability = this.getFeatureAvailability('fullscreen')
153
+ const pipAvailability = this.getFeatureAvailability('pip')
154
+ const volumeAvailability = this.getFeatureAvailability('volume')
155
+ this.events.emit('statechange', {
156
+ canFullscreen: fullscreenAvailability === 'available',
157
+ canPictureInPicture: pipAvailability === 'available',
158
+ fullscreenAvailability,
159
+ pipAvailability,
160
+ volumeAvailability,
161
+ })
162
+ this.events.emit('availabilitychange', 'fullscreen', fullscreenAvailability)
163
+ this.events.emit('availabilitychange', 'pip', pipAvailability)
164
+ this.events.emit('availabilitychange', 'volume', volumeAvailability)
165
+ this.stopEmbedFullscreen ??= onFullscreenChange(fullscreen => this.events.emit('fullscreenchange', fullscreen))
166
+ }
167
+
135
168
  getFeatureAvailability(feature: 'volume' | 'fullscreen' | 'pip'): FeatureAvailability {
136
169
  // Only pass media element if it's actually an HTMLMediaElement (not an iframe)
137
170
  const el = this.mediaElement