Skip to content

feat(packages): add time display toggle#1669

Merged
sampotts merged 1 commit into
mainfrom
feat/time-inversion
Jul 1, 2026
Merged

feat(packages): add time display toggle#1669
sampotts merged 1 commit into
mainfrom
feat/time-inversion

Conversation

@sampotts

@sampotts sampotts commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

Slightly different to the issue in that:

  • type="duration" toggle switches duration ↔ remaining.
  • type="remaining" toggle still switches remaining ↔ duration.
  • type="current" toggle still switches current ↔ remaining.

Why? I noticed Apple Music toggles between duration and remaining and that pattern also worked well with the default skin. Minimal's time layout suited the standard toggle between current and remaining. Happy to run through it on a call if need be.

  • Add a toggle prop/attribute for time displays across core, React, and HTML.
  • Update docs and add focused coverage for core, React, and HTML time behavior.

Validation

  • pnpm -F @videojs/core test src/core/ui/time
  • pnpm -F @videojs/react test src/ui/time
  • pnpm -F @videojs/html test src/ui/time
  • pnpm -F @videojs/skins build
  • git diff --check

Closes #1422


Note

Low Risk
Player UI and accessibility labeling changes with broad test coverage; no auth, data, or playback pipeline changes.

Overview
Adds a toggle prop/attribute so time displays can switch between paired modes: currentremaining, or durationremaining (initial view still comes from type).

TimeCore gains toggle-aware labels and attrs: default accessibility text is the human phrase (not static role names), aria-valuetext is removed, and when toggle is on the element gets role="button", tabIndex, and an aria-label that includes the next action (e.g. “Show remaining time.”).

TimeElement and Time.Value track an active display type, handle click/Enter/Space, respect preventDefault, and reset when type or toggle changes or media state is missing.

Built-in audio/video skins wire this up: minimal skins toggle current time; default skins use toggle type="remaining" on the end time slot (duration ↔ remaining). Skin CSS adds focus-visible styling for toggleable times. Docs and tests cover core, HTML, and React behavior.

Reviewed by Cursor Bugbot for commit ee8b10f. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Jun 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment Jul 1, 2026 2:24am

Request Review

@netlify

netlify Bot commented Jun 9, 2026

Copy link
Copy Markdown

Deploy Preview for vjs10-site ready!

Name Link
🔨 Latest commit ee8b10f
🔍 Latest deploy log https://app.netlify.com/projects/vjs10-site/deploys/6a447a38ed603000088c2e5b
😎 Deploy Preview https://deploy-preview-1669--vjs10-site.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size Report

🎨 @videojs/html

