From f04948240ad1c13800df0441c7e465c7621c6cd4 Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 3 Jul 2025 08:29:06 +0200 Subject: [PATCH] feat: support holding click for subtitles settings --- src/common/index.js | 4 + src/common/useInterval.ts | 26 +++++ src/common/useTimeout.ts | 26 +++++ src/components/Button/Button.tsx | 2 + .../DiscreteSelectInput.js | 49 --------- .../DiscreteSelectInput/index.js | 5 - .../styles.less => Stepper/Stepper.less} | 14 +-- .../Player/SubtitlesMenu/Stepper/Stepper.tsx | 98 +++++++++++++++++ .../Player/SubtitlesMenu/Stepper/index.ts | 2 + .../Player/SubtitlesMenu/SubtitlesMenu.js | 103 ++++++------------ src/routes/Player/SubtitlesMenu/styles.less | 2 +- 11 files changed, 200 insertions(+), 131 deletions(-) create mode 100644 src/common/useInterval.ts create mode 100644 src/common/useTimeout.ts delete mode 100644 src/routes/Player/SubtitlesMenu/DiscreteSelectInput/DiscreteSelectInput.js delete mode 100644 src/routes/Player/SubtitlesMenu/DiscreteSelectInput/index.js rename src/routes/Player/SubtitlesMenu/{DiscreteSelectInput/styles.less => Stepper/Stepper.less} (79%) create mode 100644 src/routes/Player/SubtitlesMenu/Stepper/Stepper.tsx create mode 100644 src/routes/Player/SubtitlesMenu/Stepper/index.ts diff --git a/src/common/index.js b/src/common/index.js index 3354a64e4..25df5c158 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -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, diff --git a/src/common/useInterval.ts b/src/common/useInterval.ts new file mode 100644 index 000000000..49fba607e --- /dev/null +++ b/src/common/useInterval.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react'; + +const useInterval = (duration: number) => { + const interval = useRef(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; diff --git a/src/common/useTimeout.ts b/src/common/useTimeout.ts new file mode 100644 index 000000000..e865aeefd --- /dev/null +++ b/src/common/useTimeout.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react'; + +const useTimeout = (duration: number) => { + const timeout = useRef(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; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index dcce809d2..a5756fc42 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -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) => void, onDoubleClick?: () => void, diff --git a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/DiscreteSelectInput.js b/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/DiscreteSelectInput.js deleted file mode 100644 index ea7948b83..000000000 --- a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/DiscreteSelectInput.js +++ /dev/null @@ -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 ( -
-
{label}
-
- -
{value}
- -
-
- ); -}; - -DiscreteSelectInput.propTypes = { - className: PropTypes.string, - value: PropTypes.string, - label: PropTypes.string, - disabled: PropTypes.bool, - dataset: PropTypes.object, - onChange: PropTypes.func -}; - -module.exports = DiscreteSelectInput; diff --git a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/index.js b/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/index.js deleted file mode 100644 index aaf93afb3..000000000 --- a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (C) 2017-2023 Smart code 203358507 - -const DiscreteSelectInput = require('./DiscreteSelectInput'); - -module.exports = DiscreteSelectInput; diff --git a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/styles.less b/src/routes/Player/SubtitlesMenu/Stepper/Stepper.less similarity index 79% rename from src/routes/Player/SubtitlesMenu/DiscreteSelectInput/styles.less rename to src/routes/Player/SubtitlesMenu/Stepper/Stepper.less index 2ceb41f80..67b034abe 100644 --- a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/styles.less +++ b/src/routes/Player/SubtitlesMenu/Stepper/Stepper.less @@ -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; diff --git a/src/routes/Player/SubtitlesMenu/Stepper/Stepper.tsx b/src/routes/Player/SubtitlesMenu/Stepper/Stepper.tsx new file mode 100644 index 000000000..0d402a455 --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/Stepper/Stepper.tsx @@ -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 ( +
+
+ { t(label) } +
+
+ +
+ { disabled ? '--' : `${value}${unit}` } +
+ +
+
+ ); +}; + +export default Stepper; diff --git a/src/routes/Player/SubtitlesMenu/Stepper/index.ts b/src/routes/Player/SubtitlesMenu/Stepper/index.ts new file mode 100644 index 000000000..9fd275c70 --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/Stepper/index.ts @@ -0,0 +1,2 @@ +import Stepper from './Stepper'; +export default Stepper; diff --git a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js index 39bc771e6..d94c5f70b 100644 --- a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js +++ b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js @@ -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) => {
{t('PLAYER_SUBTITLES_SETTINGS')}
- - -
diff --git a/src/routes/Player/SubtitlesMenu/styles.less b/src/routes/Player/SubtitlesMenu/styles.less index 71f1d5cb4..bed7be75d 100644 --- a/src/routes/Player/SubtitlesMenu/styles.less +++ b/src/routes/Player/SubtitlesMenu/styles.less @@ -114,7 +114,7 @@ flex: 1; } - .discrete-input { + .stepper { padding: 0 1.5rem 1rem; } }