ts-video-player 0.1.6 → 0.1.8

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.
@@ -6,6 +6,39 @@
6
6
  * @module elements/utils
7
7
  */
8
8
 
9
+ import type { TranslationKey } from '../core/i18n'
10
+ import { englishStrings, getI18n } from '../core/i18n'
11
+
12
+ /**
13
+ * The language a control speaks: the nearest `lang` attribute above it,
14
+ * through shadow roots (`<html lang="de">`, or `lang` on the `<video-player>`
15
+ * itself), then the browser's. A German page gets German button names and
16
+ * screen-reader announcements without configuring the player.
17
+ */
18
+ export function languageOf(el: Node | null | undefined): string {
19
+ let node: Node | null | undefined = el
20
+ while (node) {
21
+ const tagged = (node as Element).closest?.('[lang]')?.getAttribute('lang')
22
+ if (tagged) return tagged.toLowerCase()
23
+ const root = node.getRootNode?.()
24
+ node = root && (root as ShadowRoot).host ? (root as ShadowRoot).host : null
25
+ }
26
+ if (typeof navigator !== 'undefined' && navigator.language) return navigator.language.toLowerCase()
27
+ return 'en'
28
+ }
29
+
30
+ /**
31
+ * A control's words in its language (languageOf): the built-in translations
32
+ * plus any an app added with getI18n().addTranslations(), English for a
33
+ * language or key that has none.
34
+ */
35
+ export function label(el: Node | null | undefined, key: TranslationKey, params?: Record<string, string | number>): string {
36
+ const lang = languageOf(el)
37
+ const i18n = getI18n()
38
+ const text = i18n.getTranslations(lang)?.[key] || i18n.getTranslations(lang.split('-')[0]!)?.[key] || englishStrings[key] || key
39
+ return params ? text.replace(/\{(\w+)\}/g, (match, name) => (params[name] !== undefined ? String(params[name]) : match)) : text
40
+ }
41
+
9
42
  /**
10
43
  * Format time in seconds to MM:SS or HH:MM:SS
11
44
  */