Path Base initial PR initial Diff % Lazy
/video/minimal-skin 45.82 kB 46.15 kB +338 B +0.7% 🔺
/video/minimal-skin.tailwind 46.55 kB 46.92 kB +379 B +0.8% 🔺
/video/skin 45.86 kB 46.18 kB +333 B +0.7% 🔺
/video/skin.tailwind 46.67 kB 46.96 kB +302 B +0.6% 🔺
/audio/minimal-skin 38.55 kB 38.95 kB +410 B +1.0% 🔺
/audio/minimal-skin.tailwind 39.05 kB 39.36 kB +321 B +0.8% 🔺
/audio/skin 39.41 kB 39.78 kB +378 B +0.9% 🔺
/audio/skin.tailwind 39.95 kB 40.27 kB +320 B +0.8% 🔺
/live-video/minimal-skin 44.85 kB 45.20 kB +354 B +0.8% 🔺
/live-video/minimal-skin.tailwind 45.43 kB 45.73 kB +305 B +0.7% 🔺
/live-video/skin 44.47 kB 44.77 kB +307 B +0.7% 🔺
/live-video/skin.tailwind 45.14 kB 45.50 kB +368 B +0.8% 🔺
/live-audio/minimal-skin 31.18 kB 31.54 kB +367 B +1.1% 🔺
/live-audio/minimal-skin.tailwind 30.66 kB 30.98 kB +325 B +1.0% 🔺
/live-audio/skin 32.22 kB 32.54 kB +331 B +1.0% 🔺
/ui/compounds 28.76 kB 29.08 kB +324 B +1.1% 🔺
/ui/time 8.08 kB 8.66 kB +584 B +7.1% 🔺
/video (default) 45.83 kB 46.22 kB +393 B +0.8% 🔺
/video (default + hls) 185.40 kB 185.88 kB +488 B +0.3% 🔺
/video (minimal) 45.82 kB 46.20 kB +395 B +0.8% 🔺
/video (minimal + hls) 185.38 kB 185.91 kB +540 B +0.3% 🔺
/audio (default) 39.42 kB 39.78 kB +360 B +0.9% 🔺
/audio (minimal) 38.48 kB 38.84 kB +370 B +0.9% 🔺
Presets (7)
Entry Initial
/video (default) 46.22 kB
/video (default + hls) 185.88 kB
/video (minimal) 46.20 kB
/video (minimal + hls) 185.91 kB
/audio (default) 39.78 kB
/audio (minimal) 38.84 kB
/background 4.20 kB
Media (10)
Entry Initial
/media/background-video 1.14 kB
/media/container 1.72 kB
/media/dash-video 214.72 kB
/media/hlsjs-video 141.49 kB
/media/mux-audio 163.89 kB
/media/mux-video 163.96 kB
/media/native-hls-video 9.05 kB
/media/simple-hls-audio-only 17.15 kB
/media/simple-hls-video 18.72 kB
/media/vimeo-video 12.31 kB
Players (5)
Entry Initial
/video/player 8.22 kB
/audio/player 5.38 kB
/background/player 3.92 kB
/live-video/player 7.64 kB
/live-audio/player 5.39 kB
Skins (30)
Entry Type Initial
/video/minimal-skin.css css 5.65 kB
/video/skin.css css 5.61 kB
/video/minimal-skin js 46.15 kB
/video/minimal-skin.tailwind js 46.92 kB
/video/skin js 46.18 kB
/video/skin.tailwind js 46.96 kB
/audio/minimal-skin.css css 3.93 kB
/audio/skin.css css 3.82 kB
/audio/minimal-skin js 38.95 kB
/audio/minimal-skin.tailwind js 39.36 kB
/audio/skin js 39.78 kB
/audio/skin.tailwind js 40.27 kB
/background/skin.css css 133 B
/background/skin js 1.14 kB
/live-video/minimal-skin.css css 5.65 kB
/live-video/skin.css css 5.61 kB
/live-video/minimal-skin js 45.20 kB
/live-video/minimal-skin.tailwind js 45.73 kB
/live-video/skin js 44.77 kB
/live-video/skin.tailwind js 45.50 kB
/live-audio/minimal-skin.css css 3.93 kB
/live-audio/skin.css css 3.82 kB
/live-audio/minimal-skin js 31.54 kB
/live-audio/minimal-skin.tailwind js 30.98 kB
/live-audio/skin js 32.54 kB
/live-audio/skin.tailwind js 32.01 kB
/global.css css 176 B
/shared.css css 104 B
/tailwind.css css 161 B
/skin-element js 1.45 kB
UI Components (39)
Entry Initial
/ui/airplay-button 2.44 kB
/ui/alert-dialog 2.56 kB
/ui/alert-dialog-close 2.19 kB
/ui/alert-dialog-description 2.17 kB
/ui/alert-dialog-title 2.16 kB
/ui/audio-track-radio-group 2.85 kB
/ui/buffering-indicator 2.27 kB
/ui/captions-button 2.45 kB
/ui/captions-radio-group 2.83 kB
/ui/cast-button 2.41 kB
/ui/compounds 2.88 kB
/ui/controls 2.62 kB
/ui/error-dialog 2.65 kB
/ui/fullscreen-button 2.41 kB
/ui/hotkey 2.30 kB
/ui/menu 2.67 kB
/ui/mute-button 2.41 kB
/ui/pip-button 2.39 kB
/ui/play-button 2.45 kB
/ui/playback-rate-button 2.40 kB
/ui/playback-rate-radio-group 2.80 kB
/ui/popover 2.72 kB
/ui/poster 2.27 kB
/ui/quality-radio-group 2.80 kB
/ui/seek-button 2.34 kB
/ui/seek-indicator 2.37 kB
/ui/seek-indicator-value 446 B
/ui/slider 2.67 kB
/ui/status-announcer 2.30 kB
/ui/status-indicator 2.40 kB
/ui/status-indicator-value 411 B
/ui/thumbnail 2.19 kB
/ui/time 2.64 kB
/ui/time-slider 2.66 kB
/ui/tooltip 2.65 kB
/ui/volume-indicator 2.33 kB
/ui/volume-indicator-fill 416 B
/ui/volume-indicator-value 392 B
/ui/volume-slider 2.67 kB

Sizes are marginal over the root entry point.

⚛️ @videojs/react

