ts-video-player 0.1.6 → 0.1.7

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.
@@ -625,6 +625,7 @@ class YouTubeProvider2 extends BaseProvider2 {
625
625
  endTime;
626
626
  timeUpdateInterval = null;
627
627
  lastState = -1;
628
+ pendingPlay = false;
628
629
  get mediaElement() {
629
630
  return this.iframe;
630
631
  }
@@ -702,6 +703,7 @@ class YouTubeProvider2 extends BaseProvider2 {
702
703
  this.player?.destroy();
703
704
  this.player = null;
704
705
  this.playerReady = false;
706
+ this.pendingPlay = false;
705
707
  this.iframe = null;
706
708
  }
707
709
  onReady() {
@@ -714,8 +716,12 @@ class YouTubeProvider2 extends BaseProvider2 {
714
716
  this.events.emit("loadedmetadata");
715
717
  this.events.emit("canplay");
716
718
  if (this.pendingVideoId) {
717
- this.loadVideo(this.pendingVideoId, this.startTime, this.endTime);
719
+ const videoId = this.pendingVideoId;
718
720
  this.pendingVideoId = null;
721
+ this.loadVideo(videoId, this.startTime, this.endTime);
722
+ } else if (this.pendingPlay) {
723
+ this.pendingPlay = false;
724
+ this.player?.playVideo();
719
725
  }
720
726
  }
721
727
  onStateChange(event) {
@@ -727,6 +733,8 @@ class YouTubeProvider2 extends BaseProvider2 {
727
733
  this.events.emit("loadstart");
728
734
  break;
729
735
  case PlayerState.PLAYING:
736
+ if (this.getDuration() > 0)
737
+ this.events.emit("durationchange", this.getDuration());
730
738
  this.events.emit("play");
731
739
  this.events.emit("playing");
732
740
  this.events.emit("statechange", { paused: false, playing: true, waiting: false });
@@ -787,7 +795,8 @@ class YouTubeProvider2 extends BaseProvider2 {
787
795
  loadVideo(videoId, startTime, endTime) {
788
796
  if (!this.player)
789
797
  return;
790
- if (this.options.autoplay) {
798
+ if (this.options.autoplay || this.pendingPlay) {
799
+ this.pendingPlay = false;
791
800
  this.player.loadVideoById({
792
801
  videoId,
793
802
  startSeconds: startTime,
@@ -802,13 +811,21 @@ class YouTubeProvider2 extends BaseProvider2 {
802
811
  }
803
812
  }
804
813
  async play() {
805
- this.player?.playVideo();
814
+ if (!this.player || !this.playerReady || this.pendingVideoId) {
815
+ this.pendingPlay = true;
816
+ return;
817
+ }
818
+ this.player.playVideo();
806
819
  }
807
820
  pause() {
808
- this.player?.pauseVideo();
821
+ this.pendingPlay = false;
822
+ if (this.playerReady)
823
+ this.player?.pauseVideo();
809
824
  }
810
825
  stop() {
811
- this.player?.stopVideo();
826
+ this.pendingPlay = false;
827
+ if (this.playerReady)
828
+ this.player?.stopVideo();
812
829
  }
813
830
  seekTo(time) {
814
831
  this.player?.seekTo(time, true);
@@ -2040,6 +2057,7 @@ class Player2 {
2040
2057
  _cover = null;
2041
2058
  _coverDismissed = false;
2042
2059
  _coverTimer = null;
2060
+ _playRequested = false;
2043
2061
  constructor(container, options = {}) {
2044
2062
  if (typeof container === "string") {
2045
2063
  const el = document.querySelector(container);
@@ -2112,20 +2130,36 @@ class Player2 {
2112
2130
  button.setAttribute("aria-hidden", "true");
2113
2131
  button.style.cssText = "display:flex;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;background:rgba(255,255,255,0.92);box-shadow:0 4px 18px rgba(0,0,0,0.35)";
2114
2132
  const triangle = document.createElement("span");
2133
+ triangle.className = "ts-video-player__cover-play";
2115
2134
  triangle.style.cssText = "width:22px;height:24px;margin-left:5px;background:#0f172a;clip-path:polygon(0 0,100% 50%,0 100%)";
2116
2135
  button.appendChild(triangle);
2117
2136
  cover.appendChild(button);
2118
2137
  cover.addEventListener("click", (event) => {
2119
2138
  event.stopPropagation();
2139
+ if (!cover.dataset.loading) {
2140
+ cover.dataset.loading = "";
2141
+ triangle.style.display = "none";
2142
+ const ring = document.createElement("span");
2143
+ ring.className = "ts-video-player__cover-spinner";
2144
+ ring.style.cssText = "width:26px;height:26px;border-radius:50%;border:3px solid rgba(15,23,42,0.2);border-top-color:#0f172a";
2145
+ button.appendChild(ring);
2146
+ ring.animate?.([{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], { duration: 800, iterations: Infinity });
2147
+ }
2120
2148
  this.play().catch(() => {});
2121
2149
  if (this._coverTimer)
2122
2150
  clearTimeout(this._coverTimer);
2123
- this._coverTimer = setTimeout(() => {
2124
- if (this.state.started)
2125
- return;
2126
- this._coverDismissed = true;
2127
- this.updateCover(this.state);
2128
- }, 2000);
2151
+ let waits = 0;
2152
+ const settle = () => {
2153
+ this._coverTimer = setTimeout(() => {
2154
+ if (this.state.started)
2155
+ return;
2156
+ if (!this.state.canPlay && ++waits < 10)
2157
+ return settle();
2158
+ this._coverDismissed = true;
2159
+ this.updateCover(this.state);
2160
+ }, 2000);
2161
+ };
2162
+ settle();
2129
2163
  });
2130
2164
  this._el.appendChild(cover);
2131
2165
  this._cover = cover;
@@ -2145,6 +2179,13 @@ class Player2 {
2145
2179
  if (cover.style.backgroundImage !== background)
2146
2180
  cover.style.backgroundImage = background;
2147
2181
  }
2182
+ if (!show && cover.dataset.loading !== undefined) {
2183
+ delete cover.dataset.loading;
2184
+ cover.querySelector(".ts-video-player__cover-spinner")?.remove();
2185
+ const triangle = cover.querySelector(".ts-video-player__cover-play");
2186
+ if (triangle)
2187
+ triangle.style.display = "";
2188
+ }
2148
2189
  cover.hidden = !show;
2149
2190
  cover.style.display = show ? "flex" : "none";
2150
2191
  }
@@ -2307,6 +2348,7 @@ class Player2 {
2307
2348
  return;
2308
2349
  }
2309
2350
  this._coverDismissed = false;
2351
+ this._playRequested = false;
2310
2352
  this._store.batch({
2311
2353
  sources,
2312
2354
  src: sources[0],
@@ -2364,6 +2406,10 @@ class Player2 {
2364
2406
  mediaType: loader.mediaType(candidate.src),
2365
2407
  providerType: loader.type
2366
2408
  });
2409
+ if (this._playRequested) {
2410
+ this._playRequested = false;
2411
+ this._provider.play().catch(() => {});
2412
+ }
2367
2413
  return;
2368
2414
  } catch (error) {
2369
2415
  lastError = error;
@@ -2502,11 +2548,14 @@ class Player2 {
2502
2548
  });
2503
2549
  }
2504
2550
  async play() {
2505
- if (!this._provider)
2551
+ if (!this._provider) {
2552
+ this._playRequested = true;
2506
2553
  return;
2554
+ }
2507
2555
  await this._provider.play();
2508
2556
  }
2509
2557
  pause() {
2558
+ this._playRequested = false;
2510
2559
  this._provider?.pause();
2511
2560
  }
2512
2561
  togglePlay() {
@@ -2896,6 +2945,7 @@ class VideoSkin2 extends HTMLElementBase {
2896
2945
  color: white;
2897
2946
  font-family: system-ui, -apple-system, sans-serif;
2898
2947
  font-size: 14px;
2948
+ container-type: inline-size;
2899
2949
  }
2900
2950
 
2901
2951
  /* Click-to-play overlay */
@@ -2923,6 +2973,25 @@ class VideoSkin2 extends HTMLElementBase {
2923
2973
  visibility: hidden;
2924
2974
  }
2925
2975
 
2976
+ /* The time and the progress bar. One row with the buttons on a
2977
+ wide player; a phone-width one gives the progress bar a row of
2978
+ its own, where it would otherwise be squeezed to a dot between
2979
+ the buttons. */
2980
+ .center { display: contents; }
2981
+ @container (max-width: 520px) {
2982
+ .controls { flex-wrap: wrap; row-gap: 0; padding: 4px 10px 6px; }
2983
+ .center { display: flex; order: -1; flex: 1 0 100%; align-items: center; gap: 10px; min-width: 0; font-size: 12px; }
2984
+ /* Speed is in the settings menu too; one button fewer keeps the
2985
+ rest on one row. */
2986
+ ::slotted(media-playback-rate-button) { display: none; }
2987
+ }
2988
+
2989
+ /* A touch screen sets the volume with its own buttons, and iOS
2990
+ ignores a page's volume altogether: no slider there. */
2991
+ @media (pointer: coarse) {
2992
+ ::slotted(media-volume-slider) { display: none; }
2993
+ }
2994
+
2926
2995
  /* Spacer pushes right-aligned controls */
2927
2996
  .spacer {
2928
2997
  flex: 1;
@@ -2938,7 +3007,7 @@ class VideoSkin2 extends HTMLElementBase {
2938
3007
  </div>
2939
3008
  <div class="controls" part="controls">
2940
3009
  <slot name="left"></slot>
2941
- <slot name="center"></slot>
3010
+ <div class="center" part="center"><slot name="center"></slot></div>
2942
3011
  <div class="spacer"></div>
2943
3012
  <slot name="right"></slot>
2944
3013
  <slot></slot>
@@ -23,7 +23,7 @@ import {
23
23
  MediaErrorDisplay2,
24
24
  MediaRemotePlaybackButton2,
25
25
  registerElements2
26
- } from "../chunk-fvz33zn4.js";
26
+ } from "../chunk-24n6adw2.js";
27
27
  export {
28
28
  MediaAirplayButton2 as MediaAirplayButton,
29
29
  MediaErrorDisplay2 as MediaErrorDisplay,
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  applyPosition2,
52
52
  MediaSettingsMenu2,
53
53
  registerElements2
54
- } from "./chunk-fvz33zn4.js";
54
+ } from "./chunk-24n6adw2.js";
55
55
  import {
56
56
  playback2,
57
57
  volume2,
package/dist/player.d.ts CHANGED
@@ -23,6 +23,12 @@ export declare class Player implements IPlayer {
23
23
  private _cover;
24
24
  private _coverDismissed;
25
25
  private _coverTimer;
26
+ /**
27
+ * A play asked for before there was a provider to ask: a tap on the cover
28
+ * while the source was still loading. Kept and honoured once the source has
29
+ * loaded, so one tap plays the video rather than one tap per loading step.
30
+ */
31
+ private _playRequested;
26
32
  /**
27
33
  * Create a new player instance
28
34
  */
@@ -96,6 +96,12 @@ export declare class YouTubeProvider extends BaseProvider {
96
96
  private endTime?;
97
97
  private timeUpdateInterval;
98
98
  private lastState;
99
+ /**
100
+ * A play asked for before YouTube's player could take it. Its methods only
101
+ * exist after its ready event, and the video may still be pending then, so
102
+ * the play waits for both and the video loads playing.
103
+ */
104
+ private pendingPlay;
99
105
  get mediaElement(): HTMLIFrameElement | null;
100
106
  protected initialize(): Promise<void>;
101
107
  private loadYouTubeAPI;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-video-player",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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",
@@ -36,6 +36,7 @@ export class VideoSkin extends HTMLElementBase {
36
36
  color: white;
37
37
  font-family: system-ui, -apple-system, sans-serif;
38
38
  font-size: 14px;
39
+ container-type: inline-size;
39
40
  }
40
41
 
41
42
  /* Click-to-play overlay */
@@ -63,6 +64,25 @@ export class VideoSkin extends HTMLElementBase {
63
64
  visibility: hidden;
64
65
  }
65
66
 
67
+ /* The time and the progress bar. One row with the buttons on a
68
+ wide player; a phone-width one gives the progress bar a row of
69
+ its own, where it would otherwise be squeezed to a dot between
70
+ the buttons. */
71
+ .center { display: contents; }
72
+ @container (max-width: 520px) {
73
+ .controls { flex-wrap: wrap; row-gap: 0; padding: 4px 10px 6px; }
74
+ .center { display: flex; order: -1; flex: 1 0 100%; align-items: center; gap: 10px; min-width: 0; font-size: 12px; }
75
+ /* Speed is in the settings menu too; one button fewer keeps the
76
+ rest on one row. */
77
+ ::slotted(media-playback-rate-button) { display: none; }
78
+ }
79
+
80
+ /* A touch screen sets the volume with its own buttons, and iOS
81
+ ignores a page's volume altogether: no slider there. */
82
+ @media (pointer: coarse) {
83
+ ::slotted(media-volume-slider) { display: none; }
84
+ }
85
+
66
86
  /* Spacer pushes right-aligned controls */
67
87
  .spacer {
68
88
  flex: 1;
@@ -78,7 +98,7 @@ export class VideoSkin extends HTMLElementBase {
78
98
  </div>
79
99
  <div class="controls" part="controls">
80
100
  <slot name="left"></slot>
81
- <slot name="center"></slot>
101
+ <div class="center" part="center"><slot name="center"></slot></div>
82
102
  <div class="spacer"></div>
83
103
  <slot name="right"></slot>
84
104
  <slot></slot>
package/src/player.ts CHANGED
@@ -44,6 +44,12 @@ export class Player implements IPlayer {
44
44
  private _cover: HTMLButtonElement | null = null
45
45
  private _coverDismissed = false
46
46
  private _coverTimer: ReturnType<typeof setTimeout> | null = null
47
+ /**
48
+ * A play asked for before there was a provider to ask: a tap on the cover
49
+ * while the source was still loading. Kept and honoured once the source has
50
+ * loaded, so one tap plays the video rather than one tap per loading step.
51
+ */
52
+ private _playRequested = false
47
53
 
48
54
  /**
49
55
  * Create a new player instance
@@ -159,20 +165,39 @@ export class Player implements IPlayer {
159
165
  button.setAttribute('aria-hidden', 'true')
160
166
  button.style.cssText = 'display:flex;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;background:rgba(255,255,255,0.92);box-shadow:0 4px 18px rgba(0,0,0,0.35)'
161
167
  const triangle = document.createElement('span')
168
+ triangle.className = 'ts-video-player__cover-play'
162
169
  triangle.style.cssText = 'width:22px;height:24px;margin-left:5px;background:#0f172a;clip-path:polygon(0 0,100% 50%,0 100%)'
163
170
  button.appendChild(triangle)
164
171
  cover.appendChild(button)
165
172
  cover.addEventListener('click', (event) => {
166
173
  event.stopPropagation()
174
+ // The embed can take a few seconds to load on a phone: the tap is
175
+ // answered at once, with the play button turning into a spinner.
176
+ if (!cover.dataset.loading) {
177
+ cover.dataset.loading = ''
178
+ triangle.style.display = 'none'
179
+ const ring = document.createElement('span')
180
+ ring.className = 'ts-video-player__cover-spinner'
181
+ ring.style.cssText = 'width:26px;height:26px;border-radius:50%;border:3px solid rgba(15,23,42,0.2);border-top-color:#0f172a'
182
+ button.appendChild(ring)
183
+ ring.animate?.([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity })
184
+ }
167
185
  void this.play().catch(() => {})
168
186
  // Some browsers only let a tap inside the embed start it. If this one
169
- // did not, get out of the way so the embed's own button can.
187
+ // did not, get out of the way so the embed's own button can. Only once
188
+ // the embed is ready, though: before then nothing could have started,
189
+ // and lifting the cover early showed the embed half-loaded.
170
190
  if (this._coverTimer) clearTimeout(this._coverTimer)
171
- this._coverTimer = setTimeout(() => {
172
- if (this.state.started) return
173
- this._coverDismissed = true
174
- this.updateCover(this.state)
175
- }, 2000)
191
+ let waits = 0
192
+ const settle = () => {
193
+ this._coverTimer = setTimeout(() => {
194
+ if (this.state.started) return
195
+ if (!this.state.canPlay && ++waits < 10) return settle()
196
+ this._coverDismissed = true
197
+ this.updateCover(this.state)
198
+ }, 2000)
199
+ }
200
+ settle()
176
201
  })
177
202
  this._el.appendChild(cover)
178
203
  this._cover = cover
@@ -190,6 +215,13 @@ export class Player implements IPlayer {
190
215
  const background = image ? `url("${image.replace(/["\\]/g, '')}")` : 'none'
191
216
  if (cover.style.backgroundImage !== background) cover.style.backgroundImage = background
192
217
  }
218
+ // Back to a play button once it is no longer waiting on a tap's play.
219
+ if (!show && cover.dataset.loading !== undefined) {
220
+ delete cover.dataset.loading
221
+ cover.querySelector('.ts-video-player__cover-spinner')?.remove()
222
+ const triangle = cover.querySelector<HTMLElement>('.ts-video-player__cover-play')
223
+ if (triangle) triangle.style.display = ''
224
+ }
193
225
  // `hidden` alone would not do it: the inline display wins over it.
194
226
  cover.hidden = !show
195
227
  cover.style.display = show ? 'flex' : 'none'
@@ -432,6 +464,7 @@ export class Player implements IPlayer {
432
464
 
433
465
  // Update state. A new source has not played yet, so its cover returns.
434
466
  this._coverDismissed = false
467
+ this._playRequested = false
435
468
  this._store.batch({
436
469
  sources,
437
470
  src: sources[0],
@@ -498,6 +531,10 @@ export class Player implements IPlayer {
498
531
  mediaType: loader.mediaType(candidate.src),
499
532
  providerType: loader.type,
500
533
  })
534
+ if (this._playRequested) {
535
+ this._playRequested = false
536
+ void this._provider.play().catch(() => {})
537
+ }
501
538
  return
502
539
  }
503
540
  catch (error) {
@@ -667,11 +704,15 @@ export class Player implements IPlayer {
667
704
  // === Playback ===
668
705
 
669
706
  async play(): Promise<void> {
670
- if (!this._provider) return
707
+ if (!this._provider) {
708
+ this._playRequested = true
709
+ return
710
+ }
671
711
  await this._provider.play()
672
712
  }
673
713
 
674
714
  pause(): void {
715
+ this._playRequested = false
675
716
  this._provider?.pause()
676
717
  }
677
718
 
@@ -192,6 +192,12 @@ export class YouTubeProvider extends BaseProvider {
192
192
  private endTime?: number
193
193
  private timeUpdateInterval: ReturnType<typeof setInterval> | null = null
194
194
  private lastState = -1
195
+ /**
196
+ * A play asked for before YouTube's player could take it. Its methods only
197
+ * exist after its ready event, and the video may still be pending then, so
198
+ * the play waits for both and the video loads playing.
199
+ */
200
+ private pendingPlay = false
195
201
 
196
202
  get mediaElement(): HTMLIFrameElement | null {
197
203
  return this.iframe
@@ -303,6 +309,7 @@ export class YouTubeProvider extends BaseProvider {
303
309
  this.player?.destroy()
304
310
  this.player = null
305
311
  this.playerReady = false
312
+ this.pendingPlay = false
306
313
  this.iframe = null
307
314
  }
308
315
 
@@ -324,8 +331,13 @@ export class YouTubeProvider extends BaseProvider {
324
331
 
325
332
  // Load pending video if any
326
333
  if (this.pendingVideoId) {
327
- this.loadVideo(this.pendingVideoId, this.startTime, this.endTime)
334
+ const videoId = this.pendingVideoId
328
335
  this.pendingVideoId = null
336
+ this.loadVideo(videoId, this.startTime, this.endTime)
337
+ }
338
+ else if (this.pendingPlay) {
339
+ this.pendingPlay = false
340
+ this.player?.playVideo()
329
341
  }
330
342
  }
331
343
 
@@ -340,6 +352,9 @@ export class YouTubeProvider extends BaseProvider {
340
352
  this.events.emit('loadstart')
341
353
  break
342
354
  case PlayerState.PLAYING:
355
+ // A video loaded playing never passes through CUED, where the
356
+ // duration is otherwise announced.
357
+ if (this.getDuration() > 0) this.events.emit('durationchange', this.getDuration())
343
358
  this.events.emit('play')
344
359
  this.events.emit('playing')
345
360
  this.events.emit('statechange', { paused: false, playing: true, waiting: false })
@@ -415,7 +430,8 @@ export class YouTubeProvider extends BaseProvider {
415
430
  private loadVideo(videoId: string, startTime?: number, endTime?: number): void {
416
431
  if (!this.player) return
417
432
 
418
- if (this.options.autoplay) {
433
+ if (this.options.autoplay || this.pendingPlay) {
434
+ this.pendingPlay = false
419
435
  this.player.loadVideoById({
420
436
  videoId,
421
437
  startSeconds: startTime,
@@ -433,15 +449,21 @@ export class YouTubeProvider extends BaseProvider {
433
449
  // === Playback ===
434
450
 
435
451
  async play(): Promise<void> {
436
- this.player?.playVideo()
452
+ if (!this.player || !this.playerReady || this.pendingVideoId) {
453
+ this.pendingPlay = true
454
+ return
455
+ }
456
+ this.player.playVideo()
437
457
  }
438
458
 
439
459
  pause(): void {
440
- this.player?.pauseVideo()
460
+ this.pendingPlay = false
461
+ if (this.playerReady) this.player?.pauseVideo()
441
462
  }
442
463
 
443
464
  stop(): void {
444
- this.player?.stopVideo()
465
+ this.pendingPlay = false
466
+ if (this.playerReady) this.player?.stopVideo()
445
467
  }
446
468
 
447
469
  // === Seeking ===