feat(Player): implement playback speed controls

This commit is contained in:
Tim 2022-10-25 16:07:46 +02:00
parent c89072e329
commit 595e411e06
6 changed files with 119 additions and 10 deletions

View file

@ -16,7 +16,7 @@
"@sentry/browser": "6.13.3",
"@stremio/stremio-colors": "4.0.1",
"@stremio/stremio-core-web": "0.44.5",
"@stremio/stremio-icons": "3.0.5",
"@stremio/stremio-icons": "4.0.0",
"@stremio/stremio-video": "0.0.23",
"a-color-picker": "1.2.1",
"bowser": "2.11.0",

View file

@ -18,6 +18,7 @@ const ControlBar = ({
duration,
volume,
muted,
playbackSpeed,
subtitlesTracks,
audioTracks,
metaItem,
@ -29,6 +30,7 @@ const ControlBar = ({
onSeekRequested,
onToggleSubtitlesMenu,
onToggleInfoMenu,
onToggleSpeedMenu,
...props
}) => {
const { chromecast } = useServices();
@ -72,6 +74,11 @@ const ControlBar = ({
onToggleInfoMenu();
}
}, [onToggleInfoMenu]);
const onSpeedButtonClick = React.useCallback(() => {
if (typeof onToggleSpeedMenu === 'function') {
onToggleSpeedMenu();
}
}, [onToggleSpeedMenu]);
const onChromecastButtonClick = React.useCallback(() => {
chromecast.transport.requestSession();
}, []);
@ -118,6 +125,9 @@ const ControlBar = ({
<Icon className={styles['icon']} icon={'ic_more'} />
</Button>
<div className={classnames(styles['control-bar-buttons-menu-container'], { 'open': buttonsMenuOpen })}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': playbackSpeed === null })} tabIndex={-1} onClick={onSpeedButtonClick}>
<Icon className={styles['icon']} icon={'ic_speedometer'} />
</Button>
<Button className={classnames(styles['control-bar-button'], 'disabled')} tabIndex={-1}>
<Icon className={styles['icon']} icon={'ic_network'} />
</Button>
@ -146,6 +156,7 @@ ControlBar.propTypes = {
duration: PropTypes.number,
volume: PropTypes.number,
muted: PropTypes.bool,
playbackSpeed: PropTypes.number,
subtitlesTracks: PropTypes.array,
audioTracks: PropTypes.array,
metaItem: PropTypes.object,
@ -156,7 +167,8 @@ ControlBar.propTypes = {
onVolumeChangeRequested: PropTypes.func,
onSeekRequested: PropTypes.func,
onToggleSubtitlesMenu: PropTypes.func,
onToggleInfoMenu: PropTypes.func
onToggleInfoMenu: PropTypes.func,
onToggleSpeedMenu: PropTypes.func
};
module.exports = ControlBar;

View file

@ -12,6 +12,7 @@ const BufferingLoader = require('./BufferingLoader');
const ControlBar = require('./ControlBar');
const InfoMenu = require('./InfoMenu');
const SubtitlesMenu = require('./SubtitlesMenu');
const SpeedMenu = require('./SpeedMenu');
const Video = require('./Video');
const usePlayer = require('./usePlayer');
const useSettings = require('./useSettings');
@ -38,6 +39,7 @@ const Player = ({ urlParams, queryParams }) => {
const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []);
const [subtitlesMenuOpen, , closeSubtitlesMenu, toggleSubtitlesMenu] = useBinaryState(false);
const [infoMenuOpen, , closeInfoMenu, toggleInfoMenu] = useBinaryState(false);
const [speedMenuOpen, , closeSpeedMenu, toggleSpeedMenu] = useBinaryState(false);
const [error, setError] = React.useState(null);
const [videoState, setVideoState] = React.useReducer(
(videoState, nextVideoState) => ({ ...videoState, ...nextVideoState }),
@ -157,6 +159,9 @@ const Player = ({ urlParams, queryParams }) => {
const onSeekRequested = React.useCallback((time) => {
dispatch({ type: 'setProp', propName: 'time', propValue: time });
}, []);
const onPlaybackSpeedChanged = React.useCallback((rate) => {
dispatch({ type: 'setProp', propName: 'playbackSpeed', propValue: rate });
}, []);
const onSubtitlesTrackSelected = React.useCallback((id) => {
dispatch({ type: 'setProp', propName: 'selectedSubtitlesTrackId', propValue: id });
dispatch({ type: 'setProp', propName: 'selectedExtraSubtitlesTrackId', propValue: null });
@ -198,6 +203,9 @@ const Player = ({ urlParams, queryParams }) => {
if (!event.nativeEvent.infoMenuClosePrevented) {
closeInfoMenu();
}
if (!event.nativeEvent.speedMenuClosePrevented) {
closeSpeedMenu();
}
}, []);
const onContainerMouseMove = React.useCallback((event) => {
setImmersed(false);
@ -357,7 +365,7 @@ const Player = ({ urlParams, queryParams }) => {
const onKeyDown = (event) => {
switch (event.code) {
case 'Space': {
if (!subtitlesMenuOpen && !infoMenuOpen && videoState.paused !== null) {
if (!subtitlesMenuOpen && !infoMenuOpen && !speedMenuOpen && videoState.paused !== null) {
if (videoState.paused) {
onPlayRequested();
} else {
@ -368,7 +376,7 @@ const Player = ({ urlParams, queryParams }) => {
break;
}
case 'ArrowRight': {
if (!subtitlesMenuOpen && !infoMenuOpen && videoState.time !== null) {
if (!subtitlesMenuOpen && !infoMenuOpen && !speedMenuOpen && videoState.time !== null) {
const seekTimeMultiplier = event.shiftKey ? 3 : 1;
onSeekRequested(videoState.time + (settings.seekTimeDuration * seekTimeMultiplier));
}
@ -376,7 +384,7 @@ const Player = ({ urlParams, queryParams }) => {
break;
}
case 'ArrowLeft': {
if (!subtitlesMenuOpen && !infoMenuOpen && videoState.time !== null) {
if (!subtitlesMenuOpen && !infoMenuOpen && !speedMenuOpen && videoState.time !== null) {
const seekTimeMultiplier = event.shiftKey ? 3 : 1;
onSeekRequested(videoState.time - (settings.seekTimeDuration * seekTimeMultiplier));
}
@ -384,14 +392,14 @@ const Player = ({ urlParams, queryParams }) => {
break;
}
case 'ArrowUp': {
if (!subtitlesMenuOpen && !infoMenuOpen && videoState.volume !== null) {
if (!subtitlesMenuOpen && !infoMenuOpen && !speedMenuOpen && videoState.volume !== null) {
onVolumeChangeRequested(videoState.volume + 5);
}
break;
}
case 'ArrowDown': {
if (!subtitlesMenuOpen && !infoMenuOpen && videoState.volume !== null) {
if (!subtitlesMenuOpen && !infoMenuOpen && !speedMenuOpen && videoState.volume !== null) {
onVolumeChangeRequested(videoState.volume - 5);
}
@ -418,6 +426,7 @@ const Player = ({ urlParams, queryParams }) => {
case 'Escape': {
closeSubtitlesMenu();
closeInfoMenu();
closeSpeedMenu();
break;
}
}
@ -428,7 +437,7 @@ const Player = ({ urlParams, queryParams }) => {
return () => {
window.removeEventListener('keydown', onKeyDown);
};
}, [player.metaItem, settings.seekTimeDuration, routeFocused, subtitlesMenuOpen, infoMenuOpen, videoState.paused, videoState.time, videoState.volume, videoState.audioTracks, videoState.subtitlesTracks, videoState.extraSubtitlesTracks, toggleSubtitlesMenu, toggleInfoMenu]);
}, [player.metaItem, settings.seekTimeDuration, routeFocused, subtitlesMenuOpen, infoMenuOpen, speedMenuOpen, videoState.paused, videoState.time, videoState.volume, videoState.audioTracks, videoState.subtitlesTracks, videoState.extraSubtitlesTracks, toggleSubtitlesMenu, toggleInfoMenu]);
React.useLayoutEffect(() => {
return () => {
setImmersedDebounced.cancel();
@ -437,7 +446,7 @@ const Player = ({ urlParams, queryParams }) => {
};
}, []);
return (
<div className={classnames(styles['player-container'], { [styles['immersed']]: immersed && !casting && videoState.paused !== null && !videoState.paused && !subtitlesMenuOpen && !infoMenuOpen })}
<div className={classnames(styles['player-container'], { [styles['immersed']]: immersed && !casting && videoState.paused !== null && !videoState.paused && !subtitlesMenuOpen && !infoMenuOpen && !speedMenuOpen })}
onMouseDown={onContainerMouseDown}
onMouseMove={onContainerMouseMove}
onMouseOver={onContainerMouseMove}
@ -482,7 +491,7 @@ const Player = ({ urlParams, queryParams }) => {
null
}
{
subtitlesMenuOpen || infoMenuOpen ?
subtitlesMenuOpen || infoMenuOpen || speedMenuOpen ?
<div className={styles['layer']} />
:
null
@ -502,6 +511,7 @@ const Player = ({ urlParams, queryParams }) => {
duration={videoState.duration}
volume={videoState.volume}
muted={videoState.muted}
playbackSpeed={videoState.playbackSpeed}
subtitlesTracks={videoState.subtitlesTracks.concat(videoState.extraSubtitlesTracks)}
audioTracks={videoState.audioTracks}
metaItem={player.metaItem}
@ -513,6 +523,7 @@ const Player = ({ urlParams, queryParams }) => {
onSeekRequested={onSeekRequested}
onToggleSubtitlesMenu={toggleSubtitlesMenu}
onToggleInfoMenu={toggleInfoMenu}
onToggleSpeedMenu={toggleSpeedMenu}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
/>
@ -554,6 +565,16 @@ const Player = ({ urlParams, queryParams }) => {
:
null
}
{
speedMenuOpen ?
<SpeedMenu
className={classnames(styles['layer'], styles['menu-layer'])}
playbackSpeed={videoState.playbackSpeed}
onPlaybackSpeedChanged={onPlaybackSpeedChanged}
/>
:
null
}
</div>
);
};

View file

@ -0,0 +1,50 @@
// Copyright (C) 2017-2022 Smart code 203358507
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { Multiselect } = require('stremio/common');
const styles = require('./styles');
const RATES = Array.from(Array(8).keys(), (n) => n * 0.25 + 0.25).reverse();
const SpeedMenu = ({ className, playbackSpeed, onPlaybackSpeedChanged }) => {
const onMouseDown = React.useCallback((event) => {
event.nativeEvent.speedMenuClosePrevented = true;
}, []);
const onOptionSelect = React.useCallback((event) => {
if (typeof onPlaybackSpeedChanged === 'function' && event.value) {
onPlaybackSpeedChanged(parseFloat(event.value));
}
}, [onPlaybackSpeedChanged]);
const selectableOptions = React.useMemo(() => ({
title: 'Playback Speed',
options: RATES.map((rate) => ({
value: `${rate}`,
label: `${rate}x`,
title: `${rate}x`
})),
selected: [`${playbackSpeed}`],
renderLabelText: () => `${playbackSpeed}x`,
onSelect: onOptionSelect
}), [playbackSpeed, onOptionSelect]);
return (
<div className={classnames(className, styles['speed-menu-container'])} onMouseDown={onMouseDown}>
<div className={styles['title']}>
Playback Speed
</div>
<Multiselect
{...selectableOptions}
className={styles['select-input-container']}
/>
</div>
);
};
SpeedMenu.propTypes = {
className: PropTypes.string,
playbackSpeed: PropTypes.number,
onPlaybackSpeedChanged: PropTypes.func,
};
module.exports = SpeedMenu;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2022 Smart code 203358507
const SpeedMenu = require('./SpeedMenu');
module.exports = SpeedMenu;

View file

@ -0,0 +1,21 @@
// Copyright (C) 2017-2022 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.speed-menu-container {
width: 12rem;
overflow: visible !important;
.title {
flex: none;
align-self: stretch;
max-height: 2.4em;
font-weight: 600;
color: @color-surface-light5-90;
margin: 1rem;
}
.select-input-container {
padding: 1rem 1.5rem;
}
}