Path Base initial PR initial Diff % Lazy
/video/skin.tailwind 44.00 kB 44.31 kB +315 B +0.7% 🔺
/ui/time 7.36 kB 7.81 kB +466 B +6.2% 🔺
/video (minimal + hls) 176.77 kB 177.24 kB +483 B +0.3% 🔺
Presets (7)
Entry Initial
/video (default) 38.31 kB
/video (default + hls) 176.58 kB
/video (minimal) 38.65 kB
/video (minimal + hls) 177.24 kB
/audio (default) 31.51 kB
/audio (minimal) 31.60 kB
/background 754 B
Media (9)
Entry Initial
/media/background-video 575 B
/media/dash-video 213.16 kB
/media/hlsjs-video 140.07 kB
/media/mux-audio 162.52 kB
/media/mux-video 162.57 kB
/media/native-hls-video 7.41 kB
/media/simple-hls-audio-only 15.56 kB
/media/simple-hls-video 17.14 kB
/media/vimeo-video 10.58 kB
Skins (27)
Entry Type Initial
/tailwind.css css 161 B
/video/minimal-skin.css css 5.55 kB
/video/skin.css css 5.52 kB
/video/minimal-skin js 38.57 kB
/video/minimal-skin.tailwind js 44.64 kB
/video/skin js 38.21 kB
/video/skin.tailwind js 44.31 kB
/audio/minimal-skin.css css 3.79 kB
/audio/skin.css css 3.68 kB
/audio/minimal-skin js 31.48 kB
/audio/minimal-skin.tailwind js 33.72 kB
/audio/skin js 31.46 kB
/audio/skin.tailwind js 35.63 kB
/background/skin.css css 90 B
/background/skin js 272 B
/live-video/minimal-skin.css css 5.55 kB
/live-video/skin.css css 5.52 kB
/live-video/minimal-skin js 33.37 kB
/live-video/minimal-skin.tailwind js 39.33 kB
/live-video/skin js 33.38 kB
/live-video/skin.tailwind js 39.35 kB
/live-audio/minimal-skin.css css 3.79 kB
/live-audio/skin.css css 3.68 kB
/live-audio/minimal-skin js 22.45 kB
/live-audio/minimal-skin.tailwind js 25.59 kB
/live-audio/skin js 22.52 kB
/live-audio/skin.tailwind js 25.65 kB
UI Components (33)
Entry Initial
/ui/airplay-button 2.18 kB
/ui/alert-dialog 2.13 kB
/ui/audio-track 2.12 kB
/ui/buffering-indicator 2.09 kB
/ui/captions-button 2.21 kB
/ui/captions-radio-group 2.09 kB
/ui/cast-button 2.15 kB
/ui/controls 2.04 kB
/ui/error-dialog 2.19 kB
/ui/fullscreen-button 2.15 kB
/ui/gesture 2.20 kB
/ui/hotkey 2.20 kB
/ui/live-button 2.10 kB
/ui/menu 2.26 kB
/ui/mute-button 2.17 kB
/ui/pip-button 2.21 kB
/ui/play-button 2.19 kB
/ui/playback-rate 2.00 kB
/ui/playback-rate-button 2.23 kB
/ui/popover 2.57 kB
/ui/poster 2.07 kB
/ui/quality 2.08 kB
/ui/seek-button 2.20 kB
/ui/seek-indicator 2.09 kB
/ui/slider 2.20 kB
/ui/status-announcer 2.10 kB
/ui/status-indicator 2.05 kB
/ui/thumbnail 2.01 kB
/ui/time 2.03 kB
/ui/time-slider 2.19 kB
/ui/tooltip 2.56 kB
/ui/volume-indicator 2.03 kB
/ui/volume-slider 2.21 kB

Sizes are marginal over the root entry point.

🧩 @videojs/core — no changes
Entries (14)
Entry Initial
. 9.40 kB
/dom 17.52 kB
/dom/media/custom-media-element 2.09 kB
/dom/media/dash 208.96 kB
/dom/media/google-cast 4.04 kB
/dom/media/hls-js 136.00 kB
/dom/media/media-host 1.25 kB
/dom/media/media-played-ranges 576 B
/dom/media/mux 151.26 kB
/dom/media/native-hls 3.07 kB
/dom/media/simple-hls 16.49 kB
/dom/media/simple-hls-audio-only 14.92 kB
/dom/media/vimeo 9.86 kB
/media/predicate 563 B
🏷️ @videojs/element — no changes
Entries (2)
Entry Initial
. 996 B
/context 943 B
📦 @videojs/store — no changes
Entries (3)
Entry Initial
. 1.39 kB
/html 696 B
/react 360 B
🔧 @videojs/utils — no changes
Entries (10)
Entry Initial
/array 104 B
/dom 2.26 kB
/events 319 B
/function 327 B
/object 275 B
/predicate 265 B
/string 231 B
/style 190 B
/time 478 B
/number 158 B
📦 @videojs/spf — no changes
Entries (4)
Entry Initial
. 4.45 kB
/dom 6.33 kB
/hls 15.37 kB
/background-video 12.85 kB

ℹ️ How to interpret

JS sizes are initial static graph totals (minified + brotli). Lazy dynamic chunks are shown separately when present.

Icon Meaning
No change
🔺 Increased ≤ 10%
🔴 Increased > 10%
🔽 Decreased
🆕 New (no baseline)