@@ -28,10 +61,11 @@ export function formatTime(seconds: number, guide = 0): string {
28
61
 
29
62
  /**
30
63
  * Format time as a human-readable phrase for screen readers.
31
- * e.g., "1 minute, 30 seconds"
64
+ * e.g., "1 minute, 30 seconds", in the language of `el` when one is given.
32
65
  */
33
- export function formatTimePhrase(seconds: number): string {
34
- if (!isFinite(seconds) || isNaN(seconds)) return '0 seconds'
66
+ export function formatTimePhrase(seconds: number, el?: Node | null): string {
67
+ const say = (n: number, one: TranslationKey, other: TranslationKey): string => label(el, n === 1 ? one : other, { n })
68
+ if (!isFinite(seconds) || isNaN(seconds)) return say(0, 'secondOne', 'secondOther')
35
69
 
36
70
  const s = Math.floor(seconds)
37
71
  const h = Math.floor(s / 3600)
@@ -39,9 +73,9 @@ export function formatTimePhrase(seconds: number): string {
39
73
  const sec = s % 60
40
74
 
41
75
  const parts: string[] = []
42
- if (h > 0) parts.push(`${h} ${h === 1 ? 'hour' : 'hours'}`)
43
- if (m > 0) parts.push(`${m} ${m === 1 ? 'minute' : 'minutes'}`)
44
- if (sec > 0 || parts.length === 0) parts.push(`${sec} ${sec === 1 ? 'second' : 'seconds'}`)
76
+ if (h > 0) parts.push(say(h, 'hourOne', 'hourOther'))
77
+ if (m > 0) parts.push(say(m, 'minuteOne', 'minuteOther'))
78
+ if (sec > 0 || parts.length === 0) parts.push(say(sec, 'secondOne', 'secondOther'))
45
79
 
46
80
  return parts.join(', ')
47
81
  }
@@ -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
@@ -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 { label } from './elements/utils'
25
26
 
26
27
  // =============================================================================
27
28
  // Player Class
@@ -44,6 +45,12 @@ export class Player implements IPlayer {
44
45
  private _cover: HTMLButtonElement | null = null
45
46
  private _coverDismissed = false
46
47
  private _coverTimer: ReturnType<typeof setTimeout> | null = null
48
+ /**
49
+ * A play asked for before there was a provider to ask: a tap on the cover
50
+ * while the source was still loading. Kept and honoured once the source has
51
+ * loaded, so one tap plays the video rather than one tap per loading step.
52
+ */
53
+ private _playRequested = false
47
54
 
48
55
  /**
49
56
  * Create a new player instance
@@ -105,8 +112,8 @@ export class Player implements IPlayer {
105
112
  this._el.classList.add('ts-video-player')
106
113
  this._el.setAttribute('tabindex', '0')
107
114
  this._el.setAttribute('role', 'region')
108
- this._el.setAttribute('aria-roledescription', 'video player')
109
- this._el.setAttribute('aria-label', this._options.title || 'Video player')
115
+ this._el.setAttribute('aria-roledescription', label(this._el, 'videoRole'))
116
+ this._el.setAttribute('aria-label', this._options.title || label(this._el, 'videoPlayer'))
110
117
  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')
111
118
 
112
119
  // Create inner structure
@@ -152,27 +159,46 @@ export class Player implements IPlayer {
152
159
  const cover = document.createElement('button')
153
160
  cover.type = 'button'
154
161
  cover.className = 'ts-video-player__cover'
155
- cover.setAttribute('aria-label', `Play ${this._options.title || 'video'}`)
162
+ cover.setAttribute('aria-label', this._options.title ? label(this._el, 'playTitle', { title: this._options.title }) : label(this._el, 'play'))
156
163
  cover.hidden = true
157
164
  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
165
  const button = document.createElement('span')
159
166
  button.setAttribute('aria-hidden', 'true')
160
167
  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
168
  const triangle = document.createElement('span')
169
+ triangle.className = 'ts-video-player__cover-play'
162
170
  triangle.style.cssText = 'width:22px;height:24px;margin-left:5px;background:#0f172a;clip-path:polygon(0 0,100% 50%,0 100%)'
163
171
  button.appendChild(triangle)
164
172
  cover.appendChild(button)
165
173
  cover.addEventListener('click', (event) => {
166
174
  event.stopPropagation()
175
+ // The embed can take a few seconds to load on a phone: the tap is
176
+ // answered at once, with the play button turning into a spinner.
177
+ if (!cover.dataset.loading) {
178
+ cover.dataset.loading = ''
179
+ triangle.style.display = 'none'
180
+ const ring = document.createElement('span')
181
+ ring.className = 'ts-video-player__cover-spinner'
182
+ ring.style.cssText = 'width:26px;height:26px;border-radius:50%;border:3px solid rgba(15,23,42,0.2);border-top-color:#0f172a'
183
+ button.appendChild(ring)
184
+ ring.animate?.([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], { duration: 800, iterations: Infinity })
185
+ }
167
186
  void this.play().catch(() => {})
168
187
  // 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.
188
+ // did not, get out of the way so the embed's own button can. Only once
189
+ // the embed is ready, though: before then nothing could have started,
190
+ // and lifting the cover early showed the embed half-loaded.
170
191
  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)
192
+ let waits = 0
193
+ const settle = () => {
194
+ this._coverTimer = setTimeout(() => {
195
+ if (this.state.started) return
196
+ if (!this.state.canPlay && ++waits < 10) return settle()
197
+ this._coverDismissed = true
198
+ this.updateCover(this.state)
199
+ }, 2000)
200
+ }
201
+ settle()
176
202
  })
177
203
  this._el.appendChild(cover)
178
204
  this._cover = cover
@@ -190,6 +216,13 @@ export class Player implements IPlayer {
190
216
  const background = image ? `url("${image.replace(/["\\]/g, '')}")` : 'none'
191
217
  if (cover.style.backgroundImage !== background) cover.style.backgroundImage = background
192
218
  }
219
+ // Back to a play button once it is no longer waiting on a tap's play.
220
+ if (!show && cover.dataset.loading !== undefined) {
221
+ delete cover.dataset.loading
222
+ cover.querySelector('.ts-video-player__cover-spinner')?.remove()
223
+ const triangle = cover.querySelector<HTMLElement>('.ts-video-player__cover-play')
224
+ if (triangle) triangle.style.display = ''
225
+ }
193
226
  // `hidden` alone would not do it: the inline display wins over it.
194
227
  cover.hidden = !show
195
228
  cover.style.display = show ? 'flex' : 'none'
@@ -431,7 +464,11 @@ export class Player implements IPlayer {
431
464
  }
432
465
 
433
466
  // Update state. A new source has not played yet, so its cover returns.
467
+ // With autoplay it starts once loaded: a player that follows a chosen
468
+ // video (`src` set again on <video-player>) plays each one it is given,
469
+ // not only the first.
434
470
  this._coverDismissed = false
471
+ this._playRequested = !!this._options.autoplay
435
472
  this._store.batch({
436
473
  sources,
437
474
  src: sources[0],
@@ -498,6 +535,10 @@ export class Player implements IPlayer {
498
535
  mediaType: loader.mediaType(candidate.src),
499
536
  providerType: loader.type,
500
537
  })
538
+ if (this._playRequested) {
539
+ this._playRequested = false
540
+ void this.startRequestedPlay(this._provider)
541
+ }
501
542
  return
502
543
  }
503
544
  catch (error) {
@@ -554,13 +595,13 @@ export class Player implements IPlayer {
554
595
  provider.on('play', () => {
555
596
  this._store.batch({ paused: false, ended: false })
556
597
  this._events.emit('play')
557
- this.announce('Playing')
598
+ this.announce(label(this._el, 'playing'))
558
599
  })
559
600
 
560
601
  provider.on('pause', () => {
561
602
  this._store.set('paused', true)
562
603
  this._events.emit('pause')
563
- this.announce('Paused')
604
+ this.announce(label(this._el, 'paused'))
564
605
  })
565
606
 
566
607
  provider.on('playing', () => {
@@ -597,10 +638,10 @@ export class Player implements IPlayer {
597
638
  this._store.batch({ volume, muted })
598
639
  this._events.emit('volumechange', volume, muted)
599
640
  if (muted) {
600
- this.announce('Muted')
641
+ this.announce(label(this._el, 'muted'))
601
642
  // eslint-disable-next-line style/brace-style
602
643
  } else {
603
- this.announce(`Volume ${Math.round(volume * 100)}%`)
644
+ this.announce(label(this._el, 'volumeLevel', { percent: Math.round(volume * 100) }))
604
645
  }
605
646
  })
606
647
 
@@ -620,7 +661,7 @@ export class Player implements IPlayer {
620
661
  provider.on('ended', () => {
621
662
  this._store.batch({ ended: true, playing: false, playbackState: 'ended' })
622
663
  this._events.emit('ended')
623
- this.announce('Playback ended')
664
+ this.announce(label(this._el, 'ended'))
624
665
 
625
666
  // Handle loop
626
667
  if (this.state.loop) {
@@ -632,7 +673,7 @@ export class Player implements IPlayer {
632
673
  provider.on('error', (error) => {
633
674
  this._store.batch({ loadingState: 'error', error })
634
675
  this._events.emit('error', error)
635
- this.announce(`Error: ${error.message}`)
676
+ this.announce(label(this._el, 'errorMessage', { message: error.message }))
636
677
  })
637
678
 
638
679
  provider.on('statechange', (state) => {
@@ -648,7 +689,7 @@ export class Player implements IPlayer {
648
689
  provider.on('fullscreenchange', (fullscreen) => {
649
690
  this._store.set('fullscreen', fullscreen)
650
691
  this._events.emit('fullscreenchange', fullscreen)
651
- this.announce(fullscreen ? 'Entered fullscreen' : 'Exited fullscreen')
692
+ this.announce(label(this._el, fullscreen ? 'enteredFullscreen' : 'exitedFullscreen'))
652
693
  })
653
694
 
654
695
  provider.on('pipchange', (pip) => {
@@ -666,12 +707,29 @@ export class Player implements IPlayer {
666
707
 
667
708
  // === Playback ===
668
709
 
669
- async play(): Promise<void> {
670
- if (!this._provider) return
710
+ /**
711
+ * Play what was asked for before the source was ready. A browser that
712
+ * refuses sound without a tap (code 1) gets it muted instead, as autoplay
713
+ * is everywhere, and the mute button turns it up.
714
+ */
715
+ private async startRequestedPlay(provider: Provider): Promise<void> {
716
+ await provider.play().catch(() => {})
717
+ if (this._provider !== provider || this.state.error?.code !== 1 || this.state.muted) return
718
+ this._store.set('error', null)
719
+ this.setMuted(true)
720
+ await provider.play().catch(() => {})
721
+ }
722
+
723
+ async play(): Promise<void> {
724
+ if (!this._provider) {
725
+ this._playRequested = true
726
+ return
727
+ }
671
728
  await this._provider.play()
672
729
  }
673
730
 
674
731
  pause(): void {
732
+ this._playRequested = false
675
733
  this._provider?.pause()
676
734
  }
677
735
 
@@ -114,6 +114,7 @@ interface VimeoPlayer {
114
114
  pause(): Promise<void>
115
115
  unload(): Promise<void>
116
116
  destroy(): Promise<void>
117
+ loadVideo(id: number | string | { id: number, h?: string }): Promise<number>
117
118
  getCurrentTime(): Promise<number>
118
119
  setCurrentTime(seconds: number): Promise<number>
119
120
  getDuration(): Promise<number>
@@ -240,10 +241,14 @@ export class VimeoProvider extends BaseProvider {
240
241
  return
241
242
  }
242
243
 
243
- // Destroy existing player
244
+ // A player already here loads the next video in place. Destroying it and
245
+ // making a new one on the same element never became ready again: the SDK
246
+ // keeps its player per element, so a second video (or the same one
247
+ // chosen again) stayed "loading" for good.
244
248
  if (this.player) {
245
- await this.player.destroy()
246
- this.player = null
249
+ await this.player.loadVideo(parseInt(parsed.videoId, 10))
250
+ await this.announceLoaded()
251
+ return
247
252
  }
248
253
 
249
254
  // Build player options
@@ -281,6 +286,13 @@ export class VimeoProvider extends BaseProvider {
281
286
  // Setup event listeners
282
287
  this.attachVimeoEvents()
283
288
 
289
+ await this.announceLoaded()
290
+ }
291
+
292
+ /** What a loaded video tells the player: ready, its length, qualities and shape. */
293
+ private async announceLoaded(): Promise<void> {
294
+ if (!this.player) return
295
+
284
296
  // Emit ready
285
297
  this.events.emit('loadedmetadata')
286
298
  this.events.emit('canplay')
@@ -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 ===