mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 21:27:05 +00:00
Merge branch 'development' of github.com:Stremio/stremio-web into fix/binge-watching-next-episode
This commit is contained in:
commit
0809fbdf53
24 changed files with 637 additions and 45 deletions
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -12,7 +12,7 @@
|
||||||
"@babel/runtime": "7.26.0",
|
"@babel/runtime": "7.26.0",
|
||||||
"@sentry/browser": "8.42.0",
|
"@sentry/browser": "8.42.0",
|
||||||
"@stremio/stremio-colors": "5.2.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-icons": "5.4.1",
|
||||||
"@stremio/stremio-video": "0.0.60",
|
"@stremio/stremio-video": "0.0.60",
|
||||||
"a-color-picker": "1.2.1",
|
"a-color-picker": "1.2.1",
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"filter-invalid-dom-props": "3.0.1",
|
"filter-invalid-dom-props": "3.0.1",
|
||||||
"hat": "^0.0.3",
|
"hat": "^0.0.3",
|
||||||
"i18next": "^24.0.5",
|
"i18next": "^24.0.5",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"langs": "github:Stremio/nodejs-langs",
|
"langs": "github:Stremio/nodejs-langs",
|
||||||
"lodash.debounce": "4.0.8",
|
"lodash.debounce": "4.0.8",
|
||||||
"lodash.intersection": "4.4.0",
|
"lodash.intersection": "4.4.0",
|
||||||
|
|
@ -3371,9 +3372,9 @@
|
||||||
"integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg=="
|
"integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg=="
|
||||||
},
|
},
|
||||||
"node_modules/@stremio/stremio-core-web": {
|
"node_modules/@stremio/stremio-core-web": {
|
||||||
"version": "0.49.2",
|
"version": "0.49.3",
|
||||||
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.2.tgz",
|
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.3.tgz",
|
||||||
"integrity": "sha512-IYU+pdHkq4iEfqZ9G+DFZheIE53nY8XyhI1OJLvZp68/4ntRwssXwfj9InHK2Wau20fH+oV2KD1ZWb0CsTLqPA==",
|
"integrity": "sha512-Ql/08LbwU99IUL6fOLy+v1Iv75boHXpunEPScKgXJALdq/OV5tZLG/IycN0O+5+50Nc/NHrI6HslnMNLTWA8JQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "7.24.1"
|
"@babel/runtime": "7.24.1"
|
||||||
|
|
@ -10234,6 +10235,15 @@
|
||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"@babel/runtime": "7.26.0",
|
"@babel/runtime": "7.26.0",
|
||||||
"@sentry/browser": "8.42.0",
|
"@sentry/browser": "8.42.0",
|
||||||
"@stremio/stremio-colors": "5.2.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-icons": "5.4.1",
|
||||||
"@stremio/stremio-video": "0.0.60",
|
"@stremio/stremio-video": "0.0.60",
|
||||||
"a-color-picker": "1.2.1",
|
"a-color-picker": "1.2.1",
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"filter-invalid-dom-props": "3.0.1",
|
"filter-invalid-dom-props": "3.0.1",
|
||||||
"hat": "^0.0.3",
|
"hat": "^0.0.3",
|
||||||
"i18next": "^24.0.5",
|
"i18next": "^24.0.5",
|
||||||
|
"jwt-decode": "^4.0.0",
|
||||||
"langs": "github:Stremio/nodejs-langs",
|
"langs": "github:Stremio/nodejs-langs",
|
||||||
"lodash.debounce": "4.0.8",
|
"lodash.debounce": "4.0.8",
|
||||||
"lodash.intersection": "4.4.0",
|
"lodash.intersection": "4.4.0",
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,16 @@ const ModalDialog = require('stremio/components/ModalDialog');
|
||||||
const useBinaryState = require('stremio/common/useBinaryState');
|
const useBinaryState = require('stremio/common/useBinaryState');
|
||||||
const styles = require('./styles');
|
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 [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||||
const options = React.useMemo(() => {
|
const filteredOptions = React.useMemo(() => {
|
||||||
return Array.isArray(props.options) ?
|
return Array.isArray(options) ?
|
||||||
props.options.filter((option) => {
|
options.filter((option) => {
|
||||||
return option && (typeof option.value === 'string' || option.value === null);
|
return option && (typeof option.value === 'string' || option.value === null);
|
||||||
})
|
})
|
||||||
:
|
:
|
||||||
[];
|
[];
|
||||||
}, [props.options]);
|
}, [options]);
|
||||||
const selected = React.useMemo(() => {
|
const selected = React.useMemo(() => {
|
||||||
return Array.isArray(props.selected) ?
|
return Array.isArray(props.selected) ?
|
||||||
props.selected.filter((value) => {
|
props.selected.filter((value) => {
|
||||||
|
|
@ -94,7 +94,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
|
||||||
:
|
:
|
||||||
selected.length > 0 ?
|
selected.length > 0 ?
|
||||||
selected.map((value) => {
|
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' ?
|
return option && typeof option.label === 'string' ?
|
||||||
option.label
|
option.label
|
||||||
:
|
:
|
||||||
|
|
@ -109,12 +109,12 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
|
||||||
}
|
}
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
), [menuOpen, title, disabled, options, selected, labelOnClick, renderLabelContent, renderLabelText]);
|
), [menuOpen, title, disabled, filteredOptions, selected, labelOnClick, renderLabelContent, renderLabelText]);
|
||||||
const renderMenu = React.useCallback(() => (
|
const renderMenu = React.useCallback(() => (
|
||||||
<div className={styles['menu-container']} onKeyDown={menuOnKeyDown} onClick={menuOnClick}>
|
<div className={styles['menu-container']} onKeyDown={menuOnKeyDown} onClick={menuOnClick}>
|
||||||
{
|
{
|
||||||
options.length > 0 ?
|
filteredOptions.length > 0 ?
|
||||||
options.map(({ label, title, value }) => (
|
filteredOptions.map(({ label, title, value }) => (
|
||||||
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof title === 'string' ? title : typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
|
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof title === 'string' ? title : typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
|
||||||
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
|
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
|
||||||
<div className={styles['icon']} />
|
<div className={styles['icon']} />
|
||||||
|
|
@ -126,7 +126,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
), [options, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
|
), [filteredOptions, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
|
||||||
const renderPopupLabel = React.useMemo(() => (labelProps) => {
|
const renderPopupLabel = React.useMemo(() => (labelProps) => {
|
||||||
return renderLabel({
|
return renderLabel({
|
||||||
...labelProps,
|
...labelProps,
|
||||||
|
|
|
||||||
65
src/components/NumberInput/NumberInput.less
Normal file
65
src/components/NumberInput/NumberInput.less
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/components/NumberInput/NumberInput.tsx
Normal file
113
src/components/NumberInput/NumberInput.tsx
Normal file
|
|
@ -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<HTMLInputElement> & {
|
||||||
|
containerClassName?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
showButtons?: boolean;
|
||||||
|
defaultValue?: number;
|
||||||
|
label?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
value?: number;
|
||||||
|
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
onSubmit?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NumberInput = forwardRef<HTMLInputElement, Props>(({ 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<HTMLInputElement>) => {
|
||||||
|
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<HTMLInputElement>);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
handleValueChange(clampValueToRange(valueAsNumber || 0));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classnames(props.containerClassName, styles['number-input'])}>
|
||||||
|
{
|
||||||
|
showButtons ?
|
||||||
|
<Button
|
||||||
|
className={styles['button']}
|
||||||
|
onClick={handleDecrement}
|
||||||
|
disabled={props.disabled || (min !== undefined ? displayValue <= min : false)}>
|
||||||
|
<Icon className={styles['icon']} name={'remove'} />
|
||||||
|
</Button>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<div className={classnames(styles['number-display'], { [styles['buttons-container']]: showButtons })}>
|
||||||
|
{
|
||||||
|
props.label ?
|
||||||
|
<div className={styles['label']}>{props.label}</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type={'number'}
|
||||||
|
tabIndex={0}
|
||||||
|
value={displayValue}
|
||||||
|
{...props}
|
||||||
|
className={classnames(props.className, styles['value'], { [styles.disabled]: props.disabled })}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
showButtons ?
|
||||||
|
<Button
|
||||||
|
className={styles['button']} onClick={handleIncrement} disabled={props.disabled || (max !== undefined ? displayValue >= max : false)}>
|
||||||
|
<Icon className={styles['icon']} name={'add'} />
|
||||||
|
</Button>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
NumberInput.displayName = 'NumberInput';
|
||||||
|
|
||||||
|
export default memo(NumberInput);
|
||||||
5
src/components/NumberInput/index.ts
Normal file
5
src/components/NumberInput/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Copyright (C) 2017-2025 Smart code 203358507
|
||||||
|
|
||||||
|
import NumberInput from './NumberInput';
|
||||||
|
|
||||||
|
export default NumberInput;
|
||||||
|
|
@ -19,6 +19,7 @@ import ModalDialog from './ModalDialog';
|
||||||
import Multiselect from './Multiselect';
|
import Multiselect from './Multiselect';
|
||||||
import MultiselectMenu from './MultiselectMenu';
|
import MultiselectMenu from './MultiselectMenu';
|
||||||
import { HorizontalNavBar, VerticalNavBar } from './NavBar';
|
import { HorizontalNavBar, VerticalNavBar } from './NavBar';
|
||||||
|
import NumberInput from './NumberInput';
|
||||||
import Popup from './Popup';
|
import Popup from './Popup';
|
||||||
import RadioButton from './RadioButton';
|
import RadioButton from './RadioButton';
|
||||||
import SearchBar from './SearchBar';
|
import SearchBar from './SearchBar';
|
||||||
|
|
@ -52,6 +53,7 @@ export {
|
||||||
MultiselectMenu,
|
MultiselectMenu,
|
||||||
HorizontalNavBar,
|
HorizontalNavBar,
|
||||||
VerticalNavBar,
|
VerticalNavBar,
|
||||||
|
NumberInput,
|
||||||
Popup,
|
Popup,
|
||||||
RadioButton,
|
RadioButton,
|
||||||
SearchBar,
|
SearchBar,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<%= htmlWebpackPlugin.tags.bodyTags %>
|
<%= htmlWebpackPlugin.tags.bodyTags %>
|
||||||
<script src="//www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
<script src="//www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
||||||
|
<script async src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -107,10 +107,12 @@ const Discover = ({ urlParams, queryParams }) => {
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Button className={styles['filter-container']} title={'All filters'} onClick={openInputsModal}>
|
<div className={styles['filter-container']}>
|
||||||
|
<Button className={styles['filter-button']} title={'All filters'} onClick={openInputsModal}>
|
||||||
<Icon className={styles['filter-icon']} name={'filters'} />
|
<Icon className={styles['filter-icon']} name={'filters'} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{
|
{
|
||||||
discover.catalog !== null && !discover.catalog.installed ?
|
discover.catalog !== null && !discover.catalog.installed ?
|
||||||
<div className={styles['missing-addon-warning-container']}>
|
<div className={styles['missing-addon-warning-container']}>
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,11 @@
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
&~.filter-container {
|
&~.filter-container {
|
||||||
|
.filter-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.multiselect-menu-container {
|
.multiselect-menu-container {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
@ -69,12 +71,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-container {
|
.filter-container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 0 5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.filter-button {
|
||||||
flex: none;
|
flex: none;
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 3rem;
|
width: 3rem;
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
|
margin-left: 1.5rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
background-color: var(--overlay-color);
|
background-color: var(--overlay-color);
|
||||||
|
|
||||||
|
|
@ -86,6 +94,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.missing-addon-warning-container {
|
.missing-addon-warning-container {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|
@ -219,10 +228,15 @@
|
||||||
|
|
||||||
.select-input {
|
.select-input {
|
||||||
height: 3.5rem;
|
height: 3.5rem;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&:nth-child(n+4) {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
&:not(:last-child) {
|
&:not(:last-child) {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.multiselect-menu-container {
|
.multiselect-menu-container {
|
||||||
max-height: calc(3.2rem * 3);
|
max-height: calc(3.2rem * 3);
|
||||||
|
|
@ -363,11 +377,13 @@
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
&~.filter-container {
|
&~.filter-container {
|
||||||
|
.filter-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.meta-items-container {
|
.meta-items-container {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
|
@ -375,4 +391,22 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selectable-inputs-modal {
|
||||||
|
.selectable-inputs-modal-container {
|
||||||
|
.selectable-inputs-modal-content {
|
||||||
|
.select-input {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&:nth-child(n+2) {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,6 +12,8 @@ const { Button, Image, Checkbox } = require('stremio/components');
|
||||||
const CredentialsTextInput = require('./CredentialsTextInput');
|
const CredentialsTextInput = require('./CredentialsTextInput');
|
||||||
const PasswordResetModal = require('./PasswordResetModal');
|
const PasswordResetModal = require('./PasswordResetModal');
|
||||||
const useFacebookLogin = require('./useFacebookLogin');
|
const useFacebookLogin = require('./useFacebookLogin');
|
||||||
|
const { default: useAppleLogin } = require('./useAppleLogin');
|
||||||
|
|
||||||
const styles = require('./styles');
|
const styles = require('./styles');
|
||||||
|
|
||||||
const SIGNUP_FORM = 'signup';
|
const SIGNUP_FORM = 'signup';
|
||||||
|
|
@ -22,6 +24,7 @@ const Intro = ({ queryParams }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const routeFocused = useRouteFocused();
|
const routeFocused = useRouteFocused();
|
||||||
const [startFacebookLogin, stopFacebookLogin] = useFacebookLogin();
|
const [startFacebookLogin, stopFacebookLogin] = useFacebookLogin();
|
||||||
|
const [startAppleLogin, stopAppleLogin] = useAppleLogin();
|
||||||
const emailRef = React.useRef(null);
|
const emailRef = React.useRef(null);
|
||||||
const passwordRef = React.useRef(null);
|
const passwordRef = React.useRef(null);
|
||||||
const confirmPasswordRef = React.useRef(null);
|
const confirmPasswordRef = React.useRef(null);
|
||||||
|
|
@ -106,6 +109,37 @@ const Intro = ({ queryParams }) => {
|
||||||
stopFacebookLogin();
|
stopFacebookLogin();
|
||||||
closeLoaderModal();
|
closeLoaderModal();
|
||||||
}, []);
|
}, []);
|
||||||
|
const loginWithApple = React.useCallback(() => {
|
||||||
|
openLoaderModal();
|
||||||
|
startAppleLogin()
|
||||||
|
.then(({ email, token, sub, name }) => {
|
||||||
|
core.transport.dispatch({
|
||||||
|
action: 'Ctx',
|
||||||
|
args: {
|
||||||
|
action: 'Authenticate',
|
||||||
|
args: {
|
||||||
|
type: 'Apple',
|
||||||
|
token,
|
||||||
|
sub,
|
||||||
|
email,
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
closeLoaderModal();
|
||||||
|
if (error.error === 'popup_closed_by_user') {
|
||||||
|
dispatch({ type: 'error', error: 'Apple login popup was closed.' });
|
||||||
|
} else {
|
||||||
|
dispatch({ type: 'error', error: error.error });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const cancelLoginWithApple = React.useCallback(() => {
|
||||||
|
stopAppleLogin();
|
||||||
|
closeLoaderModal();
|
||||||
|
}, []);
|
||||||
const loginWithEmail = React.useCallback(() => {
|
const loginWithEmail = React.useCallback(() => {
|
||||||
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
|
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
|
||||||
dispatch({ type: 'error', error: 'Invalid email' });
|
dispatch({ type: 'error', error: 'Invalid email' });
|
||||||
|
|
@ -336,7 +370,7 @@ const Intro = ({ queryParams }) => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
state.error.length > 0 ?
|
state.error && state.error.length > 0 ?
|
||||||
<div ref={errorRef} className={styles['error-message']}>{state.error}</div>
|
<div ref={errorRef} className={styles['error-message']}>{state.error}</div>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
|
|
@ -350,6 +384,10 @@ const Intro = ({ queryParams }) => {
|
||||||
<Icon className={styles['icon']} name={'facebook'} />
|
<Icon className={styles['icon']} name={'facebook'} />
|
||||||
<div className={styles['label']}>Continue with Facebook</div>
|
<div className={styles['label']}>Continue with Facebook</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button className={classnames(styles['form-button'], styles['apple-button'])} onClick={loginWithApple}>
|
||||||
|
<Icon className={styles['icon']} name={'macos'} />
|
||||||
|
<div className={styles['label']}>Continue with Apple</div>
|
||||||
|
</Button>
|
||||||
{
|
{
|
||||||
state.form === SIGNUP_FORM ?
|
state.form === SIGNUP_FORM ?
|
||||||
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
|
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
|
||||||
|
|
@ -388,7 +426,7 @@ const Intro = ({ queryParams }) => {
|
||||||
<div className={styles['loader-container']}>
|
<div className={styles['loader-container']}>
|
||||||
<Icon className={styles['icon']} name={'person'} />
|
<Icon className={styles['icon']} name={'person'} />
|
||||||
<div className={styles['label']}>Authenticating...</div>
|
<div className={styles['label']}>Authenticating...</div>
|
||||||
<Button className={styles['button']} onClick={cancelLoginWithFacebook}>
|
<Button className={styles['button']} onClick={cancelLoginWithFacebook && cancelLoginWithApple}>
|
||||||
{t('BUTTON_CANCEL')}
|
{t('BUTTON_CANCEL')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -175,15 +175,43 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 22rem;
|
width: 22rem;
|
||||||
margin-left: 2rem;
|
margin-left: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
.facebook-button {
|
.facebook-button {
|
||||||
background: var(--color-facebook);
|
background: var(--color-facebook);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
&:hover, &:focus {
|
&:hover, &:focus {
|
||||||
outline: var(--focus-outline-size) solid var(--color-facebook);
|
outline: var(--focus-outline-size) solid var(--color-facebook);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.apple-button {
|
||||||
|
background: var(--primary-foreground-color);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--primary-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--primary-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
src/routes/Intro/useAppleLogin.ts
Normal file
104
src/routes/Intro/useAppleLogin.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { jwtDecode, JwtPayload } from 'jwt-decode';
|
||||||
|
|
||||||
|
type AppleLoginResponse = {
|
||||||
|
token: string;
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppleSignInResponse = {
|
||||||
|
authorization: {
|
||||||
|
code?: string;
|
||||||
|
id_token: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
email?: string;
|
||||||
|
fullName?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomJWTPayload = JwtPayload & {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLIENT_ID = 'com.stremio.services';
|
||||||
|
|
||||||
|
const useAppleLogin = (): [() => Promise<AppleLoginResponse>, () => void] => {
|
||||||
|
const started = useRef(false);
|
||||||
|
|
||||||
|
const start = useCallback((): Promise<AppleLoginResponse> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof window.AppleID === 'undefined') {
|
||||||
|
reject(new Error('Apple Sign-In not loaded'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (started.current) {
|
||||||
|
reject(new Error('Apple login already in progress'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
started.current = true;
|
||||||
|
|
||||||
|
window.AppleID.auth.init({
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
scope: 'name email',
|
||||||
|
redirectURI: 'https://web.stremio.com/',
|
||||||
|
state: 'signin',
|
||||||
|
usePopup: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.AppleID.auth.signIn().then((response: AppleSignInResponse) => {
|
||||||
|
if (response.authorization) {
|
||||||
|
try {
|
||||||
|
const idToken = response.authorization.id_token;
|
||||||
|
const payload: CustomJWTPayload = jwtDecode(idToken);
|
||||||
|
const sub = payload.sub;
|
||||||
|
const email = payload.email ?? response.email ?? '';
|
||||||
|
|
||||||
|
let name = '';
|
||||||
|
if (response.fullName) {
|
||||||
|
const firstName = response.fullName.firstName || '';
|
||||||
|
const lastName = response.fullName.lastName || '';
|
||||||
|
name = [firstName, lastName].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sub) {
|
||||||
|
reject(new Error('No sub token received from Apple'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
token: idToken,
|
||||||
|
sub: sub,
|
||||||
|
email: email,
|
||||||
|
name: name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Failed to parse id_token: ${error}`));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error('No authorization received from Apple'));
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
started.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => stop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [start, stop];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAppleLogin;
|
||||||
|
|
@ -63,6 +63,11 @@ const Library = ({ model, urlParams, queryParams }) => {
|
||||||
scrollContainerRef.current.scrollTop = 0;
|
scrollContainerRef.current.scrollTop = 0;
|
||||||
}
|
}
|
||||||
}, [profile.auth, library.selected]);
|
}, [profile.auth, library.selected]);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!library.selected?.type && typeSelect.selected) {
|
||||||
|
window.location = typeSelect.selected[0];
|
||||||
|
}
|
||||||
|
}, [typeSelect.selected, library.selected]);
|
||||||
return (
|
return (
|
||||||
<MainNavBars className={styles['library-container']} route={model}>
|
<MainNavBars className={styles['library-container']} route={model}>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ const React = require('react');
|
||||||
const { useTranslate } = require('stremio/common');
|
const { useTranslate } = require('stremio/common');
|
||||||
|
|
||||||
const mapSelectableInputs = (library, t) => {
|
const mapSelectableInputs = (library, t) => {
|
||||||
|
const selectedType = library.selectable.types
|
||||||
|
.filter(({ selected }) => selected).map(({ deepLinks }) => deepLinks.library);
|
||||||
const typeSelect = {
|
const typeSelect = {
|
||||||
title: t.string('SELECT_TYPE'),
|
title: t.string('SELECT_TYPE'),
|
||||||
options: library.selectable.types
|
options: library.selectable.types
|
||||||
|
|
@ -11,9 +13,9 @@ const mapSelectableInputs = (library, t) => {
|
||||||
value: deepLinks.library,
|
value: deepLinks.library,
|
||||||
label: type === null ? t.string('TYPE_ALL') : t.stringWithPrefix(type, 'TYPE_')
|
label: type === null ? t.string('TYPE_ALL') : t.stringWithPrefix(type, 'TYPE_')
|
||||||
})),
|
})),
|
||||||
selected: library.selectable.types
|
selected: selectedType.length
|
||||||
.filter(({ selected }) => selected)
|
? selectedType
|
||||||
.map(({ deepLinks }) => deepLinks.library),
|
: [library.selectable.types[0]].map(({ deepLinks }) => deepLinks.library),
|
||||||
onSelect: (event) => {
|
onSelect: (event) => {
|
||||||
window.location = event.value;
|
window.location = event.value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
src/routes/MetaDetails/EpisodePicker/EpisodePicker.less
Normal file
29
src/routes/MetaDetails/EpisodePicker/EpisodePicker.less
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Copyright (C) 2017-2025 Smart code 203358507
|
||||||
|
|
||||||
|
.button-container {
|
||||||
|
flex: none;
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: var(--focus-outline-size) solid var(--primary-accent-color);
|
||||||
|
background-color: var(--primary-accent-color);
|
||||||
|
height: 4rem;
|
||||||
|
padding: 0 2rem;
|
||||||
|
margin: 1rem auto;
|
||||||
|
border-radius: 2rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
max-height: 3.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/routes/MetaDetails/EpisodePicker/EpisodePicker.tsx
Normal file
71
src/routes/MetaDetails/EpisodePicker/EpisodePicker.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
// Copyright (C) 2017-2025 Smart code 203358507
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useState, ChangeEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, NumberInput } from 'stremio/components';
|
||||||
|
import styles from './EpisodePicker.less';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string,
|
||||||
|
seriesId: string;
|
||||||
|
onSubmit: (season: number, episode: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EpisodePicker = ({ className, onSubmit }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { initialSeason, initialEpisode } = useMemo(() => {
|
||||||
|
const splitPath = window.location.hash.split('/');
|
||||||
|
const videoId = decodeURIComponent(splitPath[splitPath.length - 1]);
|
||||||
|
const [, pathSeason, pathEpisode] = videoId ? videoId.split(':') : [];
|
||||||
|
return {
|
||||||
|
initialSeason: parseInt(pathSeason) || 0,
|
||||||
|
initialEpisode: parseInt(pathEpisode) || 1
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [season, setSeason] = useState(initialSeason);
|
||||||
|
const [episode, setEpisode] = useState(initialEpisode);
|
||||||
|
|
||||||
|
const handleSeasonChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSeason(parseInt(event.target.value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEpisodeChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setEpisode(parseInt(event.target.value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onSubmit(season, episode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = season === initialSeason && episode === initialEpisode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<NumberInput
|
||||||
|
min={0}
|
||||||
|
label={t('SEASON')}
|
||||||
|
defaultValue={season}
|
||||||
|
onChange={handleSeasonChange}
|
||||||
|
showButtons
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
min={1}
|
||||||
|
label={t('EPISODE')}
|
||||||
|
defaultValue={episode}
|
||||||
|
onChange={handleEpisodeChange}
|
||||||
|
showButtons
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className={styles['button-container']}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<div className={styles['label']}>{t('SIDEBAR_SHOW_STREAMS')}</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EpisodePicker;
|
||||||
5
src/routes/MetaDetails/EpisodePicker/index.ts
Normal file
5
src/routes/MetaDetails/EpisodePicker/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Copyright (C) 2017-2025 Smart code 203358507
|
||||||
|
|
||||||
|
import SeasonEpisodePicker from './EpisodePicker';
|
||||||
|
|
||||||
|
export default SeasonEpisodePicker;
|
||||||
|
|
@ -76,6 +76,13 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
||||||
const seasonOnSelect = React.useCallback((event) => {
|
const seasonOnSelect = React.useCallback((event) => {
|
||||||
setSeason(event.value);
|
setSeason(event.value);
|
||||||
}, [setSeason]);
|
}, [setSeason]);
|
||||||
|
const handleEpisodeSearch = React.useCallback((season, episode) => {
|
||||||
|
const searchVideoHash = encodeURIComponent(`${urlParams.id}:${season}:${episode}`);
|
||||||
|
const url = window.location.hash;
|
||||||
|
const searchVideoPath = url.replace(encodeURIComponent(urlParams.videoId), searchVideoHash);
|
||||||
|
window.location = searchVideoPath;
|
||||||
|
}, [urlParams, window.location]);
|
||||||
|
|
||||||
const renderBackgroundImageFallback = React.useCallback(() => null, []);
|
const renderBackgroundImageFallback = React.useCallback(() => null, []);
|
||||||
const renderBackground = React.useMemo(() => !!(
|
const renderBackground = React.useMemo(() => !!(
|
||||||
metaPath &&
|
metaPath &&
|
||||||
|
|
@ -129,7 +136,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
||||||
metaDetails.metaItem === null ?
|
metaDetails.metaItem === null ?
|
||||||
<div className={styles['meta-message-container']}>
|
<div className={styles['meta-message-container']}>
|
||||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||||
<div className={styles['message-label']}>No addons ware requested for this meta!</div>
|
<div className={styles['message-label']}>No addons were requested for this meta!</div>
|
||||||
</div>
|
</div>
|
||||||
:
|
:
|
||||||
metaDetails.metaItem.content.type === 'Err' ?
|
metaDetails.metaItem.content.type === 'Err' ?
|
||||||
|
|
@ -169,6 +176,8 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
||||||
className={styles['streams-list']}
|
className={styles['streams-list']}
|
||||||
streams={metaDetails.streams}
|
streams={metaDetails.streams}
|
||||||
video={video}
|
video={video}
|
||||||
|
type={streamPath.type}
|
||||||
|
onEpisodeSearch={handleEpisodeSearch}
|
||||||
/>
|
/>
|
||||||
:
|
:
|
||||||
metaPath !== null ?
|
metaPath !== null ?
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,11 @@ const { useServices } = require('stremio/services');
|
||||||
const Stream = require('./Stream');
|
const Stream = require('./Stream');
|
||||||
const styles = require('./styles');
|
const styles = require('./styles');
|
||||||
const { usePlatform, useProfile } = require('stremio/common');
|
const { usePlatform, useProfile } = require('stremio/common');
|
||||||
|
const { default: SeasonEpisodePicker } = require('../EpisodePicker');
|
||||||
|
|
||||||
const ALL_ADDONS_KEY = 'ALL';
|
const ALL_ADDONS_KEY = 'ALL';
|
||||||
|
|
||||||
const StreamsList = ({ className, video, ...props }) => {
|
const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { core } = useServices();
|
const { core } = useServices();
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
|
|
@ -25,8 +26,8 @@ const StreamsList = ({ className, video, ...props }) => {
|
||||||
setSelectedAddon(event.value);
|
setSelectedAddon(event.value);
|
||||||
}, [platform]);
|
}, [platform]);
|
||||||
const showInstallAddonsButton = React.useMemo(() => {
|
const showInstallAddonsButton = React.useMemo(() => {
|
||||||
return !profile || profile.auth === null || profile.auth?.user?.isNewUser === true;
|
return !profile || profile.auth === null || profile.auth?.user?.isNewUser === true && !video?.upcoming;
|
||||||
}, [profile]);
|
}, [profile, video]);
|
||||||
const backButtonOnClick = React.useCallback(() => {
|
const backButtonOnClick = React.useCallback(() => {
|
||||||
if (video.deepLinks && typeof video.deepLinks.metaDetailsVideos === 'string') {
|
if (video.deepLinks && typeof video.deepLinks.metaDetailsVideos === 'string') {
|
||||||
window.location.replace(video.deepLinks.metaDetailsVideos + (
|
window.location.replace(video.deepLinks.metaDetailsVideos + (
|
||||||
|
|
@ -93,6 +94,11 @@ const StreamsList = ({ className, video, ...props }) => {
|
||||||
onSelect: onAddonSelected
|
onSelect: onAddonSelected
|
||||||
};
|
};
|
||||||
}, [streamsByAddon, selectedAddon]);
|
}, [streamsByAddon, selectedAddon]);
|
||||||
|
|
||||||
|
const handleEpisodePicker = React.useCallback((season, episode) => {
|
||||||
|
onEpisodeSearch(season, episode);
|
||||||
|
}, [onEpisodeSearch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames(className, styles['streams-list-container'])}>
|
<div className={classnames(className, styles['streams-list-container'])}>
|
||||||
<div className={styles['select-choices-wrapper']}>
|
<div className={styles['select-choices-wrapper']}>
|
||||||
|
|
@ -122,12 +128,27 @@ const StreamsList = ({ className, video, ...props }) => {
|
||||||
{
|
{
|
||||||
props.streams.length === 0 ?
|
props.streams.length === 0 ?
|
||||||
<div className={styles['message-container']}>
|
<div className={styles['message-container']}>
|
||||||
|
{
|
||||||
|
type === 'series' ?
|
||||||
|
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
|
||||||
|
: null
|
||||||
|
}
|
||||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||||
<div className={styles['label']}>No addons were requested for streams!</div>
|
<div className={styles['label']}>No addons were requested for streams!</div>
|
||||||
</div>
|
</div>
|
||||||
:
|
:
|
||||||
props.streams.every((streams) => streams.content.type === 'Err') ?
|
props.streams.every((streams) => streams.content.type === 'Err') ?
|
||||||
<div className={styles['message-container']}>
|
<div className={styles['message-container']}>
|
||||||
|
{
|
||||||
|
type === 'series' ?
|
||||||
|
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
{
|
||||||
|
video?.upcoming ?
|
||||||
|
<div className={styles['label']}>{t('UPCOMING')}...</div>
|
||||||
|
: null
|
||||||
|
}
|
||||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||||
<div className={styles['label']}>{t('NO_STREAM')}</div>
|
<div className={styles['label']}>{t('NO_STREAM')}</div>
|
||||||
{
|
{
|
||||||
|
|
@ -193,7 +214,9 @@ const StreamsList = ({ className, video, ...props }) => {
|
||||||
StreamsList.propTypes = {
|
StreamsList.propTypes = {
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
streams: PropTypes.arrayOf(PropTypes.object).isRequired,
|
streams: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
video: PropTypes.object
|
video: PropTypes.object,
|
||||||
|
type: PropTypes.string,
|
||||||
|
onEpisodeSearch: PropTypes.func
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = StreamsList;
|
module.exports = StreamsList;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.search {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 10rem;
|
width: 10rem;
|
||||||
|
|
@ -38,6 +42,7 @@
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--primary-foreground-color);
|
color: var(--primary-foreground-color);
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,6 +176,7 @@
|
||||||
max-height: 3.6em;
|
max-height: 3.6em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--primary-foreground-color);
|
color: var(--primary-foreground-color);
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const { t } = require('i18next');
|
||||||
const { useServices } = require('stremio/services');
|
const { useServices } = require('stremio/services');
|
||||||
const { Image, SearchBar, Toggle, Video } = require('stremio/components');
|
const { Image, SearchBar, Toggle, Video } = require('stremio/components');
|
||||||
const SeasonsBar = require('./SeasonsBar');
|
const SeasonsBar = require('./SeasonsBar');
|
||||||
|
const { default: EpisodePicker } = require('../EpisodePicker');
|
||||||
const styles = require('./styles');
|
const styles = require('./styles');
|
||||||
|
|
||||||
const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, toggleNotifications }) => {
|
const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, toggleNotifications }) => {
|
||||||
|
|
@ -92,6 +93,15 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSeasonSearch = (value) => {
|
||||||
|
if (value) {
|
||||||
|
seasonOnSelect({
|
||||||
|
type: 'select',
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames(className, styles['videos-list-container'])}>
|
<div className={classnames(className, styles['videos-list-container'])}>
|
||||||
{
|
{
|
||||||
|
|
@ -110,6 +120,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
||||||
:
|
:
|
||||||
metaItem.content.type === 'Err' || videosForSeason.length === 0 ?
|
metaItem.content.type === 'Err' || videosForSeason.length === 0 ?
|
||||||
<div className={styles['message-container']}>
|
<div className={styles['message-container']}>
|
||||||
|
<EpisodePicker className={styles['episode-picker']} onSubmit={onSeasonSearch} />
|
||||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||||
<div className={styles['label']}>No videos found for this meta!</div>
|
<div className={styles['label']}>No videos found for this meta!</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,13 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.episode-picker {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 10rem;
|
width: 10rem;
|
||||||
|
|
|
||||||
25
src/types/global.d.ts
vendored
25
src/types/global.d.ts
vendored
|
|
@ -26,6 +26,31 @@ interface Chrome {
|
||||||
declare global {
|
declare global {
|
||||||
var qt: Qt | undefined;
|
var qt: Qt | undefined;
|
||||||
var chrome: Chrome | undefined;
|
var chrome: Chrome | undefined;
|
||||||
|
interface Window {
|
||||||
|
AppleID: {
|
||||||
|
auth: {
|
||||||
|
init: (config: {
|
||||||
|
clientId: string;
|
||||||
|
scope: string;
|
||||||
|
redirectURI: string;
|
||||||
|
state: string;
|
||||||
|
usePopup: boolean;
|
||||||
|
}) => void;
|
||||||
|
signIn: () => Promise<{
|
||||||
|
authorization: {
|
||||||
|
code?: string;
|
||||||
|
id_token: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
email?: string;
|
||||||
|
fullName?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue