Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat:(ProgressBar) Improved Current Progress Bar Time Readability for Screen Readers #8985

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
}
192 changes: 192 additions & 0 deletions src/js/control-bar/progress-control/progress-time-display.js
Original file line number Diff line number Diff line change
@@ -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;

Check warning on line 93 in src/js/control-bar/progress-control/progress-time-display.js

View check run for this annotation

Codecov / codecov/patch

src/js/control-bar/progress-control/progress-time-display.js#L93

Added line #L93 was not covered by tests
}

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'));

Check warning on line 139 in src/js/control-bar/progress-control/progress-time-display.js

View check run for this annotation

Codecov / codecov/patch

src/js/control-bar/progress-control/progress-time-display.js#L139

Added line #L139 was not covered by tests
}
if (minutes) {
parts.push(this._formatLocalizedUnit(minutes, 'minute'));

Check warning on line 142 in src/js/control-bar/progress-control/progress-time-display.js

View check run for this annotation

Codecov / codecov/patch

src/js/control-bar/progress-control/progress-time-display.js#L142

Added line #L142 was not covered by tests
}
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`;

Check warning on line 169 in src/js/control-bar/progress-control/progress-time-display.js

View check run for this annotation

Codecov / codecov/patch

src/js/control-bar/progress-control/progress-time-display.js#L169

Added line #L169 was not covered by tests
}

if (mm > 0) {
isoDuration += `${mm}M`;

Check warning on line 173 in src/js/control-bar/progress-control/progress-time-display.js

View check run for this annotation

Codecov / codecov/patch

src/js/control-bar/progress-control/progress-time-display.js#L173

Added line #L173 was not covered by tests
}
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;
19 changes: 8 additions & 11 deletions src/js/control-bar/progress-control/seek-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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}
Expand Down Expand Up @@ -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'
});
}

Expand All @@ -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() ?
Expand All @@ -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;
Expand Down Expand Up @@ -544,6 +540,7 @@ class SeekBar extends Slider {
*/
SeekBar.prototype.options_ = {
children: [
'progressTimeDisplay',
'loadProgressBar',
'playProgressBar'
],
Expand Down
4 changes: 2 additions & 2 deletions test/unit/reset-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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%');
Expand Down Expand Up @@ -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
Expand Down
Loading