ts-video-player 0.1.3 → 0.1.5

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.
@@ -666,6 +666,7 @@ class YouTubeProvider2 extends BaseProvider2 {
666
666
  });
667
667
  this.iframe = this.player.getIframe();
668
668
  this.iframe.className = "ts-video-player__media";
669
+ this.iframe.style.cssText = "position:absolute;inset:0;width:100%;height:100%;border:0";
669
670
  }
670
671
  async loadYouTubeAPI() {
671
672
  if (window.YT?.Player)
@@ -1028,6 +1029,7 @@ class VimeoProvider2 extends BaseProvider2 {
1028
1029
  this.iframe = this.container_el.querySelector("iframe");
1029
1030
  if (this.iframe) {
1030
1031
  this.iframe.className = "ts-video-player__media";
1032
+ this.iframe.style.cssText = "position:absolute;inset:0;width:100%;height:100%;border:0";
1031
1033
  }
1032
1034
  this.attachVimeoEvents();
1033
1035
  this.events.emit("loadedmetadata");
@@ -2007,6 +2009,19 @@ function getPreconnectHints2(src, loaders = defaultLoaders2) {
2007
2009
  return loader?.preconnect?.() || [];
2008
2010
  }
2009
2011
 
2012
+ // src/core/cover.ts
2013
+ function embedCover(state, dismissed) {
2014
+ const embed = state.providerType === "youtube" || state.providerType === "vimeo";
2015
+ if (!embed || state.started || state.error || dismissed)
2016
+ return { show: false, image: "" };
2017
+ if (state.poster)
2018
+ return { show: true, image: state.poster };
2019
+ const src = state.src;
2020
+ const url = typeof src === "string" ? src : src?.src || "";
2021
+ const youtubeId = state.providerType === "youtube" ? extractYouTubeId2(url) : null;
2022
+ return { show: true, image: youtubeId ? `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg` : "" };
2023
+ }
2024
+
2010
2025
  // src/player.ts
2011
2026
  class Player2 {
2012
2027
  _el;
@@ -2019,6 +2034,9 @@ class Player2 {
2019
2034
  _destroyed = false;
2020
2035
  _sourceLoadId = 0;
2021
2036
  _announcer = null;
2037
+ _cover = null;
2038
+ _coverDismissed = false;
2039
+ _coverTimer = null;
2022
2040
  constructor(container, options = {}) {
2023
2041
  if (typeof container === "string") {
2024
2042
  const el = document.querySelector(container);
@@ -2064,7 +2082,9 @@ class Player2 {
2064
2082
  this._el.setAttribute("aria-keyshortcuts", "Space K M F I C ArrowLeft ArrowRight J L ArrowUp ArrowDown Home End 0 1 2 3 4 5 6 7 8 9");
2065
2083
  const mediaContainer = document.createElement("div");
2066
2084
  mediaContainer.className = "ts-video-player__container";
2085
+ mediaContainer.style.cssText = "position:relative;width:100%;height:100%";
2067
2086
  this._el.appendChild(mediaContainer);
2087
+ this.setupCover();
2068
2088
  if (this._options.announcements !== false) {
2069
2089
  const liveRegion = document.createElement("div");
2070
2090
  liveRegion.className = "ts-video-player__announcer";
@@ -2078,6 +2098,53 @@ class Player2 {
2078
2098
  const _el = this._el;
2079
2099
  _el.__videoPlayer = this;
2080
2100
  }
2101
+ setupCover() {
2102
+ const cover = document.createElement("button");
2103
+ cover.type = "button";
2104
+ cover.className = "ts-video-player__cover";
2105
+ cover.setAttribute("aria-label", `Play ${this._options.title || "video"}`);
2106
+ cover.hidden = true;
2107
+ cover.style.cssText = "position:absolute;inset:0;z-index:2;display:none;align-items:center;justify-content:center;width:100%;height:100%;margin:0;padding:0;border:0;border-radius:inherit;background:#000 center/cover no-repeat;cursor:pointer;-webkit-tap-highlight-color:transparent";
2108
+ const button = document.createElement("span");
2109
+ button.setAttribute("aria-hidden", "true");
2110
+ 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)";
2111
+ const triangle = document.createElement("span");
2112
+ triangle.style.cssText = "width:22px;height:24px;margin-left:5px;background:#0f172a;clip-path:polygon(0 0,100% 50%,0 100%)";
2113
+ button.appendChild(triangle);
2114
+ cover.appendChild(button);
2115
+ cover.addEventListener("click", (event) => {
2116
+ event.stopPropagation();
2117
+ this.play().catch(() => {});
2118
+ if (this._coverTimer)
2119
+ clearTimeout(this._coverTimer);
2120
+ this._coverTimer = setTimeout(() => {
2121
+ if (this.state.started)
2122
+ return;
2123
+ this._coverDismissed = true;
2124
+ this.updateCover(this.state);
2125
+ }, 2000);
2126
+ });
2127
+ this._el.appendChild(cover);
2128
+ this._cover = cover;
2129
+ this._cleanupFns.push(() => {
2130
+ if (this._coverTimer)
2131
+ clearTimeout(this._coverTimer);
2132
+ cover.remove();
2133
+ });
2134
+ }
2135
+ updateCover(state) {
2136
+ const cover = this._cover;
2137
+ if (!cover)
2138
+ return;
2139
+ const { show, image } = embedCover(state, this._coverDismissed);
2140
+ if (show) {
2141
+ const background = image ? `url("${image.replace(/["\\]/g, "")}")` : "none";
2142
+ if (cover.style.backgroundImage !== background)
2143
+ cover.style.backgroundImage = background;
2144
+ }
2145
+ cover.hidden = !show;
2146
+ cover.style.display = show ? "flex" : "none";
2147
+ }
2081
2148
  setupState() {
2082
2149
  this._store.batch({
2083
2150
  volume: this._options.volume ?? 1,
@@ -2092,6 +2159,7 @@ class Player2 {
2092
2159
  });
2093
2160
  const unsubscribe = this._store.subscribe((state) => {
2094
2161
  this.updateAttributes(state);
2162
+ this.updateCover(state);
2095
2163
  });
2096
2164
  this._cleanupFns.push(unsubscribe);
2097
2165
  }
@@ -2235,11 +2303,13 @@ class Player2 {
2235
2303
  if (sources.length === 0) {
2236
2304
  return;
2237
2305
  }
2306
+ this._coverDismissed = false;
2238
2307
  this._store.batch({
2239
2308
  sources,
2240
2309
  src: sources[0],
2241
2310
  loadingState: "loading",
2242
- error: null
2311
+ error: null,
2312
+ started: false
2243
2313
  });
2244
2314
  this._events.emit("sourceschange", sources);
2245
2315
  const candidates = findSourceCandidates(sources, this._loaders);
@@ -2308,6 +2378,9 @@ class Player2 {
2308
2378
  error: { code: 4, message: "Failed to load all compatible sources", details: lastError }
2309
2379
  });
2310
2380
  }
2381
+ setPoster(url) {
2382
+ this._store.set("poster", url);
2383
+ }
2311
2384
  getSrc() {
2312
2385
  return this.state.src;
2313
2386
  }
@@ -2626,6 +2699,9 @@ class VideoPlayerElement2 extends HTMLElementBase {
2626
2699
  if (value)
2627
2700
  this._player.setSrc(value);
2628
2701
  break;
2702
+ case "poster":
2703
+ this._player.setPoster(value || "");
2704
+ break;
2629
2705
  case "muted":
2630
2706
  this._player.setMuted(value !== null);
2631
2707
  break;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The poster over an embedded video until it plays: when to show it, and
3
+ * with which image.
4
+ *
5
+ * @module core/cover
6
+ */
7
+ import type { PlayerState } from '../types';
8
+ export interface EmbedCover {
9
+ show: boolean;
10
+ /** The poster to draw, or '' for a plain black cover. */
11
+ image: string;
12
+ }
13
+ /**
14
+ * A YouTube or Vimeo embed draws its own chrome before playback (a title bar,
15
+ * a play button, "Watch on YouTube", and on iOS the system's controls for the
16
+ * embed's own video), none of which can be styled from outside the iframe.
17
+ * Until the first play the player covers it with its poster instead: the one
18
+ * the page gave it, or else YouTube's thumbnail for the video.
19
+ *
20
+ * `dismissed` is set when a tap on the cover did not start playback, so the
21
+ * embed's own button is left reachable.
22
+ */
23
+ export declare function embedCover(state: Pick<PlayerState, 'providerType' | 'started' | 'error' | 'poster' | 'src'>, dismissed: boolean): EmbedCover;
@@ -23,7 +23,7 @@ import {
23
23
  MediaErrorDisplay2,
24
24
  MediaRemotePlaybackButton2,
25
25
  registerElements2
26
- } from "../chunk-yfrrczn9.js";
26
+ } from "../chunk-z21ez1kz.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-yfrrczn9.js";
54
+ } from "./chunk-z21ez1kz.js";
55
55
  import {
56
56
  playback2,
57
57
  volume2,
package/dist/player.d.ts CHANGED
@@ -20,6 +20,9 @@ export declare class Player implements IPlayer {
20
20
  private _destroyed;
21
21
  private _sourceLoadId;
22
22
  private _announcer;
23
+ private _cover;
24
+ private _coverDismissed;
25
+ private _coverTimer;
23
26
  /**
24
27
  * Create a new player instance
25
28
  */
@@ -29,6 +32,18 @@ export declare class Player implements IPlayer {
29
32
  get provider(): Provider | null;
30
33
  get ready(): boolean;
31
34
  private setupElement;
35
+ /**
36
+ * The poster over an embedded video until it plays.
37
+ *
38
+ * A YouTube or Vimeo embed draws its own chrome before playback: a title
39
+ * bar, a play button, "Watch on YouTube", and on iOS the system's controls
40
+ * for the embed's own video. None of it can be styled from outside the
41
+ * iframe, and it stacks under the player's controls. Until the first play,
42
+ * the player shows its poster and one play button over the embed instead;
43
+ * once the video plays the embed's chrome is gone and the cover lifts.
44
+ */
45
+ private setupCover;
46
+ private updateCover;
32
47
  private setupState;
33
48
  private setupKeyboard;
34
49
  private setupActivity;
@@ -42,6 +57,8 @@ export declare class Player implements IPlayer {
42
57
  private announce;
43
58
  destroy(): void;
44
59
  setSrc(src: Src | Src[]): Promise<void>;
60
+ /** The image shown before playback, and on an embed's cover. */
61
+ setPoster(url: string): void;
45
62
  getSrc(): Src | null;
46
63
  private attachProviderEvents;
47
64
  play(): Promise<void>;
package/dist/types.d.ts CHANGED
@@ -632,6 +632,8 @@ export interface Player {
632
632
  destroy(): void;
633
633
  setSrc(src: Src | Src[]): void;
634
634
  getSrc(): Src | null;
635
+ /** The image shown before playback, and on an embed's cover */
636
+ setPoster(url: string): void;
635
637
  play(): Promise<void>;
636
638
  pause(): void;
637
639
  togglePlay(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-video-player",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The poster over an embedded video until it plays: when to show it, and
3
+ * with which image.
4
+ *
5
+ * @module core/cover
6
+ */
7
+
8
+ import type { PlayerState } from '../types'
9
+ import { extractYouTubeId } from '../providers/youtube'
10
+
11
+ export interface EmbedCover {
12
+ show: boolean
13
+ /** The poster to draw, or '' for a plain black cover. */
14
+ image: string
15
+ }
16
+
17
+ /**
18
+ * A YouTube or Vimeo embed draws its own chrome before playback (a title bar,
19
+ * a play button, "Watch on YouTube", and on iOS the system's controls for the
20
+ * embed's own video), none of which can be styled from outside the iframe.
21
+ * Until the first play the player covers it with its poster instead: the one
22
+ * the page gave it, or else YouTube's thumbnail for the video.
23
+ *
24
+ * `dismissed` is set when a tap on the cover did not start playback, so the
25
+ * embed's own button is left reachable.
26
+ */
27
+ export function embedCover(
28
+ state: Pick<PlayerState, 'providerType' | 'started' | 'error' | 'poster' | 'src'>,
29
+ dismissed: boolean,
30
+ ): EmbedCover {
31
+ const embed = state.providerType === 'youtube' || state.providerType === 'vimeo'
32
+ if (!embed || state.started || state.error || dismissed)
33
+ return { show: false, image: '' }
34
+
35
+ if (state.poster)
36
+ return { show: true, image: state.poster }
37
+
38
+ const src = state.src
39
+ const url = typeof src === 'string' ? src : src?.src || ''
40
+ const youtubeId = state.providerType === 'youtube' ? extractYouTubeId(url) : null
41
+ return { show: true, image: youtubeId ? `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg` : '' }
42
+ }
@@ -73,6 +73,9 @@ export class VideoPlayerElement extends HTMLElementBase {
73
73
  case 'src':
74
74
  if (value) this._player.setSrc(value)
75
75
  break
76
+ case 'poster':
77
+ this._player.setPoster(value || '')
78
+ break
76
79
  case 'muted':
77
80
  this._player.setMuted(value !== null)
78
81
  break
package/src/player.ts CHANGED
@@ -21,6 +21,7 @@ import { EventEmitter, createKeyboardHandler, createActivityDetector, type Keybo
21
21
  import { createMediaSession, type MediaSessionOptions } from './core/media-session'
22
22
  import { lockOrientation, unlockOrientation } from './core/orientation'
23
23
  import { defaultLoaders, findSourceCandidates } from './providers'
24
+ import { embedCover } from './core/cover'
24
25
 
25
26
  // =============================================================================
26
27
  // Player Class
@@ -40,6 +41,9 @@ export class Player implements IPlayer {
40
41
  private _destroyed = false
41
42
  private _sourceLoadId = 0
42
43
  private _announcer: HTMLElement | null = null
44
+ private _cover: HTMLButtonElement | null = null
45
+ private _coverDismissed = false
46
+ private _coverTimer: ReturnType<typeof setTimeout> | null = null
43
47
 
44
48
  /**
45
49
  * Create a new player instance
@@ -108,8 +112,14 @@ export class Player implements IPlayer {
108
112
  // Create inner structure
109
113
  const mediaContainer = document.createElement('div')
110
114
  mediaContainer.className = 'ts-video-player__container'
115
+ // Sized here, not only in a stylesheet: inside <video-player>'s shadow
116
+ // root no page stylesheet reaches it, and an embed's iframe sizes itself
117
+ // from this box.
118
+ mediaContainer.style.cssText = 'position:relative;width:100%;height:100%'
111
119
  this._el.appendChild(mediaContainer)
112
120
 
121
+ this.setupCover()
122
+
113
123
  // Create ARIA live region for screen reader announcements
114
124
  if (this._options.announcements !== false) {
115
125
  const liveRegion = document.createElement('div')
@@ -128,6 +138,63 @@ export class Player implements IPlayer {
128
138
  _el.__videoPlayer = this
129
139
  }
130
140
 
141
+ /**
142
+ * The poster over an embedded video until it plays.
143
+ *
144
+ * A YouTube or Vimeo embed draws its own chrome before playback: a title
145
+ * bar, a play button, "Watch on YouTube", and on iOS the system's controls
146
+ * for the embed's own video. None of it can be styled from outside the
147
+ * iframe, and it stacks under the player's controls. Until the first play,
148
+ * the player shows its poster and one play button over the embed instead;
149
+ * once the video plays the embed's chrome is gone and the cover lifts.
150
+ */
151
+ private setupCover(): void {
152
+ const cover = document.createElement('button')
153
+ cover.type = 'button'
154
+ cover.className = 'ts-video-player__cover'
155
+ cover.setAttribute('aria-label', `Play ${this._options.title || 'video'}`)
156
+ cover.hidden = true
157
+ cover.style.cssText = 'position:absolute;inset:0;z-index:2;display:none;align-items:center;justify-content:center;width:100%;height:100%;margin:0;padding:0;border:0;border-radius:inherit;background:#000 center/cover no-repeat;cursor:pointer;-webkit-tap-highlight-color:transparent'
158
+ const button = document.createElement('span')
159
+ button.setAttribute('aria-hidden', 'true')
160
+ 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
+ const triangle = document.createElement('span')
162
+ triangle.style.cssText = 'width:22px;height:24px;margin-left:5px;background:#0f172a;clip-path:polygon(0 0,100% 50%,0 100%)'
163
+ button.appendChild(triangle)
164
+ cover.appendChild(button)
165
+ cover.addEventListener('click', (event) => {
166
+ event.stopPropagation()
167
+ void this.play().catch(() => {})
168
+ // 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.
170
+ 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)
176
+ })
177
+ this._el.appendChild(cover)
178
+ this._cover = cover
179
+ this._cleanupFns.push(() => {
180
+ if (this._coverTimer) clearTimeout(this._coverTimer)
181
+ cover.remove()
182
+ })
183
+ }
184
+
185
+ private updateCover(state: PlayerState): void {
186
+ const cover = this._cover
187
+ if (!cover) return
188
+ const { show, image } = embedCover(state, this._coverDismissed)
189
+ if (show) {
190
+ const background = image ? `url("${image.replace(/["\\]/g, '')}")` : 'none'
191
+ if (cover.style.backgroundImage !== background) cover.style.backgroundImage = background
192
+ }
193
+ // `hidden` alone would not do it: the inline display wins over it.
194
+ cover.hidden = !show
195
+ cover.style.display = show ? 'flex' : 'none'
196
+ }
197
+
131
198
  private setupState(): void {
132
199
  // Initial state from options
133
200
  this._store.batch({
@@ -146,6 +213,7 @@ export class Player implements IPlayer {
146
213
  const unsubscribe = this._store.subscribe((state) => {
147
214
  // Update DOM attributes
148
215
  this.updateAttributes(state)
216
+ this.updateCover(state)
149
217
  })
150
218
 
151
219
  this._cleanupFns.push(unsubscribe)
@@ -362,12 +430,14 @@ export class Player implements IPlayer {
362
430
  return
363
431
  }
364
432
 
365
- // Update state
433
+ // Update state. A new source has not played yet, so its cover returns.
434
+ this._coverDismissed = false
366
435
  this._store.batch({
367
436
  sources,
368
437
  src: sources[0],
369
438
  loadingState: 'loading',
370
439
  error: null,
440
+ started: false,
371
441
  })
372
442
 
373
443
  this._events.emit('sourceschange', sources)
@@ -447,6 +517,11 @@ export class Player implements IPlayer {
447
517
  })
448
518
  }
449
519
 
520
+ /** The image shown before playback, and on an embed's cover. */
521
+ setPoster(url: string): void {
522
+ this._store.set('poster', url)
523
+ }
524
+
450
525
  getSrc(): Src | null {
451
526
  return this.state.src
452
527
  }
@@ -274,6 +274,8 @@ export class VimeoProvider extends BaseProvider {
274
274
  this.iframe = this.container_el.querySelector('iframe')
275
275
  if (this.iframe) {
276
276
  this.iframe.className = 'ts-video-player__media'
277
+ // Filling the player, whatever size the provider's API gave the frame.
278
+ this.iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0'
277
279
  }
278
280
 
279
281
  // Setup event listeners
@@ -254,6 +254,8 @@ export class YouTubeProvider extends BaseProvider {
254
254
  // Get iframe reference
255
255
  this.iframe = this.player.getIframe()
256
256
  this.iframe.className = 'ts-video-player__media'
257
+ // Filling the player, whatever size the provider's API gave the frame.
258
+ this.iframe.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0'
257
259
  }
258
260
 
259
261
  private async loadYouTubeAPI(): Promise<void> {
package/src/types.ts CHANGED
@@ -756,6 +756,8 @@ export interface Player {
756
756
  // Source
757
757
  setSrc(src: Src | Src[]): void
758
758
  getSrc(): Src | null
759
+ /** The image shown before playback, and on an embed's cover */
760
+ setPoster(url: string): void
759
761
 
760
762
  // Playback
761
763
  play(): Promise<void>