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.
package/dist/player.d.ts CHANGED
@@ -67,6 +67,12 @@ export declare class Player implements IPlayer {
67
67
  setPoster(url: string): void;
68
68
  getSrc(): Src | null;
69
69
  private attachProviderEvents;
70
+ /**
71
+ * Play what was asked for before the source was ready. A browser that
72
+ * refuses sound without a tap (code 1) gets it muted instead, as autoplay
73
+ * is everywhere, and the mute button turns it up.
74
+ */
75
+ private startRequestedPlay;
70
76
  play(): Promise<void>;
71
77
  pause(): void;
72
78
  togglePlay(): void;
@@ -48,6 +48,10 @@ interface VimeoPlayer {
48
48
  pause(): Promise<void>;
49
49
  unload(): Promise<void>;
50
50
  destroy(): Promise<void>;
51
+ loadVideo(id: number | string | {
52
+ id: number;
53
+ h?: string;
54
+ }): Promise<number>;
51
55
  getCurrentTime(): Promise<number>;
52
56
  setCurrentTime(seconds: number): Promise<number>;
53
57
  getDuration(): Promise<number>;
@@ -118,6 +122,8 @@ export declare class VimeoProvider extends BaseProvider {
118
122
  protected cleanup(): void;
119
123
  canPlay(src: Src): boolean;
120
124
  load(src: Src): Promise<void>;
125
+ /** What a loaded video tells the player: ready, its length, qualities and shape. */
126
+ private announceLoaded;
121
127
  private attachVimeoEvents;
122
128
  private updateQualities;
123
129
  play(): Promise<void>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-video-player",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",
@@ -208,7 +208,12 @@ export class MediaEventsNormalizer {
208
208
  export function onFullscreenChange(handler: (fullscreen: boolean) => void): () => void {
209
209
  const events = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
210
210
 
211
- const listener = () => {
211
+ const listener = (event?: Event) => {
212
+ // The browser's own change only. <video-player> re-dispatches the player's
213
+ // events on itself, bubbling and composed, under the same names; its
214
+ // "fullscreenchange" reached this document listener, which announced it
215
+ // again, and the two fed each other until the stack overflowed.
216
+ if (event && typeof CustomEvent !== 'undefined' && event instanceof CustomEvent) return
212
217
  const fullscreen = !!(
213
218
  document.fullscreenElement ||
214
219
  (document as any).webkitFullscreenElement ||
@@ -261,8 +266,8 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: Record<string, string> = {
261
266
  c: 'toggleCaptions',
262
267
  ArrowLeft: 'seekBackward',
263
268
  ArrowRight: 'seekForward',
264
- j: 'seekBackward',
265
- l: 'seekForward',
269
+ j: 'seekBackwardLong',
270
+ l: 'seekForwardLong',
266
271
  ArrowUp: 'volumeUp',
267
272
  ArrowDown: 'volumeDown',
268
273
  '>': 'speedUp',
@@ -292,6 +297,9 @@ export interface KeyboardActions {
292
297
  toggleCaptions(): void
293
298
  seekBackward(): void
294
299
  seekForward(): void
300
+ /** J and L: ten seconds, as YouTube's keys go (the arrows are five). Falls back to seekBackward/seekForward. */
301
+ seekBackwardLong?(): void
302
+ seekForwardLong?(): void
295
303
  volumeUp(): void
296
304
  volumeDown(): void
297
305
  speedUp(): void
@@ -351,6 +359,14 @@ export function createKeyboardHandler(
351
359
  case 'seekForward':
352
360
  actions.seekForward()
353
361
  break
362
+ case 'seekBackwardLong':
363
+ if (actions.seekBackwardLong) actions.seekBackwardLong()
364
+ else actions.seekBackward()
365
+ break
366
+ case 'seekForwardLong':
367
+ if (actions.seekForwardLong) actions.seekForwardLong()
368
+ else actions.seekForward()
369
+ break
354
370
  case 'volumeUp':
355
371
  actions.volumeUp()
356
372
  break
package/src/core/i18n.ts CHANGED
@@ -74,6 +74,31 @@ export interface TranslationStrings {
74
74
  close: string
75
75
  menu: string
76
76
  back: string
77
+ // The controls' own names and what a screen reader hears (elements/*)
78
+ seek: string
79
+ playbackRate: string
80
+ airPlayPlay: string
81
+ remotePlayback: string
82
+ videoPlayer: string
83
+ videoRole: string
84
+ playTitle: string
85
+ audio: string
86
+ defaultTrack: string
87
+ playing: string
88
+ paused: string
89
+ muted: string
90
+ volumeLevel: string
91
+ ended: string
92
+ enteredFullscreen: string
93
+ exitedFullscreen: string
94
+ errorMessage: string
95
+ timeOf: string
96
+ hourOne: string
97
+ hourOther: string
98
+ minuteOne: string
99
+ minuteOther: string
100
+ secondOne: string
101
+ secondOther: string
77
102
  }
78
103
 
79
104
  export type TranslationKey = keyof TranslationStrings
@@ -157,6 +182,30 @@ export const englishStrings: TranslationStrings = {
157
182
  close: 'Close',
158
183
  menu: 'Menu',
159
184
  back: 'Back',
185
+ seek: 'Seek',
186
+ playbackRate: 'Playback speed {rate}x',
187
+ airPlayPlay: 'Play on AirPlay',
188
+ remotePlayback: 'Play on another device',
189
+ videoPlayer: 'Video player',
190
+ videoRole: 'video player',
191
+ playTitle: 'Play {title}',
192
+ audio: 'Audio',
193
+ defaultTrack: 'Default',
194
+ playing: 'Playing',
195
+ paused: 'Paused',
196
+ muted: 'Muted',
197
+ volumeLevel: 'Volume {percent}%',
198
+ ended: 'Playback ended',
199
+ enteredFullscreen: 'Entered fullscreen',
200
+ exitedFullscreen: 'Exited fullscreen',
201
+ errorMessage: 'Error: {message}',
202
+ timeOf: '{current} of {duration}',
203
+ hourOne: '{n} hour',
204
+ hourOther: '{n} hours',
205
+ minuteOne: '{n} minute',
206
+ minuteOther: '{n} minutes',
207
+ secondOne: '{n} second',
208
+ secondOther: '{n} seconds',
160
209
  }
161
210
 
162
211
  export const spanishStrings: TranslationStrings = {
@@ -209,6 +258,30 @@ export const spanishStrings: TranslationStrings = {
209
258
  close: 'Cerrar',
210
259
  menu: 'Menú',
211
260
  back: 'Volver',
261
+ seek: 'Buscar',
262
+ playbackRate: 'Velocidad de reproducción {rate}x',
263
+ airPlayPlay: 'Reproducir con AirPlay',
264
+ remotePlayback: 'Reproducir en otro dispositivo',
265
+ videoPlayer: 'Reproductor de vídeo',
266
+ videoRole: 'reproductor de vídeo',
267
+ playTitle: 'Reproducir {title}',
268
+ audio: 'Audio',
269
+ defaultTrack: 'Predeterminado',
270
+ playing: 'Reproduciendo',
271
+ paused: 'En pausa',
272
+ muted: 'Silenciado',
273
+ volumeLevel: 'Volumen {percent}%',
274
+ ended: 'Reproducción finalizada',
275
+ enteredFullscreen: 'Pantalla completa activada',
276
+ exitedFullscreen: 'Pantalla completa desactivada',
277
+ errorMessage: 'Error: {message}',
278
+ timeOf: '{current} de {duration}',
279
+ hourOne: '{n} hora',
280
+ hourOther: '{n} horas',
281
+ minuteOne: '{n} minuto',
282
+ minuteOther: '{n} minutos',
283
+ secondOne: '{n} segundo',
284
+ secondOther: '{n} segundos',
212
285
  }
213
286
 
214
287
  export const frenchStrings: TranslationStrings = {
@@ -261,6 +334,30 @@ export const frenchStrings: TranslationStrings = {
261
334
  close: 'Fermer',
262
335
  menu: 'Menu',
263
336
  back: 'Retour',
337
+ seek: 'Rechercher',
338
+ playbackRate: 'Vitesse de lecture {rate}x',
339
+ airPlayPlay: 'Lire avec AirPlay',
340
+ remotePlayback: 'Lire sur un autre appareil',
341
+ videoPlayer: 'Lecteur vidéo',
342
+ videoRole: 'lecteur vidéo',
343
+ playTitle: 'Lire {title}',
344
+ audio: 'Audio',
345
+ defaultTrack: 'Par défaut',
346
+ playing: 'Lecture',
347
+ paused: 'En pause',
348
+ muted: 'Son coupé',
349
+ volumeLevel: 'Volume {percent} %',
350
+ ended: 'Lecture terminée',
351
+ enteredFullscreen: 'Plein écran activé',
352
+ exitedFullscreen: 'Plein écran désactivé',
353
+ errorMessage: 'Erreur : {message}',
354
+ timeOf: '{current} sur {duration}',
355
+ hourOne: '{n} heure',
356
+ hourOther: '{n} heures',
357
+ minuteOne: '{n} minute',
358
+ minuteOther: '{n} minutes',
359
+ secondOne: '{n} seconde',
360
+ secondOther: '{n} secondes',
264
361
  }
265
362
 
266
363
  export const germanStrings: TranslationStrings = {
@@ -313,6 +410,30 @@ export const germanStrings: TranslationStrings = {
313
410
  close: 'Schließen',
314
411
  menu: 'Menü',
315
412
  back: 'Zurück',
413
+ seek: 'Spulen',
414
+ playbackRate: 'Wiedergabegeschwindigkeit {rate}x',
415
+ airPlayPlay: 'Über AirPlay abspielen',
416
+ remotePlayback: 'Auf einem anderen Gerät abspielen',
417
+ videoPlayer: 'Videoplayer',
418
+ videoRole: 'Videoplayer',
419
+ playTitle: '{title} abspielen',
420
+ audio: 'Audio',
421
+ defaultTrack: 'Standard',
422
+ playing: 'Wird abgespielt',
423
+ paused: 'Pausiert',
424
+ muted: 'Stumm',
425
+ volumeLevel: 'Lautstärke {percent} %',
426
+ ended: 'Wiedergabe beendet',
427
+ enteredFullscreen: 'Vollbild an',
428
+ exitedFullscreen: 'Vollbild aus',
429
+ errorMessage: 'Fehler: {message}',
430
+ timeOf: '{current} von {duration}',
431
+ hourOne: '{n} Stunde',
432
+ hourOther: '{n} Stunden',
433
+ minuteOne: '{n} Minute',
434
+ minuteOther: '{n} Minuten',
435
+ secondOne: '{n} Sekunde',
436
+ secondOther: '{n} Sekunden',
316
437
  }
317
438
 
318
439
  export const japaneseStrings: TranslationStrings = {
@@ -365,6 +486,30 @@ export const japaneseStrings: TranslationStrings = {
365
486
  close: '閉じる',
366
487
  menu: 'メニュー',
367
488
  back: '戻る',
489
+ seek: 'シーク',
490
+ playbackRate: '再生速度 {rate}x',
491
+ airPlayPlay: 'AirPlayで再生',
492
+ remotePlayback: '別のデバイスで再生',
493
+ videoPlayer: '動画プレーヤー',
494
+ videoRole: '動画プレーヤー',
495
+ playTitle: '{title}を再生',
496
+ audio: '音声',
497
+ defaultTrack: 'デフォルト',
498
+ playing: '再生中',
499
+ paused: '一時停止中',
500
+ muted: 'ミュート',
501
+ volumeLevel: '音量 {percent}%',
502
+ ended: '再生終了',
503
+ enteredFullscreen: '全画面表示',
504
+ exitedFullscreen: '全画面表示を終了',
505
+ errorMessage: 'エラー: {message}',
506
+ timeOf: '{current} / {duration}',
507
+ hourOne: '{n}時間',
508
+ hourOther: '{n}時間',
509
+ minuteOne: '{n}分',
510
+ minuteOther: '{n}分',
511
+ secondOne: '{n}秒',
512
+ secondOther: '{n}秒',
368
513
  }
369
514
 
370
515
  export const chineseStrings: TranslationStrings = {
@@ -417,6 +562,30 @@ export const chineseStrings: TranslationStrings = {
417
562
  close: '关闭',
418
563
  menu: '菜单',
419
564
  back: '返回',
565
+ seek: '跳转',
566
+ playbackRate: '播放速度 {rate}x',
567
+ airPlayPlay: '通过 AirPlay 播放',
568
+ remotePlayback: '在其他设备上播放',
569
+ videoPlayer: '视频播放器',
570
+ videoRole: '视频播放器',
571
+ playTitle: '播放{title}',
572
+ audio: '音频',
573
+ defaultTrack: '默认',
574
+ playing: '正在播放',
575
+ paused: '已暂停',
576
+ muted: '已静音',
577
+ volumeLevel: '音量 {percent}%',
578
+ ended: '播放结束',
579
+ enteredFullscreen: '已进入全屏',
580
+ exitedFullscreen: '已退出全屏',
581
+ errorMessage: '错误:{message}',
582
+ timeOf: '{current} / {duration}',
583
+ hourOne: '{n}小时',
584
+ hourOther: '{n}小时',
585
+ minuteOne: '{n}分钟',
586
+ minuteOther: '{n}分钟',
587
+ secondOne: '{n}秒',
588
+ secondOther: '{n}秒',
420
589
  }
421
590
 
422
591
  export const defaultTranslations: Record<string, TranslationStrings> = {
@@ -439,9 +608,12 @@ export class I18n {
439
608
  private interpolationPattern: RegExp
440
609
 
441
610
  constructor(config: I18nConfig = {}) {
611
+ // The translations first: detectLanguage() looks the browser's language
612
+ // up in them, and before they were set it threw for every instance made
613
+ // without a language.
614
+ this.translations = { ...defaultTranslations }
442
615
  this.language = config.language || this.detectLanguage() || 'en'
443
616
  this.fallbackLanguage = config.fallbackLanguage || 'en'
444
- this.translations = { ...defaultTranslations }
445
617
 
446
618
  // Merge custom translations
447
619
  if (config.translations) {
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { HTMLElementBase } from './base'
8
- import { resolvePlayer } from './utils'
8
+ import { label, resolvePlayer } from './utils'
9
9
 
10
10
  const AIRPLAY_ICON = 'M6.2 19h11.6L12 13.2 6.2 19zM19 3H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h2v-2H5V5h14v10h-2v2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z'
11
11
 
@@ -27,8 +27,10 @@ export class MediaAirplayButton extends HTMLElementBase {
27
27
  button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
28
28
  button:hover { opacity: 0.8; }
29
29
  :host([connected]) button { color: var(--media-airplay-active-color, #38bdf8); }
30
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
31
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
30
32
  </style>
31
- <button part="button" type="button" aria-label="Play on AirPlay">
33
+ <button part="button" type="button" aria-label="${label(this, 'airPlayPlay')}">
32
34
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${AIRPLAY_ICON}"/></svg>
33
35
  </button>
34
36
  `
@@ -4,7 +4,7 @@
4
4
  * @module elements/media-fullscreen-button
5
5
  */
6
6
 
7
- import { resolvePlayer } from './utils'
7
+ import { label, resolvePlayer } from './utils'
8
8
 
9
9
  const ENTER_FS = 'M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z'
10
10
  const EXIT_FS = 'M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z'
@@ -28,8 +28,10 @@ export class MediaFullscreenButton extends HTMLElementBase {
28
28
  }
29
29
  button:hover { opacity: 0.8; }
30
30
  button:disabled { opacity: 0.4; cursor: not-allowed; }
31
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
32
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
31
33
  </style>
32
- <button part="button" type="button" aria-label="Enter fullscreen">
34
+ <button part="button" type="button" aria-label="${label(this, 'enterFullscreen')}">
33
35
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
34
36
  <path d="${ENTER_FS}"/>
35
37
  </svg>
@@ -62,7 +64,7 @@ export class MediaFullscreenButton extends HTMLElementBase {
62
64
  btn.disabled = avail !== 'available'
63
65
 
64
66
  const fs = state.fullscreen
65
- btn.setAttribute('aria-label', fs ? 'Exit fullscreen' : 'Enter fullscreen')
67
+ btn.setAttribute('aria-label', label(this, fs ? 'exitFullscreen' : 'enterFullscreen'))
66
68
  const path = this.shadowRoot!.querySelector('path')!
67
69
  path.setAttribute('d', fs ? EXIT_FS : ENTER_FS)
68
70
  })
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { HTMLElementBase } from './base'
8
- import { resolvePlayer } from './utils'
8
+ import { label, resolvePlayer } from './utils'
9
9
 
10
10
  export class MediaLiveButton extends HTMLElementBase {
11
11
  private cleanup: (() => void) | null = null
@@ -20,8 +20,10 @@ export class MediaLiveButton extends HTMLElementBase {
20
20
  button { display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; font: inherit; font-size: 12px; font-weight: 700; }
21
21
  i { width: 8px; height: 8px; border-radius: 9999px; background: #ef4444; }
22
22
  :host(:not([at-live-edge])) button { opacity: 0.72; }
23
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
24
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
23
25
  </style>
24
- <button part="button" type="button" aria-label="Go to live edge"><i aria-hidden="true"></i><span>LIVE</span></button>
26
+ <button part="button" type="button" aria-label="${label(this, 'seekToLive')}"><i aria-hidden="true"></i><span>${label(this, 'live')}</span></button>
25
27
  `
26
28
  }
27
29
  queueMicrotask(() => this.attach())
@@ -4,7 +4,7 @@
4
4
  * @module elements/media-mute-button
5
5
  */
6
6
 
7
- import { resolvePlayer } from './utils'
7
+ import { label, resolvePlayer } from './utils'
8
8
 
9
9
  const ICON_MUTED = 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 0 0 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z'
10
10
  const ICON_LOW = 'M18.5 12A4.5 4.5 0 0 0 16 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z'
@@ -33,8 +33,10 @@ export class MediaMuteButton extends HTMLElementBase {
33
33
  background: transparent; border: none; color: inherit; cursor: pointer;
34
34
  }
35
35
  button:hover { opacity: 0.8; }
36
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
37
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
36
38
  </style>
37
- <button part="button" type="button" aria-label="Mute">
39
+ <button part="button" type="button" aria-label="${label(this, 'mute')}">
38
40
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
39
41
  <path d="${ICON_HIGH}"/>
40
42
  </svg>
@@ -59,7 +61,7 @@ export class MediaMuteButton extends HTMLElementBase {
59
61
 
60
62
  const unsub = player.subscribe((state: any) => {
61
63
  const icon = getVolumeIcon(state.muted, state.volume)
62
- btn.setAttribute('aria-label', state.muted ? 'Unmute' : 'Mute')
64
+ btn.setAttribute('aria-label', label(this, state.muted ? 'unmute' : 'mute'))
63
65
  const path = this.shadowRoot!.querySelector('path')!
64
66
  path.setAttribute('d', icon)
65
67
  })
@@ -4,7 +4,7 @@
4
4
  * @module elements/media-pip-button
5
5
  */
6
6
 
7
- import { resolvePlayer } from './utils'
7
+ import { label, resolvePlayer } from './utils'
8
8
 
9
9
  const PIP_ICON = 'M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H3V5h18v14z'
10
10
 
@@ -27,8 +27,10 @@ export class MediaPipButton extends HTMLElementBase {
27
27
  }
28
28
  button:hover { opacity: 0.8; }
29
29
  button:disabled { opacity: 0.4; cursor: not-allowed; }
30
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
31
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
30
32
  </style>
31
- <button part="button" type="button" aria-label="Picture in picture">
33
+ <button part="button" type="button" aria-label="${label(this, 'enterPiP')}">
32
34
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
33
35
  <path d="${PIP_ICON}"/>
34
36
  </svg>
@@ -59,6 +61,7 @@ export class MediaPipButton extends HTMLElementBase {
59
61
  this.removeAttribute('hidden')
60
62
  }
61
63
  btn.disabled = avail !== 'available'
64
+ btn.setAttribute('aria-label', label(this, state.pictureInPicture ? 'exitPiP' : 'enterPiP'))
62
65
  })
63
66
 
64
67
  this._cleanup = unsub
@@ -4,7 +4,7 @@
4
4
  * @module elements/media-play-button
5
5
  */
6
6
 
7
- import { resolvePlayer } from './utils'
7
+ import { label, resolvePlayer } from './utils'
8
8
 
9
9
  const PLAY_ICON = 'M8 5v14l11-7z'
10
10
  const PAUSE_ICON = 'M6 19h4V5H6v14zm8-14v14h4V5h-4z'
@@ -27,8 +27,10 @@ export class MediaPlayButton extends HTMLElementBase {
27
27
  background: transparent; border: none; color: inherit; cursor: pointer;
28
28
  }
29
29
  button:hover { opacity: 0.8; }
30
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
31
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
30
32
  </style>
31
- <button part="button" type="button" aria-label="Play">
33
+ <button part="button" type="button" aria-label="${label(this, 'play')}">
32
34
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon">
33
35
  <path d="${PLAY_ICON}"/>
34
36
  </svg>
@@ -58,7 +60,7 @@ export class MediaPlayButton extends HTMLElementBase {
58
60
 
59
61
  const unsub = player.subscribe('paused', (state: any) => {
60
62
  const paused = state.paused
61
- btn.setAttribute('aria-label', paused ? 'Play' : 'Pause')
63
+ btn.setAttribute('aria-label', label(this, paused ? 'play' : 'pause'))
62
64
  btn.setAttribute('aria-pressed', String(!paused))
63
65
  const path = this.shadowRoot!.querySelector('path')!
64
66
  path.setAttribute('d', paused ? PLAY_ICON : PAUSE_ICON)
@@ -1,4 +1,4 @@
1
- import { resolvePlayer } from './utils'
1
+ import { label, resolvePlayer } from './utils'
2
2
  import type { PlayerState } from '../types'
3
3
 
4
4
  const defaultRates = [0.5, 0.75, 1, 1.25, 1.5, 2]
@@ -18,8 +18,10 @@ export class MediaPlaybackRateButton extends HTMLElementBase {
18
18
  button { min-width: 42px; height: 40px; padding: 0 8px; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; font-variant-numeric: tabular-nums; }
19
19
  button:hover { opacity: .8; }
20
20
  button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
21
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
22
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
21
23
  </style>
22
- <button part="button" type="button" aria-label="Playback speed"></button>
24
+ <button part="button" type="button" aria-label="${label(this, 'speed')}"></button>
23
25
  `
24
26
  }
25
27
  queueMicrotask(() => this.attach())
@@ -53,7 +55,7 @@ export class MediaPlaybackRateButton extends HTMLElementBase {
53
55
  button.addEventListener('click', this.onClick)
54
56
  this.cleanup = player.subscribe('playbackRate', (state: PlayerState) => {
55
57
  button.textContent = `${state.playbackRate}x`
56
- button.setAttribute('aria-label', `Playback speed ${state.playbackRate}x`)
58
+ button.setAttribute('aria-label', label(this, 'playbackRate', { rate: state.playbackRate }))
57
59
  })
58
60
  }
59
61
  }
@@ -6,7 +6,7 @@
6
6
  * @module elements/media-progress-bar
7
7
  */
8
8
 
9
- import { resolvePlayer, formatTime, formatTimePhrase } from './utils'
9
+ import { label, resolvePlayer, formatTime, formatTimePhrase } from './utils'
10
10
 
11
11
  import { HTMLElementBase } from './base'
12
12
 
@@ -65,10 +65,12 @@ export class MediaProgressBar extends HTMLElementBase {
65
65
  border-top-color: rgba(0,0,0,0.85);
66
66
  }
67
67
  .container:hover .tooltip { opacity: 1; }
68
+ /* A finger needs more than the 20px a mouse does. */
69
+ @media (pointer: coarse) { .container { height: 36px; } .thumb { width: 18px; height: 18px; } }
68
70
  </style>
69
71
  <div class="container" part="container" role="slider"
70
72
  aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
71
- aria-label="Seek" tabindex="0">
73
+ aria-label="${label(this, 'seek')}" tabindex="0">
72
74
  <div class="tooltip" part="tooltip">0:00</div>
73
75
  <div class="track" part="track">
74
76
  <div class="waveform" part="waveform" aria-hidden="true"></div>
@@ -188,7 +190,7 @@ export class MediaProgressBar extends HTMLElementBase {
188
190
  thumb.style.left = `${percent}%`
189
191
 
190
192
  container.setAttribute('aria-valuenow', String(Math.round(percent)))
191
- container.setAttribute('aria-valuetext', `${formatTimePhrase(state.currentTime)} of ${formatTimePhrase(duration)}`)
193
+ container.setAttribute('aria-valuetext', label(this, 'timeOf', { current: formatTimePhrase(state.currentTime, this), duration: formatTimePhrase(duration, this) }))
192
194
 
193
195
  // Buffered
194
196
  const bufferedPct = (state.bufferedAmount || 0) * 100
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { HTMLElementBase } from './base'
11
- import { resolvePlayer } from './utils'
11
+ import { label, resolvePlayer } from './utils'
12
12
 
13
13
  const REMOTE_ICON = 'M1 18v3h3a3 3 0 0 0-3-3zm0-4v2a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7zm0-4v2c5 0 9 4 9 9h2c0-6.1-4.9-11-11-11zm18-7H5a2 2 0 0 0-2 2v3h2V5h14v10h-5v2h5a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z'
14
14
 
@@ -32,8 +32,10 @@ export class MediaRemotePlaybackButton extends HTMLElementBase {
32
32
  button { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 8px; background: transparent; border: 0; color: inherit; cursor: pointer; }
33
33
  button:hover { opacity: 0.8; }
34
34
  :host([connected]) button { color: var(--media-remote-active-color, #38bdf8); }
35
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
36
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
35
37
  </style>
36
- <button part="button" type="button" aria-label="Play on another device">
38
+ <button part="button" type="button" aria-label="${label(this, 'remotePlayback')}">
37
39
  <svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor" part="icon"><path d="${REMOTE_ICON}"/></svg>
38
40
  </button>
39
41
  `
@@ -1,4 +1,4 @@
1
- import { resolvePlayer } from './utils'
1
+ import { label, resolvePlayer } from './utils'
2
2
  import type { PlayerState } from '../types'
3
3
 
4
4
  import { HTMLElementBase } from './base'
@@ -17,6 +17,8 @@ export class MediaSeekButton extends HTMLElementBase {
17
17
  button:hover { opacity: .8; }
18
18
  button:focus-visible { outline: 2px solid currentColor; outline-offset: -2px; }
19
19
  button:disabled { cursor: default; opacity: .45; }
20
+ /* 44px, the smallest a finger hits reliably, on a touch screen. */
21
+ @media (pointer: coarse) { button { min-width: 44px; min-height: 44px; } }
20
22
  </style>
21
23
  <button part="button" type="button"></button>
22
24
  `
@@ -38,9 +40,8 @@ export class MediaSeekButton extends HTMLElementBase {
38
40
  if (!player || !button) return
39
41
  const seconds = Number.parseFloat(this.getAttribute('seconds') ?? '10')
40
42
  const offset = Number.isFinite(seconds) && seconds !== 0 ? seconds : 10
41
- const label = offset > 0 ? `Forward ${offset} seconds` : `Back ${Math.abs(offset)} seconds`
42
43
  button.textContent = offset > 0 ? `+${offset}` : String(offset)
43
- button.setAttribute('aria-label', label)
44
+ button.setAttribute('aria-label', label(this, offset > 0 ? 'seekForward' : 'seekBackward', { seconds: Math.abs(offset) }))
44
45
  this.onClick = () => player.seekBy(offset)
45
46
  button.addEventListener('click', this.onClick)
46
47
  this.cleanup = player.subscribe((state: PlayerState) => {