ts-video-player 0.1.4 → 0.1.6

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,7 +666,9 @@ 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
+ this.iframe.style.cssText = "position:absolute;left:0;top:-100%;width:100%;height:300%;border:0";
670
+ if (this.iframe.parentElement)
671
+ this.iframe.parentElement.style.overflow = "hidden";
670
672
  }
671
673
  async loadYouTubeAPI() {
672
674
  if (window.YT?.Player)
@@ -2009,6 +2011,20 @@ function getPreconnectHints2(src, loaders = defaultLoaders2) {
2009
2011
  return loader?.preconnect?.() || [];
2010
2012
  }
2011
2013
 
2014
+ // src/core/cover.ts
2015
+ function embedCover(state, dismissed) {
2016
+ const embed = state.providerType === "youtube" || state.providerType === "vimeo";
2017
+ const waiting = !state.started || state.ended;
2018
+ if (!embed || !waiting || state.error || dismissed)
2019
+ return { show: false, image: "" };
2020
+ if (state.poster)
2021
+ return { show: true, image: state.poster };
2022
+ const src = state.src;
2023
+ const url = typeof src === "string" ? src : src?.src || "";
2024
+ const youtubeId = state.providerType === "youtube" ? extractYouTubeId2(url) : null;
2025
+ return { show: true, image: youtubeId ? `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg` : "" };
2026
+ }
2027
+
2012
2028
  // src/player.ts
2013
2029
  class Player2 {
2014
2030
  _el;
@@ -2021,6 +2037,9 @@ class Player2 {
2021
2037
  _destroyed = false;
2022
2038
  _sourceLoadId = 0;
2023
2039
  _announcer = null;
2040
+ _cover = null;
2041
+ _coverDismissed = false;
2042
+ _coverTimer = null;
2024
2043
  constructor(container, options = {}) {
2025
2044
  if (typeof container === "string") {
2026
2045
  const el = document.querySelector(container);
@@ -2068,6 +2087,7 @@ class Player2 {
2068
2087
  mediaContainer.className = "ts-video-player__container";
2069
2088
  mediaContainer.style.cssText = "position:relative;width:100%;height:100%";
2070
2089
  this._el.appendChild(mediaContainer);
2090
+ this.setupCover();
2071
2091
  if (this._options.announcements !== false) {
2072
2092
  const liveRegion = document.createElement("div");
2073
2093
  liveRegion.className = "ts-video-player__announcer";
@@ -2081,6 +2101,53 @@ class Player2 {
2081
2101
  const _el = this._el;
2082
2102
  _el.__videoPlayer = this;
2083
2103
  }
2104
+ setupCover() {
2105
+ const cover = document.createElement("button");
2106
+ cover.type = "button";
2107
+ cover.className = "ts-video-player__cover";
2108
+ cover.setAttribute("aria-label", `Play ${this._options.title || "video"}`);
2109
+ cover.hidden = true;
2110
+ 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";
2111
+ const button = document.createElement("span");
2112
+ button.setAttribute("aria-hidden", "true");
2113
+ 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
+ const triangle = document.createElement("span");
2115
+ triangle.style.cssText = "width:22px;height:24px;margin-left:5px;background:#0f172a;clip-path:polygon(0 0,100% 50%,0 100%)";
2116
+ button.appendChild(triangle);
2117
+ cover.appendChild(button);
2118
+ cover.addEventListener("click", (event) => {
2119
+ event.stopPropagation();
2120
+ this.play().catch(() => {});
2121
+ if (this._coverTimer)
2122
+ 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);
2129
+ });
2130
+ this._el.appendChild(cover);
2131
+ this._cover = cover;
2132
+ this._cleanupFns.push(() => {
2133
+ if (this._coverTimer)
2134
+ clearTimeout(this._coverTimer);
2135
+ cover.remove();
2136
+ });
2137
+ }
2138
+ updateCover(state) {
2139
+ const cover = this._cover;
2140
+ if (!cover)
2141
+ return;
2142
+ const { show, image } = embedCover(state, this._coverDismissed);
2143
+ if (show) {
2144
+ const background = image ? `url("${image.replace(/["\\]/g, "")}")` : "none";
2145
+ if (cover.style.backgroundImage !== background)
2146
+ cover.style.backgroundImage = background;
2147
+ }
2148
+ cover.hidden = !show;
2149
+ cover.style.display = show ? "flex" : "none";
2150
+ }
2084
2151
  setupState() {
2085
2152
  this._store.batch({
2086
2153
  volume: this._options.volume ?? 1,
@@ -2095,6 +2162,7 @@ class Player2 {
2095
2162
  });
2096
2163
  const unsubscribe = this._store.subscribe((state) => {
2097
2164
  this.updateAttributes(state);
2165
+ this.updateCover(state);
2098
2166
  });
2099
2167
  this._cleanupFns.push(unsubscribe);
2100
2168
  }
@@ -2238,11 +2306,13 @@ class Player2 {
2238
2306
  if (sources.length === 0) {
2239
2307
  return;
2240
2308
  }
2309
+ this._coverDismissed = false;
2241
2310
  this._store.batch({
2242
2311
  sources,
2243
2312
  src: sources[0],
2244
2313
  loadingState: "loading",
2245
- error: null
2314
+ error: null,
2315
+ started: false
2246
2316
  });
2247
2317
  this._events.emit("sourceschange", sources);
2248
2318
  const candidates = findSourceCandidates(sources, this._loaders);
@@ -2311,6 +2381,9 @@ class Player2 {
2311
2381
  error: { code: 4, message: "Failed to load all compatible sources", details: lastError }
2312
2382
  });
2313
2383
  }
2384
+ setPoster(url) {
2385
+ this._store.set("poster", url);
2386
+ }
2314
2387
  getSrc() {
2315
2388
  return this.state.src;
2316
2389
  }
@@ -2629,6 +2702,9 @@ class VideoPlayerElement2 extends HTMLElementBase {
2629
2702
  if (value)
2630
2703
  this._player.setSrc(value);
2631
2704
  break;
2705
+ case "poster":
2706
+ this._player.setPoster(value || "");
2707
+ break;
2632
2708
  case "muted":
2633
2709
  this._player.setMuted(value !== null);
2634
2710
  break;
@@ -0,0 +1,24 @@
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, and again once it ends (where YouTube shows other
18
+ * videos), the player covers it with its poster instead: the one the page
19
+ * gave it, or else YouTube's thumbnail for the video.
20
+ *
21
+ * `dismissed` is set when a tap on the cover did not start playback, so the
22
+ * embed's own button is left reachable.
23
+ */
24
+ export declare function embedCover(state: Pick<PlayerState, 'providerType' | 'started' | 'ended' | 'error' | 'poster' | 'src'>, dismissed: boolean): EmbedCover;
@@ -23,7 +23,7 @@ import {
23
23
  MediaErrorDisplay2,
24
24
  MediaRemotePlaybackButton2,
25
25
  registerElements2
26
- } from "../chunk-hfd9k46d.js";
26
+ } from "../chunk-fvz33zn4.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-hfd9k46d.js";
54
+ } from "./chunk-fvz33zn4.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.4",
3
+ "version": "0.1.6",
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,46 @@
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, and again once it ends (where YouTube shows other
22
+ * videos), the player covers it with its poster instead: the one the page
23
+ * gave it, or else YouTube's thumbnail for the video.
24
+ *
25
+ * `dismissed` is set when a tap on the cover did not start playback, so the
26
+ * embed's own button is left reachable.
27
+ */
28
+ export function embedCover(
29
+ state: Pick<PlayerState, 'providerType' | 'started' | 'ended' | 'error' | 'poster' | 'src'>,
30
+ dismissed: boolean,
31
+ ): EmbedCover {
32
+ const embed = state.providerType === 'youtube' || state.providerType === 'vimeo'
33
+ // Back over the embed when the video ends, where YouTube would otherwise
34
+ // fill the frame with suggestions of other videos.
35
+ const waiting = !state.started || state.ended
36
+ if (!embed || !waiting || state.error || dismissed)
37
+ return { show: false, image: '' }
38
+
39
+ if (state.poster)
40
+ return { show: true, image: state.poster }
41
+
42
+ const src = state.src
43
+ const url = typeof src === 'string' ? src : src?.src || ''
44
+ const youtubeId = state.providerType === 'youtube' ? extractYouTubeId(url) : null
45
+ return { show: true, image: youtubeId ? `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg` : '' }
46
+ }
@@ -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
@@ -114,6 +118,8 @@ export class Player implements IPlayer {
114
118
  mediaContainer.style.cssText = 'position:relative;width:100%;height:100%'
115
119
  this._el.appendChild(mediaContainer)
116
120
 
121
+ this.setupCover()
122
+
117
123
  // Create ARIA live region for screen reader announcements
118
124
  if (this._options.announcements !== false) {
119
125
  const liveRegion = document.createElement('div')
@@ -132,6 +138,63 @@ export class Player implements IPlayer {
132
138
  _el.__videoPlayer = this
133
139
  }
134
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
+
135
198
  private setupState(): void {
136
199
  // Initial state from options
137
200
  this._store.batch({
@@ -150,6 +213,7 @@ export class Player implements IPlayer {
150
213
  const unsubscribe = this._store.subscribe((state) => {
151
214
  // Update DOM attributes
152
215
  this.updateAttributes(state)
216
+ this.updateCover(state)
153
217
  })
154
218
 
155
219
  this._cleanupFns.push(unsubscribe)
@@ -366,12 +430,14 @@ export class Player implements IPlayer {
366
430
  return
367
431
  }
368
432
 
369
- // Update state
433
+ // Update state. A new source has not played yet, so its cover returns.
434
+ this._coverDismissed = false
370
435
  this._store.batch({
371
436
  sources,
372
437
  src: sources[0],
373
438
  loadingState: 'loading',
374
439
  error: null,
440
+ started: false,
375
441
  })
376
442
 
377
443
  this._events.emit('sourceschange', sources)
@@ -451,6 +517,11 @@ export class Player implements IPlayer {
451
517
  })
452
518
  }
453
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
+
454
525
  getSrc(): Src | null {
455
526
  return this.state.src
456
527
  }
@@ -254,8 +254,14 @@ 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
+ // Three times the player's height, centred, and clipped by the player.
258
+ // YouTube letterboxes the video in the middle of its frame, so the video
259
+ // still fills the player, while the chrome it draws at the frame's edges
260
+ // during playback (the title bar and channel avatar at the top, the
261
+ // YouTube logo and "More videos" at the bottom) falls outside the clip.
262
+ // It cannot be styled from outside the iframe any other way.
263
+ this.iframe.style.cssText = 'position:absolute;left:0;top:-100%;width:100%;height:300%;border:0'
264
+ if (this.iframe.parentElement) this.iframe.parentElement.style.overflow = 'hidden'
259
265
  }
260
266
 
261
267
  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>