@openplayerjs/player 3.4.0 → 3.4.1

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/index.js CHANGED
@@ -7,11 +7,15 @@ const defaultUIConfiguration = {
7
7
  };
8
8
  const defaultLabels = Object.freeze({
9
9
  auto: 'Auto',
10
+ back: 'Back',
10
11
  captions: 'CC/Subtitles',
12
+ captionsOff: 'Captions off',
13
+ captionsOn: 'Captions on',
11
14
  click: 'Click to unmute',
12
15
  container: 'Media player',
16
+ enterFullscreen: 'Enter Fullscreen',
17
+ exitFullscreen: 'Exit Fullscreen',
13
18
  fullscreen: 'Fullscreen',
14
- levels: 'Quality Levels',
15
19
  live: 'Live',
16
20
  loading: 'Loading...',
17
21
  media: 'Media',
@@ -22,6 +26,7 @@ const defaultLabels = Object.freeze({
22
26
  progressRail: 'Time Rail',
23
27
  progressSlider: 'Time Slider',
24
28
  restart: 'Restart',
29
+ seekTo: 'Seek to %s',
25
30
  settings: 'Player Settings',
26
31
  speed: 'Speed',
27
32
  speedNormal: 'Normal',
@@ -30,6 +35,7 @@ const defaultLabels = Object.freeze({
30
35
  unmute: 'Unmute',
31
36
  volume: 'Volume',
32
37
  volumeControl: 'Volume Control',
38
+ volumePercent: 'Volume: %s%',
33
39
  volumeSlider: 'Volume Slider',
34
40
  });
35
41
  function resolveUIConfig(coreOrConfig) {
@@ -206,7 +212,7 @@ function bindCenterOverlay(core, keyTarget, bindings) {
206
212
  window.addEventListener('click', onPointer, EVENT_OPTIONS);
207
213
  window.addEventListener('pointerdown', onPointer, EVENT_OPTIONS);
208
214
  window.addEventListener('keydown', onKeyboard, EVENT_OPTIONS);
209
- keyTarget.addEventListener('keydown', async (e) => {
215
+ const onKeydown = async (e) => {
210
216
  const key = e.key;
211
217
  onKeyboard();
212
218
  const activeEl = document.activeElement;
@@ -246,23 +252,20 @@ function bindCenterOverlay(core, keyTarget, bindings) {
246
252
  e.preventDefault();
247
253
  e.stopPropagation();
248
254
  break;
249
- // Volume
250
255
  case 'ArrowUp': {
251
256
  const upVolume = Math.min(core.volume + 0.1, 1);
252
257
  core.volume = upVolume;
253
258
  core.muted = !(upVolume > 0);
254
259
  if (upVolume > 0)
255
260
  lastNonZeroVolume = upVolume;
256
- const el = getActiveMedia(core);
257
- if (el && el !== core.surface) {
258
- try {
261
+ try {
262
+ const el = getActiveMedia(core);
263
+ if (el && el !== core.surface) {
259
264
  el.volume = upVolume;
260
265
  el.muted = !(upVolume > 0);
261
266
  }
262
- catch {
263
- // ignore
264
- }
265
267
  }
268
+ catch { /* ignore */ }
266
269
  e.preventDefault();
267
270
  e.stopPropagation();
268
271
  break;
@@ -273,16 +276,14 @@ function bindCenterOverlay(core, keyTarget, bindings) {
273
276
  core.muted = !(downVolume > 0);
274
277
  if (downVolume > 0)
275
278
  lastNonZeroVolume = downVolume;
276
- const el = getActiveMedia(core);
277
- if (el && el !== core.surface) {
278
- try {
279
+ try {
280
+ const el = getActiveMedia(core);
281
+ if (el && el !== core.surface) {
279
282
  el.volume = downVolume;
280
283
  el.muted = !(downVolume > 0);
281
284
  }
282
- catch {
283
- // ignore
284
- }
285
285
  }
286
+ catch { /* ignore */ }
286
287
  e.preventDefault();
287
288
  e.stopPropagation();
288
289
  break;
@@ -305,40 +306,35 @@ function bindCenterOverlay(core, keyTarget, bindings) {
305
306
  e.stopPropagation();
306
307
  break;
307
308
  }
308
- // Mute toggle preserving last volume
309
309
  case 'm':
310
310
  case 'M': {
311
- const el = getActiveMedia(core);
312
311
  const nextMuted = !core.muted;
313
312
  if (nextMuted) {
314
- // store last non-zero volume before muting
315
313
  if (core.volume > 0)
316
314
  lastNonZeroVolume = core.volume;
317
315
  core.volume = 0;
318
316
  core.muted = true;
319
- if (el && el !== core.surface) {
320
- try {
317
+ try {
318
+ const el = getActiveMedia(core);
319
+ if (el && el !== core.surface) {
321
320
  el.volume = 0;
322
321
  el.muted = true;
323
322
  }
324
- catch {
325
- // ignore
326
- }
327
323
  }
324
+ catch { /* ignore */ }
328
325
  }
329
326
  else {
330
327
  const restore = lastNonZeroVolume > 0 ? lastNonZeroVolume : 1;
331
328
  core.volume = restore;
332
329
  core.muted = false;
333
- if (el && el !== core.surface) {
334
- try {
330
+ try {
331
+ const el = getActiveMedia(core);
332
+ if (el && el !== core.surface) {
335
333
  el.volume = restore;
336
334
  el.muted = false;
337
335
  }
338
- catch {
339
- // ignore
340
- }
341
336
  }
337
+ catch { /* ignore */ }
342
338
  }
343
339
  e.preventDefault();
344
340
  e.stopPropagation();
@@ -375,38 +371,55 @@ function bindCenterOverlay(core, keyTarget, bindings) {
375
371
  e.stopPropagation();
376
372
  break;
377
373
  }
378
- }, EVENT_OPTIONS);
379
- core.events.on('waiting', () => {
374
+ };
375
+ keyTarget.addEventListener('keydown', onKeydown, EVENT_OPTIONS);
376
+ const offWaiting = core.events.on('waiting', () => {
380
377
  bindings?.showLoader(true);
381
378
  bindings?.showButton(false);
382
379
  });
383
- core.events.on('seeking', () => {
380
+ const offSeeking = core.events.on('seeking', () => {
384
381
  bindings?.showLoader(true);
385
382
  bindings?.showButton(false);
386
383
  });
387
- core.events.on('seeked', () => {
384
+ const offSeeked = core.events.on('seeked', () => {
388
385
  bindings?.showLoader(false);
389
386
  // After seeking, only show the center button if we are paused.
390
387
  // When seeking during playback (common on iOS), keeping the play button visible
391
388
  // causes it to linger even after the loader is hidden.
392
389
  bindings?.showButton(core.media?.paused ?? false);
393
390
  });
394
- core.events.on('play', () => {
391
+ const offPlay = core.events.on('play', () => {
395
392
  bindings?.showLoader(false);
396
393
  bindings?.showButton(false);
397
394
  });
398
- core.events.on('pause', () => {
395
+ const offPause = core.events.on('pause', () => {
399
396
  bindings?.showLoader(false);
400
397
  bindings?.showButton(true);
401
398
  });
402
- core.events.on('playing', () => {
399
+ const offPlaying = core.events.on('playing', () => {
403
400
  bindings?.showLoader(false);
404
401
  bindings?.showButton(false);
405
402
  });
406
- core.events.on('ended', () => {
403
+ const offEnded = core.events.on('ended', () => {
407
404
  bindings?.showLoader(false);
408
405
  bindings?.showButton(true);
409
406
  });
407
+ return () => {
408
+ keyTarget.removeEventListener('click', onPointer);
409
+ keyTarget.removeEventListener('pointerdown', onPointer);
410
+ keyTarget.removeEventListener('pointerleave', onPointer);
411
+ keyTarget.removeEventListener('keydown', onKeydown);
412
+ window.removeEventListener('click', onPointer);
413
+ window.removeEventListener('pointerdown', onPointer);
414
+ window.removeEventListener('keydown', onKeyboard);
415
+ offWaiting();
416
+ offSeeking();
417
+ offSeeked();
418
+ offPlay();
419
+ offPause();
420
+ offPlaying();
421
+ offEnded();
422
+ };
410
423
  }
411
424
 
412
425
  let srId$1 = 0;
@@ -462,6 +475,60 @@ function setA11yLabel(el, labelText, opts) {
462
475
  }
463
476
  ensureLabelledBy(el, labelText, opts);
464
477
  }
478
+ const _sharedAnnouncers = new WeakMap();
479
+ /**
480
+ * Returns the shared announcer for `wrapper`, creating it on first call.
481
+ * Multiple controls attached to the same wrapper share the same 2 live-region
482
+ * nodes. Each caller receives its own `destroy` handle; the DOM nodes are only
483
+ * removed once every caller has destroyed its handle (ref-counting).
484
+ */
485
+ function getSharedAnnouncer(wrapper) {
486
+ let entry = _sharedAnnouncers.get(wrapper);
487
+ if (!entry) {
488
+ const { announce, destroy } = createAnnouncer(wrapper);
489
+ entry = { announce, refs: 0, _destroy: destroy };
490
+ _sharedAnnouncers.set(wrapper, entry);
491
+ }
492
+ entry.refs += 1;
493
+ const captured = entry;
494
+ const destroy = () => {
495
+ captured.refs -= 1;
496
+ if (captured.refs <= 0) {
497
+ captured._destroy();
498
+ _sharedAnnouncers.delete(wrapper);
499
+ }
500
+ };
501
+ return { announce: entry.announce, destroy };
502
+ }
503
+ function createAnnouncer(wrapper) {
504
+ const regions = [];
505
+ let turn = 0;
506
+ for (let i = 0; i < 2; i++) {
507
+ const el = document.createElement('div');
508
+ el.setAttribute('role', 'status');
509
+ el.setAttribute('aria-live', 'polite');
510
+ el.setAttribute('aria-atomic', 'true');
511
+ el.className = 'op-player__sr-only op-player__announcer';
512
+ el.setAttribute('aria-hidden', 'true');
513
+ wrapper.appendChild(el);
514
+ regions.push(el);
515
+ }
516
+ const announce = (message) => {
517
+ // Rotate between the two regions — forces a DOM mutation even for the
518
+ // same string, which re-triggers announcement in most screen readers.
519
+ const current = regions[turn % 2];
520
+ const other = regions[(turn + 1) % 2];
521
+ turn++;
522
+ other.textContent = '';
523
+ other.setAttribute('aria-hidden', 'true');
524
+ current.setAttribute('aria-hidden', 'false');
525
+ current.textContent = message;
526
+ };
527
+ const destroy = () => {
528
+ regions.forEach((r) => r.remove());
529
+ };
530
+ return { announce, destroy };
531
+ }
465
532
 
466
533
  function createCenterOverlayDom(core) {
467
534
  const labels = resolveUIConfig(core).labels;
@@ -669,10 +736,11 @@ function createUI(core, media, controls, options = {}) {
669
736
  mediaContainer.appendChild(overlay.button);
670
737
  mediaContainer.appendChild(overlay.loader);
671
738
  }
672
- bindCenterOverlay(core, wrapper, overlay);
739
+ const teardownCenterOverlay = bindCenterOverlay(core, wrapper, overlay);
673
740
  const controlsRoot = document.createElement('div');
674
741
  controlsRoot.className = 'op-controls';
675
742
  controlsRoot.setAttribute('aria-hidden', 'false');
743
+ controlsRoot.inert = false;
676
744
  if (isMediaAudio) {
677
745
  const grid = createControlGrid(controlsRoot);
678
746
  wrapper.appendChild(mediaContainer);
@@ -705,6 +773,7 @@ function createUI(core, media, controls, options = {}) {
705
773
  // ignore
706
774
  }
707
775
  try {
776
+ teardownCenterOverlay();
708
777
  offAddElement();
709
778
  offAddControl();
710
779
  offDestroy();
@@ -745,6 +814,7 @@ function createUI(core, media, controls, options = {}) {
745
814
  if (hideTimer)
746
815
  window.clearTimeout(hideTimer);
747
816
  controlsRoot.setAttribute('aria-hidden', 'false');
817
+ controlsRoot.inert = false;
748
818
  core.events.emit('ui:controls:show');
749
819
  };
750
820
  const hideControls = () => {
@@ -761,6 +831,7 @@ function createUI(core, media, controls, options = {}) {
761
831
  wrapper.classList.add('op-controls--hidden');
762
832
  mediaContainer.classList.add('op-media--controls-hidden');
763
833
  controlsRoot.setAttribute('aria-hidden', 'true');
834
+ controlsRoot.inert = true;
764
835
  core.events.emit('ui:controls:hide');
765
836
  };
766
837
  const scheduleHide = (ms) => {
@@ -779,37 +850,45 @@ function createUI(core, media, controls, options = {}) {
779
850
  showControls();
780
851
  scheduleHide(POINTER_SHOW_MS);
781
852
  };
782
- controlsRoot.addEventListener('focusin', () => {
853
+ // Track all video-path DOM listeners so they can be removed on destroy.
854
+ const domUnsubs = [];
855
+ const trackListener = (target, type, handler, options) => {
856
+ target.addEventListener(type, handler, options);
857
+ domUnsubs.push(() => target.removeEventListener(type, handler, options));
858
+ };
859
+ const onControlsFocusIn = () => {
783
860
  lastInteraction = 'keyboard';
784
861
  showControls();
785
862
  scheduleHide(KEYBOARD_SHOW_MS);
786
- });
787
- controlsRoot.addEventListener('focusout', () => {
863
+ };
864
+ const onControlsFocusOut = () => {
788
865
  window.setTimeout(() => {
789
866
  if (!controlsHaveFocus())
790
867
  scheduleHide(lastInteraction === 'keyboard' ? KEYBOARD_SHOW_MS : POINTER_SHOW_MS);
791
868
  }, 0);
792
- });
869
+ };
870
+ trackListener(controlsRoot, 'focusin', onControlsFocusIn);
871
+ trackListener(controlsRoot, 'focusout', onControlsFocusOut);
793
872
  const isFocusInMediaArea = () => {
794
873
  const active = document.activeElement;
795
874
  if (!active)
796
875
  return false;
797
876
  return wrapper.contains(active) && !controlsRoot.contains(active);
798
877
  };
799
- wrapper.addEventListener('focusin', () => {
878
+ const onWrapperFocusIn = () => {
800
879
  if (isFocusInMediaArea()) {
801
880
  showControls();
802
881
  if (hideTimer)
803
882
  window.clearTimeout(hideTimer);
804
883
  }
805
- }, EVENT_OPTIONS);
806
- wrapper.addEventListener('focusout', () => {
884
+ };
885
+ const onWrapperFocusOut = () => {
807
886
  window.setTimeout(() => {
808
887
  if (!wrapper.contains(document.activeElement) && !controlsHaveFocus())
809
888
  scheduleHide();
810
889
  }, 0);
811
- }, EVENT_OPTIONS);
812
- wrapper.addEventListener('keydown', () => {
890
+ };
891
+ const onWrapperKeyDown = () => {
813
892
  lastInteraction = 'keyboard';
814
893
  if (isFocusInMediaArea()) {
815
894
  showControls();
@@ -820,20 +899,25 @@ function createUI(core, media, controls, options = {}) {
820
899
  showControls();
821
900
  scheduleHide(KEYBOARD_SHOW_MS);
822
901
  }
823
- }, EVENT_OPTIONS);
902
+ };
903
+ trackListener(wrapper, 'focusin', onWrapperFocusIn, EVENT_OPTIONS);
904
+ trackListener(wrapper, 'focusout', onWrapperFocusOut, EVENT_OPTIONS);
905
+ trackListener(wrapper, 'keydown', onWrapperKeyDown, EVENT_OPTIONS);
824
906
  if (mobile) {
825
- wrapper.addEventListener('pointerdown', () => {
907
+ const onWrapperPointerDown = () => {
826
908
  lastInteraction = 'pointer';
827
909
  showControls();
828
910
  scheduleHide(POINTER_SHOW_MS);
829
- }, EVENT_OPTIONS);
911
+ };
912
+ trackListener(wrapper, 'pointerdown', onWrapperPointerDown, EVENT_OPTIONS);
830
913
  }
831
914
  else {
832
- wrapper.addEventListener('pointermove', onControlsHover, EVENT_OPTIONS);
833
- wrapper.addEventListener('pointerenter', onControlsHover, EVENT_OPTIONS);
834
- controlsRoot.addEventListener('pointerenter', onControlsHover, EVENT_OPTIONS);
835
- controlsRoot.addEventListener('pointermove', onControlsHover, EVENT_OPTIONS);
836
- controlsRoot.addEventListener('pointerleave', () => scheduleHide(POINTER_SHOW_MS), EVENT_OPTIONS);
915
+ const onControlsPointerLeave = () => scheduleHide(POINTER_SHOW_MS);
916
+ trackListener(wrapper, 'pointermove', onControlsHover, EVENT_OPTIONS);
917
+ trackListener(wrapper, 'pointerenter', onControlsHover, EVENT_OPTIONS);
918
+ trackListener(controlsRoot, 'pointerenter', onControlsHover, EVENT_OPTIONS);
919
+ trackListener(controlsRoot, 'pointermove', onControlsHover, EVENT_OPTIONS);
920
+ trackListener(controlsRoot, 'pointerleave', onControlsPointerLeave, EVENT_OPTIONS);
837
921
  }
838
922
  const grid = createControlGrid(controlsRoot, mainControls);
839
923
  wrapper.appendChild(mediaContainer);
@@ -847,7 +931,7 @@ function createUI(core, media, controls, options = {}) {
847
931
  });
848
932
  const ctx = { wrapper, mediaContainer, controlsRoot, placeholder, grid };
849
933
  maybeAutoplayUnmute(core, wrapper);
850
- wrapper.addEventListener('click', async (e) => {
934
+ const onWrapperClick = async (e) => {
851
935
  // Linear overlays (e.g. full-screen ads with their own video) own click handling.
852
936
  // Non-linear ads (no fullscreenVideoEl) run alongside the content, so we allow pause.
853
937
  if (getOverlayManager(core).active?.fullscreenVideoEl)
@@ -867,7 +951,8 @@ function createUI(core, media, controls, options = {}) {
867
951
  else {
868
952
  await core.play().catch(() => undefined);
869
953
  }
870
- }, EVENT_OPTIONS);
954
+ };
955
+ trackListener(wrapper, 'click', onWrapperClick, EVENT_OPTIONS);
871
956
  const offPlaying = core.events.on('playing', () => scheduleHide(POINTER_SHOW_MS));
872
957
  const offPause = core.events.on('pause', () => showControls());
873
958
  const offEnded = core.events.on('ended', () => showControls());
@@ -900,13 +985,15 @@ function createUI(core, media, controls, options = {}) {
900
985
  // ignore
901
986
  }
902
987
  try {
988
+ domUnsubs.forEach((u) => u());
989
+ teardownCenterOverlay();
903
990
  offPlaying?.();
904
991
  offPause?.();
905
992
  offEnded?.();
906
993
  offMenuOpen?.();
907
994
  offMenuClose?.();
908
- offAddElement();
909
- offAddControl();
995
+ offAddElement?.();
996
+ offAddControl?.();
910
997
  offDestroy();
911
998
  }
912
999
  catch {
@@ -1144,6 +1231,8 @@ class CaptionsControl extends BaseControl {
1144
1231
  const labels = resolveUIConfig(core).labels;
1145
1232
  const label = labels.captions;
1146
1233
  const buttonLabel = labels.toggleCaptions;
1234
+ const { announce, destroy } = getSharedAnnouncer(this.resolvePlayerRoot());
1235
+ this.dispose.add(destroy);
1147
1236
  this.button = document.createElement('button');
1148
1237
  this.button.type = 'button';
1149
1238
  this.button.className = 'op-controls__captions';
@@ -1232,6 +1321,8 @@ class CaptionsControl extends BaseControl {
1232
1321
  }
1233
1322
  }
1234
1323
  refresh();
1324
+ const on = this.button.getAttribute('aria-pressed') === 'true';
1325
+ announce(on ? (labels.captionsOn ?? `${label} on`) : (labels.captionsOff ?? `${label} off`));
1235
1326
  me.preventDefault();
1236
1327
  me.stopPropagation();
1237
1328
  }, EVENT_OPTIONS);
@@ -1447,6 +1538,7 @@ class DurationControl extends BaseControl {
1447
1538
  const el = document.createElement('time');
1448
1539
  el.className = 'op-controls__duration';
1449
1540
  el.setAttribute('aria-hidden', 'false');
1541
+ el.setAttribute('aria-live', 'off');
1450
1542
  el.setAttribute('datetime', 'PT0M0S');
1451
1543
  const update = () => {
1452
1544
  if (this.activeOverlay) {
@@ -1547,6 +1639,8 @@ class FullscreenControl extends BaseControl {
1547
1639
  const core = this.core;
1548
1640
  const labels = resolveUIConfig(core).labels;
1549
1641
  const btn = document.createElement('button');
1642
+ const { announce, destroy } = getSharedAnnouncer(this.resolvePlayerRoot());
1643
+ this.dispose.add(destroy);
1550
1644
  btn.tabIndex = 0;
1551
1645
  btn.type = 'button';
1552
1646
  btn.className = 'op-controls__fullscreen';
@@ -1633,6 +1727,10 @@ class FullscreenControl extends BaseControl {
1633
1727
  me.preventDefault();
1634
1728
  me.stopPropagation();
1635
1729
  }, EVENT_OPTIONS);
1730
+ this.onPlayer('player:fullscreenchange', () => {
1731
+ const key = getFullscreenElement() ? 'enterFullscreen' : 'exitFullscreen';
1732
+ announce(labels[key] ?? key);
1733
+ });
1636
1734
  sync();
1637
1735
  return btn;
1638
1736
  }
@@ -1674,6 +1772,12 @@ class PlayControl extends BaseControl {
1674
1772
  const playLabel = labels.play;
1675
1773
  const pauseLabel = labels.pause;
1676
1774
  const restartLabel = labels.restart;
1775
+ const { announce, destroy } = getSharedAnnouncer(this.resolvePlayerRoot());
1776
+ this.dispose.add(destroy);
1777
+ const fmt = (key, value) => {
1778
+ const t = labels[key] ?? key;
1779
+ return value != null ? t.replace('%s', value) : t;
1780
+ };
1677
1781
  let isEnded = false;
1678
1782
  const btn = document.createElement('button');
1679
1783
  btn.tabIndex = 0;
@@ -1704,8 +1808,13 @@ class PlayControl extends BaseControl {
1704
1808
  this.onPlayer('playing', () => {
1705
1809
  isEnded = false;
1706
1810
  setPlaying(true);
1811
+ if (!core.media.seeking)
1812
+ announce(labels['pause'] ? fmt('play') : 'Playing');
1813
+ });
1814
+ this.onPlayer('pause', () => {
1815
+ setPlaying(false);
1816
+ announce(labels['pause'] ?? 'Paused');
1707
1817
  });
1708
- this.onPlayer('pause', () => setPlaying(false));
1709
1818
  this.onPlayer('ended', () => {
1710
1819
  isEnded = true;
1711
1820
  setPlaying(false);
@@ -1746,6 +1855,13 @@ class ProgressControl extends BaseControl {
1746
1855
  const core = this.core;
1747
1856
  const ui = resolveUIConfig(core);
1748
1857
  const { allowRewind, allowSkip, labels } = ui;
1858
+ const labelsMap = labels;
1859
+ const { announce, destroy } = getSharedAnnouncer(this.resolvePlayerRoot());
1860
+ this.dispose.add(destroy);
1861
+ const fmt = (key, value) => {
1862
+ const t = labelsMap[key] ?? key;
1863
+ return value != null ? t.replace('%s', value) : t;
1864
+ };
1749
1865
  const progressLabel = labels.progressSlider;
1750
1866
  const railLabel = labels.progressRail;
1751
1867
  const progress = document.createElement('div');
@@ -1934,6 +2050,11 @@ class ProgressControl extends BaseControl {
1934
2050
  }, EVENT_OPTIONS);
1935
2051
  this.onPlayer('durationchange', updateUI);
1936
2052
  this.onPlayer('timeupdate', updateUI);
2053
+ this.onPlayer('seeked', () => {
2054
+ const t = formatTime(core.currentTime);
2055
+ const d = isFinite(core.duration) ? formatTime(core.duration) : undefined;
2056
+ announce(d ? `${fmt('seekTo', t)} of ${d}` : fmt('seekTo', t));
2057
+ });
1937
2058
  this.onPlayer('waiting', () => {
1938
2059
  if (!slider.classList.contains('loading'))
1939
2060
  slider.classList.add('loading');
@@ -2219,10 +2340,11 @@ class SettingsControl extends BaseControl {
2219
2340
  }
2220
2341
  const header = document.createElement('div');
2221
2342
  header.className = 'op-menu__header';
2343
+ const { labels } = resolveUIConfig(this.core);
2222
2344
  const back = document.createElement('button');
2223
2345
  back.type = 'button';
2224
2346
  back.className = 'op-submenu__back';
2225
- setA11yLabel(back, 'Back');
2347
+ setA11yLabel(back, labels.back);
2226
2348
  this.listen(back, 'click', (e) => {
2227
2349
  const me = e;
2228
2350
  this.activeSubmenuId = null;
@@ -2295,6 +2417,7 @@ class TimeControl extends BaseControl {
2295
2417
  const delimiter = document.createElement('span');
2296
2418
  delimiter.className = 'op-controls__time-delimiter';
2297
2419
  delimiter.textContent = '/';
2420
+ delimiter.setAttribute('aria-hidden', 'true');
2298
2421
  const container = document.createElement('span');
2299
2422
  container.className = 'op-controls-time';
2300
2423
  const currentTime = new CurrentTimeControl().create(core);
@@ -2331,11 +2454,23 @@ class VolumeControl extends BaseControl {
2331
2454
  build() {
2332
2455
  const core = this.core;
2333
2456
  const labels = resolveUIConfig(core).labels;
2457
+ const labelsMap = labels;
2334
2458
  const muteLabel = labels.mute;
2335
2459
  const unmuteLabel = labels.unmute;
2336
2460
  const volumeLabel = labels.volume;
2337
2461
  const volumeControlLabel = labels.volumeControl;
2338
2462
  const volumeSliderLabel = labels.volumeSlider;
2463
+ const { announce, destroy } = getSharedAnnouncer(this.resolvePlayerRoot());
2464
+ this.dispose.add(destroy);
2465
+ const fmt = (key, value) => {
2466
+ const t = labelsMap[key] ?? key;
2467
+ return value != null ? t.replace('%s', value) : t;
2468
+ };
2469
+ let volTimer = null;
2470
+ this.dispose.add(() => {
2471
+ if (volTimer)
2472
+ clearTimeout(volTimer);
2473
+ });
2339
2474
  const wrapper = document.createElement('div');
2340
2475
  wrapper.className = 'op-controls__volume';
2341
2476
  wrapper.tabIndex = 0;
@@ -2415,15 +2550,23 @@ class VolumeControl extends BaseControl {
2415
2550
  }
2416
2551
  updateSlider(v);
2417
2552
  updateBtn(v);
2553
+ if (volTimer)
2554
+ clearTimeout(volTimer);
2555
+ volTimer = setTimeout(() => {
2556
+ announce(fmt('volumePercent', String(Math.round(v * 100))));
2557
+ volTimer = null;
2558
+ }, 400);
2418
2559
  }, EVENT_OPTIONS);
2419
2560
  this.listen(btn, 'click', (e) => {
2420
2561
  const me = e;
2421
2562
  const el = getActiveMedia(core);
2563
+ let announcePct;
2422
2564
  if (!core.muted) {
2423
2565
  if (core.volume > 0)
2424
2566
  lastVolume = core.volume;
2425
2567
  core.volume = 0;
2426
2568
  core.muted = true;
2569
+ announcePct = 0;
2427
2570
  if (el && el !== core.surface) {
2428
2571
  try {
2429
2572
  el.volume = 0;
@@ -2440,6 +2583,7 @@ class VolumeControl extends BaseControl {
2440
2583
  const restore = lastVolume > 0 ? lastVolume : 1;
2441
2584
  core.volume = restore;
2442
2585
  core.muted = false;
2586
+ announcePct = Math.round(restore * 100);
2443
2587
  if (el && el !== core.surface) {
2444
2588
  try {
2445
2589
  el.volume = restore;
@@ -2452,9 +2596,38 @@ class VolumeControl extends BaseControl {
2452
2596
  }
2453
2597
  }
2454
2598
  }
2599
+ if (volTimer)
2600
+ clearTimeout(volTimer);
2601
+ volTimer = null;
2602
+ announce(fmt('volumePercent', String(announcePct)));
2455
2603
  me.preventDefault();
2456
2604
  me.stopPropagation();
2457
2605
  }, EVENT_OPTIONS);
2606
+ // Re-entrancy guard: writing el.volume/el.muted fires a DOM volumechange which
2607
+ // bridges back to core.events, re-triggering this handler. The ads plugin also
2608
+ // writes core.volume/core.muted from its own volumechange listener on the ad
2609
+ // video element, creating a cross-handler loop. The flag breaks the cycle.
2610
+ let syncingVolume = false;
2611
+ const syncActiveMedia = (muted, vol) => {
2612
+ if (syncingVolume)
2613
+ return;
2614
+ const el = getActiveMedia(core);
2615
+ if (!el || el === core.surface)
2616
+ return;
2617
+ syncingVolume = true;
2618
+ try {
2619
+ if (el.muted !== muted)
2620
+ el.muted = muted;
2621
+ if (!muted && el.volume !== vol)
2622
+ el.volume = vol;
2623
+ }
2624
+ catch {
2625
+ // ignore
2626
+ }
2627
+ finally {
2628
+ syncingVolume = false;
2629
+ }
2630
+ };
2458
2631
  this.onPlayer('loadedmetadata', () => {
2459
2632
  const muted = core.muted || core.volume === 0;
2460
2633
  const vol = formatVolume(core.volume);
@@ -2463,19 +2636,11 @@ class VolumeControl extends BaseControl {
2463
2636
  slider.value = (muted ? 0 : vol).toString();
2464
2637
  updateSlider(muted ? 0 : vol);
2465
2638
  updateBtn(muted ? 0 : vol);
2466
- const el = getActiveMedia(core);
2467
- if (el && el !== core.surface) {
2468
- try {
2469
- el.muted = muted;
2470
- if (!muted)
2471
- el.volume = vol;
2472
- }
2473
- catch {
2474
- // ignore
2475
- }
2476
- }
2639
+ syncActiveMedia(muted, vol);
2477
2640
  });
2478
2641
  this.onPlayer('volumechange', () => {
2642
+ if (syncingVolume)
2643
+ return;
2479
2644
  const muted = core.muted || core.volume === 0;
2480
2645
  const vol = formatVolume(core.volume);
2481
2646
  if (vol > 0)
@@ -2484,17 +2649,7 @@ class VolumeControl extends BaseControl {
2484
2649
  updateSlider(muted ? 0 : vol);
2485
2650
  updateBtn(muted ? 0 : vol);
2486
2651
  btn.setAttribute('aria-pressed', muted ? 'true' : 'false');
2487
- const el = getActiveMedia(core);
2488
- if (el && el !== core.surface) {
2489
- try {
2490
- el.muted = muted;
2491
- if (!muted)
2492
- el.volume = vol;
2493
- }
2494
- catch {
2495
- // ignore
2496
- }
2497
- }
2652
+ syncActiveMedia(muted, vol);
2498
2653
  });
2499
2654
  const container = document.createElement('div');
2500
2655
  container.className = 'op-controls__volume--container';