Skip to content

Commit

Permalink
feat(dash): Add react version of filmstrip controls
Browse files Browse the repository at this point in the history
  • Loading branch information
jstoffan committed Jul 16, 2021
1 parent 19722e6 commit 485c96f
Show file tree
Hide file tree
Showing 8 changed files with 330 additions and 8 deletions.
29 changes: 29 additions & 0 deletions src/lib/viewers/controls/media/Filmstrip.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
@import './styles';

.bp-Filmstrip {
position: absolute;
bottom: 100%;
overflow: hidden;
background: transparent;
box-shadow: 0 0 1px $sunset-grey;
visibility: hidden; // Use visibility instead of display to prevent layout thrash

&.bp-is-shown {
visibility: visible;
}
}

.bp-Filmstrip-frame {
display: flex;
align-items: center;
justify-content: center;
}

.bp-Filmstrip-time {
height: 20px;
color: $white;
font-size: 13px;
line-height: 20px;
text-align: center;
background-color: $twos;
}
72 changes: 72 additions & 0 deletions src/lib/viewers/controls/media/Filmstrip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React from 'react';
import classNames from 'classnames';
import { formatTime } from './DurationLabels';
import './Filmstrip.scss';

const FILMSTRIP_FRAMES_PER_ROW = 100;
const FILMSTRIP_FRAME_HEIGHT = 90;
const FILMSTRIP_FRAME_WIDTH = 160; // Default frame width for loading crawler

export type Props = {
aspectRatio?: number;
imageUrl?: string;
interval?: number;
isShown?: boolean;
position?: number;
positionMax?: number;
time?: number;
};

export default function Filmstrip({
aspectRatio = 0,
imageUrl = '',
interval = 1,
isShown,
position = 0,
positionMax = 0,
time = 0,
}: Props): JSX.Element | null {
const [isLoading, setIsLoading] = React.useState(true);
const frameNumber = Math.floor(time / interval); // Current frame based on current time
const frameRow = Math.floor(frameNumber / FILMSTRIP_FRAMES_PER_ROW); // Row number if there is more than one row
const frameWidth = Math.floor(aspectRatio * FILMSTRIP_FRAME_HEIGHT) || FILMSTRIP_FRAME_WIDTH;
const frameBackgroundLeft = -(frameNumber % FILMSTRIP_FRAMES_PER_ROW) * frameWidth; // Frame position in its row
const frameBackgroundTop = -(frameRow * FILMSTRIP_FRAME_HEIGHT); // Row position in its filmstrip
const filmstripLeft = Math.min(Math.max(0, position - frameWidth / 2), positionMax - frameWidth);

React.useEffect((): void => {
if (!imageUrl) return;

const filmstripImage = document.createElement('img');
filmstripImage.onload = (): void => setIsLoading(false);
filmstripImage.src = imageUrl;
}, [imageUrl]);

return (
<div className={classNames('bp-Filmstrip', { 'bp-is-shown': isShown })} style={{ left: `${filmstripLeft}px` }}>
<div
className="bp-Filmstrip-frame"
data-testid="bp-Filmstrip-frame"
style={{
backgroundImage: imageUrl ? `url('${imageUrl}')` : '',
backgroundPositionX: frameBackgroundLeft,
backgroundPositionY: frameBackgroundTop,
height: FILMSTRIP_FRAME_HEIGHT,
width: frameWidth,
}}
>
{isLoading && (
<div className="bp-crawler" data-testid="bp-Filmstrip-crawler">
<div />
<div />
<div />
</div>
)}
</div>

<div className="bp-Filmstrip-time" data-testid="bp-Filmstrip-time">
{formatTime(time)}
</div>
</div>
);
}
35 changes: 35 additions & 0 deletions src/lib/viewers/controls/media/TimeControls.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import React from 'react';
import isFinite from 'lodash/isFinite';
import noop from 'lodash/noop';
import { bdlBoxBlue, bdlGray62, white } from 'box-ui-elements/es/styles/variables';
import Filmstrip from './Filmstrip';
import SliderControl from '../slider';
import './TimeControls.scss';

