diff --git a/package-lock.json b/package-lock.json
index b052d8342..7f2e0b955 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
- "@stremio/stremio-core-web": "0.49.2",
+ "@stremio/stremio-core-web": "0.49.3",
"@stremio/stremio-icons": "5.4.1",
"@stremio/stremio-video": "0.0.60",
"a-color-picker": "1.2.1",
@@ -23,6 +23,7 @@
"filter-invalid-dom-props": "3.0.1",
"hat": "^0.0.3",
"i18next": "^24.0.5",
+ "jwt-decode": "^4.0.0",
"langs": "github:Stremio/nodejs-langs",
"lodash.debounce": "4.0.8",
"lodash.intersection": "4.4.0",
@@ -3371,9 +3372,9 @@
"integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg=="
},
"node_modules/@stremio/stremio-core-web": {
- "version": "0.49.2",
- "resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.2.tgz",
- "integrity": "sha512-IYU+pdHkq4iEfqZ9G+DFZheIE53nY8XyhI1OJLvZp68/4ntRwssXwfj9InHK2Wau20fH+oV2KD1ZWb0CsTLqPA==",
+ "version": "0.49.3",
+ "resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.3.tgz",
+ "integrity": "sha512-Ql/08LbwU99IUL6fOLy+v1Iv75boHXpunEPScKgXJALdq/OV5tZLG/IycN0O+5+50Nc/NHrI6HslnMNLTWA8JQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "7.24.1"
@@ -10234,6 +10235,15 @@
"node": ">=4.0"
}
},
+ "node_modules/jwt-decode": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
+ "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
diff --git a/package.json b/package.json
index f7fc127a1..c12a0b7e8 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
- "@stremio/stremio-core-web": "0.49.2",
+ "@stremio/stremio-core-web": "0.49.3",
"@stremio/stremio-icons": "5.4.1",
"@stremio/stremio-video": "0.0.60",
"a-color-picker": "1.2.1",
@@ -27,6 +27,7 @@
"filter-invalid-dom-props": "3.0.1",
"hat": "^0.0.3",
"i18next": "^24.0.5",
+ "jwt-decode": "^4.0.0",
"langs": "github:Stremio/nodejs-langs",
"lodash.debounce": "4.0.8",
"lodash.intersection": "4.4.0",
diff --git a/src/components/Multiselect/Multiselect.js b/src/components/Multiselect/Multiselect.js
index c791c60d1..0e353eef4 100644
--- a/src/components/Multiselect/Multiselect.js
+++ b/src/components/Multiselect/Multiselect.js
@@ -10,16 +10,16 @@ const ModalDialog = require('stremio/components/ModalDialog');
const useBinaryState = require('stremio/common/useBinaryState');
const styles = require('./styles');
-const Multiselect = ({ className, mode, direction, title, disabled, dataset, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
+const Multiselect = ({ className, mode, direction, title, disabled, dataset, options, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
- const options = React.useMemo(() => {
- return Array.isArray(props.options) ?
- props.options.filter((option) => {
+ const filteredOptions = React.useMemo(() => {
+ return Array.isArray(options) ?
+ options.filter((option) => {
return option && (typeof option.value === 'string' || option.value === null);
})
:
[];
- }, [props.options]);
+ }, [options]);
const selected = React.useMemo(() => {
return Array.isArray(props.selected) ?
props.selected.filter((value) => {
@@ -94,7 +94,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
:
selected.length > 0 ?
selected.map((value) => {
- const option = options.find((option) => option.value === value);
+ const option = filteredOptions.find((option) => option.value === value);
return option && typeof option.label === 'string' ?
option.label
:
@@ -109,12 +109,12 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
}
{children}
- ), [menuOpen, title, disabled, options, selected, labelOnClick, renderLabelContent, renderLabelText]);
+ ), [menuOpen, title, disabled, filteredOptions, selected, labelOnClick, renderLabelContent, renderLabelText]);
const renderMenu = React.useCallback(() => (
{
- options.length > 0 ?
- options.map(({ label, title, value }) => (
+ filteredOptions.length > 0 ?
+ filteredOptions.map(({ label, title, value }) => (
}
- ), [options, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
+ ), [filteredOptions, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
const renderPopupLabel = React.useMemo(() => (labelProps) => {
return renderLabel({
...labelProps,
diff --git a/src/components/NumberInput/NumberInput.less b/src/components/NumberInput/NumberInput.less
new file mode 100644
index 000000000..a88bc6d20
--- /dev/null
+++ b/src/components/NumberInput/NumberInput.less
@@ -0,0 +1,65 @@
+// Copyright (C) 2017-2025 Smart code 203358507
+
+.number-input {
+ user-select: text;
+ display: flex;
+ max-width: 14rem;
+ height: 3.5rem;
+ margin-bottom: 1rem;
+ color: var(--primary-foreground-color);
+ background: var(--overlay-color);
+ border-radius: 3.5rem;
+
+ .button {
+ flex: none;
+ width: 3.5rem;
+ height: 3.5rem;
+ padding: 1rem;
+ background: var(--overlay-color);
+ border: none;
+ border-radius: 100%;
+ cursor: pointer;
+ z-index: 1;
+
+ .icon {
+ width: 100%;
+ height: 100%;
+ }
+ }
+
+ .number-display {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 0 1rem;
+
+ &::-moz-focus-inner {
+ border: none;
+ }
+
+ .label {
+ font-size: 0.8rem;
+ font-weight: 400;
+ opacity: 0.7;
+ }
+
+ .value {
+ font-size: 1.2rem;
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ color: var(--primary-foreground-color);
+ text-align: center;
+ appearance: none;
+
+ &::-webkit-outer-spin-button,
+ &::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/components/NumberInput/NumberInput.tsx b/src/components/NumberInput/NumberInput.tsx
new file mode 100644
index 000000000..a286decf4
--- /dev/null
+++ b/src/components/NumberInput/NumberInput.tsx
@@ -0,0 +1,113 @@
+// Copyright (C) 2017-2025 Smart code 203358507
+
+import Icon from '@stremio/stremio-icons/react';
+import React, { ChangeEvent, forwardRef, memo, useCallback, useState } from 'react';
+import { type KeyboardEvent, type InputHTMLAttributes } from 'react';
+import classnames from 'classnames';
+import styles from './NumberInput.less';
+import Button from '../Button';
+
+type Props = InputHTMLAttributes & {
+ containerClassName?: string;
+ className?: string;
+ disabled?: boolean;
+ showButtons?: boolean;
+ defaultValue?: number;
+ label?: string;
+ min?: number;
+ max?: number;
+ value?: number;
+ onKeyDown?: (event: KeyboardEvent) => void;
+ onSubmit?: (event: KeyboardEvent) => void;
+ onChange?: (event: ChangeEvent) => void;
+};
+
+const NumberInput = forwardRef(({ defaultValue = 0, showButtons, onKeyDown, onSubmit, min, max, onChange, ...props }, ref) => {
+ const [value, setValue] = useState(defaultValue);
+ const displayValue = props.value ?? value;
+
+ const handleKeyDown = useCallback((event: KeyboardEvent) => {
+ onKeyDown?.(event);
+
+ if (event.key === 'Enter') {
+ onSubmit?.(event);
+ }
+ }, [onKeyDown, onSubmit]);
+
+ const handleValueChange = (newValue: number) => {
+ if (props.value === undefined) {
+ setValue(newValue);
+ }
+ onChange?.({ target: { value: newValue.toString() }} as ChangeEvent);
+ };
+
+ const handleIncrement = () => {
+ handleValueChange(clampValueToRange((displayValue || 0) + 1));
+ };
+
+ const handleDecrement = () => {
+ handleValueChange(clampValueToRange((displayValue || 0) - 1));
+ };
+
+ const clampValueToRange = (value: number): number => {
+ const minValue = min ?? 0;
+
+ if (value < minValue) {
+ return minValue;
+ }
+
+ if (max !== undefined && value > max) {
+ return max;
+ }
+
+ return value;
+ };
+
+ const handleInputChange = useCallback(({ target: { valueAsNumber }}: ChangeEvent) => {
+ handleValueChange(clampValueToRange(valueAsNumber || 0));
+ }, []);
+
+ return (
+
+ {
+ showButtons ?
+
+ : null
+ }
+
+ {
+ props.label ?
+
{props.label}
+ : null
+ }
+
+
+ {
+ showButtons ?
+
+ : null
+ }
+
+ );
+});
+
+NumberInput.displayName = 'NumberInput';
+
+export default memo(NumberInput);
diff --git a/src/components/NumberInput/index.ts b/src/components/NumberInput/index.ts
new file mode 100644
index 000000000..4a25f86df
--- /dev/null
+++ b/src/components/NumberInput/index.ts
@@ -0,0 +1,5 @@
+// Copyright (C) 2017-2025 Smart code 203358507
+
+import NumberInput from './NumberInput';
+
+export default NumberInput;
diff --git a/src/components/index.ts b/src/components/index.ts
index bd819f658..a5638007e 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -19,6 +19,7 @@ import ModalDialog from './ModalDialog';
import Multiselect from './Multiselect';
import MultiselectMenu from './MultiselectMenu';
import { HorizontalNavBar, VerticalNavBar } from './NavBar';
+import NumberInput from './NumberInput';
import Popup from './Popup';
import RadioButton from './RadioButton';
import SearchBar from './SearchBar';
@@ -52,6 +53,7 @@ export {
MultiselectMenu,
HorizontalNavBar,
VerticalNavBar,
+ NumberInput,
Popup,
RadioButton,
SearchBar,
diff --git a/src/index.html b/src/index.html
index ba8a9e795..033f9a267 100644
--- a/src/index.html
+++ b/src/index.html
@@ -15,6 +15,7 @@
<%= htmlWebpackPlugin.tags.bodyTags %>
+