diff --git a/package-lock.json b/package-lock.json index 694c92c45d3b..9ce71aaed7f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2084,6 +2084,17 @@ "md5": "^2.2.1" } }, + "node_modules/@cloudflare/stream-react": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@cloudflare/stream-react/-/stream-react-1.9.1.tgz", + "integrity": "sha512-Yeftxpgzs69hK3VmVZjhvqIqhyo5PHWLAoZMmbzPiTH0rqkyzMMHxRvyNe7jfBRAGknOiTeeU7TQCdPBDCkuvg==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/@cnakazawa/watch": { "version": "1.0.4", "license": "Apache-2.0", diff --git a/packages/components/src/components/dropdown/dropdown.tsx b/packages/components/src/components/dropdown/dropdown.tsx index 03722aeb8e1e..5b5cae940398 100644 --- a/packages/components/src/components/dropdown/dropdown.tsx +++ b/packages/components/src/components/dropdown/dropdown.tsx @@ -44,6 +44,7 @@ type TDropdown = { onClick?: () => void; placeholder?: string; suffix_icon?: string; + should_open_on_hover?: boolean; should_scroll_to_selected?: boolean; should_autohide?: boolean; test_id?: string; @@ -275,6 +276,7 @@ const Dropdown = ({ onClick, placeholder, suffix_icon, + should_open_on_hover = false, should_scroll_to_selected, should_autohide, test_id, @@ -341,7 +343,14 @@ const Dropdown = ({ handleVisibility(); }; - const handleVisibility = () => { + const handleVisibility = (e?: React.MouseEvent) => { + if (e && ['mouseover', 'mouseleave'].includes(e.type)) { + if (!should_open_on_hover) return; + if (e.type === 'mouseover') setIsListVisible(true); + else setIsListVisible(false); + return; + } + if (typeof onClick === 'function') { onClick(); @@ -447,7 +456,13 @@ const Dropdown = ({ data-testid={test_id} value={value || 0} /> -
+
null} + onMouseLeave={handleVisibility} + >
\ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-replay.svg b/packages/components/src/components/icon/common/ic-replay.svg new file mode 100644 index 000000000000..0ad75111c165 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-replay.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-sound-off.svg b/packages/components/src/components/icon/common/ic-sound-off.svg new file mode 100644 index 000000000000..462e526f5f60 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-sound-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/common/ic-sound-on.svg b/packages/components/src/components/icon/common/ic-sound-on.svg new file mode 100644 index 000000000000..6b75c628d1b9 --- /dev/null +++ b/packages/components/src/components/icon/common/ic-sound-on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/components/src/components/icon/icons.js b/packages/components/src/components/icon/icons.js index cf1e50b8cc4f..d01e9e5a94aa 100644 --- a/packages/components/src/components/icon/icons.js +++ b/packages/components/src/components/icon/icons.js @@ -504,6 +504,7 @@ import './common/ic-pix-dark.svg'; import './common/ic-pix-light.svg'; import './common/ic-play-outline.svg'; import './common/ic-play.svg'; +import './common/ic-playback-rate.svg'; import './common/ic-poa-error.svg'; import './common/ic-poa-file-clear.svg'; import './common/ic-poa-file-eight-mb.svg'; @@ -556,6 +557,7 @@ import './common/ic-red-warning.svg'; import './common/ic-redo.svg'; import './common/ic-regulatory-information.svg'; import './common/ic-remove-token.svg'; +import './common/ic-replay.svg'; import './common/ic-reports.svg'; import './common/ic-reset.svg'; import './common/ic-sample-credit-card.svg'; @@ -576,6 +578,8 @@ import './common/ic-share.svg'; import './common/ic-skrill-dark.svg'; import './common/ic-skrill-light.svg'; import './common/ic-sort.svg'; +import './common/ic-sound-off.svg'; +import './common/ic-sound-on.svg'; import './common/ic-stage1.svg'; import './common/ic-stage2.svg'; import './common/ic-stage3.svg'; diff --git a/packages/components/stories/icon/icons.js b/packages/components/stories/icon/icons.js index 0ba5440476e2..2ae86724e359 100644 --- a/packages/components/stories/icon/icons.js +++ b/packages/components/stories/icon/icons.js @@ -513,6 +513,7 @@ export const icons = 'IcPixLight', 'IcPlayOutline', 'IcPlay', + 'IcPlaybackRate', 'IcPoaError', 'IcPoaFileClear', 'IcPoaFileEightMb', @@ -565,6 +566,7 @@ export const icons = 'IcRedo', 'IcRegulatoryInformation', 'IcRemoveToken', + 'IcReplay', 'IcReports', 'IcReset', 'IcSampleCreditCard', @@ -585,6 +587,8 @@ export const icons = 'IcSkrillDark', 'IcSkrillLight', 'IcSort', + 'IcSoundOff', + 'IcSoundOn', 'IcStage1', 'IcStage2', 'IcStage3', diff --git a/packages/shared/src/utils/browser/__tests__/browser_detect.spec.ts b/packages/shared/src/utils/browser/__tests__/browser_detect.spec.ts new file mode 100644 index 000000000000..11bf78a43431 --- /dev/null +++ b/packages/shared/src/utils/browser/__tests__/browser_detect.spec.ts @@ -0,0 +1,43 @@ +import { isSafariBrowser } from '../browser_detect'; + +describe('isSafariBrowser', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return false for Chrome browser', () => { + Object.defineProperty(window.navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, + }); + + expect(isSafariBrowser()).toBe(false); + }); + + it('should detect Safari browser', () => { + Object.defineProperty(window.navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15', + configurable: true, + }); + + expect(isSafariBrowser()).toBe(true); + }); + + it('should return false for FireFox browser', () => { + Object.defineProperty(window.navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0', + configurable: true, + }); + + expect(isSafariBrowser()).toBe(false); + }); + + it('should return false for Opera browser', () => { + Object.defineProperty(window.navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Opera/121.0', + configurable: true, + }); + + expect(isSafariBrowser()).toBe(false); + }); +}); diff --git a/packages/shared/src/utils/browser/browser_detect.ts b/packages/shared/src/utils/browser/browser_detect.ts index b30fd1ce9547..733729221f46 100644 --- a/packages/shared/src/utils/browser/browser_detect.ts +++ b/packages/shared/src/utils/browser/browser_detect.ts @@ -13,3 +13,21 @@ export const isSafari = () => { })(!window.safari || (typeof window.safari !== 'undefined' && window.safari.pushNotification)) ); }; + +const getUserBrowser = () => { + // We can't rely only on navigator.userAgent.index, the verification order is also important + if ((navigator.userAgent.indexOf('Opera') || navigator.userAgent.indexOf('OPR')) !== -1) { + return 'Opera'; + } else if (navigator.userAgent.indexOf('Edg') !== -1) { + return 'Edge'; + } else if (navigator.userAgent.indexOf('Chrome') !== -1) { + return 'Chrome'; + } else if (navigator.userAgent.indexOf('Safari') !== -1) { + return 'Safari'; + } else if (navigator.userAgent.indexOf('Firefox') !== -1) { + return 'Firefox'; + } + return 'unknown'; +}; + +export const isSafariBrowser = () => getUserBrowser() === 'Safari'; diff --git a/packages/shared/src/utils/helpers/__tests__/durations.ts b/packages/shared/src/utils/helpers/__tests__/durations.ts index 484190def948..bedd48598c68 100644 --- a/packages/shared/src/utils/helpers/__tests__/durations.ts +++ b/packages/shared/src/utils/helpers/__tests__/durations.ts @@ -143,3 +143,20 @@ describe('convertDurationLimit', () => { expect(Duration.convertDurationLimit(86400, 'd')).toEqual(1); }); }); + +describe('formatDurationTime', () => { + it('should return dummy duration if time was not passed', () => { + expect(Duration.formatDurationTime()).toBe('00:00'); + }); + + it('should return dummy duration if time is equal to 0', () => { + expect(Duration.formatDurationTime(0)).toBe('00:00'); + }); + + it('should return correct duration', () => { + expect(Duration.formatDurationTime(3)).toBe('00:03'); + expect(Duration.formatDurationTime(33)).toBe('00:33'); + expect(Duration.formatDurationTime(60)).toBe('01:00'); + expect(Duration.formatDurationTime(130)).toBe('02:10'); + }); +}); diff --git a/packages/shared/src/utils/helpers/duration.ts b/packages/shared/src/utils/helpers/duration.ts index f4fc6a8b97b2..daa2faec944d 100644 --- a/packages/shared/src/utils/helpers/duration.ts +++ b/packages/shared/src/utils/helpers/duration.ts @@ -180,3 +180,17 @@ export const getDurationMinMaxValues = ( return [min_value, max_value]; }; + +const formatDisplayedTime = (time_unit: number) => (time_unit < 10 ? `0${time_unit}` : time_unit); + +export const formatDurationTime = (time?: number) => { + if (time && !isNaN(time)) { + const minutes = Math.floor(time / 60); + const format_minutes = formatDisplayedTime(minutes); + const seconds = Math.floor(time % 60); + const format_seconds = formatDisplayedTime(seconds); + return `${format_minutes}:${format_seconds}`; + } + + return '00:00'; +}; diff --git a/packages/trader/package.json b/packages/trader/package.json index 056db6ae5b96..2a16873a1821 100644 --- a/packages/trader/package.json +++ b/packages/trader/package.json @@ -85,6 +85,7 @@ "webpack-node-externals": "^2.5.2" }, "dependencies": { + "@cloudflare/stream-react": "^1.9.1", "@deriv-com/analytics": "1.4.10", "@deriv/components": "^1.0.0", "@deriv/deriv-api": "^1.0.15", diff --git a/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/playback-rate-control.spec.tsx b/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/playback-rate-control.spec.tsx new file mode 100644 index 000000000000..917b2afd9892 --- /dev/null +++ b/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/playback-rate-control.spec.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PlaybackRateControl from '../playback-rate-control'; + +const mocked_props = { + onPlaybackRateChange: jest.fn(), + playback_rate: 1, +}; + +const default_selected_item = 'Normal'; +const playback_rate_list = ['0.25x', '0.5x', '0.75x', default_selected_item, '1.5x', '2.0x']; + +describe('', () => { + it('should render the component', () => { + const { container } = render(); + + expect(container).not.toBeEmptyDOMElement(); + }); + + it('should render all available options of playback rate if user clicked on default one', () => { + render(); + + playback_rate_list.forEach(item => + item === default_selected_item + ? expect(screen.getByText(item)).toBeInTheDocument() + : expect(screen.queryByText(item)).not.toBeInTheDocument() + ); + userEvent.click(screen.getByText(default_selected_item)); + + playback_rate_list.forEach(item => + item === default_selected_item + ? expect(screen.getAllByText(item)).toHaveLength(2) + : expect(screen.getByText(item)).toBeInTheDocument() + ); + }); + + it('should call onPlaybackRateChange if user chooses another option', () => { + render(); + + expect(mocked_props.onPlaybackRateChange).not.toBeCalled(); + userEvent.click(screen.getByText(default_selected_item)); + userEvent.click(screen.getByText(playback_rate_list[0])); + + expect(mocked_props.onPlaybackRateChange).toBeCalled(); + }); +}); diff --git a/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/video-controls.spec.tsx b/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/video-controls.spec.tsx new file mode 100644 index 000000000000..82680ba96604 --- /dev/null +++ b/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/video-controls.spec.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import VideoControls from '../video-controls'; + +const mocked_props: React.ComponentProps = { + current_time: 3, + dragStartHandler: jest.fn(), + is_animated: true, + is_ended: false, + is_playing: true, + is_muted: false, + onRewind: jest.fn(), + onVolumeChange: jest.fn(), + onPlaybackRateChange: jest.fn(), + progress_bar_filled_ref: { current: null }, + progress_bar_ref: { current: null }, + progress_dot_ref: { current: null }, + show_controls: true, + togglePlay: jest.fn(), + toggleMute: jest.fn(), + video_duration: 33, + volume: 0.5, + playback_rate: 1, +}; + +const volume_control = 'VolumeControl component'; +const playback_rate_control = 'PlaybackRateControl component'; +const progress_bar = 'dt_progress_bar'; +const progress_bar_filled = 'dt_progress_bar_filled'; +const progress_bar_dot = 'dt_progress_bar_dot'; + +jest.mock('../volume-control', () => jest.fn(() =>
{volume_control}
)); +jest.mock('../playback-rate-control', () => jest.fn(() =>
{playback_rate_control}
)); + +describe('', () => { + it('should render the component', () => { + render(); + + expect(screen.getByTestId(progress_bar)).toBeInTheDocument(); + expect(screen.getByTestId(progress_bar_filled)).toBeInTheDocument(); + expect(screen.getByText(/00:03 \/ 00:33/i)).toBeInTheDocument(); + expect(screen.getByText(volume_control)).toBeInTheDocument(); + expect(screen.getByText(playback_rate_control)).toBeInTheDocument(); + }); + + it('should render drag dot on progress bar mouseover event and hide it on mouseleave event', () => { + render(); + const progress = screen.getByTestId(progress_bar); + + expect(screen.queryByTestId(progress_bar_dot)).not.toBeInTheDocument(); + fireEvent.mouseOver(progress); + expect(screen.getByTestId(progress_bar_dot)).toBeInTheDocument(); + + fireEvent.mouseLeave(progress); + expect(screen.queryByTestId(progress_bar_dot)).not.toBeInTheDocument(); + }); + + it('should always render drag dot on mobile', () => { + render(); + + expect(screen.getByTestId(progress_bar_dot)).toBeInTheDocument(); + }); +}); diff --git a/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/video-overlay.spec.tsx b/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/video-overlay.spec.tsx new file mode 100644 index 000000000000..154743f2c207 --- /dev/null +++ b/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/video-overlay.spec.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import VideoOverlay from '../video-overlay'; + +const mocked_props = { + onClick: jest.fn(), +}; + +const icon_testid = 'dt_player_overlay_icon'; + +describe('', () => { + it('should render the component', () => { + const { container } = render(); + + expect(container).not.toBeEmptyDOMElement(); + }); + + it('should pass correct size to icon for desktop', () => { + render(); + + expect(screen.getByTestId(icon_testid)).toHaveAttribute('height', '128'); + }); + + it('should pass correct size to icon for mobile', () => { + render(); + + expect(screen.getByTestId(icon_testid)).toHaveAttribute('height', '88'); + }); +}); diff --git a/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/video-player.spec.tsx b/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/video-player.spec.tsx new file mode 100644 index 000000000000..df9885375ab6 --- /dev/null +++ b/packages/trader/src/App/Components/Elements/VideoPlayer/__tests__/video-player.spec.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import VideoPlayer from '../video-player'; +import userEvent from '@testing-library/user-event'; + +type TMockedStreamProps = { + onEnded: () => void; + onPlay: () => void; + onLoadedMetaData: () => void; + streamRef: React.MutableRefObject; + src: string; +}; + +const default_playback_rate = 'Normal'; +const player_data_testid = 'dt_video_player'; +const video_data_testid = 'dt_video'; +const icon_play = 'IcPlay'; +const icon_pause = 'IcPause'; +const icon_replay = 'IcReplay'; +const faster_playback_rate = '1.5x'; + +const mocked_props: React.ComponentProps = { + data_testid: player_data_testid, + src: 'test_src', +}; + +jest.mock('@deriv/components', () => ({ + ...jest.requireActual('@deriv/components'), + Icon: jest.fn(({ icon }: { icon: string }) =>
{icon}
), +})); + +jest.mock('@cloudflare/stream-react', () => ({ + ...jest.requireActual('@cloudflare/stream-react'), + Stream: jest.fn(({ onEnded, onPlay, onLoadedMetaData, streamRef, src }: TMockedStreamProps) => ( +