diff --git a/src/screens/Watch/Components/ControlBar/VolumeControl/index.js b/src/screens/Watch/Components/ControlBar/VolumeControl/index.js index 8f7a4c738..4acd776cd 100644 --- a/src/screens/Watch/Components/ControlBar/VolumeControl/index.js +++ b/src/screens/Watch/Components/ControlBar/VolumeControl/index.js @@ -1,5 +1,5 @@ import { connect } from 'dva'; -import React from 'react'; +import React, { useRef, useEffect } from 'react'; import { Popup } from 'semantic-ui-react'; import './index.scss'; import './slider.scss'; @@ -28,6 +28,17 @@ function VolumeControl({ muted = false, volume = true, dispatch }) { } }; + const sliderRef = useRef(); + useEffect(() => { + window.focusVolumeSlider = () => { + sliderRef.current?.focus(); + }; + + return () => { + delete window.focusVolumeSlider; + }; + }, []); + const iconName = muted || volume < 0.04 ? 'volume_off' : volume >= 0.6 ? 'volume_up' : 'volume_down'; @@ -70,6 +81,7 @@ function VolumeControl({ muted = false, volume = true, dispatch }) { content={Volume: {Math.floor(volume * 100)}%} trigger={ { + const app = dva({ + initialState: { + playerpref: { volume: 0.5, ...initialState } + } + }); + + app.model({ + namespace: 'playerpref', + state: { volume: 0.5, ...initialState } + }); + + app.router(({ history }) => { + return ; + }); + + return app; +}; + +// Helper function to render with DVA +const renderWithDva = (initialState = {}) => { + const app = createApp(initialState); + return { + ...render( + + + + ), + app + }; + }; + + describe('VolumeControl', () => { + beforeEach(() => { + delete window.focusVolumeSlider; + }); + + it('sets up focus handler on mount', () => { + renderWithDva(); + expect(window.focusVolumeSlider).toBeDefined(); + }); + + it('cleans up focus handler on unmount', () => { + const { unmount } = renderWithDva(); + unmount(); + expect(window.focusVolumeSlider).toBeUndefined(); + }); + + it('focuses slider when focusVolumeSlider is called', () => { + const { container } = renderWithDva(); + const slider = container.querySelector('.volume-slider'); + expect(slider).not.toBeNull(); + window.focusVolumeSlider(); + expect(document.activeElement).toBe(slider); + }); + + it('displays correct volume percentage', () => { + const { container } = renderWithDva({ volume: 0.75 }); + const slider = container.querySelector('.volume-slider'); + expect(slider).not.toBeNull(); + + console.log('Container HTML:', container.innerHTML); // eslint-disable-line + console.log('Slider element:', slider); // eslint-disable-line + + expect(slider.getAttribute('aria-label')).toBe('Volume at 75 %'); + }); + }); \ No newline at end of file diff --git a/src/screens/Watch/Utils/keydown.control.js b/src/screens/Watch/Utils/keydown.control.js index ee44197a2..3fd72536a 100644 --- a/src/screens/Watch/Utils/keydown.control.js +++ b/src/screens/Watch/Utils/keydown.control.js @@ -237,9 +237,9 @@ export const keydownControl = { * Function for handling down-arrow key down */ handleDownArrow(e) { - // If there is no menu opening - decrease the volume by 0.1 each time + // If there is no menu opening - decrease the volume by slider amount each time if (!this.isMenuOpen()) { - $('#volume-slider').focus(); // NEED TO MODIFY + window.focusVolumeSlider?.(); return; } diff --git a/src/screens/Watch/Utils/keydown.control.test.js b/src/screens/Watch/Utils/keydown.control.test.js new file mode 100644 index 000000000..6770d9fae --- /dev/null +++ b/src/screens/Watch/Utils/keydown.control.test.js @@ -0,0 +1,441 @@ +import * as KeyCode from 'keycode-js'; +import { keydownControl } from './keydown.control'; +import { + MENU_LANGUAGE, + MENU_SCREEN_MODE, + MENU_PLAYLISTS, + MENU_PLAYBACKRATE, + MENU_SETTING, + MENU_DOWNLOAD, + MENU_SHORTCUTS +} from './constants.util'; + +describe('keydownControl', () => { + let mockDispatch; + let originalLocation; + + beforeEach(() => { + mockDispatch = jest.fn(); + keydownControl.dispatch = mockDispatch; + keydownControl.menu = null; + + // This is because we only enable the keydown on /video or /liveplayer + originalLocation = window.location; + delete window.location; + window.location = { pathname: '/video' }; + }); + + afterEach(() => { + window.location = originalLocation; + }); + + const pressKey = (keyCode, options = {}) => { + const event = new KeyboardEvent('keydown', { + keyCode, + ...options + }); + + // Allow setting target after event creation + if (options.target) { + Object.defineProperty(event, 'target', { + value: options.target, + enumerable: true + }); + } + + if (options.testPreventDefault) { + event.preventDefault = jest.fn(); + } + + keydownControl.handleKeyDown(event); + return event; + }; + + const expectDispatch = (keyCode, expectedAction, options = {}) => { + const event = pressKey(keyCode, options); + expect(mockDispatch).toHaveBeenCalledWith(expectedAction); + + if (options.testPreventDefault) { + expect(event.preventDefault).toHaveBeenCalled(); + } + }; + + const withAlt = { altKey: true }; + const withShift = { shiftKey: true }; + const withCtrl = { ctrlKey: true }; + const withCmd = { metaKey: true }; + + describe('basic keyboard shortcuts', () => { + it('should handle space key for play/pause when no menu is open', () => { + expectDispatch( + KeyCode.KEY_SPACE, + { type: 'watch/onPlayPauseClick' }, + { testPreventDefault: true } + ); + }); + + it('should not trigger play/pause when menu is open', () => { + keydownControl.menu = 'some-menu'; + const event = pressKey(KeyCode.KEY_SPACE, { testPreventDefault: true }); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should handle "k" key for play/pause', () => { + expectDispatch( + KeyCode.KEY_K, + { type: 'watch/onPlayPauseClick' } + ); + }); + + it('should handle "m" key for mute', () => { + expectDispatch( + KeyCode.KEY_M, + { type: 'watch/media_mute' } + ); + }); + + it('should handle left arrow for rewind when no menu is open', () => { + expectDispatch( + KeyCode.KEY_LEFT, + { type: 'watch/media_backward' } + ); + }); + + it('should not handle shortcuts when not on video page', () => { + window.location.pathname = '/some-other-page'; + pressKey(KeyCode.KEY_SPACE); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should handle "j" key for rewind', () => { + expectDispatch( + KeyCode.KEY_J, + { type: 'watch/media_backward' } + ); + }); + + it('should handle "l" key for forward', () => { + expectDispatch( + KeyCode.KEY_L, + { type: 'watch/media_forward' } + ); + }); + + it('should handle "f" key for fullscreen', () => { + expectDispatch( + KeyCode.KEY_F, + { type: 'watch/toggleFullScreen' } + ); + }); + + it('should handle "c" key for closed captions', () => { + expectDispatch( + KeyCode.KEY_C, + { type: 'playerpref/toggleOpenCC' } + ); + }); + + it('should handle "d" key for audio description', () => { + expectDispatch( + KeyCode.KEY_D, + { type: 'playerpref/toggleOpenAD' } + ); + }); + + it('should handle right arrow for forward when no menu is open', () => { + expectDispatch( + KeyCode.KEY_RIGHT, + { type: 'watch/media_forward' } + ); + }); + + it('should handle number keys 0-9 for seeking to percentage', () => { + for(let i = 0; i <= 9; i += 1) { + expectDispatch( + KeyCode[`KEY_${i}`], + { + type: 'watch/seekToPercentage', + payload: i/10 + } + ); + mockDispatch.mockClear(); + } + }); + + it('should handle ESC key to close menu', () => { + expectDispatch( + KeyCode.KEY_ESCAPE, + { type: 'watch/menu_close' } + ); + }); + + it('should handle comma key with shift for switching videos', () => { + expectDispatch( + KeyCode.KEY_COMMA, + { type: 'watch/switchVideo' }, + withShift + ); + }); + + it('should handle down arrow for volume down when no menu is open', () => { + expectDispatch( + KeyCode.KEY_DOWN, + { type: 'watch/media_volumeDown' } + ); + }); + }); + + describe('shift key combinations', () => { + it('should handle Shift + ESC to stop audio description', () => { + expectDispatch( + KeyCode.KEY_ESCAPE, + { + type: 'playerpref/setPreference', + payload: { stopAD: true } + }, + withShift + ); + }); + + it('should handle Shift + = to increase caption size', () => { + expectDispatch( + KeyCode.KEY_EQUALS, + { + type: 'playerpref/changeCCSizeByValue', + payload: 0.25 + }, + withShift + ); + }); + + it('should handle Shift + - to decrease caption size', () => { + expectDispatch( + KeyCode.KEY_DASH, + { + type: 'playerpref/changeCCSizeByValue', + payload: -0.25 + }, + withShift + ); + }); + + it('should handle Shift + UP to increase playback rate', () => { + expectDispatch( + KeyCode.KEY_UP, + { + type: 'playerpref/changePlaybackrateByValue', + payload: 0.25 + }, + withShift + ); + }); + + it('should handle Shift + DOWN to decrease playback rate', () => { + expectDispatch( + KeyCode.KEY_DOWN, + { + type: 'playerpref/changePlaybackrateByValue', + payload: -0.25 + }, + withShift + ); + }); + + it('should handle Shift + / to open search', () => { + expectDispatch( + KeyCode.KEY_SLASH, + { type: 'watch/search_open' }, + { ...withShift, testPreventDefault: true } + ); + }); + }); + + describe('caption position controls', () => { + it('should handle Shift + W to move captions up', () => { + expectDispatch( + KeyCode.KEY_W, + { + type: 'playerpref/changeYTranslateByValue', + payload: 5 + }, + withShift + ); + }); + + it('should handle Shift + S to move captions down', () => { + expectDispatch( + KeyCode.KEY_S, + { + type: 'playerpref/changeYTranslateByValue', + payload: -5 + }, + withShift + ); + }); + + it('should handle Shift + A to move captions left', () => { + expectDispatch( + KeyCode.KEY_A, + { + type: 'playerpref/changeXTranslateByValue', + payload: 5 + }, + withShift + ); + }); + + it('should handle Shift + D to move captions right', () => { + expectDispatch( + KeyCode.KEY_D, + { + type: 'playerpref/changeXTranslateByValue', + payload: -5 + }, + withShift + ); + }); + }); + + describe('menu shortcuts', () => { + it('should handle Shift + Q to close menu', () => { + expectDispatch( + KeyCode.KEY_Q, + { type: 'watch/menu_close' }, + withShift + ); + }); + + it('should handle Shift + C to open settings menu', () => { + expectDispatch( + KeyCode.KEY_C, + { + type: 'watch/menu_open', + payload: { type: MENU_SETTING, option: 'b' } + }, + withShift + ); + }); + + it('should handle Shift + X to open download menu', () => { + expectDispatch( + KeyCode.KEY_X, + { + type: 'watch/menu_open', + payload: { type: MENU_DOWNLOAD, option: 'b' } + }, + withShift + ); + }); + + it('should handle Shift + L to open language menu', () => { + expectDispatch( + KeyCode.KEY_L, + { + type: 'watch/menu_open', + payload: { type: MENU_LANGUAGE, option: 'b' } + }, + withShift + ); + }); + + it('should handle Shift + P to open playlists menu', () => { + expectDispatch( + KeyCode.KEY_P, + { + type: 'watch/menu_open', + payload: { type: MENU_PLAYLISTS, option: 'b' } + }, + withShift + ); + }); + + it('should handle Shift + R to open playback rates menu', () => { + expectDispatch( + KeyCode.KEY_R, + { + type: 'watch/menu_open', + payload: { type: MENU_PLAYBACKRATE, option: 'b' } + }, + withShift + ); + }); + + it('should handle Shift + M to open screen mode menu', () => { + expectDispatch( + KeyCode.KEY_M, + { + type: 'watch/menu_open', + payload: { type: MENU_SCREEN_MODE, option: 'b' } + }, + withShift + ); + }); + + it('should handle Shift + \\ to open shortcuts menu', () => { + expectDispatch( + KeyCode.KEY_BACK_SLASH, + { + type: 'watch/menu_open', + payload: { type: MENU_SHORTCUTS, option: 'b' } + }, + withShift + ); + }); + }); + + describe('edit mode shortcuts', () => { + it.skip('should handle Alt + E to edit current caption', () => { + // Skipping this test until we properly mock trans.control + }); + }); + + describe('input handling', () => { + it('should not handle shortcuts when focused on text input', () => { + const input = document.createElement('input'); + input.type = 'text'; + + pressKey(KeyCode.KEY_SPACE, { target: input }); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should not handle shortcuts when focused on textarea', () => { + const textarea = document.createElement('textarea'); + + pressKey(KeyCode.KEY_SPACE, { target: textarea }); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('should not handle shortcuts when focused on contentEditable element', () => { + const div = document.createElement('div'); + div.contentEditable = 'true'; + + pressKey(KeyCode.KEY_SPACE, { target: div }); + + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + + describe('ctrl/cmd key combinations', () => { + // Test both ctrl and cmd key combinations + [withCtrl, withCmd].forEach(modifier => { + const modifierName = Object.keys(modifier)[0].replace('Key', ''); + + it(`should handle ${modifierName} key combinations`, () => { + pressKey(KeyCode.KEY_A, modifier); + // Most ctrl/cmd combinations should be ignored (return early) + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('alt key combinations', () => { + it('should handle alt key combinations', () => { + pressKey(KeyCode.KEY_A, withAlt); + // Most alt combinations should be ignored (return early) + expect(mockDispatch).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file