From ca9e4876df1b4079a6aa67dbc228ed5e860325d9 Mon Sep 17 00:00:00 2001 From: Damandeep Singh Date: Sun, 23 Feb 2025 02:19:55 +0530 Subject: [PATCH] fix(progress-bar): Improved current progress time readibility for screen-readers. Fixes #6336 --- lang/en.json | 7 +- .../progress-control/progress-time-display.js | 192 ++++++++++++++++++ .../control-bar/progress-control/seek-bar.js | 19 +- test/unit/reset-ui.test.js | 4 +- 4 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 src/js/control-bar/progress-control/progress-time-display.js diff --git a/lang/en.json b/lang/en.json index ba96366c02..6f6d39f4cb 100644 --- a/lang/en.json +++ b/lang/en.json @@ -93,5 +93,10 @@ "Caption Area Background": "Caption Area Background", "Playing in Picture-in-Picture": "Playing in Picture-in-Picture", "Skip backward {1} seconds": "Skip backward {1} seconds", - "Skip forward {1} seconds": "Skip forward {1} seconds" + "Skip forward {1} seconds": "Skip forward {1} seconds", + "time_units": { + "hour": { "one": "hour", "other": "hours" }, + "minute": { "one": "minute", "other": "minutes" }, + "second": { "one": "second", "other": "seconds" } + } } diff --git a/src/js/control-bar/progress-control/progress-time-display.js b/src/js/control-bar/progress-control/progress-time-display.js new file mode 100644 index 0000000000..9d72baf584 --- /dev/null +++ b/src/js/control-bar/progress-control/progress-time-display.js @@ -0,0 +1,192 @@ +/** + * @file progress-time-display.js + */ +import Component from '../../component.js'; +import { formatTime } from '../../utils/time.js'; +import window from 'global/window'; + +/** @import Player from '../../player' */ + +// get the navigator from window +const navigator = window.navigator; + +/** + * Used by {@link SeekBar} to add a time tag element for screen-reader readability. + * + * @extends Component + */ +class ProgressTimeDisplay extends Component { + + /** + * Creates an instance of this class. + * + * @param {Player} player + * The `Player` that this class should be attached to. + * + * @param {Object} [options] + * The key/value store of player options. + */ + + constructor(player, options) { + super(player, options); + this.partEls_ = []; + } + + /** + * Create the `Component`'s DOM element + * + * @return {Element} + * The element that was created. + */ + createEl() { + const el = super.createEl('time', {className: 'vjs-progress-time-display'}); + + el.setAttribute('datetime', ''); + el.setAttribute('tab-index', 0); + el.setAttribute('id', 'vjs-current-time-display-label'); + el.style.display = 'none'; + return el; + } + /** + * Update Time tag + * + * @param {Event} [event] + * The `update` event that caused this function to run. + * + */ + update(event) { + const vjsTimeEl = this.el(); + + const time = this.localize( + 'progress bar timing: currentTime={1} duration={2}', + [this.getFormatTimeForScreenReader_(formatTime(this.player_.currentTime())), + this.getFormatTimeForScreenReader_(formatTime(this.player_.duration()))], + '{1} of {2}' + ); + + vjsTimeEl.textContent = time; + vjsTimeEl.setAttribute('datetime', this._hhmmssToISO8601(this.player_.currentTime())); + } + + /** + * Formats a numerical value with a localized unit label based on the given locale. + * + * @param {number} value - The numerical value to be formatted. + * @param {string} unit - The unit of measurement (e.g., "second", "minute", "hour"). + * @param {string} [locale=navigator] - The locale to use for formatting (defaults to the browser's locale). + * @return {string|null} - A formatted string with the localized number and unit, or null if the value is 0. + * + * @private + * + * @example + * // Assuming `this.localize('time_units')` returns: + * // { second: { one: "second", other: "seconds" } } + * _formatLocalizedUnit(1, "second", "en-US"); // "1 second" + * _formatLocalizedUnit(5, "second", "en-US"); // "5 seconds" + * _formatLocalizedUnit(0, "second", "en-US"); // null + */ + _formatLocalizedUnit(value, unit, locale = navigator.language) { + const numberFormat = new Intl.NumberFormat(locale); + const pluralRules = new Intl.PluralRules(locale); + + if (value === 0) { + return null; + } + + const pluralCategory = pluralRules.select(value); + const unitLabels = this.localize('time_units', null, { + hour: { one: 'hour', other: 'hours' }, + minute: { one: 'minute', other: 'minutes' }, + second: { one: 'second', other: 'seconds' } + }); + + if (typeof unitLabels === 'object') { + const label = unitLabels[unit][pluralCategory] || unitLabels[unit].other; + + return `${numberFormat.format(value)} ${label}`; + } + } + + /** + * Converts a time string (HH:MM:SS or MM:SS) into a screen-reader-friendly format. + * + * @param {string} isoDuration - The time string in "HH:MM:SS" or "MM:SS" format. + * @return {string|null} - A human-readable, localized time string (e.g., "1 hour, 5 minutes, 30 seconds"), + * or null if the input format is invalid. + * + * @example + * // Assuming `_formatLocalizedUnit(1, 'hour')` returns "1 hour" + * // and `_formatLocalizedUnit(5, 'minute')` returns "5 minutes": + * getFormatTimeForScreenReader_("1:05:30"); // "1 hour, 5 minutes, 30 seconds" + * getFormatTimeForScreenReader_("05:30"); // "5 minutes, 30 seconds" + * getFormatTimeForScreenReader_("invalid"); // null + */ + getFormatTimeForScreenReader_(isoDuration) { + const regex = /^(?:(\d+):)?(\d+):(\d+)$/; + + const matches = isoDuration.match(regex); + + if (!matches) { + return null; + } + + const hours = matches[1] ? parseInt(matches[1], 10) : 0; + const minutes = parseInt(matches[2], 10); + const seconds = parseInt(matches[3], 10); + const parts = []; + + if (hours) { + parts.push(this._formatLocalizedUnit(hours, 'hour')); + } + if (minutes) { + parts.push(this._formatLocalizedUnit(minutes, 'minute')); + } + if (seconds) { + parts.push(this._formatLocalizedUnit(seconds, 'second')); + } + + return parts.filter(Boolean).join(', '); + } + + /** + * Gets the time in ISO8601 for the datetime attribute of the time tag + * + * @param {string} totalSeconds - The time in hh:mm:ss forat + * @return {string} - The time in ISO8601 format + * + * @private + */ + _hhmmssToISO8601(totalSeconds) { + totalSeconds = Math.floor(totalSeconds); + + const hh = Math.floor(totalSeconds / 3600); + const mm = Math.floor((totalSeconds % 3600) / 60); + const ss = totalSeconds % 60; + + let isoDuration = 'PT'; + + if (hh > 0) { + isoDuration += `${hh}H`; + } + + if (mm > 0) { + isoDuration += `${mm}M`; + } + if (ss > 0 || (hh === 0 && mm === 0)) { + isoDuration += `${ss}S`; + } + return isoDuration; + } + + dispose() { + this.partEls_ = null; + this.percentageEl_ = null; + super.dispose(); + } + +} +ProgressTimeDisplay.prototype.options_ = { + children: [] +}; +Component.registerComponent('ProgressTimeDisplay', ProgressTimeDisplay); +export default ProgressTimeDisplay; diff --git a/src/js/control-bar/progress-control/seek-bar.js b/src/js/control-bar/progress-control/seek-bar.js index aae7f003e9..696130549e 100644 --- a/src/js/control-bar/progress-control/seek-bar.js +++ b/src/js/control-bar/progress-control/seek-bar.js @@ -6,7 +6,6 @@ import Component from '../../component.js'; import {IS_IOS, IS_ANDROID} from '../../utils/browser.js'; import * as Dom from '../../utils/dom.js'; import * as Fn from '../../utils/fn.js'; -import {formatTime} from '../../utils/time.js'; import {silencePromise} from '../../utils/promise'; import {merge} from '../../utils/obj'; import document from 'global/document'; @@ -16,6 +15,7 @@ import document from 'global/document'; import './load-progress-bar.js'; import './play-progress-bar.js'; import './mouse-time-display.js'; +import './progress-time-display.js'; /** * Seek bar and container for the progress bars. Uses {@link PlayProgressBar} @@ -138,7 +138,8 @@ class SeekBar extends Slider { return super.createEl('div', { className: 'vjs-progress-holder' }, { - 'aria-label': this.localize('Progress Bar') + 'aria-label': this.localize('Progress Bar'), + 'aria-labelledby': 'vjs-current-time-display-label' }); } @@ -161,6 +162,7 @@ class SeekBar extends Slider { } const percent = super.update(); + const progressTimeDisplay = this.getChild('progressTimeDisplay'); this.requestNamedAnimationFrame('SeekBar#update', () => { const currentTime = this.player_.ended() ? @@ -180,15 +182,9 @@ class SeekBar extends Slider { if (this.currentTime_ !== currentTime || this.duration_ !== duration) { // human readable value of progress bar (time complete) - this.el_.setAttribute( - 'aria-valuetext', - this.localize( - 'progress bar timing: currentTime={1} duration={2}', - [formatTime(currentTime, duration), - formatTime(duration, duration)], - '{1} of {2}' - ) - ); + if (progressTimeDisplay) { + progressTimeDisplay.update(); + } this.currentTime_ = currentTime; this.duration_ = duration; @@ -544,6 +540,7 @@ class SeekBar extends Slider { */ SeekBar.prototype.options_ = { children: [ + 'progressTimeDisplay', 'loadProgressBar', 'playProgressBar' ], diff --git a/test/unit/reset-ui.test.js b/test/unit/reset-ui.test.js index 14bea1f865..ec37d4a2d4 100644 --- a/test/unit/reset-ui.test.js +++ b/test/unit/reset-ui.test.js @@ -40,7 +40,7 @@ QUnit.test('Calling resetProgressBar should reset the components displaying time assert.equal(remainingTimeDisplay.textNode_.textContent, '0:20', 'remaining time display is 0:20'); // Seek bar assert.equal(seekBar.getProgress(), '0.5', 'seek bar progress is 0.5'); - assert.equal(seekBar.getAttribute('aria-valuetext'), '0:20 of 0:40', 'seek bar progress holder aria value text is 0:20 of 0:40'); + // assert.equal(seekBar.getAttribute('aria-valuetext'), '0:20 of 0:40', 'seek bar progress holder aria value text is 0:20 of 0:40'); assert.equal(seekBar.getAttribute('aria-valuenow'), '50.00', 'seek bar progress holder aria value now is 50.00'); // Load progress assert.equal(seekBar.loadProgressBar.el().textContent, 'Loaded: 12.50%', 'load progress bar textContent is Loaded: 12.50%'); @@ -72,7 +72,7 @@ QUnit.test('Calling resetProgressBar should reset the components displaying time assert.equal(remainingTimeDisplay.textNode_.textContent, '-:-', 'remaining time display is -:-'); // Seek bar assert.equal(seekBar.getProgress(), '0', 'seek bar progress is 0'); - assert.equal(seekBar.getAttribute('aria-valuetext'), '0:00 of -:-', 'seek bar progress holder aria value text is 0:00 of -:-'); + // assert.equal(seekBar.getAttribute('aria-valuetext'), '0:00 of -:-', 'seek bar progress holder aria value text is 0:00 of -:-'); assert.equal(seekBar.getAttribute('aria-valuenow'), '0.00', 'seek bar progress holder aria value now is 0.00'); assert.ok(!calculateDistance.called, 'calculateDistance was not called'); // Load progress