ts-video-player 0.1.4 → 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.
@@ -2009,6 +2009,19 @@ function getPreconnectHints2(src, loaders = defaultLoaders2) {
2009
2009
  return loader?.preconnect?.() || [];
2010
2010
  }
2011
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
+
2012
2025
  // src/player.ts
2013
2026
  class Player2 {
2014
2027
  _el;
@@ -2021,6 +2034,9 @@ class Player2 {
2021
2034
  _destroyed = false;
2022
2035
  _sourceLoadId = 0;
2023
2036
  _announcer = null;
2037
+ _cover = null;
2038
+ _coverDismissed = false;
2039
+ _coverTimer = null;
2024
2040
  constructor(container, options = {}) {
2025
2041
  if (typeof container === "string") {
2026
2042
  const el = document.querySelector(container);
@@ -2068,6 +2084,7 @@ class Player2 {
2068
2084
  mediaContainer.className = "ts-video-player__container";
2069
2085
  mediaContainer.style.cssText = "position:relative;width:100%;height:100%";
2070
2086
  this._el.appendChild(mediaContainer);
2087
+ this.setupCover();
2071
2088
  if (this._options.announcements !== false) {
2072
2089
  const liveRegion = document.createElement("div");
2073
2090
  liveRegion.className = "ts-video-player__announcer";
@@ -2081,6 +2098,53 @@ class Player2 {
2081
2098
  const _el = this._el;
2082
2099
  _el.__videoPlayer = this;
2083
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
+ }
2084
2148
  setupState() {
2085
2149
  this._store.batch({
2086
2150
  volume: this._options.volume ?? 1,
@@ -2095,6 +2159,7 @@ class Player2 {
2095
2159
  });
2096
2160
  const unsubscribe = this._store.subscribe((state) => {
2097
2161
  this.updateAttributes(state);
2162
+ this.updateCover(state);
2098
2163
  });
2099
2164
  this._cleanupFns.push(unsubscribe);
2100
2165
  }
@@ -2238,11 +2303,13 @@ class Player2 {
2238
2303
  if (sources.length === 0) {
2239
2304
  return;
2240
2305
  }
2306
+ this._coverDismissed = false;
2241
2307
  this._store.batch({
2242
2308
  sources,
2243
2309
  src: sources[0],
2244
2310
  loadingState: "loading",
2245
- error: null
2311
+ error: null,
2312
+ started: false
2246
2313
  });
2247
2314
  this._events.emit("sourceschange", sources);
2248
2315
  const candidates = findSourceCandidates(sources, this._loaders);
@@ -2311,6 +2378,9 @@ class Player2 {
2311
2378
  error: { code: 4, message: "Failed to load all compatible sources", details: lastError }
2312
2379
  });
2313
2380
  }
2381
+ setPoster(url) {
2382
+ this._store.set("poster", url);
2383
+ }
2314
2384
  getSrc() {
2315
2385
  return this.state.src;
2316
2386
  }
@@ -2629,6 +2699,9 @@ class VideoPlayerElement2 extends HTMLElementBase {
2629
2699
  if (value)
2630
2700
  this._player.setSrc(value);
2631
2701
  break;
2702
+ case "poster":
2703
+ this._player.setPoster(value || "");
2704
+ break;
2632
2705
  case "muted":
2633
2706
  this._player.setMuted(value !== null);
2634
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-hfd9k46d.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-hfd9k46d.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.4",
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
@@ -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
  }
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>