ts-video-player 0.1.7 → 0.1.9

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.
@@ -7,7 +7,7 @@
7
7
  * @module elements/media-settings-menu
8
8
  */
9
9
 
10
- import { resolvePlayer } from './utils'
10
+ import { label as say, resolvePlayer } from './utils'
11
11
  import { computePosition } from '../core/popover'
12
12
 
13
13
  const GEAR_ICON = 'M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z'
@@ -54,9 +54,11 @@ export class MediaSettingsMenu extends HTMLElementBase {
54
54
  .item[aria-checked="true"]::before { content: '\\2713'; }
55
55
  .item[aria-checked="false"]::before { content: ''; min-width: 12px; display: inline-block; }
56
56
  .back { font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.2); }
57
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
58
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
57
59
  </style>
58
60
  <button class="trigger" part="trigger" type="button"
59
- aria-label="Settings" aria-haspopup="true" aria-expanded="false">
61
+ aria-label="${say(this, 'settings')}" aria-haspopup="true" aria-expanded="false">
60
62
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
61
63
  <path d="${GEAR_ICON}"/>
62
64
  </svg>
@@ -177,7 +179,7 @@ export class MediaSettingsMenu extends HTMLElementBase {
177
179
  const state = player.state
178
180
 
179
181
  // Speed
180
- this.addItem(menu, `Speed: ${state.playbackRate}x`, () => {
182
+ this.addItem(menu, `${say(this, 'speed')}: ${state.playbackRate}x`, () => {
181
183
  this._currentPanel = 'speed'
182
184
  this.renderPanel()
183
185
  })
@@ -185,8 +187,8 @@ export class MediaSettingsMenu extends HTMLElementBase {
185
187
  // Quality
186
188
  if (state.qualities.length > 0) {
187
189
  const current = state.qualities.find((q: any) => q.selected)
188
- const label = state.autoQuality ? 'Auto' : (current ? `${current.height}p` : 'Auto')
189
- this.addItem(menu, `Quality: ${label}`, () => {
190
+ const label = state.autoQuality ? say(this, 'auto') : (current ? `${current.height}p` : say(this, 'auto'))
191
+ this.addItem(menu, `${say(this, 'quality')}: ${label}`, () => {
190
192
  this._currentPanel = 'quality'
191
193
  this.renderPanel()
192
194
  })
@@ -195,7 +197,7 @@ export class MediaSettingsMenu extends HTMLElementBase {
195
197
  // Captions
196
198
  if (state.textTracks.length > 0) {
197
199
  const showing = state.textTracks.find((t: any) => t.mode === 'showing')
198
- this.addItem(menu, `Captions: ${showing?.label || 'Off'}`, () => {
200
+ this.addItem(menu, `${say(this, 'captions')}: ${showing?.label || say(this, 'captionsOff')}`, () => {
199
201
  this._currentPanel = 'captions'
200
202
  this.renderPanel()
201
203
  })
@@ -203,7 +205,7 @@ export class MediaSettingsMenu extends HTMLElementBase {
203
205
 
204
206
  if (state.audioTracks.length > 1) {
205
207
  const selected = state.audioTracks.find((track: { selected: boolean }) => track.selected)
206
- this.addItem(menu, `Audio: ${selected?.label || 'Default'}`, () => {
208
+ this.addItem(menu, `${say(this, 'audio')}: ${selected?.label || say(this, 'defaultTrack')}`, () => {
207
209
  this._currentPanel = 'audio'
208
210
  this.renderPanel()
209
211
  })
@@ -211,7 +213,7 @@ export class MediaSettingsMenu extends HTMLElementBase {
211
213
  }
212
214
 
213
215
  private renderSpeed(menu: Element, player: any): void {
214
- this.addItem(menu, 'Back', () => {
216
+ this.addItem(menu, say(this, 'back'), () => {
215
217
  this._currentPanel = 'main'
216
218
  this.renderPanel()
217
219
  }, false, true)
@@ -226,13 +228,13 @@ export class MediaSettingsMenu extends HTMLElementBase {
226
228
  }
227
229
 
228
230
  private renderQuality(menu: Element, player: any): void {
229
- this.addItem(menu, 'Back', () => {
231
+ this.addItem(menu, say(this, 'back'), () => {
230
232
  this._currentPanel = 'main'
231
233
  this.renderPanel()
232
234
  }, false, true)
233
235
 
234
236
  // Auto option
235
- this.addItem(menu, 'Auto', () => {
237
+ this.addItem(menu, say(this, 'auto'), () => {
236
238
  player.setQuality('auto')
237
239
  this.close()
238
240
  }, player.state.autoQuality)
@@ -246,14 +248,14 @@ export class MediaSettingsMenu extends HTMLElementBase {
246
248
  }
247
249
 
248
250
  private renderCaptions(menu: Element, player: any): void {
249
- this.addItem(menu, 'Back', () => {
251
+ this.addItem(menu, say(this, 'back'), () => {
250
252
  this._currentPanel = 'main'
251
253
  this.renderPanel()
252
254
  }, false, true)
253
255
 
254
256
  // Off option
255
257
  const anyShowing = player.state.textTracks.some((t: any) => t.mode === 'showing')
256
- this.addItem(menu, 'Off', () => {
258
+ this.addItem(menu, say(this, 'captionsOff'), () => {
257
259
  for (const t of player.state.textTracks) {
258
260
  if (t.mode === 'showing') player.setTextTrack(t.id, 'disabled')
259
261
  }
@@ -271,13 +273,13 @@ export class MediaSettingsMenu extends HTMLElementBase {
271
273
  }
272
274
 
273
275
  private renderAudio(menu: Element, player: any): void {
274
- this.addItem(menu, 'Back', () => {
276
+ this.addItem(menu, say(this, 'back'), () => {
275
277
  this._currentPanel = 'main'
276
278
  this.renderPanel()
277
279
  }, false, true)
278
280
 
279
281
  for (const track of player.state.audioTracks) {
280
- this.addItem(menu, track.label || track.language || 'Audio track', () => {
282
+ this.addItem(menu, track.label || track.language || say(this, 'audioTrack'), () => {
281
283
  player.setAudioTrack(track.id)
282
284
  this.close()
283
285
  }, track.selected)
@@ -39,7 +39,7 @@ export class MediaTimeDisplay extends HTMLElementBase {
39
39
  time { color: inherit; }
40
40
  .separator { margin: 0 2px; }
41
41
  </style>
42
- <time part="time" datetime="PT0S" aria-valuetext="0 seconds">0:00</time>
42
+ <time part="time" datetime="PT0S" aria-valuetext="${formatTimePhrase(0, this)}">0:00</time>
43
43
  `
44
44
  }
45
45
 
@@ -94,6 +94,6 @@ export class MediaTimeDisplay extends HTMLElementBase {
94
94
  }
95
95
 
96
96
  timeEl.setAttribute('datetime', toISODuration(seconds))
97
- timeEl.setAttribute('aria-valuetext', formatTimePhrase(seconds))
97
+ timeEl.setAttribute('aria-valuetext', formatTimePhrase(seconds, this))
98
98
  }
99
99
  }
@@ -6,7 +6,7 @@
6
6
  * @module elements/media-volume-slider
7
7
  */
8
8
 
9
- import { resolvePlayer } from './utils'
9
+ import { label, resolvePlayer } from './utils'
10
10
 
11
11
  import { HTMLElementBase } from './base'
12
12
 
@@ -42,10 +42,12 @@ export class MediaVolumeSlider extends HTMLElementBase {
42
42
  transition: transform 0.1s;
43
43
  }
44
44
  .container:hover .thumb { transform: translate(-50%, -50%) scale(1.2); }
45
+ /* A finger needs more than the 20px a mouse does. */
46
+ @media (pointer: coarse) { .container { height: 36px; } .thumb { width: 18px; height: 18px; } }
45
47
  </style>
46
48
  <div class="container" part="container" role="slider"
47
49
  aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"
48
- aria-label="Volume" tabindex="0">
50
+ aria-label="${label(this, 'volume')}" tabindex="0">
49
51
  <div class="track" part="track">
50
52
  <div class="fill" part="fill"></div>
51
53
  <div class="thumb" part="thumb"></div>
@@ -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
  }
@@ -31,7 +31,9 @@ export class VideoPlayerElement extends HTMLElementBase {
31
31
  if (this._player) return
32
32
 
33
33
  // Create shadow DOM
34
- const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' })
34
+ // delegatesFocus: focusing <video-player> (a dialog's first focus, an
35
+ // `autofocus`) puts focus on the player itself, where its keys work.
36
+ const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open', delegatesFocus: true })
35
37
 
36
38
  // Container
37
39
  this._container = document.createElement('div')
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')
@@ -262,6 +263,8 @@ export class Player implements IPlayer {
262
263
  toggleCaptions: () => this.toggleCaptions(),
263
264
  seekBackward: () => this.seekBy(-5),
264
265
  seekForward: () => this.seekBy(5),
266
+ seekBackwardLong: () => this.seekBy(-10),
267
+ seekForwardLong: () => this.seekBy(10),
265
268
  volumeUp: () => this.setVolume(Math.min(1, this.state.volume + 0.1)),
266
269
  volumeDown: () => this.setVolume(Math.max(0, this.state.volume - 0.1)),
267
270
  speedUp: () => this.setPlaybackRate(Math.min(4, this.state.playbackRate + 0.25)),
@@ -463,8 +466,11 @@ export class Player implements IPlayer {
463
466
  }
464
467
 
465
468
  // Update state. A new source has not played yet, so its cover returns.
469
+ // With autoplay it starts once loaded: a player that follows a chosen
470
+ // video (`src` set again on <video-player>) plays each one it is given,
471
+ // not only the first.
466
472
  this._coverDismissed = false
467
- this._playRequested = false
473
+ this._playRequested = !!this._options.autoplay
468
474
  this._store.batch({
469
475
  sources,
470
476
  src: sources[0],
@@ -533,7 +539,7 @@ export class Player implements IPlayer {
533
539
  })
534
540
  if (this._playRequested) {
535
541
  this._playRequested = false
536
- void this._provider.play().catch(() => {})
542
+ void this.startRequestedPlay(this._provider)
537
543
  }
538
544
  return
539
545
  }
@@ -591,13 +597,16 @@ export class Player implements IPlayer {
591
597
  provider.on('play', () => {
592
598
  this._store.batch({ paused: false, ended: false })
593
599
  this._events.emit('play')
594
- this.announce('Playing')
600
+ this.announce(label(this._el, 'playing'))
595
601
  })
596
602
 
597
603
  provider.on('pause', () => {
598
- this._store.set('paused', true)
604
+ // Not playing any more either: the controls hide only while playing,
605
+ // and a paused player that still said `playing` hid them over a still
606
+ // frame, and told everyone reading state.playing it was still going.
607
+ this._store.batch({ paused: true, playing: false, playbackState: 'paused' })
599
608
  this._events.emit('pause')
600
- this.announce('Paused')
609
+ this.announce(label(this._el, 'paused'))
601
610
  })
602
611
 
603
612
  provider.on('playing', () => {
@@ -634,10 +643,10 @@ export class Player implements IPlayer {
634
643
  this._store.batch({ volume, muted })
635
644
  this._events.emit('volumechange', volume, muted)
636
645
  if (muted) {
637
- this.announce('Muted')
646
+ this.announce(label(this._el, 'muted'))
638
647
  // eslint-disable-next-line style/brace-style
639
648
  } else {
640
- this.announce(`Volume ${Math.round(volume * 100)}%`)
649
+ this.announce(label(this._el, 'volumeLevel', { percent: Math.round(volume * 100) }))
641
650
  }
642
651
  })
643
652
 
@@ -657,7 +666,7 @@ export class Player implements IPlayer {
657
666
  provider.on('ended', () => {
658
667
  this._store.batch({ ended: true, playing: false, playbackState: 'ended' })
659
668
  this._events.emit('ended')
660
- this.announce('Playback ended')
669
+ this.announce(label(this._el, 'ended'))
661
670
 
662
671
  // Handle loop
663
672
  if (this.state.loop) {
@@ -669,7 +678,7 @@ export class Player implements IPlayer {
669
678
  provider.on('error', (error) => {
670
679
  this._store.batch({ loadingState: 'error', error })
671
680
  this._events.emit('error', error)
672
- this.announce(`Error: ${error.message}`)
681
+ this.announce(label(this._el, 'errorMessage', { message: error.message }))
673
682
  })
674
683
 
675
684
  provider.on('statechange', (state) => {
@@ -685,7 +694,7 @@ export class Player implements IPlayer {
685
694
  provider.on('fullscreenchange', (fullscreen) => {
686
695
  this._store.set('fullscreen', fullscreen)
687
696
  this._events.emit('fullscreenchange', fullscreen)
688
- this.announce(fullscreen ? 'Entered fullscreen' : 'Exited fullscreen')
697
+ this.announce(label(this._el, fullscreen ? 'enteredFullscreen' : 'exitedFullscreen'))
689
698
  })
690
699
 
691
700
  provider.on('pipchange', (pip) => {
@@ -703,7 +712,20 @@ export class Player implements IPlayer {
703
712
 
704
713
  // === Playback ===
705
714
 
706
- async play(): Promise<void> {
715
+ /**
716
+ * Play what was asked for before the source was ready. A browser that
717
+ * refuses sound without a tap (code 1) gets it muted instead, as autoplay
718
+ * is everywhere, and the mute button turns it up.
719
+ */
720
+ private async startRequestedPlay(provider: Provider): Promise<void> {
721
+ await provider.play().catch(() => {})
722
+ if (this._provider !== provider || this.state.error?.code !== 1 || this.state.muted) return
723
+ this._store.set('error', null)
724
+ this.setMuted(true)
725
+ await provider.play().catch(() => {})
726
+ }
727
+
728
+ async play(): Promise<void> {
707
729
  if (!this._provider) {
708
730
  this._playRequested = true
709
731
  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')