ts-video-player 0.1.7 → 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
  }
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
@@ -111,8 +112,8 @@ export class Player implements IPlayer {
111
112
  this._el.classList.add('ts-video-player')
112
113
  this._el.setAttribute('tabindex', '0')
113
114
  this._el.setAttribute('role', 'region')
114
- this._el.setAttribute('aria-roledescription', 'video player')
115
- 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'))
116
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')
117
118
 
118
119
  // Create inner structure
@@ -158,7 +159,7 @@ export class Player implements IPlayer {
158
159
  const cover = document.createElement('button')
159
160
  cover.type = 'button'
160
161
  cover.className = 'ts-video-player__cover'
161
- 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'))
162
163
  cover.hidden = true
163
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'
164
165
  const button = document.createElement('span')
@@ -463,8 +464,11 @@ export class Player implements IPlayer {
463
464
  }
464
465
 
465
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.
466
470
  this._coverDismissed = false
467
- this._playRequested = false
471
+ this._playRequested = !!this._options.autoplay
468
472
  this._store.batch({
469
473
  sources,
470
474
  src: sources[0],
@@ -533,7 +537,7 @@ export class Player implements IPlayer {
533
537
  })
534
538
  if (this._playRequested) {
535
539
  this._playRequested = false
536
- void this._provider.play().catch(() => {})
540
+ void this.startRequestedPlay(this._provider)
537
541
  }
538
542
  return
539
543
  }
@@ -591,13 +595,13 @@ export class Player implements IPlayer {
591
595
  provider.on('play', () => {
592
596
  this._store.batch({ paused: false, ended: false })
593
597
  this._events.emit('play')
594
- this.announce('Playing')
598
+ this.announce(label(this._el, 'playing'))
595
599
  })
596
600
 
597
601
  provider.on('pause', () => {
598
602
  this._store.set('paused', true)
599
603
  this._events.emit('pause')
600
- this.announce('Paused')
604
+ this.announce(label(this._el, 'paused'))
601
605
  })
602
606
 
603
607
  provider.on('playing', () => {
@@ -634,10 +638,10 @@ export class Player implements IPlayer {
634
638
  this._store.batch({ volume, muted })
635
639
  this._events.emit('volumechange', volume, muted)
636
640
  if (muted) {
637
- this.announce('Muted')
641
+ this.announce(label(this._el, 'muted'))
638
642
  // eslint-disable-next-line style/brace-style
639
643
  } else {
640
- this.announce(`Volume ${Math.round(volume * 100)}%`)
644
+ this.announce(label(this._el, 'volumeLevel', { percent: Math.round(volume * 100) }))
641
645
  }
642
646
  })
643
647
 
@@ -657,7 +661,7 @@ export class Player implements IPlayer {
657
661
  provider.on('ended', () => {
658
662
  this._store.batch({ ended: true, playing: false, playbackState: 'ended' })
659
663
  this._events.emit('ended')
660
- this.announce('Playback ended')
664
+ this.announce(label(this._el, 'ended'))
661
665
 
662
666
  // Handle loop
663
667
  if (this.state.loop) {
@@ -669,7 +673,7 @@ export class Player implements IPlayer {
669
673
  provider.on('error', (error) => {
670
674
  this._store.batch({ loadingState: 'error', error })
671
675
  this._events.emit('error', error)
672
- this.announce(`Error: ${error.message}`)
676
+ this.announce(label(this._el, 'errorMessage', { message: error.message }))
673
677
  })
674
678
 
675
679
  provider.on('statechange', (state) => {
@@ -685,7 +689,7 @@ export class Player implements IPlayer {
685
689
  provider.on('fullscreenchange', (fullscreen) => {
686
690
  this._store.set('fullscreen', fullscreen)
687
691
  this._events.emit('fullscreenchange', fullscreen)
688
- this.announce(fullscreen ? 'Entered fullscreen' : 'Exited fullscreen')
692
+ this.announce(label(this._el, fullscreen ? 'enteredFullscreen' : 'exitedFullscreen'))
689
693
  })
690
694
 
691
695
  provider.on('pipchange', (pip) => {
@@ -703,7 +707,20 @@ export class Player implements IPlayer {
703
707
 
704
708
  // === Playback ===
705
709
 
706
- async play(): Promise<void> {
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> {
707
724
  if (!this._provider) {
708
725
  this._playRequested = true
709
726
  return
@@ -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')