diff --git a/src/lib/viewers/controls/_styles.scss b/src/lib/viewers/controls/_styles.scss
index e3b528e8c..07610e22c 100644
--- a/src/lib/viewers/controls/_styles.scss
+++ b/src/lib/viewers/controls/_styles.scss
@@ -5,6 +5,7 @@ $bp-controls-background: fade-out($black, .2);
@mixin bp-Control--hover {
opacity: .7;
transition: opacity 150ms;
+ will-change: opacity; // Prevent flickering in Safari
&:focus,
&:hover {
diff --git a/src/lib/viewers/controls/hooks/__tests__/useAttention-test.tsx b/src/lib/viewers/controls/hooks/__tests__/useAttention-test.tsx
new file mode 100644
index 000000000..5c81fb499
--- /dev/null
+++ b/src/lib/viewers/controls/hooks/__tests__/useAttention-test.tsx
@@ -0,0 +1,38 @@
+import * as React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mount, ReactWrapper } from 'enzyme';
+import useAttention from '../useAttention';
+
+describe('useAttention', () => {
+ function TestComponent(): JSX.Element {
+ const [isActive, handlers] = useAttention();
+ return
;
+ }
+
+ const getElement = (wrapper: ReactWrapper): ReactWrapper => wrapper.childAt(0);
+ const getWrapper = (): ReactWrapper => mount();
+
+ test('should return isActive based on focus and/or hover state', () => {
+ const wrapper = getWrapper();
+ const simulate = (event: string): void => {
+ act(() => {
+ wrapper.simulate(event);
+ });
+ wrapper.update();
+ };
+
+ expect(getElement(wrapper).hasClass('active')).toBe(false); // Default
+
+ simulate('focus');
+ expect(getElement(wrapper).hasClass('active')).toBe(true); // Focus
+
+ simulate('mouseover');
+ expect(getElement(wrapper).hasClass('active')).toBe(true); // Focus & Hover
+
+ simulate('blur');
+ expect(getElement(wrapper).hasClass('active')).toBe(true); // Hover
+
+ simulate('mouseout');
+ expect(getElement(wrapper).hasClass('active')).toBe(false); // Default
+ });
+});
diff --git a/src/lib/viewers/controls/hooks/index.ts b/src/lib/viewers/controls/hooks/index.ts
index 2a7594ea7..8b27d74f0 100644
--- a/src/lib/viewers/controls/hooks/index.ts
+++ b/src/lib/viewers/controls/hooks/index.ts
@@ -1,2 +1,3 @@
+export { default as useAttention } from './useAttention';
export { default as useFullscreen } from './useFullscreen';
export { default as usePreventKey } from './usePreventKey';
diff --git a/src/lib/viewers/controls/hooks/useAttention.ts b/src/lib/viewers/controls/hooks/useAttention.ts
new file mode 100644
index 000000000..96e356d96
--- /dev/null
+++ b/src/lib/viewers/controls/hooks/useAttention.ts
@@ -0,0 +1,30 @@
+import * as React from 'react';
+
+export type handlers = {
+ onBlur: () => void;
+ onFocus: () => void;
+ onMouseOut: () => void;
+ onMouseOver: () => void;
+};
+
+export type isActive = boolean;
+
+export default function useAttention(): [isActive, handlers] {
+ const [isFocused, setFocused] = React.useState(false);
+ const [isHovered, setHovered] = React.useState(false);
+
+ const handleBlur = (): void => setFocused(false);
+ const handleFocus = (): void => setFocused(true);
+ const handleMouseOut = (): void => setHovered(false);
+ const handleMouseOver = (): void => setHovered(true);
+
+ return [
+ isFocused || isHovered,
+ {
+ onBlur: handleBlur,
+ onFocus: handleFocus,
+ onMouseOut: handleMouseOut,
+ onMouseOver: handleMouseOver,
+ },
+ ];
+}
diff --git a/src/lib/viewers/controls/media/TimeControls.scss b/src/lib/viewers/controls/media/TimeControls.scss
new file mode 100644
index 000000000..52d699dc9
--- /dev/null
+++ b/src/lib/viewers/controls/media/TimeControls.scss
@@ -0,0 +1,39 @@
+@import '../slider/styles';
+
+$bp-TimeControls-size: 18px; // Divisible by 3px (default) and 6px (hovered) for better vertical centering
+$bp-TimeControls-space: 10px;
+
+.bp-TimeControls {
+ height: $bp-TimeControls-size;
+
+ .bp-SliderControl-input {
+ padding-right: $bp-TimeControls-space;
+ padding-left: $bp-TimeControls-space;
+
+ @include bp-SliderThumb {
+ transform: scale(0);
+ transition: transform 100ms ease;
+ will-change: transform; // Prevent flickering in Safari
+ }
+ }
+
+ .bp-SliderControl-track {
+ margin-right: $bp-TimeControls-space;
+ margin-left: $bp-TimeControls-space;
+ backface-visibility: hidden; // Prevent jank in Firefox
+ transition: transform 100ms ease;
+ will-change: transform; // Prevent flickering in Safari
+ }
+
+ &:hover {
+ .bp-SliderControl-input {
+ @include bp-SliderThumb {
+ transform: scale(1);
+ }
+ }
+
+ .bp-SliderControl-track {
+ transform: scaleY(2);
+ }
+ }
+}
diff --git a/src/lib/viewers/controls/media/TimeControls.tsx b/src/lib/viewers/controls/media/TimeControls.tsx
new file mode 100644
index 000000000..10b888cc4
--- /dev/null
+++ b/src/lib/viewers/controls/media/TimeControls.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { bdlBoxBlue, bdlGray62, white } from 'box-ui-elements/es/styles/variables';
+import SliderControl from '../slider';
+import './TimeControls.scss';
+
+export type Props = {
+ bufferedRange?: TimeRanges;
+ currentTime?: number;
+ durationTime?: number;
+ onTimeChange: (volume: number) => void;
+};
+
+export const round = (value: number): number => {
+ return +value.toFixed(4);
+};
+
+export const percent = (value1: number, value2: number): number => {
+ return round((value1 / value2) * 100);
+};
+
+export default function TimeControls({
+ bufferedRange,
+ currentTime = 0,
+ durationTime = 0,
+ onTimeChange,
+}: Props): JSX.Element {
+ const currentValue = percent(currentTime, durationTime);
+ const bufferedAmount = bufferedRange && bufferedRange.length ? bufferedRange.end(bufferedRange.length - 1) : 0;
+ const bufferedValue = percent(bufferedAmount, durationTime);
+
+ const handleChange = (newValue: number): void => {
+ onTimeChange(round(durationTime * (newValue / 100)));
+ };
+
+ return (
+
+ );
+}
diff --git a/src/lib/viewers/controls/media/VolumeControls.scss b/src/lib/viewers/controls/media/VolumeControls.scss
new file mode 100644
index 000000000..d469c06af
--- /dev/null
+++ b/src/lib/viewers/controls/media/VolumeControls.scss
@@ -0,0 +1,26 @@
+@import './styles';
+
+.bp-VolumeControls {
+ display: flex;
+ align-items: center;
+}
+
+.bp-VolumeControls-flyout {
+ flex: 0 0 auto;
+ max-width: 0;
+ height: $bp-MediaControl-height;
+ overflow: hidden;
+ transition: max-width 200ms ease-in-out;
+
+ &.bp-is-open {
+ max-width: 100px;
+ }
+}
+
+.bp-VolumeControls-slider {
+ width: 100px;
+}
+
+.bp-VolumeControls-toggle {
+ @include bp-MediaButton;
+}
diff --git a/src/lib/viewers/controls/media/VolumeControls.tsx b/src/lib/viewers/controls/media/VolumeControls.tsx
new file mode 100644
index 000000000..0baded470
--- /dev/null
+++ b/src/lib/viewers/controls/media/VolumeControls.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import classNames from 'classnames';
+import { bdlBoxBlue, white } from 'box-ui-elements/es/styles/variables';
+import IconVolumeHigh24 from '../icons/IconVolumeHigh24';
+import IconVolumeLow24 from '../icons/IconVolumeLow24';
+import IconVolumeMedium24 from '../icons/IconVolumeMedium24';
+import IconVolumeMute24 from '../icons/IconVolumeMute24';
+import MediaToggle from './MediaToggle';
+import SliderControl, { Ref as SliderControlRef } from '../slider';
+import useAttention from '../hooks/useAttention';
+import usePreventKey from '../hooks/usePreventKey';
+import './VolumeControls.scss';
+
+export type Props = {
+ onMuteChange: (isMuted: boolean) => void;
+ onVolumeChange: (volume: number) => void;
+ volume?: number;
+};
+
+export function getIcon(volume: number): (props: React.SVGProps) => JSX.Element {
+ let Icon = IconVolumeMute24;
+
+ if (volume >= 0.66) {
+ Icon = IconVolumeHigh24;
+ } else if (volume >= 0.33) {
+ Icon = IconVolumeMedium24;
+ } else if (volume >= 0.01) {
+ Icon = IconVolumeLow24;
+ }
+
+ return Icon;
+}
+
+export default function VolumeControls({ onMuteChange, onVolumeChange, volume = 1 }: Props): JSX.Element {
+ const [isActive, handlers] = useAttention();
+ const inputElRef = React.useRef(null);
+ const isMuted = !volume;
+ const Icon = isMuted ? IconVolumeMute24 : getIcon(volume);
+ const title = isMuted ? __('media_unmute') : __('media_mute');
+ const value = Math.round(volume * 100);
+
+ const handleMute = (): void => {
+ onMuteChange(!isMuted);
+ };
+
+ const handleVolume = (newValue: number): void => {
+ onVolumeChange(newValue / 100);
+ };
+
+ // Allow the range input to handle its own left/right keydown events
+ usePreventKey(inputElRef, ['ArrowLeft', 'ArrowRight']);
+
+ return (
+
+ );
+}
diff --git a/src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx b/src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx
new file mode 100644
index 000000000..8b2940f54
--- /dev/null
+++ b/src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import { shallow, ShallowWrapper } from 'enzyme';
+import SliderControl from '../../slider';
+import TimeControls from '../TimeControls';
+
+describe('TimeControls', () => {
+ const getBuffer = (end = 1000, start = 0): TimeRanges => ({
+ length: end - start,
+ end: jest.fn().mockReturnValue(end),
+ start: jest.fn().mockReturnValue(start),
+ });
+ const getWrapper = (props = {}): ShallowWrapper =>
+ shallow();
+
+ describe('event handlers', () => {
+ test.each`
+ percentage | value
+ ${0} | ${0}
+ ${20} | ${1111}
+ ${33} | ${1833.15}
+ ${33.3333} | ${1851.6648}
+ ${100} | ${5555}
+ `(
+ 'should calculate the absolute value $value based on the relative slider percentage $percentage',
+ ({ percentage, value }) => {
+ const onChange = jest.fn();
+ const wrapper = getWrapper({ durationTime: 5555, onTimeChange: onChange });
+ const slider = wrapper.find(SliderControl);
+
+ slider.simulate('change', percentage);
+
+ expect(onChange).toBeCalledWith(value);
+ },
+ );
+ });
+
+ describe('render', () => {
+ test('should return a valid wrapper', () => {
+ const wrapper = getWrapper();
+
+ expect(wrapper.hasClass('bp-TimeControls')).toBe(true);
+ expect(wrapper.prop('step')).toEqual(0.1);
+ });
+
+ test.each`
+ currentTime | track | value
+ ${0} | ${'linear-gradient(to right, #0061d5 0%, #fff 0%, #fff 10%, #767676 10%, #767676 100%)'} | ${0}
+ ${50} | ${'linear-gradient(to right, #0061d5 0.5%, #fff 0.5%, #fff 10%, #767676 10%, #767676 100%)'} | ${0.5}
+ ${1000} | ${'linear-gradient(to right, #0061d5 10%, #fff 10%, #fff 10%, #767676 10%, #767676 100%)'} | ${10}
+ ${2500} | ${'linear-gradient(to right, #0061d5 25%, #fff 25%, #fff 10%, #767676 10%, #767676 100%)'} | ${25}
+ ${10000} | ${'linear-gradient(to right, #0061d5 100%, #fff 100%, #fff 10%, #767676 10%, #767676 100%)'} | ${100}
+ `('should render the correct track and value for currentTime $currentTime', ({ currentTime, track, value }) => {
+ const buffer = getBuffer(1000, 0); // 10% buffered
+ const wrapper = getWrapper({ bufferedRange: buffer, currentTime });
+
+ expect(wrapper.find(SliderControl).props()).toMatchObject({
+ track,
+ value,
+ });
+ });
+ });
+});
diff --git a/src/lib/viewers/controls/media/__tests__/VolumeControls-test.tsx b/src/lib/viewers/controls/media/__tests__/VolumeControls-test.tsx
new file mode 100644
index 000000000..095ee81cf
--- /dev/null
+++ b/src/lib/viewers/controls/media/__tests__/VolumeControls-test.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { shallow, ShallowWrapper } from 'enzyme';
+import IconVolumeHigh24 from '../../icons/IconVolumeHigh24';
+import IconVolumeLow24 from '../../icons/IconVolumeLow24';
+import IconVolumeMedium24 from '../../icons/IconVolumeMedium24';
+import IconVolumeMute24 from '../../icons/IconVolumeMute24';
+import MediaToggle from '../MediaToggle';
+import SliderControl from '../../slider';
+import VolumeControls from '../VolumeControls';
+import usePreventKey from '../../hooks/usePreventKey';
+
+jest.mock('../../hooks/usePreventKey');
+jest.mock('../../slider');
+
+describe('VolumeControls', () => {
+ const getWrapper = (props = {}): ShallowWrapper =>
+ shallow();
+
+ describe('event handlers', () => {
+ test.each`
+ volume | isMuted
+ ${0} | ${false}
+ ${0.5} | ${true}
+ ${1} | ${true}
+ `('should toggle mute to $isMuted when volume is $volume', ({ isMuted, volume }) => {
+ const onMuteChange = jest.fn();
+ const wrapper = getWrapper({ onMuteChange, volume });
+ const toggle = wrapper.find(MediaToggle);
+
+ toggle.simulate('click');
+ expect(onMuteChange).toBeCalledWith(isMuted);
+ });
+
+ test('should defer to the inner input for left/right arrow keys', () => {
+ getWrapper();
+
+ expect(usePreventKey).toBeCalledWith(expect.any(Object), ['ArrowLeft', 'ArrowRight']);
+ });
+ });
+
+ describe('render', () => {
+ test('should return a valid wrapper', () => {
+ const wrapper = getWrapper();
+
+ expect(wrapper.hasClass('bp-VolumeControls')).toBe(true);
+ });
+
+ test.each`
+ volume | icon | title
+ ${0} | ${IconVolumeMute24} | ${'Unmute'}
+ ${0.0} | ${IconVolumeMute24} | ${'Unmute'}
+ ${0.01} | ${IconVolumeLow24} | ${'Mute'}
+ ${0.25} | ${IconVolumeLow24} | ${'Mute'}
+ ${0.33} | ${IconVolumeMedium24} | ${'Mute'}
+ ${0.51} | ${IconVolumeMedium24} | ${'Mute'}
+ ${0.66} | ${IconVolumeHigh24} | ${'Mute'}
+ ${1.0} | ${IconVolumeHigh24} | ${'Mute'}
+ `('should render the correct icon and title for volume $volume', ({ icon, title, volume }) => {
+ const wrapper = getWrapper({ volume });
+
+ expect(wrapper.exists(icon)).toBe(true);
+ expect(wrapper.find(MediaToggle).prop('title')).toBe(title);
+ });
+
+ test.each`
+ volume | track | value
+ ${0} | ${`linear-gradient(to right, #0061d5 0%, #fff 0%)`} | ${0}
+ ${0.0} | ${`linear-gradient(to right, #0061d5 0%, #fff 0%)`} | ${0}
+ ${0.01} | ${`linear-gradient(to right, #0061d5 1%, #fff 1%)`} | ${1}
+ ${0.25} | ${`linear-gradient(to right, #0061d5 25%, #fff 25%)`} | ${25}
+ ${0.254} | ${`linear-gradient(to right, #0061d5 25%, #fff 25%)`} | ${25}
+ ${0.255} | ${`linear-gradient(to right, #0061d5 26%, #fff 26%)`} | ${26}
+ ${1.0} | ${`linear-gradient(to right, #0061d5 100%, #fff 100%)`} | ${100}
+ `('should render the correct track and value for volume $volume', ({ track, value, volume }) => {
+ const wrapper = getWrapper({ volume });
+
+ expect(wrapper.find(SliderControl).props()).toMatchObject({
+ track,
+ value,
+ });
+ });
+ });
+});
diff --git a/src/lib/viewers/controls/slider/SliderControl.scss b/src/lib/viewers/controls/slider/SliderControl.scss
new file mode 100644
index 000000000..5bc53f65e
--- /dev/null
+++ b/src/lib/viewers/controls/slider/SliderControl.scss
@@ -0,0 +1,98 @@
+@import './styles';
+
+$bp-SliderControl-thumb-size: 12px;
+$bp-SliderControl-track-size: 3px;
+$bp-SliderControl-track-space: 3px;
+
+.bp-SliderControl {
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
+
+.bp-SliderControl-input {
+ @include bp-Control--focus;
+
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ padding: 0 $bp-SliderControl-track-space;
+ background: transparent;
+ cursor: pointer;
+ appearance: none;
+
+ @include bp-SliderThumb {
+ width: $bp-SliderControl-thumb-size;
+ height: $bp-SliderControl-thumb-size;
+ background: $box-blue;
+ border: 0;
+ border-radius: $bp-SliderControl-thumb-size;
+ cursor: pointer;
+ }
+
+ @include bp-SliderTrack {
+ width: 100%;
+ height: $bp-SliderControl-track-size;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ }
+
+ // Thumb Button
+ &::-ms-thumb {
+ margin-top: 0;
+ }
+
+ &::-webkit-slider-thumb {
+ margin-top: -5px;
+ appearance: none;
+ }
+
+ // Track
+ &::-ms-fill-lower {
+ background: transparent;
+ }
+
+ &::-ms-fill-upper {
+ background: transparent;
+ }
+
+ &::-ms-track {
+ color: transparent;
+ border-color: transparent;
+ border-width: 5px 0;
+ }
+
+ // Tooltip
+ &::-ms-tooltip {
+ display: none;
+ }
+
+ // Overrides
+ @supports (-ms-ime-align: auto) {
+ & {
+ margin: 0; // Edge starts the margin from the thumb, not the track
+ }
+
+ &::-webkit-slider-thumb {
+ margin-top: 0;
+ }
+ }
+}
+
+.bp-SliderControl-track {
+ position: absolute;
+ top: 0;
+ right: $bp-SliderControl-thumb-size / 2; // Allow the thumb to fully dock to edge of the track
+ bottom: 0;
+ left: $bp-SliderControl-thumb-size / 2; // Allow the thumb to fully dock to edge of the track
+ height: $bp-SliderControl-track-size;
+ margin: auto $bp-SliderControl-track-space; // Center the track within the slider
+ background: transparent linear-gradient($white, $white) no-repeat center border-box;
+
+ @media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
+ & {
+ transition: background-image 100ms ease; // Match the transition IE applies to thumb movement
+ }
+ }
+}
diff --git a/src/lib/viewers/controls/slider/SliderControl.tsx b/src/lib/viewers/controls/slider/SliderControl.tsx
new file mode 100644
index 000000000..a9d6c2cdb
--- /dev/null
+++ b/src/lib/viewers/controls/slider/SliderControl.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import classNames from 'classnames';
+import noop from 'lodash/noop';
+import './SliderControl.scss';
+
+export type Props = Omit, 'onChange'> & {
+ className?: string;
+ onChange?: (value: number) => void;
+ track?: string;
+};
+
+export type Ref = HTMLInputElement;
+
+function SliderControl({ className, onChange = noop, track, ...rest }: Props, ref: React.Ref[): JSX.Element {
+ const handleChange = ({ target }: React.ChangeEvent): void => {
+ onChange(parseFloat(target.value));
+ };
+
+ return (
+
+ );
+}
+
+export default React.forwardRef(SliderControl);
diff --git a/src/lib/viewers/controls/slider/__tests__/SliderControl-test.tsx b/src/lib/viewers/controls/slider/__tests__/SliderControl-test.tsx
new file mode 100644
index 000000000..a8ce5adcc
--- /dev/null
+++ b/src/lib/viewers/controls/slider/__tests__/SliderControl-test.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { shallow, ShallowWrapper } from 'enzyme';
+import SliderControl from '../SliderControl';
+
+describe('SliderControl', () => {
+ const getWrapper = (props = {}): ShallowWrapper =>
+ shallow();
+
+ describe('event handlers', () => {
+ test('should parse and return the current value on change', () => {
+ const onChange = jest.fn();
+ const wrapper = getWrapper({ step: 0.1, onChange });
+ const input = wrapper.find('[data-testid="bp-SliderControl-input"]');
+
+ input.simulate('change', { target: { value: '0.5' } });
+
+ expect(onChange).toBeCalledWith(0.5);
+ });
+ });
+
+ describe('render', () => {
+ test('should return a valid wrapper', () => {
+ const wrapper = getWrapper();
+
+ expect(wrapper.hasClass('bp-SliderControl')).toBe(true);
+ expect(wrapper.find('[data-testid="bp-SliderControl-input"]').props()).toMatchObject({
+ max: 100,
+ min: 0,
+ step: 1,
+ });
+ });
+
+ test('should forward the track and value properties properly', () => {
+ const track = 'linear-gradient(#fff %20, #000 100%';
+ const wrapper = getWrapper({ track, value: 20 });
+
+ expect(wrapper.find('[data-testid="bp-SliderControl-input"]').prop('value')).toEqual(20);
+ expect(wrapper.find('[data-testid="bp-SliderControl-track"]').prop('style')).toEqual({
+ backgroundImage: track,
+ });
+ });
+ });
+});
diff --git a/src/lib/viewers/controls/slider/_styles.scss b/src/lib/viewers/controls/slider/_styles.scss
new file mode 100644
index 000000000..a2c9630be
--- /dev/null
+++ b/src/lib/viewers/controls/slider/_styles.scss
@@ -0,0 +1,17 @@
+@import '../styles';
+
+@mixin bp-SliderThumb {
+ @each $prefix in ('-moz-range-thumb', '-ms-thumb', '-webkit-slider-thumb') {
+ &::#{$prefix} {
+ @content;
+ }
+ }
+}
+
+@mixin bp-SliderTrack {
+ @each $prefix in ('-moz-range-track', '-ms-track', '-webkit-slider-runnable-track') {
+ &::#{$prefix} {
+ @content;
+ }
+ }
+}
diff --git a/src/lib/viewers/controls/slider/index.ts b/src/lib/viewers/controls/slider/index.ts
new file mode 100644
index 000000000..bec19d23f
--- /dev/null
+++ b/src/lib/viewers/controls/slider/index.ts
@@ -0,0 +1,2 @@
+export { default } from './SliderControl';
+export * from './SliderControl';
]