Run pnpm size locally to check current initial sizes.

@sampotts sampotts marked this pull request as ready for review June 9, 2026 05:24
@sampotts sampotts requested review from luwes and mihar-22 June 9, 2026 05:24
Comment on lines +43 to +46
const TOGGLE_LABELS: Partial<Record<TimeType, string>> = {
current: 'Current time, press to show remaining time',
remaining: 'Remaining time, press to show current time',
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will need to update i18n PRs when this is merged.

Comment thread packages/react/src/ui/time/time-value.tsx Outdated
Comment thread packages/core/src/core/ui/time/time-core.ts
<div class="${time.controls}">
<media-time-group class="${time.group}">
<media-time type="current" class="${time.current}"></media-time>
<media-time toggle type="current" class="${time.current}"></media-time>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only added it to the minimal skin as the default skin felt a bit better with the remaining in place of the duration. We'll revisit this before GA though I guess as we may want to make minimal a bit more minimal in terms of UI. I'm just waiting for the menus stuff to merge so we're reasonably feature stable for the skins.

Comment thread packages/html/src/ui/time/time-element.ts
Comment thread packages/html/src/ui/time/time-element.ts
Comment thread packages/html/src/ui/time/time-element.ts Outdated
Comment thread packages/html/src/ui/time/time-element.ts

@mihar-22 mihar-22 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing this! A second pass and simplifications on additions to Time components would be great, plus considering removing the toggle labels from core.

Comment on lines +43 to +47
const TOGGLE_LABELS: Record<TimeType, string> = {
current: 'Current time, press to toggle display',
duration: 'Duration, press to toggle display',
remaining: 'Remaining time, press to toggle display',
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this would complicate the i18n work. I think the ARIA attributes are already indicating that this can be pressed/toggled. YT seems to only require the addition of role and tabIndex. Ideally we can remove.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've asked in the shared a11y about the best pattern here. I don't think YouTube has this quite right as there's no indication of what the button actually does, the label only informs of the current state. I could be wrong but waiting on clarification. I think we want the current state and next state to be obvious to screen readers.

Comment on lines +38 to +45
applyElementProps(
this,
{
onClick: this.#handleClick,
onKeyDown: this.#handleKeyDown,
},
{ signal: this.#disconnect.signal }
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For perf reasons it'd be great if these event listeners are attached when toggle is true.

Comment on lines +100 to +114
get #activeType(): TimeType {
return this.#canToggle ? this.#displayType : this.type;
}

get #canToggle(): boolean {
return this.toggle;
}

#getMedia() {
try {
return this.#state.value;
} catch {
return undefined;
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove all of this?

};

#handleKeyDown = (event: KeyboardEvent): void => {
if (event.target !== event.currentTarget || event.defaultPrevented) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this check?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The element can have children that could also fire events but we can remove it. Checking that default is prevented also allows folks to prevent the toggle but perhaps we don't need that either.

}

#handleClick = (event: MouseEvent): void => {
if (event.defaultPrevented || !this.#canToggle || !this.#getMedia()) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this check?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking that default is prevented allows folks to prevent the toggle but perhaps we don't need that. The rest can go if we're only binding when toggle is enabled.

};

const handleKeyDown = (event: KeyboardEvent<HTMLTimeElement>) => {
if (event.defaultPrevented || (event.key !== 'Enter' && event.key !== ' ')) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as HTML, we might have a utility for key press/click, if not great to create one!


const handleKeyDown = (event: KeyboardEvent<HTMLTimeElement>) => {
if (event.defaultPrevented || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are we preventing?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above - space will scroll the page.

);

const toggleType = () => {
if (!canToggle) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not guarding anything meaningful. In addition, won't be called because listeners below won't be attached unless it can toggle.

Comment on lines +101 to +108
function getNextType(type: TimeType, defaultType: TimeType): TimeType {
if (defaultType === 'current') {
return type === 'remaining' ? 'current' : 'remaining';
}

return type === 'duration' ? 'remaining' : 'duration';
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only called in one place, fine to inline for now imo.

toggleType();
};

const toggleProps = canToggle ? { onClick: handleClick, onKeyDown: handleKeyDown } : undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be fine to inline this but leave it up to you.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6f691de. Configure here.

Comment thread packages/html/src/ui/time/time-element.ts Outdated
@sampotts sampotts force-pushed the feat/time-inversion branch from 6f691de to ee8b10f Compare July 1, 2026 02:23
@sampotts sampotts merged commit be4d5a1 into main Jul 1, 2026
26 checks passed
@sampotts sampotts deleted the feat/time-inversion branch July 1, 2026 02:27
@luwes luwes mentioned this pull request Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Time Display Inversion (Elapsed / Remaining)

3 participants