export type Props = {
aspectRatio?: number;
bufferedRange?: TimeRanges;
currentTime?: number;
durationTime?: number;
filmstripInterval?: number;
filmstripUrl?: string;
onTimeChange: (volume: number) => void;
};

Expand All @@ -20,23 +25,53 @@ export const percent = (value1: number, value2: number): number => {
};

export default function TimeControls({
aspectRatio,
bufferedRange,
currentTime = 0,
durationTime = 0,
filmstripInterval,
filmstripUrl,
onTimeChange,
}: Props): JSX.Element {
const [isSliderHovered, setIsSliderHovered] = React.useState(false);
const [hoverPosition, setHoverPosition] = React.useState(0);
const [hoverPositionMax, setHoverPositionMax] = React.useState(0);
const [hoverTime, setHoverTime] = React.useState(0);
const currentValue = isFinite(currentTime) ? currentTime : 0;
const durationValue = isFinite(durationTime) ? durationTime : 0;
const currentPercentage = percent(currentValue, durationValue);
const bufferedAmount = bufferedRange && bufferedRange.length ? bufferedRange.end(bufferedRange.length - 1) : 0;
const bufferedPercentage = percent(bufferedAmount, durationValue);

const handleMouseMove = (newTime: number, newPosition: number, width: number): void => {
setHoverPosition(newPosition);
setHoverPositionMax(width);
setHoverTime(newTime);
};

return (
<div className="bp-TimeControls">
{!!filmstripInterval && (
<Filmstrip
aspectRatio={aspectRatio}
imageUrl={filmstripUrl}
interval={filmstripInterval}
isShown={isSliderHovered}
position={hoverPosition}
positionMax={hoverPositionMax}
time={hoverTime}
/>
)}

<SliderControl
className="bp-TimeControls-slider"
max={durationValue}
min={0}
onBlur={noop} // Filmstrip is not currently shown during keyboard navigation
onFocus={noop} // Filmstrip is not currently shown during keyboard navigation
onMouseOut={(): void => setIsSliderHovered(false)}
onMouseOver={(): void => setIsSliderHovered(true)}
onMove={handleMouseMove}
onUpdate={onTimeChange}
step={5}
title={__('media_time_slider')}
Expand Down
77 changes: 77 additions & 0 deletions src/lib/viewers/controls/media/__tests__/Filmstrip-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import Filmstrip from '../Filmstrip';

describe('Filmstrip', () => {
const getWrapper = (props = {}): ShallowWrapper =>
shallow(<Filmstrip aspectRatio={2} imageUrl="https://app.box.com" {...props} />);

describe('render', () => {
test('should return a valid wrapper', () => {
const wrapper = getWrapper();
expect(wrapper.hasClass('bp-Filmstrip')).toBe(true);
});

test.each`
time | left | top
${0} | ${-0} | ${-0}
${1} | ${-180} | ${-0}
${10} | ${-1800} | ${-0}
${30} | ${-5400} | ${-0}
${60} | ${-10800} | ${-0}
${100} | ${-0} | ${-90}
${110} | ${-1800} | ${-90}
${500} | ${-0} | ${-450}
${510} | ${-1800} | ${-450}
`('should display the frame position for time $time as $left/$top', ({ left, time, top }) => {
const wrapper = getWrapper({ time });
expect(wrapper.find('[data-testid="bp-Filmstrip-frame"]').prop('style')).toMatchObject({
backgroundImage: "url('https://app.box.com')",
backgroundPositionX: left,
backgroundPositionY: top,
});
});

test.each`
aspectRatio | width
${undefined} | ${160}
${0} | ${160}
${1} | ${90}
${2} | ${180}
`('should display the frame size for aspect ratio $aspectRatio as $width', ({ aspectRatio, width }) => {
const wrapper = getWrapper({ aspectRatio });
expect(wrapper.find('[data-testid="bp-Filmstrip-frame"]').prop('style')).toMatchObject({
height: 90,
width,
});
});

test('should display the correct filmstrip time', () => {
const wrapper = getWrapper({ time: 120 });
expect(wrapper.find('[data-testid="bp-Filmstrip-time"]').text()).toBe('2:00');
});

test('should display the crawler while the filmstrip image loads', done => {
const mockImage = document.createElement('img');

Object.defineProperty(mockImage, 'src', {
set() {
setTimeout(() => {
this.onload();
done();
});
},
});

jest.useFakeTimers();
jest.spyOn(document, 'createElement').mockImplementation(() => mockImage);
jest.spyOn(React, 'useEffect').mockImplementationOnce(func => func());

const wrapper = getWrapper();
expect(wrapper.exists('[data-testid="bp-Filmstrip-crawler"]')).toBe(true);

jest.advanceTimersByTime(0); // Simulate loading complete
expect(wrapper.exists('[data-testid="bp-Filmstrip-crawler"]')).toBe(false);
});
});
});
48 changes: 48 additions & 0 deletions src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import Filmstrip from '../Filmstrip';
import SliderControl from '../../slider';
import TimeControls from '../TimeControls';

Expand All @@ -12,6 +13,30 @@ describe('TimeControls', () => {
const getWrapper = (props = {}): ShallowWrapper =>
shallow(<TimeControls currentTime={0} durationTime={10000} onTimeChange={jest.fn()} {...props} />);

describe('event handlers', () => {
test('should update the slider hover state on mousemove', () => {
const wrapper = getWrapper({ filmstripInterval: 1 });

wrapper.find(SliderControl).simulate('move', 100, 1000, 10000); // Time, position, max position

expect(wrapper.find(Filmstrip).props()).toMatchObject({
position: 1000,
positionMax: 10000,
time: 100,
});
});

test('should update the slider hover state on mouseover and mouseout', () => {
const wrapper = getWrapper({ filmstripInterval: 1 });

wrapper.find(SliderControl).simulate('mouseover');
expect(wrapper.find(Filmstrip).prop('isShown')).toBe(true);

wrapper.find(SliderControl).simulate('mouseout');
expect(wrapper.find(Filmstrip).prop('isShown')).toBe(false);
});
});

describe('render', () => {
test('should return a valid wrapper', () => {
const wrapper = getWrapper();
Expand Down Expand Up @@ -47,5 +72,28 @@ describe('TimeControls', () => {
const wrapper = getWrapper({ bufferedRange: buffer, currentTime });
expect(wrapper.find(SliderControl).prop('track')).toEqual(track);
});

test('should render the filmstrip with the correct props', () => {
const wrapper = getWrapper({
aspectRatio: 1.5,
filmstripInterval: 2,
filmstripUrl: 'https://app.box.com',
});

expect(wrapper.find(Filmstrip).props()).toMatchObject({
aspectRatio: 1.5,
imageUrl: 'https://app.box.com',
interval: 2,
});
});

test('should not render the filmstrip if the interval is missing', () => {
const wrapper = getWrapper({
aspectRatio: 1.5,
imageUrl: 'https://app.box.com',
});

expect(wrapper.exists(Filmstrip)).toBe(false);
});
});
});
10 changes: 9 additions & 1 deletion src/lib/viewers/media/DashControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,20 @@ export type Props = DurationLabelsProps &
PlayControlsProps &
SubtitlesToggleProps &
TimeControlsProps &
VolumeControlsProps & { isPlayingHD?: boolean };
VolumeControlsProps & {
isPlayingHD?: boolean;
};

export default function DashControls({
audioTrack,
audioTracks,
autoplay,
aspectRatio,
bufferedRange,
currentTime,
durationTime,
filmstripInterval,
filmstripUrl,
isAutoGeneratedSubtitles,
isHDSupported,
isPlaying,
Expand All @@ -49,9 +54,12 @@ export default function DashControls({
return (
<div className="bp-DashControls" data-testid="media-controls-wrapper">
<TimeControls
aspectRatio={aspectRatio}
bufferedRange={bufferedRange}
currentTime={currentTime}
durationTime={durationTime}
filmstripInterval={filmstripInterval}
filmstripUrl={filmstripUrl}
onTimeChange={onTimeChange}
/>

Expand Down
Loading

0 comments on commit 485c96f

Please sign in to comment.