Merge pull request #1291 from Aztup/feat/customPlaybackSpeed

Player: Redesign Speed Menu
This commit is contained in:
Timothy Z. 2026-07-17 15:48:29 +03:00 committed by GitHub
commit ab2f9527c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 130 additions and 106 deletions

View file

@ -8,9 +8,10 @@ const useAnimationFrame = require('stremio/common/useAnimationFrame');
const useLiveRef = require('stremio/common/useLiveRef');
const styles = require('./styles');
const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabled, onSlide, onComplete, audioBoost }) => {
const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabled, onSlide, onComplete, audioBoost, stepValue }) => {
const minimumValueRef = useLiveRef(minimumValue !== null && !isNaN(minimumValue) ? minimumValue : 0);
const maximumValueRef = useLiveRef(maximumValue !== null && !isNaN(maximumValue) ? maximumValue : 100);
const stepValueRef = useLiveRef(stepValue !== null && !isNaN(stepValue) ? stepValue : null);
const valueRef = useLiveRef(value !== null && !isNaN(value) ? Math.min(maximumValueRef.current, Math.max(minimumValueRef.current, value)) : 0);
const bufferedRef = useLiveRef(buffered !== null && !isNaN(buffered) ? Math.min(maximumValueRef.current, Math.max(minimumValueRef.current, buffered)) : 0);
const onSlideRef = useLiveRef(onSlide);
@ -26,7 +27,8 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl
const { x: sliderX, width: sliderWidth } = sliderContainerRef.current.getBoundingClientRect();
const thumbStart = Math.min(Math.max(mouseX - sliderX, 0), sliderWidth);
const value = (thumbStart / sliderWidth) * (maximumValueRef.current - minimumValueRef.current) + minimumValueRef.current;
return value;
const normalizedValue = stepValueRef.current ? parseFloat((Math.round(value / stepValueRef.current) * stepValueRef.current).toFixed(2)) : value;
return normalizedValue;
}, []);
const retainThumb = React.useCallback(() => {
window.addEventListener('blur', onBlur);
@ -158,6 +160,7 @@ Slider.propTypes = {
buffered: PropTypes.number,
minimumValue: PropTypes.number,
maximumValue: PropTypes.number,
stepValue: PropTypes.number,
disabled: PropTypes.bool,
onSlide: PropTypes.func,
onComplete: PropTypes.func,

View file

@ -1,33 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { Button } = require('stremio/components');
const styles = require('./styles');
const OptionButton = ({ className, value, selected, onSelect }) => {
const onClick = React.useCallback(() => {
if (typeof onSelect === 'function') {
onSelect(value);
}
}, [onSelect, value]);
return (
<Button
className={classnames(className, styles['option'], { 'selected': selected })}
onClick={onClick}
>
<div className={styles['label']}>{ value }x</div>
<div className={styles['icon']} />
</Button>
);
};
OptionButton.propTypes = {
className: PropTypes.string,
value: PropTypes.number,
selected: PropTypes.bool,
onSelect: PropTypes.func,
};
module.exports = OptionButton;

View file

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

View file

@ -1,40 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.option {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 1.5em;
margin-bottom: 0.5rem;
border-radius: var(--border-radius);
&:global(.selected) {
background-color: var(--overlay-color);
.icon {
display: block;
}
}
&:hover, &:focus {
background-color: var(--overlay-color);
}
.label {
flex: 1;
font-weight: 400;
color: var(--primary-foreground-color);
}
.icon {
flex: none;
display: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
margin-left: 1rem;
background-color: var(--secondary-accent-color);
}
}

View file

@ -4,38 +4,58 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useTranslation } = require('react-i18next');
const Option = require('./Option');
const styles = require('./styles');
const Button = require('stremio/components/Button').default;
const Slider = require('stremio/components/Slider');
const RATES = Array.from(Array(8).keys(), (n) => n * 0.25 + 0.25).reverse();
const styles = require('./styles');
const RATES = [0.25, 0.5, 1, 1.5, 2, 2.5, 3, 4];
const SpeedMenu = React.memo(React.forwardRef(({ className, playbackSpeed, onPlaybackSpeedChanged }, ref) => {
const { t } = useTranslation();
const onMouseDown = React.useCallback((event) => {
event.nativeEvent.speedMenuClosePrevented = true;
}, []);
const onOptionSelect = React.useCallback((value) => {
const onSpeedChanged = React.useCallback((value) => {
if (typeof onPlaybackSpeedChanged === 'function') {
onPlaybackSpeedChanged(value);
}
}, [onPlaybackSpeedChanged]);
const onSpeedButtonClick = React.useCallback((event) => {
onSpeedChanged(Number(event.currentTarget.dataset.rate));
}, [onSpeedChanged]);
return (
<div ref={ref} className={classnames(className, styles['speed-menu-container'])} onMouseDown={onMouseDown}>
<div className={styles['title']}>
{ t('PLAYBACK_SPEED') }
</div>
<div className={styles['options-container']}>
{
RATES.map((rate) => (
<Option
className={styles['option']}
key={rate}
value={rate}
selected={rate === playbackSpeed}
onSelect={onOptionSelect}
/>
))
}
<div className={styles['main-container']}>
<div className={styles['top-container']}>
<div className={styles['speed-title']}>
{playbackSpeed}x
</div>
<Slider
className={styles['slider']}
value={playbackSpeed}
minimumValue={0.1}
maximumValue={4}
stepValue={0.1}
onSlide={onSpeedChanged}
onComplete={onSpeedChanged}
/>
</div>
<div className={styles['options-container']}>
{
RATES.map((rate) => (
<Button className={classnames(styles['speed-button'], {[styles['active']]: rate === playbackSpeed})} data-rate={rate} key={rate} onClick={onSpeedButtonClick}>
{rate}x
</Button>
))
}
</div>
</div>
</div>
);

View file

@ -2,24 +2,103 @@
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
:import('~stremio/components/Slider/styles.less') {
slider-track-after: track-after;
slider-thumb: thumb;
}
.speed-menu-container {
width: 14rem;
width: 30rem;
max-width: 100%;
display: flex;
flex-direction: column;
.title {
flex: none;
align-self: stretch;
font-weight: 700;
color: var(--primary-foreground-color);
padding: 1.5rem 2rem;
padding: 1rem;
text-align: center;
}
.options-container {
flex: 0 1 auto;
max-height: calc(3.2rem * 10);
padding: 0 1rem 0.5rem;
.option {
height: 3.2rem;
.main-container {
display: flex;
flex-direction: column;
flex: 1;
padding-bottom: 1rem;
.options-container {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0 1rem;
.speed-button {
flex: 1 0 3rem;
color: var(--primary-foreground-color);
padding: 0.2rem 0.25rem;
border-radius: var(--border-radius);
text-align: center;
font-variant-numeric: tabular-nums;
transition: background-color 150ms ease, color 150ms ease;
&:hover, &:focus {
background-color: var(--overlay-color);
}
&.active {
background-color: var(--overlay-color);
color: @color-secondaryvariant1-light4;
}
}
}
.top-container {
display: flex;
flex-direction: column;
flex: none;
min-height: 3rem;
gap: 0.5rem;
padding: 0.5rem 0;
.speed-title {
font-weight: 700;
color: var(--primary-foreground-color);
text-align: center;
}
}
.slider {
--track-size: 0.35rem;
--thumb-size: 1rem;
flex: none;
min-height: calc(var(--thumb-size) * 2);
align-self: stretch;
margin: 0.5rem var(--thumb-size) 0;
position: relative;
.slider-thumb {
background-color: @color-secondaryvariant1-light4;
transition: transform 150ms ease;
&:after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 100%;
box-shadow: 0 0 0 0.25rem white inset;
}
}
&:hover .slider-thumb {
transform: translateX(-50%) scale(1.2);
}
}
}
}
}