diff --git a/src/lib/viewers/controls/media/Filmstrip.scss b/src/lib/viewers/controls/media/Filmstrip.scss new file mode 100644 index 000000000..d3ae57a5e --- /dev/null +++ b/src/lib/viewers/controls/media/Filmstrip.scss @@ -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; +} diff --git a/src/lib/viewers/controls/media/Filmstrip.tsx b/src/lib/viewers/controls/media/Filmstrip.tsx new file mode 100644 index 000000000..251bf4c65 --- /dev/null +++ b/src/lib/viewers/controls/media/Filmstrip.tsx @@ -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 ( +
+
+ {isLoading && ( +
+
+
+
+
+ )} +
+ +
+ {formatTime(time)} +
+
+ ); +} diff --git a/src/lib/viewers/controls/media/TimeControls.tsx b/src/lib/viewers/controls/media/TimeControls.tsx index 75d95ad30..e0fd5f34d 100644 --- a/src/lib/viewers/controls/media/TimeControls.tsx +++ b/src/lib/viewers/controls/media/TimeControls.tsx @@ -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; }; @@ -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 (
+ {!!filmstripInterval && ( + + )} + setIsSliderHovered(false)} + onMouseOver={(): void => setIsSliderHovered(true)} + onMove={handleMouseMove} onUpdate={onTimeChange} step={5} title={__('media_time_slider')} diff --git a/src/lib/viewers/controls/media/__tests__/Filmstrip-test.tsx b/src/lib/viewers/controls/media/__tests__/Filmstrip-test.tsx new file mode 100644 index 000000000..1e2023fa0 --- /dev/null +++ b/src/lib/viewers/controls/media/__tests__/Filmstrip-test.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import Filmstrip from '../Filmstrip'; + +describe('Filmstrip', () => { + const getWrapper = (props = {}): ShallowWrapper => + shallow(); + + 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); + }); + }); +}); diff --git a/src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx b/src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx index 5bf186278..9795be555 100644 --- a/src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx +++ b/src/lib/viewers/controls/media/__tests__/TimeControls-test.tsx @@ -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'; @@ -12,6 +13,30 @@ describe('TimeControls', () => { const getWrapper = (props = {}): ShallowWrapper => shallow(); + 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(); @@ -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); + }); }); }); diff --git a/src/lib/viewers/media/DashControls.tsx b/src/lib/viewers/media/DashControls.tsx index ac3739467..fecbe206c 100644 --- a/src/lib/viewers/media/DashControls.tsx +++ b/src/lib/viewers/media/DashControls.tsx @@ -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, @@ -49,9 +54,12 @@ export default function DashControls({ return (
diff --git a/src/lib/viewers/media/DashViewer.js b/src/lib/viewers/media/DashViewer.js index cc8733185..ac883f73f 100644 --- a/src/lib/viewers/media/DashViewer.js +++ b/src/lib/viewers/media/DashViewer.js @@ -28,6 +28,15 @@ class DashViewer extends VideoBaseViewer { /** @property {Array} - Array of audio tracks for the video */ audioTracks = []; + /** @property {number} - Frequency of filmstrip frame images */ + filmstripInterval; + + /** @property {Object} - Status of the filmstrip representation */ + filmstripStatus; + + /** @property {string} - URL for the filmstrip image */ + filmstripUrl; + /** @property {string} - ID of the selected audio track */ selectedAudioTrack; @@ -792,12 +801,10 @@ class DashViewer extends VideoBaseViewer { this.autoplay(); } - if (!this.getViewerOption('useReactControls')) { - this.loadFilmStrip(); - } this.resize(); this.handleVolume(); this.startBandwidthTracking(); + this.loadFilmStrip(); this.loadSubtitles(); this.loadAlternateAudio(); this.showPlayButton(); @@ -807,7 +814,14 @@ class DashViewer extends VideoBaseViewer { // Make media element visible after resize this.showMedia(); - if (!this.getViewerOption('useReactControls')) { + + // Show controls briefly after content loads + if (this.getViewerOption('useReactControls')) { + if (this.controls) { + this.controls.controlsLayer.show(); + this.controls.controlsLayer.hide(); + } + } else { this.mediaControls.show(); } @@ -846,10 +860,22 @@ class DashViewer extends VideoBaseViewer { */ loadFilmStrip() { const filmstrip = getRepresentation(this.options.file, 'filmstrip'); - if (filmstrip && filmstrip.metadata && filmstrip.metadata.interval > 0) { + const filmstripInterval = filmstrip && filmstrip.metadata && filmstrip.metadata.interval; + + if (filmstripInterval > 0) { const url = this.createContentUrlWithAuthParams(filmstrip.content.url_template); + + this.filmstripInterval = filmstripInterval; this.filmstripStatus = this.getRepStatus(filmstrip); - this.mediaControls.initFilmstrip(url, this.filmstripStatus, this.aspect, filmstrip.metadata.interval); + this.filmstripUrl = url; + + if (this.getViewerOption('useReactControls')) { + this.filmstripStatus.getPromise().then(() => { + this.renderUI(); // Render once the filmstrip is ready + }); + } else { + this.mediaControls.initFilmstrip(url, this.filmstripStatus, this.aspect, filmstripInterval); + } } } @@ -1141,12 +1167,15 @@ class DashViewer extends VideoBaseViewer { this.controls.render( { expect(dash.loadUIReact).toBeCalled(); expect(dash.loadUI).not.toBeCalled(); - expect(dash.loadFilmStrip).not.toBeCalled(); + expect(dash.loadFilmStrip).toBeCalled(); expect(dash.loadSubtitles).toBeCalled(); expect(dash.loadAlternateAudio).toBeCalled(); }); @@ -789,6 +789,7 @@ describe('lib/viewers/media/DashViewer', () => { }, }; stubs.createUrl = jest.spyOn(dash, 'createContentUrlWithAuthParams'); + stubs.renderUI = jest.spyOn(dash, 'renderUI'); jest.spyOn(dash, 'getRepStatus'); }); @@ -830,6 +831,29 @@ describe('lib/viewers/media/DashViewer', () => { dash.loadFilmStrip(); expect(stubs.createUrl).toBeCalled(); }); + + test('should render the controls again after the filmstrip is ready', done => { + const mockPromise = Promise.resolve(); + + jest.spyOn(dash, 'getViewerOption').mockReturnValueOnce(true); + jest.spyOn(dash, 'getRepStatus').mockReturnValueOnce({ + destroy: jest.fn(), + getPromise: () => mockPromise, + }); + + dash.options.file.representations.entries[1] = { + representation: 'filmstrip', + content: { url_template: 'https://api.box.com' }, + metadata: { interval: 1 }, + status: { state: 'ready' }, + }; + dash.loadFilmStrip(); + + mockPromise.then(() => { + expect(stubs.renderUI).toBeCalled(); + done(); + }); + }); }); describe('loadSubtitles()', () => {