Skip to content

Commit

Permalink
fix(progress-bar): Improved current progress time readibility for scr…
Browse files Browse the repository at this point in the history
…een-readers.

Fixes videojs#6336
  • Loading branch information
damanV5 committed Feb 22, 2025
1 parent 8842d37 commit ca9e487
Show file tree
Hide file tree
Showing 4 changed files with 208 additions and 14 deletions.
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;
}

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

0 comments on commit ca9e487

Please sign in to comment.