Merge pull request #965 from Stremio/feat/player-subtitles-settings-hold-click

Player: Support holding click for subtitles settings
This commit is contained in:
Tim 2025-07-07 14:27:44 +02:00 committed by GitHub
commit 85fea50c15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 200 additions and 131 deletions

View file

@ -15,6 +15,7 @@ const routesRegexp = require('./routesRegexp');
const useAnimationFrame = require('./useAnimationFrame');
const useBinaryState = require('./useBinaryState');
const { default: useFullscreen } = require('./useFullscreen');
const { default: useInterval } = require('./useInterval');
const useLiveRef = require('./useLiveRef');
const useModelState = require('./useModelState');
const useNotifications = require('./useNotifications');
@ -23,6 +24,7 @@ const useProfile = require('./useProfile');
const { default: useSettings } = require('./useSettings');
const { default: useShell } = require('./useShell');
const useStreamingServer = require('./useStreamingServer');
const { default: useTimeout } = require('./useTimeout');
const useTorrent = require('./useTorrent');
const useTranslate = require('./useTranslate');
const { default: useOrientation } = require('./useOrientation');
@ -49,6 +51,7 @@ module.exports = {
useAnimationFrame,
useBinaryState,
useFullscreen,
useInterval,
useLiveRef,
useModelState,
useNotifications,
@ -57,6 +60,7 @@ module.exports = {
useSettings,
useShell,
useStreamingServer,
useTimeout,
useTorrent,
useTranslate,
useOrientation,

26
src/common/useInterval.ts Normal file
View file

@ -0,0 +1,26 @@
import { useEffect, useRef } from 'react';
const useInterval = (duration: number) => {
const interval = useRef<NodeJS.Timer | null>(null);
const start = (callback: () => void) => {
cancel();
interval.current = setInterval(callback, duration);
};
const cancel = () => {
interval.current && clearInterval(interval.current);
interval.current = null;
};
useEffect(() => {
return () => cancel();
}, []);
return {
start,
cancel,
};
};
export default useInterval;

26
src/common/useTimeout.ts Normal file
View file

@ -0,0 +1,26 @@
import { useEffect, useRef } from 'react';
const useTimeout = (duration: number) => {
const timeout = useRef<NodeJS.Timeout | null>(null);
const start = (callback: () => void) => {
cancel();
timeout.current = setTimeout(callback, duration);
};
const cancel = () => {
timeout.current && clearTimeout(timeout.current);
timeout.current = null;
};
useEffect(() => {
return () => cancel();
}, []);
return {
start,
cancel,
};
};
export default useTimeout;

View file

@ -16,6 +16,8 @@ type Props = {
children: React.ReactNode,
onKeyDown?: (event: React.KeyboardEvent) => void,
onMouseDown?: (event: React.MouseEvent) => void,
onMouseUp?: (event: React.MouseEvent) => void,
onMouseLeave?: (event: React.MouseEvent) => void,
onLongPress?: () => void,
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void,
onDoubleClick?: () => void,

View file

@ -1,49 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button } = require('stremio/components');
const styles = require('./styles');
const DiscreteSelectInput = ({ className, value, label, disabled, dataset, onChange }) => {
const { t } = useTranslation();
const buttonOnClick = React.useCallback((event) => {
if (typeof onChange === 'function') {
onChange({
type: 'change',
value: event.currentTarget.dataset.type,
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}, [dataset, onChange]);
return (
<div className={classnames(className, styles['discrete-input-container'], { 'disabled': disabled })}>
<div className={styles['header']}>{label}</div>
<div className={styles['input-container']} title={disabled ? t('DISABLED_LABEL', { label }) : null}>
<Button className={classnames(styles['button-container'], { 'disabled': disabled })} data-type={'decrement'} onClick={buttonOnClick}>
<Icon className={styles['icon']} name={'remove'} />
</Button>
<div className={styles['option-label']} title={value}>{value}</div>
<Button className={classnames(styles['button-container'], { 'disabled': disabled })} data-type={'increment'} onClick={buttonOnClick}>
<Icon className={styles['icon']} name={'add'} />
</Button>
</div>
</div>
);
};
DiscreteSelectInput.propTypes = {
className: PropTypes.string,
value: PropTypes.string,
label: PropTypes.string,
disabled: PropTypes.bool,
dataset: PropTypes.object,
onChange: PropTypes.func
};
module.exports = DiscreteSelectInput;

View file

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

View file

@ -1,14 +1,10 @@
// Copyright (C) 2017-2023 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.discrete-input-container {
.stepper {
&:global(.disabled) {
.header {
color: var(--primary-foreground-color);
}
.input-container {
.content {
opacity: 0.4;
}
}
@ -19,14 +15,14 @@
opacity: 0.6;
}
.input-container {
.content {
display: flex;
flex-direction: row;
align-items: center;
border-radius: 3.5rem;
background: var(--overlay-color);
.button-container {
.button {
flex: none;
width: 3.5rem;
height: 3.5rem;
@ -42,7 +38,7 @@
}
}
.option-label {
.value {
flex: 1;
font-weight: 500;
text-align: center;

View file

@ -0,0 +1,98 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import Icon from '@stremio/stremio-icons/react';
import { Button } from 'stremio/components';
import { useInterval, useTimeout } from 'stremio/common';
import styles from './Stepper.less';
const clamp = (value: number, min?: number, max?: number) => {
const minClamped = typeof min === 'number' ? Math.max(value, min) : value;
const maxClamped = typeof max === 'number' ? Math.min(minClamped, max) : minClamped;
return maxClamped;
};
type Props = {
className: string,
label: string,
value: number,
unit?: string,
step: number,
min?: number,
max?: number,
disabled?: boolean,
onChange: (value: number) => void,
};
const Stepper = ({ className, label, value, unit, step, min, max, disabled, onChange }: Props) => {
const { t } = useTranslation();
const localValue = useRef(value);
const interval = useInterval(100);
const timeout = useTimeout(250);
const cancel = () => {
interval.cancel();
timeout.cancel();
};
const updateValue = useCallback((delta: number) => {
onChange(clamp(localValue.current + delta, min, max));
}, [onChange]);
const onDecrementMouseDown = useCallback(() => {
cancel();
timeout.start(() => interval.start(() => updateValue(-step)));
}, [updateValue]);
const onDecrementMouseUp = useCallback(() => {
cancel();
updateValue(-step);
}, [updateValue]);
const onIncrementMouseDown = useCallback(() => {
cancel();
timeout.start(() => interval.start(() => updateValue(step)));
}, [updateValue]);
const onIncrementMouseUp = useCallback(() => {
cancel();
updateValue(step);
}, [updateValue]);
useEffect(() => {
localValue.current = value;
}, [value]);
return (
<div className={classNames(styles['stepper'], className)}>
<div className={styles['header']}>
{ t(label) }
</div>
<div className={styles['content']}>
<Button
className={classNames(styles['button'], { 'disabled': disabled })}
onMouseDown={onDecrementMouseDown}
onMouseUp={onDecrementMouseUp}
onMouseLeave={cancel}
>
<Icon className={styles['icon']} name={'remove'} />
</Button>
<div className={styles['value']}>
{ disabled ? '--' : `${value}${unit}` }
</div>
<Button
className={classNames(styles['button'], { 'disabled': disabled })}
onMouseDown={onIncrementMouseDown}
onMouseUp={onIncrementMouseUp}
onMouseLeave={cancel}
>
<Icon className={styles['icon']} name={'add'} />
</Button>
</div>
</div>
);
};
export default Stepper;

View file

@ -0,0 +1,2 @@
import Stepper from './Stepper';
export default Stepper;

View file

@ -3,11 +3,12 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { CONSTANTS, comparatorWithPriorities, languages } = require('stremio/common');
const { comparatorWithPriorities, languages } = require('stremio/common');
const { SUBTITLES_SIZES } = require('stremio/common/CONSTANTS');
const { Button } = require('stremio/components');
const DiscreteSelectInput = require('./DiscreteSelectInput');
const styles = require('./styles');
const { t } = require('i18next');
const { default: Stepper } = require('./Stepper');
const ORIGIN_PRIORITIES = {
'LOCAL': 3,
@ -98,51 +99,41 @@ const SubtitlesMenu = React.memo((props) => {
}
}
}, [props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
const onSubtitlesDelayChanged = React.useCallback((event) => {
const delta = event.value === 'increment' ? 250 : -250;
const onSubtitlesDelayChanged = React.useCallback((value) => {
if (typeof props.selectedExtraSubtitlesTrackId === 'string') {
if (props.extraSubtitlesDelay !== null && !isNaN(props.extraSubtitlesDelay)) {
const extraDelay = props.extraSubtitlesDelay + delta;
if (typeof props.onExtraSubtitlesDelayChanged === 'function') {
props.onExtraSubtitlesDelayChanged(extraDelay);
props.onExtraSubtitlesDelayChanged(value * 1000);
}
}
}
}, [props.selectedExtraSubtitlesTrackId, props.extraSubtitlesDelay, props.onExtraSubtitlesDelayChanged]);
const onSubtitlesSizeChanged = React.useCallback((event) => {
const delta = event.value === 'increment' ? 1 : -1;
const onSubtitlesSizeChanged = React.useCallback((value) => {
if (typeof props.selectedSubtitlesTrackId === 'string') {
if (props.subtitlesSize !== null && !isNaN(props.subtitlesSize)) {
const sizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(props.subtitlesSize);
const size = CONSTANTS.SUBTITLES_SIZES[Math.max(0, Math.min(CONSTANTS.SUBTITLES_SIZES.length - 1, sizeIndex + delta))];
if (typeof props.onSubtitlesSizeChanged === 'function') {
props.onSubtitlesSizeChanged(size);
props.onSubtitlesSizeChanged(value);
}
}
} else if (typeof props.selectedExtraSubtitlesTrackId === 'string') {
if (props.extraSubtitlesSize !== null && !isNaN(props.extraSubtitlesSize)) {
const extraSizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(props.extraSubtitlesSize);
const extraSize = CONSTANTS.SUBTITLES_SIZES[Math.max(0, Math.min(CONSTANTS.SUBTITLES_SIZES.length - 1, extraSizeIndex + delta))];
if (typeof props.onExtraSubtitlesSizeChanged === 'function') {
props.onExtraSubtitlesSizeChanged(extraSize);
props.onExtraSubtitlesSizeChanged(value);
}
}
}
}, [props.selectedSubtitlesTrackId, props.selectedExtraSubtitlesTrackId, props.subtitlesSize, props.extraSubtitlesSize, props.onSubtitlesSizeChanged, props.onExtraSubtitlesSizeChanged]);
const onSubtitlesOffsetChanged = React.useCallback((event) => {
const delta = event.value === 'increment' ? 1 : -1;
const onSubtitlesOffsetChanged = React.useCallback((value) => {
if (typeof props.selectedSubtitlesTrackId === 'string') {
if (props.subtitlesOffset !== null && !isNaN(props.subtitlesOffset)) {
const offset = Math.max(0, Math.min(100, Math.floor(props.subtitlesOffset + delta)));
if (typeof props.onSubtitlesOffsetChanged === 'function') {
props.onSubtitlesOffsetChanged(offset);
props.onSubtitlesOffsetChanged(value);
}
}
} else if (typeof props.selectedExtraSubtitlesTrackId === 'string') {
if (props.extraSubtitlesOffset !== null && !isNaN(props.extraSubtitlesOffset)) {
const offset = Math.max(0, Math.min(100, Math.floor(props.extraSubtitlesOffset + delta)));
if (typeof props.onExtraSubtitlesOffsetChanged === 'function') {
props.onExtraSubtitlesOffsetChanged(offset);
props.onExtraSubtitlesOffsetChanged(value);
}
}
}
@ -215,57 +206,35 @@ const SubtitlesMenu = React.memo((props) => {
<div className={styles['subtitles-settings-container']}>
<div className={styles['settings-header']}>{t('PLAYER_SUBTITLES_SETTINGS')}</div>
<div className={styles['settings-list']}>
<DiscreteSelectInput
className={styles['discrete-input']}
label={t('DELAY')}
value={typeof props.selectedExtraSubtitlesTrackId === 'string' && props.extraSubtitlesDelay !== null && !isNaN(props.extraSubtitlesDelay) ? `${(props.extraSubtitlesDelay / 1000).toFixed(2)}s` : '--'}
disabled={typeof props.selectedExtraSubtitlesTrackId !== 'string' || props.extraSubtitlesDelay === null || isNaN(props.extraSubtitlesDelay)}
<Stepper
className={styles['stepper']}
label={'DELAY'}
value={props.extraSubtitlesDelay / 1000}
unit={'s'}
step={0.25}
disabled={props.extraSubtitlesDelay === null}
onChange={onSubtitlesDelayChanged}
/>
<DiscreteSelectInput
className={styles['discrete-input']}
label={t('SIZE')}
value={
typeof props.selectedSubtitlesTrackId === 'string' ?
props.subtitlesSize !== null && !isNaN(props.subtitlesSize) ? `${props.subtitlesSize}%` : '--'
:
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
props.extraSubtitlesSize !== null && !isNaN(props.extraSubtitlesSize) ? `${props.extraSubtitlesSize}%` : '--'
:
'--'
}
disabled={
typeof props.selectedSubtitlesTrackId === 'string' ?
props.subtitlesSize === null || isNaN(props.subtitlesSize)
:
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
props.extraSubtitlesSize === null || isNaN(props.extraSubtitlesSize)
:
true
}
<Stepper
className={styles['stepper']}
label={'SIZE'}
value={props.selectedSubtitlesTrackId ? props.subtitlesSize : props.selectedExtraSubtitlesTrackId ? props.extraSubtitlesSize : null}
unit={'%'}
step={25}
min={SUBTITLES_SIZES[0]}
max={SUBTITLES_SIZES[SUBTITLES_SIZES.length - 1]}
disabled={(props.selectedSubtitlesTrackId && props.subtitlesSize === null) || (props.selectedExtraSubtitlesTrackId && props.extraSubtitlesSize === null)}
onChange={onSubtitlesSizeChanged}
/>
<DiscreteSelectInput
className={styles['discrete-input']}
label={t('PLAYER_SUBTITLES_VERTICAL_POSIITON')}
value={
typeof props.selectedSubtitlesTrackId === 'string' ?
props.subtitlesOffset !== null && !isNaN(props.subtitlesOffset) ? `${props.subtitlesOffset}%` : '--'
:
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
props.extraSubtitlesOffset !== null && !isNaN(props.extraSubtitlesOffset) ? `${props.extraSubtitlesOffset}%` : '--'
:
'--'
}
disabled={
typeof props.selectedSubtitlesTrackId === 'string' ?
props.subtitlesOffset === null || isNaN(props.subtitlesOffset)
:
typeof props.selectedExtraSubtitlesTrackId === 'string' ?
props.extraSubtitlesOffset === null || isNaN(props.extraSubtitlesOffset)
:
true
}
<Stepper
className={styles['stepper']}
label={'PLAYER_SUBTITLES_VERTICAL_POSIITON'}
value={props.selectedSubtitlesTrackId ? props.subtitlesOffset : props.selectedExtraSubtitlesTrackId ? props.extraSubtitlesOffset : null}
unit={'%'}
step={1}
min={0}
max={100}
disabled={(props.selectedSubtitlesTrackId && props.subtitlesOffset === null) || (props.selectedExtraSubtitlesTrackId && props.extraSubtitlesOffset === null)}
onChange={onSubtitlesOffsetChanged}
/>
</div>

View file

@ -114,7 +114,7 @@
flex: 1;
}
.discrete-input {
.stepper {
padding: 0 1.5rem 1rem;
}
}