mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-30 23:18:47 +00:00
Merge branch 'master' of github.com:Stremio/stremio-web into user-notifications-menu
This commit is contained in:
commit
b34746ce7e
106 changed files with 1749 additions and 1891 deletions
BIN
images/stremio_symbol.png
Normal file
BIN
images/stremio_symbol.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
|
|
@ -20,10 +20,11 @@
|
|||
"prop-types": "15.7.2",
|
||||
"react": "16.9.0",
|
||||
"react-dom": "16.9.0",
|
||||
"react-focus-lock": "2.1.1",
|
||||
"spatial-navigation-polyfill": "git+ssh://git@github.com/NikolaBorislavovHristov/spatial-navigation.git#964d09bf2b0853e27af6c25924b595d6621a019d",
|
||||
"stremio-colors": "git+ssh://git@github.com/Stremio/stremio-colors.git#v2.0.4",
|
||||
"stremio-icons": "git+ssh://git@github.com/Stremio/stremio-icons.git#v1.0.11",
|
||||
"stremio-core-web": "git+ssh://git@github.com/stremio/stremio-core-web.git#v0.6.0",
|
||||
"stremio-icons": "git+ssh://git@github.com/Stremio/stremio-icons.git#v1.0.11",
|
||||
"vtt.js": "0.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -54,4 +55,4 @@
|
|||
"webpack-cli": "3.3.9",
|
||||
"webpack-dev-server": "3.8.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
const React = require('react');
|
||||
const classnames = require('classnames');
|
||||
const useTabIndex = require('stremio/common/useTabIndex');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Button = React.forwardRef(({ children, ...props }, ref) => {
|
||||
const tabIndex = useTabIndex(props.tabIndex, props.disabled);
|
||||
const onKeyUp = React.useCallback((event) => {
|
||||
if (typeof props.onKeyUp === 'function') {
|
||||
props.onKeyUp(event);
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.nativeEvent.clickPrevented) {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.buttonClickPrevented) {
|
||||
event.currentTarget.click();
|
||||
}
|
||||
}, [props.onKeyUp]);
|
||||
|
|
@ -19,7 +17,7 @@ const Button = React.forwardRef(({ children, ...props }, ref) => {
|
|||
props.onMouseDown(event);
|
||||
}
|
||||
|
||||
if (!event.nativeEvent.blurPrevented) {
|
||||
if (!event.nativeEvent.buttonBlurPrevented) {
|
||||
event.preventDefault();
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
|
|
@ -29,10 +27,10 @@ const Button = React.forwardRef(({ children, ...props }, ref) => {
|
|||
return React.createElement(
|
||||
typeof props.href === 'string' && props.href.length > 0 ? 'a' : 'div',
|
||||
{
|
||||
tabIndex: 0,
|
||||
...props,
|
||||
ref,
|
||||
className: classnames(props.className, styles['button-container'], { 'disabled': props.disabled }),
|
||||
tabIndex,
|
||||
onKeyUp,
|
||||
onMouseDown
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +1,82 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const AColorPicker = require('a-color-picker');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Modal } = require('stremio-router');
|
||||
const Button = require('stremio/common/Button');
|
||||
const ColorPicker = require('stremio/common/ColorPicker');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const useDataset = require('stremio/common/useDataset');
|
||||
const ColorPicker = require('./ColorPicker');
|
||||
const styles = require('./styles');
|
||||
|
||||
const ColorInput = ({ className, id, value, onChange }) => {
|
||||
const [colorInputVisible, showColorInput, closeColorInput] = useBinaryState(false);
|
||||
const [selectedColor, setSelectedColor] = React.useState(value);
|
||||
const COLOR_FORMAT = 'hexcss4';
|
||||
|
||||
const confirmColorInput = React.useCallback((event) => {
|
||||
if(typeof onChange === "function") {
|
||||
event.nativeEvent.value = selectedColor;
|
||||
onChange(event);
|
||||
}
|
||||
closeColorInput();
|
||||
}, [selectedColor, onChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedColor(value);
|
||||
}, [value, colorInputVisible]);
|
||||
|
||||
const modalBackgroundOnClick = React.useCallback((event) => {
|
||||
if(event.target === event.currentTarget) {
|
||||
closeColorInput();
|
||||
const ColorInput = ({ className, value, onChange, ...props }) => {
|
||||
value = AColorPicker.parseColor(value, COLOR_FORMAT);
|
||||
const dataset = useDataset(props);
|
||||
const [modalOpen, openModal, closeModal] = useBinaryState(false);
|
||||
const [tempValue, setTempValue] = React.useState(value);
|
||||
const pickerLabelOnClick = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.openModalPrevented) {
|
||||
openModal();
|
||||
}
|
||||
}, []);
|
||||
const modalContainerOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.openModalPrevented = true;
|
||||
}, []);
|
||||
const modalContainerOnMouseDown = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.closeModalPrevented) {
|
||||
closeModal();
|
||||
}
|
||||
}, []);
|
||||
const modalContentOnMouseDown = React.useCallback((event) => {
|
||||
event.nativeEvent.closeModalPrevented = true;
|
||||
}, []);
|
||||
const colorPickerOnInput = React.useCallback((event) => {
|
||||
setTempValue(event.value);
|
||||
}, []);
|
||||
const submitButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange({
|
||||
type: 'change',
|
||||
value: tempValue,
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}, [onChange, tempValue, dataset]);
|
||||
React.useEffect(() => {
|
||||
setTempValue(value);
|
||||
}, [value, modalOpen]);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Button className={className} title={selectedColor} onClick={showColorInput} style={{ backgroundColor: value }}></Button>
|
||||
<Button style={{ backgroundColor: value }} className={className} title={value} onClick={pickerLabelOnClick}>
|
||||
{
|
||||
colorInputVisible
|
||||
?
|
||||
<Modal className={styles['color-input-modal']} onMouseDown={modalBackgroundOnClick}>
|
||||
<div className={styles['color-input-container']}>
|
||||
<Button onClick={closeColorInput}>
|
||||
<Icon className={styles['x-icon']} icon={'ic_x'} />
|
||||
modalOpen ?
|
||||
<Modal className={styles['color-input-modal-container']} onMouseDown={modalContainerOnMouseDown} onClick={modalContainerOnClick}>
|
||||
<div className={styles['color-input-container']} onMouseDown={modalContentOnMouseDown}>
|
||||
<div className={styles['header-container']}>
|
||||
<div className={styles['title']}>Choose a color:</div>
|
||||
<Button className={styles['close-button-container']} title={'Close'} onClick={closeModal}>
|
||||
<Icon className={styles['icon']} icon={'ic_x'} />
|
||||
</Button>
|
||||
</div>
|
||||
<ColorPicker className={styles['color-picker']} value={tempValue} onInput={colorPickerOnInput} />
|
||||
<Button className={styles['submit-button-container']} title={'Submit'} onClick={submitButtonOnClick}>
|
||||
<div className={styles['label']}>Select</div>
|
||||
</Button>
|
||||
<h1>Choose a color:</h1>
|
||||
<ColorPicker className={styles['color-input']} value={selectedColor} onChange={setSelectedColor} />
|
||||
<Button className={styles['button']} data-id={id} onClick={confirmColorInput}>Select</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
:
|
||||
null
|
||||
}
|
||||
</React.Fragment>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
ColorInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
id: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const AColorPicker = require('a-color-picker');
|
||||
const styles = require('./styles');
|
||||
|
||||
const COLOR_FORMAT = 'rgbacss';
|
||||
const COLOR_FORMAT = 'hexcss4';
|
||||
|
||||
// TODO implement custom picker which is keyboard accessible
|
||||
const ColorPicker = ({ className, value, onChange }) => {
|
||||
const ColorPicker = ({ className, value, onInput }) => {
|
||||
value = AColorPicker.parseColor(value, COLOR_FORMAT);
|
||||
const pickerRef = React.useRef(null);
|
||||
const pickerElementRef = React.useRef(null);
|
||||
|
|
@ -17,29 +19,38 @@ const ColorPicker = ({ className, value, onChange }) => {
|
|||
showRGB: false,
|
||||
showAlpha: true
|
||||
});
|
||||
const clipboardPicker = pickerElementRef.current.querySelector('.a-color-picker-clipbaord');
|
||||
if (clipboardPicker instanceof HTMLElement) {
|
||||
clipboardPicker.tabIndex = -1;
|
||||
}
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
pickerRef.current.off('change');
|
||||
pickerRef.current.on('change', (picker, color) => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange(AColorPicker.parseColor(color, COLOR_FORMAT));
|
||||
if (typeof onInput === 'function') {
|
||||
onInput({
|
||||
type: 'input',
|
||||
value: AColorPicker.parseColor(color, COLOR_FORMAT)
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [onChange]);
|
||||
return () => {
|
||||
pickerRef.current.off('change');
|
||||
};
|
||||
}, [onInput]);
|
||||
React.useEffect(() => {
|
||||
if (AColorPicker.parseColor(pickerRef.current.color, COLOR_FORMAT) !== value) {
|
||||
pickerRef.current.color = value;
|
||||
}
|
||||
}, [value]);
|
||||
return (
|
||||
<div ref={pickerElementRef} className={className} />
|
||||
<div ref={pickerElementRef} className={classnames(className, styles['color-picker-container'])} />
|
||||
);
|
||||
};
|
||||
|
||||
ColorPicker.propTypes = {
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func
|
||||
onInput: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = ColorPicker;
|
||||
19
src/common/ColorInput/ColorPicker/styles.less
Normal file
19
src/common/ColorInput/ColorPicker/styles.less
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
.color-picker-container {
|
||||
overflow: visible;
|
||||
|
||||
* {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:global(.a-color-picker-stack):not(:global(.a-color-picker-row-top)) canvas, :global(.a-color-picker-circle) {
|
||||
border: solid thin var(--color-surfacedark);
|
||||
}
|
||||
|
||||
:global(.a-color-picker-circle) {
|
||||
box-shadow: 0 0 .2rem var(--color-surfacedark);
|
||||
}
|
||||
|
||||
:global(.a-color-picker-clipbaord) {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +1,81 @@
|
|||
.color-input-modal {
|
||||
background-color: var(--color-backgrounddarker40);
|
||||
.color-input-modal-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
background-color: var(--color-backgrounddarker40);
|
||||
|
||||
.color-input-container {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 25rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-surfacelighter);
|
||||
|
||||
margin: auto;
|
||||
.header-container {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
|
||||
* {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.x-icon {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
fill: var(--color-surfacedark);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
margin: 1rem auto 0 auto;
|
||||
:global(.a-color-picker-stack):not(:global(.a-color-picker-row-top)) canvas, :global(.a-color-picker-circle) {
|
||||
border: solid 1px var(--color-surfacedark);
|
||||
.title {
|
||||
flex: 1;
|
||||
margin-right: 1rem;
|
||||
font-size: 1.2rem;
|
||||
max-height: 2.4em;
|
||||
}
|
||||
:global(.a-color-picker-circle) {
|
||||
box-shadow: 0 0 .2rem var(--color-surfacedark);
|
||||
|
||||
.close-button-container {
|
||||
flex: none;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0.25rem;
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-surfacedark20);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-surfacedarker);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--color-surfacedarker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
background-color: var(--color-signal5);
|
||||
.color-picker {
|
||||
flex: none;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.submit-button-container {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
background-color: var(--color-signal5);
|
||||
|
||||
&:hover, &:focus {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-surfacedarker);
|
||||
}
|
||||
|
||||
.label {
|
||||
max-height: 2.4em;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const Button = require('stremio/common/Button');
|
||||
const Popup = require('stremio/common/Popup');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const styles = require('./styles');
|
||||
|
||||
// TODO rename to multiselect
|
||||
const Dropdown = ({ className, menuClassName, menuMatchLabelWidth, renderLabel, name, selected, options, tabIndex, onOpen, onClose, onSelect }) => {
|
||||
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const optionOnClick = React.useCallback((event) => {
|
||||
if (typeof onSelect === 'function') {
|
||||
onSelect(event);
|
||||
}
|
||||
|
||||
if (!event.nativeEvent.closeMenuPrevented) {
|
||||
closeMenu();
|
||||
}
|
||||
}, [onSelect]);
|
||||
React.useEffect(() => {
|
||||
if (menuOpen) {
|
||||
if (typeof onOpen === 'function') {
|
||||
onOpen();
|
||||
}
|
||||
} else {
|
||||
if (typeof onClose === 'function') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [menuOpen, onOpen, onClose]);
|
||||
return (
|
||||
<Popup
|
||||
open={menuOpen}
|
||||
menuMatchLabelWidth={typeof menuMatchLabelWidth === 'boolean' ? menuMatchLabelWidth : true}
|
||||
onCloseRequest={closeMenu}
|
||||
renderLabel={(ref) => (
|
||||
<Button ref={ref} className={classnames(className, styles['dropdown-label-container'], { 'active': menuOpen })} title={name} tabIndex={tabIndex} onClick={toggleMenu}>
|
||||
{
|
||||
typeof renderLabel === 'function' ?
|
||||
renderLabel()
|
||||
:
|
||||
<React.Fragment>
|
||||
<div className={styles['label']}>
|
||||
{
|
||||
Array.isArray(selected) && selected.length > 0 ?
|
||||
options.reduce((labels, { label, value }) => {
|
||||
if (selected.includes(value)) {
|
||||
labels.push(label);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}, []).join(', ')
|
||||
:
|
||||
name
|
||||
}
|
||||
</div>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_down'} />
|
||||
</React.Fragment>
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
renderMenu={() => (
|
||||
<div className={classnames(menuClassName, styles['dropdown-menu-container'])}>
|
||||
{
|
||||
Array.isArray(options) && options.length > 0 ?
|
||||
options.map(({ label, value }) => (
|
||||
<Button key={value} className={classnames(styles['dropdown-option-container'], { 'selected': Array.isArray(selected) && selected.includes(value) })} title={label} data-name={name} data-value={value} onClick={optionOnClick}>
|
||||
<div className={styles['label']}>{label}</div>
|
||||
<Icon className={styles['icon']} icon={'ic_check'} />
|
||||
</Button>
|
||||
))
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Dropdown.propTypes = {
|
||||
className: PropTypes.string,
|
||||
menuClassName: PropTypes.string,
|
||||
menuMatchLabelWidth: PropTypes.bool,
|
||||
renderLabel: PropTypes.func,
|
||||
name: PropTypes.string,
|
||||
selected: PropTypes.arrayOf(PropTypes.string),
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
})),
|
||||
tabIndex: PropTypes.number,
|
||||
onOpen: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
onSelect: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Dropdown;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
const Dropdown = require('./Dropdown');
|
||||
|
||||
module.exports = Dropdown;
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
.dropdown-label-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
&:hover, &:focus {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
&:global(.active) {
|
||||
background-color: var(--color-surfacelight);
|
||||
|
||||
.label {
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
|
||||
.icon {
|
||||
fill: var(--color-backgrounddarker);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
max-height: 2.4em;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 1rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
.dropdown-option-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
&:global(.selected) {
|
||||
background-color: var(--color-surfacedarker);
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-surfacedark);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
max-height: 4.8em;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
display: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 1rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/common/Image/Image.js
Normal file
29
src/common/Image/Image.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
|
||||
const Image = ({ className, src, alt, fallbackSrc, renderFallback }) => {
|
||||
const [broken, setBroken] = React.useState(false);
|
||||
const onError = React.useCallback(() => {
|
||||
setBroken(true);
|
||||
}, []);
|
||||
React.useLayoutEffect(() => {
|
||||
setBroken(false);
|
||||
}, [src]);
|
||||
return (broken || typeof src !== 'string' || src.length === 0) && (typeof renderFallback === 'function' || typeof fallbackSrc === 'string') ?
|
||||
typeof renderFallback === 'function' ?
|
||||
renderFallback()
|
||||
:
|
||||
<img className={className} src={fallbackSrc} alt={alt} />
|
||||
:
|
||||
<img className={className} src={src} alt={alt} onError={onError} />;
|
||||
};
|
||||
|
||||
Image.propTypes = {
|
||||
className: PropTypes.string,
|
||||
src: PropTypes.string,
|
||||
alt: PropTypes.string,
|
||||
fallbackSrc: PropTypes.string,
|
||||
renderFallback: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Image;
|
||||
3
src/common/Image/index.js
Normal file
3
src/common/Image/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
const Image = require('./Image');
|
||||
|
||||
module.exports = Image;
|
||||
|
|
@ -3,8 +3,11 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const Button = require('stremio/common/Button');
|
||||
const PlayIconCircleCentered = require('stremio/common/PlayIconCircleCentered');
|
||||
const Dropdown = require('stremio/common/Dropdown');
|
||||
const Image = require('stremio/common/Image');
|
||||
const Multiselect = require('stremio/common/Multiselect');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const useDataset = require('stremio/common/useDataset');
|
||||
const PlayIconCircleCentered = require('./PlayIconCircleCentered');
|
||||
const styles = require('./styles');
|
||||
|
||||
const ICON_FOR_TYPE = Object.assign(Object.create(null), {
|
||||
|
|
@ -15,39 +18,48 @@ const ICON_FOR_TYPE = Object.assign(Object.create(null), {
|
|||
'other': 'ic_movies'
|
||||
});
|
||||
|
||||
const MetaItem = React.memo(({ className, id, type, name, posterShape, poster, title, subtitle, progress, playIcon, menuOptions, onSelect, menuOptionOnSelect }) => {
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const onOpen = React.useCallback(() => {
|
||||
setMenuOpen(true);
|
||||
}, []);
|
||||
const onClose = React.useCallback(() => {
|
||||
setMenuOpen(false);
|
||||
}, []);
|
||||
const MetaItem = React.memo(({ className, type, name, poster, posterShape, playIcon, progress, menuOptions, onSelect, menuOptionOnSelect, ...props }) => {
|
||||
const dataset = useDataset(props);
|
||||
const [menuOpen, onMenuOpen, onMenuClose] = useBinaryState(false);
|
||||
const metaItemOnClick = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.selectMetaItemPrevented && typeof onSelect === 'function') {
|
||||
onSelect(event);
|
||||
onSelect({
|
||||
type: 'select',
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}, [onSelect]);
|
||||
const menuOnClick = React.useCallback((event) => {
|
||||
}, [onSelect, dataset]);
|
||||
const multiselectOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.selectMetaItemPrevented = true;
|
||||
}, []);
|
||||
const multiselectOnSelect = React.useCallback((event) => {
|
||||
if (typeof menuOptionOnSelect === 'function') {
|
||||
menuOptionOnSelect({
|
||||
type: 'select-option',
|
||||
dataset: dataset,
|
||||
reactEvent: event.reactEvent,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
}
|
||||
}, [menuOptionOnSelect, dataset]);
|
||||
return (
|
||||
<Button className={classnames(className, styles['meta-item-container'], styles['poster-shape-poster'], styles[`poster-shape-${posterShape}`], { 'active': menuOpen })} title={name} data-id={id} onClick={metaItemOnClick}>
|
||||
<div className={styles['poster-image-container']}>
|
||||
<div className={styles['placeholder-icon-layer']}>
|
||||
<Icon
|
||||
className={styles['placeholder-icon']}
|
||||
icon={typeof ICON_FOR_TYPE[type] === 'string' ? ICON_FOR_TYPE[type] : ICON_FOR_TYPE['other']}
|
||||
<Button className={classnames(className, styles['meta-item-container'], styles['poster-shape-poster'], styles[`poster-shape-${posterShape}`], { 'active': menuOpen })} title={name} onClick={metaItemOnClick}>
|
||||
<div className={styles['poster-container']}>
|
||||
<div className={styles['poster-image-layer']}>
|
||||
<Image
|
||||
className={styles['poster-image']}
|
||||
src={poster}
|
||||
alt={' '}
|
||||
renderFallback={() => (
|
||||
<Icon
|
||||
className={styles['placeholder-icon']}
|
||||
icon={typeof ICON_FOR_TYPE[type] === 'string' ? ICON_FOR_TYPE[type] : ICON_FOR_TYPE['other']}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
typeof poster === 'string' && poster.length > 0 ?
|
||||
<div className={styles['poster-image-layer']}>
|
||||
<img className={styles['poster-image']} src={poster} alt={' '} />
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
playIcon ?
|
||||
<div className={styles['play-icon-layer']}>
|
||||
|
|
@ -66,45 +78,32 @@ const MetaItem = React.memo(({ className, id, type, name, posterShape, poster, t
|
|||
}
|
||||
</div>
|
||||
{
|
||||
(typeof title === 'string' && title.length > 0) || (typeof subtitle === 'string' && subtitle.length > 0) || (Array.isArray(menuOptions) && menuOptions.length > 0) ?
|
||||
<React.Fragment>
|
||||
<div className={styles['title-bar-container']}>
|
||||
{
|
||||
typeof title === 'string' && title.length > 0 ?
|
||||
<div className={styles['title-label']}>{title}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
Array.isArray(menuOptions) && menuOptions.length > 0 ?
|
||||
<div className={styles['dropdown-menu-container']} onClick={menuOnClick}>
|
||||
<Dropdown
|
||||
className={styles['menu-label-container']}
|
||||
menuClassName={styles['menu-container']}
|
||||
menuMatchLabelWidth={false}
|
||||
renderLabel={() => (
|
||||
<Icon className={styles['menu-icon']} icon={'ic_more'} />
|
||||
)}
|
||||
options={menuOptions}
|
||||
tabIndex={-1}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
onSelect={menuOptionOnSelect}
|
||||
/>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
(typeof name === 'string' && name.length > 0) || (Array.isArray(menuOptions) && menuOptions.length > 0) ?
|
||||
<div className={styles['title-bar-container']}>
|
||||
{
|
||||
typeof subtitle === 'string' && subtitle.length > 0 ?
|
||||
<div className={styles['title-bar-container']}>
|
||||
<div className={styles['title-label']}>{subtitle}</div>
|
||||
typeof name === 'string' && name.length > 0 ?
|
||||
<div className={styles['title-label']}>{name}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
Array.isArray(menuOptions) && menuOptions.length > 0 ?
|
||||
<div className={styles['multiselect-container']} onClick={multiselectOnClick}>
|
||||
<Multiselect
|
||||
className={styles['multiselect-label-container']}
|
||||
renderLabelContent={() => (
|
||||
<Icon className={styles['icon']} icon={'ic_more'} />
|
||||
)}
|
||||
options={menuOptions}
|
||||
onOpen={onMenuOpen}
|
||||
onClose={onMenuClose}
|
||||
onSelect={multiselectOnSelect}
|
||||
/>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
@ -116,19 +115,13 @@ MetaItem.displayName = 'MetaItem';
|
|||
|
||||
MetaItem.propTypes = {
|
||||
className: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
posterShape: PropTypes.oneOf(['poster', 'landscape', 'square']),
|
||||
poster: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
subtitle: PropTypes.string,
|
||||
progress: PropTypes.number,
|
||||
posterShape: PropTypes.oneOf(['poster', 'landscape', 'square']),
|
||||
playIcon: PropTypes.bool,
|
||||
menuOptions: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired
|
||||
})),
|
||||
progress: PropTypes.number,
|
||||
menuOptions: PropTypes.array,
|
||||
onSelect: PropTypes.func,
|
||||
menuOptionOnSelect: PropTypes.func
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,30 +1,35 @@
|
|||
:import('~stremio/common/PlayIconCircleCentered/styles.less') {
|
||||
:import('~stremio/common/Popup/styles.less') {
|
||||
popup-menu-container: menu-container;
|
||||
}
|
||||
|
||||
:import('~stremio/common/Multiselect/styles.less') {
|
||||
multiselect-menu-container: menu-container;
|
||||
multiselect-option-container: option-container;
|
||||
multiselect-option-label: label;
|
||||
}
|
||||
|
||||
:import('./PlayIconCircleCentered/styles.less') {
|
||||
play-icon-circle-centered-background: background;
|
||||
play-icon-circle-centered-icon: icon;
|
||||
}
|
||||
|
||||
:import('~stremio/common/Dropdown/styles.less') {
|
||||
dropdown-option-container: dropdown-option-container;
|
||||
dropdown-option-label: label;
|
||||
}
|
||||
|
||||
.meta-item-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
background-color: var(--color-backgroundlight);
|
||||
overflow: visible;
|
||||
|
||||
&:hover, &:focus, &:global(.active) {
|
||||
outline-style: solid;
|
||||
background-color: var(--color-surfacelight);
|
||||
outline-offset: 0;
|
||||
|
||||
.title-bar-container {
|
||||
background-color: var(--color-surfacelight);
|
||||
|
||||
.title-label {
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
.menu-label-container {
|
||||
.menu-icon {
|
||||
.multiselect-container {
|
||||
.multiselect-label-container {
|
||||
.icon {
|
||||
fill: var(--color-backgrounddarker);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,58 +38,53 @@
|
|||
}
|
||||
|
||||
&.poster-shape-poster {
|
||||
.poster-image-container {
|
||||
.poster-container {
|
||||
padding-top: calc(100% * var(--poster-shape-ratio));
|
||||
}
|
||||
}
|
||||
|
||||
&.poster-shape-square {
|
||||
.poster-image-container {
|
||||
.poster-container {
|
||||
padding-top: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.poster-shape-landscape {
|
||||
.poster-image-container {
|
||||
.poster-container {
|
||||
padding-top: calc(100% * var(--landscape-shape-ratio));
|
||||
}
|
||||
}
|
||||
|
||||
.poster-image-container {
|
||||
.poster-container {
|
||||
position: relative;
|
||||
z-index: -1;
|
||||
z-index: 0;
|
||||
background-color: var(--color-backgroundlight);
|
||||
|
||||
.placeholder-icon-layer {
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
right: 10%;
|
||||
bottom: 25%;
|
||||
left: 10%;
|
||||
z-index: 0;
|
||||
|
||||
.placeholder-icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--color-surfacelight20);
|
||||
}
|
||||
}
|
||||
|
||||
.poster-image-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
z-index: -3;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.poster-image {
|
||||
display: block;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
flex: none;
|
||||
width: 80%;
|
||||
height: 50%;
|
||||
fill: var(--color-surfacelight20);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +94,7 @@
|
|||
right: 0;
|
||||
bottom: 30%;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
z-index: -2;
|
||||
overflow: visible;
|
||||
|
||||
.play-icon {
|
||||
|
|
@ -118,7 +118,7 @@
|
|||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
z-index: -1;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
.progress-bar {
|
||||
|
|
@ -134,6 +134,8 @@
|
|||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 2.8rem;
|
||||
background-color: var(--color-backgroundlight);
|
||||
overflow: visible;
|
||||
|
||||
.title-label {
|
||||
flex: 1;
|
||||
|
|
@ -146,52 +148,54 @@
|
|||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
.multiselect-container {
|
||||
flex: none;
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
overflow: visible;
|
||||
|
||||
.menu-label-container {
|
||||
.multiselect-label-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover, &:global(.active) {
|
||||
filter: none;
|
||||
background-color: var(--color-surface80);
|
||||
|
||||
.menu-icon {
|
||||
fill: var(--color-backgrounddarker);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.popup-menu-container {
|
||||
width: auto;
|
||||
right: initial;
|
||||
left: 0;
|
||||
|
||||
.multiselect-menu-container {
|
||||
min-width: 8rem;
|
||||
max-width: 12rem;
|
||||
|
||||
.multiselect-option-container {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-surfacelighter);
|
||||
|
||||
&:hover, &:focus {
|
||||
outline: none;
|
||||
background-color: var(--color-surfacelight);
|
||||
}
|
||||
|
||||
.multiselect-option-label {
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
min-width: 8rem;
|
||||
max-width: 12rem;
|
||||
|
||||
.dropdown-option-container {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-surfacelighter);
|
||||
|
||||
&:hover, &:focus {
|
||||
outline: none;
|
||||
background-color: var(--color-surfacelight);
|
||||
}
|
||||
|
||||
.dropdown-option-label {
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,9 @@ const ActionButton = ({ className, icon, label, ...props }) => {
|
|||
<Button {...props} className={classnames(className, styles['action-button-container'])} title={label}>
|
||||
{
|
||||
typeof icon === 'string' && icon.length > 0 ?
|
||||
<Icon className={styles['icon']} icon={icon} />
|
||||
<div className={styles['icon-container']}>
|
||||
<Icon className={styles['icon']} icon={icon} />
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,20 +3,26 @@
|
|||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-surfacedarker60);
|
||||
}
|
||||
|
||||
.icon {
|
||||
.icon-container {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 50%;
|
||||
padding-top: 15%;
|
||||
fill: var(--color-surfacelighter);
|
||||
|
||||
&:only-child {
|
||||
padding: 5% 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.label-container {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const { Modal } = require('stremio-router');
|
|||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const ActionButton = require('./ActionButton');
|
||||
const MetaLinks = require('./MetaLinks');
|
||||
const MetaPreviewPlaceholder = require('./MetaPreviewPlaceholder');
|
||||
const styles = require('./styles');
|
||||
|
||||
const MetaPreview = ({ className, compact, id, type, name, logo, background, duration, releaseInfo, released, description, genres, writers, directors, cast, imdbId, imdbRating, trailer, share, inLibrary, toggleInLibrary }) => {
|
||||
|
|
@ -147,8 +148,8 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
icon={inLibrary ? 'ic_removelib' : 'ic_addlib'}
|
||||
label={inLibrary ? 'Remove from Library' : 'Add to library'}
|
||||
data-id={id}
|
||||
tabIndex={compact ? -1 : null}
|
||||
onClick={toggleInLibrary}
|
||||
{...(!compact ? { tabIndex: -1 } : null)}
|
||||
/>
|
||||
:
|
||||
null
|
||||
|
|
@ -159,8 +160,8 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
className={styles['action-button']}
|
||||
icon={'ic_movies'}
|
||||
label={'Trailer'}
|
||||
tabIndex={compact ? -1 : null}
|
||||
href={`#/player?stream=${trailer}`}
|
||||
{...(compact ? { tabIndex: -1 } : null)}
|
||||
/>
|
||||
:
|
||||
null
|
||||
|
|
@ -171,9 +172,9 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
className={styles['action-button']}
|
||||
icon={'ic_imdb'}
|
||||
label={typeof imdbRating === 'string' && imdbRating.length > 0 ? `${imdbRating} / 10` : null}
|
||||
tabIndex={compact ? -1 : null}
|
||||
href={`https://imdb.com/title/${imdbId}`}
|
||||
target={'_blank'}
|
||||
{...(compact ? { tabIndex: -1 } : null)}
|
||||
/>
|
||||
:
|
||||
null
|
||||
|
|
@ -185,8 +186,8 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
className={styles['action-button']}
|
||||
icon={'ic_share'}
|
||||
label={'Share'}
|
||||
tabIndex={compact ? -1 : null}
|
||||
onClick={openShareModal}
|
||||
{...(compact ? { tabIndex: -1 } : null)}
|
||||
/>
|
||||
{
|
||||
shareModalOpen ?
|
||||
|
|
@ -210,6 +211,8 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
|
|||
);
|
||||
};
|
||||
|
||||
MetaPreview.Placeholder = MetaPreviewPlaceholder;
|
||||
|
||||
MetaPreview.propTypes = {
|
||||
className: PropTypes.string,
|
||||
compact: PropTypes.bool,
|
||||
|
|
|
|||
|
|
@ -4,69 +4,61 @@ const classnames = require('classnames');
|
|||
const Icon = require('stremio-icons/dom');
|
||||
const Button = require('stremio/common/Button');
|
||||
const MetaItem = require('stremio/common/MetaItem');
|
||||
const MetaRowPlaceholder = require('./MetaRowPlaceholder');
|
||||
const styles = require('./styles');
|
||||
|
||||
const MetaRow = ({ className, title, message, items, itemMenuOptions }) => {
|
||||
const MetaRow = ({ className, title, message, items, maximumItemsCount, itemMenuOptions }) => {
|
||||
maximumItemsCount = maximumItemsCount !== null && isFinite(maximumItemsCount) ? maximumItemsCount : 20;
|
||||
items = Array.isArray(items) ? items.slice(0, maximumItemsCount) : [];
|
||||
return (
|
||||
<div className={classnames(className, styles['meta-row-container'])}>
|
||||
{
|
||||
typeof title === 'string' && title.length > 0 ?
|
||||
<div className={styles['title-container']}>{title}</div>
|
||||
<div className={styles['title-container']} title={title}>{title}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
typeof message === 'string' && message.length > 0 ?
|
||||
<div className={styles['message-container']}>{message}</div>
|
||||
<div className={styles['message-container']} title={message}>{message}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
Array.isArray(items) && items.length > 0 ?
|
||||
<React.Fragment>
|
||||
<div className={styles['meta-items-container']}>
|
||||
{items.map((item) => (
|
||||
{items.map((item, index) => (
|
||||
<MetaItem
|
||||
{...item}
|
||||
key={item.id}
|
||||
key={index}
|
||||
data-id={item.id}
|
||||
data-type={item.type}
|
||||
className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${item.posterShape}`])}
|
||||
title={item.name}
|
||||
menuOptions={itemMenuOptions}
|
||||
/>
|
||||
))}
|
||||
<div className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${items[0].posterShape}`])} />
|
||||
<div className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${items[0].posterShape}`])} />
|
||||
<div className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${items[0].posterShape}`])} />
|
||||
<div className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${items[0].posterShape}`])} />
|
||||
<div className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${items[0].posterShape}`])} />
|
||||
<div className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${items[0].posterShape}`])} />
|
||||
<div className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${items[0].posterShape}`])} />
|
||||
<div className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${items[0].posterShape}`])} />
|
||||
<div className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${items[0].posterShape}`])} />
|
||||
<div className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${items[0].posterShape}`])} />
|
||||
{Array(Math.max(maximumItemsCount - items.length, 0)).fill(null).map((_, index) => (
|
||||
<div key={index} className={classnames(styles['meta-item'], styles['poster-shape-poster'])} />
|
||||
))}
|
||||
</div>
|
||||
<Button className={styles['see-all-container']} title={'SEE ALL'}>
|
||||
<div className={styles['see-all-label']}>SEE ALL</div>
|
||||
<div className={styles['label']}>SEE ALL</div>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_thin_right'} />
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MetaRow.Placeholder = MetaRowPlaceholder;
|
||||
|
||||
MetaRow.propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
message: PropTypes.string,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
posterShape: PropTypes.string
|
||||
})),
|
||||
itemMenuOptions: PropTypes.array
|
||||
maximumItemsCount: PropTypes.number,
|
||||
itemMenuOptions: PropTypes.any
|
||||
};
|
||||
|
||||
module.exports = MetaRow;
|
||||
|
|
|
|||
30
src/common/MetaRow/MetaRowPlaceholder/MetaRowPlaceholder.js
Normal file
30
src/common/MetaRow/MetaRowPlaceholder/MetaRowPlaceholder.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const styles = require('./styles');
|
||||
|
||||
const MetaRowPlaceholder = ({ className, title, maximumItemsCount }) => {
|
||||
maximumItemsCount = maximumItemsCount !== null && isFinite(maximumItemsCount) ? maximumItemsCount : 20;
|
||||
return (
|
||||
<div className={classnames(className, styles['meta-row-placeholder-container'])}>
|
||||
<div className={styles['title-container']} title={title}>{title}</div>
|
||||
<div className={styles['meta-items-container']}>
|
||||
{Array(maximumItemsCount).fill(null).map((_, index) => (
|
||||
<div key={index} className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
<div className={styles['title-bar-container']} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles['see-all-container']} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MetaRowPlaceholder.propTypes = {
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
maximumItemsCount: PropTypes.number
|
||||
};
|
||||
|
||||
module.exports = MetaRowPlaceholder;
|
||||
|
|
@ -7,13 +7,14 @@
|
|||
|
||||
.title-container {
|
||||
grid-area: title-area;
|
||||
height: 2.4rem;
|
||||
max-height: 2.4em;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-surface);
|
||||
|
||||
.title-label-container {
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background-color: var(--color-placeholder);
|
||||
&:empty {
|
||||
height: 1.2em;
|
||||
background: linear-gradient(to right, var(--color-placeholder) 0 40%, transparent 40% 100%);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +31,16 @@
|
|||
.poster-container {
|
||||
padding-bottom: calc(100% * var(--poster-shape-ratio));
|
||||
}
|
||||
|
||||
.title-bar-container {
|
||||
height: 2.8rem;
|
||||
background-color: var(--color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.see-all-container {
|
||||
grid-area: see-all-area;
|
||||
background-color: var(--color-placeholder);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"title-area title-area"
|
||||
"message-area message-area"
|
||||
"meta-items-area see-all-area";
|
||||
overflow: visible;
|
||||
|
||||
.title-container {
|
||||
grid-area: title-area;
|
||||
|
|
@ -19,10 +20,6 @@
|
|||
max-height: 3.6em;
|
||||
font-size: 1.3rem;
|
||||
color: var(--color-surfacelighter);
|
||||
|
||||
&~.meta-items-container, &~.see-all-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-items-container {
|
||||
|
|
@ -30,6 +27,7 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
overflow: visible;
|
||||
|
||||
.meta-item {
|
||||
margin-right: 2rem;
|
||||
|
|
@ -58,22 +56,18 @@
|
|||
background-color: var(--color-backgroundlight);
|
||||
|
||||
&:hover, &:focus {
|
||||
outline-offset: 0;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
.see-all-label {
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.icon {
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.see-all-label {
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
max-height: 2.4em;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelight);
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
|
@ -81,7 +75,7 @@
|
|||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
margin-left: 0.3rem;
|
||||
fill: var(--color-surfacelight);
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const styles = require('./styles');
|
||||
|
||||
const MetaRowPlaceholder = ({ className }) => {
|
||||
return (
|
||||
<div className={classnames(className, styles['meta-row-placeholder-container'])}>
|
||||
<div className={styles['title-container']}>
|
||||
<div className={styles['title-label-container']} />
|
||||
</div>
|
||||
<div className={styles['meta-items-container']}>
|
||||
<div className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
</div>
|
||||
<div className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
</div>
|
||||
<div className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
</div>
|
||||
<div className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
</div>
|
||||
<div className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
</div>
|
||||
<div className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
</div>
|
||||
<div className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
</div>
|
||||
<div className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
</div>
|
||||
<div className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
</div>
|
||||
<div className={styles['meta-item']}>
|
||||
<div className={styles['poster-container']} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MetaRowPlaceholder.propTypes = {
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
module.exports = MetaRowPlaceholder;
|
||||
122
src/common/Multiselect/Multiselect.js
Normal file
122
src/common/Multiselect/Multiselect.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const Button = require('stremio/common/Button');
|
||||
const Popup = require('stremio/common/Popup');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const useDataset = require('stremio/common/useDataset');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Multiselect = ({ className, direction, title, renderLabelContent, options, selected, onOpen, onClose, onSelect, ...props }) => {
|
||||
options = Array.isArray(options) ?
|
||||
options.filter(option => option && typeof option.value === 'string')
|
||||
:
|
||||
[];
|
||||
selected = Array.isArray(selected) ?
|
||||
selected.filter(value => typeof value === 'string')
|
||||
:
|
||||
[];
|
||||
const dataset = useDataset(props);
|
||||
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const popupLabelOnClick = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.togglePopupPrevented) {
|
||||
toggleMenu();
|
||||
}
|
||||
}, [toggleMenu]);
|
||||
const popupMenuOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.togglePopupPrevented = true;
|
||||
}, []);
|
||||
const optionOnClick = React.useCallback((event) => {
|
||||
if (typeof onSelect === 'function') {
|
||||
onSelect({
|
||||
type: 'select',
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent,
|
||||
dataset: dataset
|
||||
});
|
||||
}
|
||||
|
||||
if (!event.nativeEvent.closeMenuPrevented) {
|
||||
closeMenu();
|
||||
}
|
||||
}, [onSelect, dataset]);
|
||||
React.useLayoutEffect(() => {
|
||||
if (menuOpen) {
|
||||
if (typeof onOpen === 'function') {
|
||||
onOpen({
|
||||
type: 'open',
|
||||
dataset: dataset
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (typeof onClose === 'function') {
|
||||
onClose({
|
||||
type: 'close',
|
||||
dataset: dataset
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [menuOpen]);
|
||||
return (
|
||||
<Popup
|
||||
open={menuOpen}
|
||||
direction={direction}
|
||||
onCloseRequest={closeMenu}
|
||||
renderLabel={({ ref, className: popupLabelClassName, children }) => (
|
||||
<Button ref={ref} className={classnames(className, popupLabelClassName, styles['label-container'], { 'active': menuOpen })} title={title} onClick={popupLabelOnClick}>
|
||||
{
|
||||
typeof renderLabelContent === 'function' ?
|
||||
renderLabelContent()
|
||||
:
|
||||
<React.Fragment>
|
||||
<div className={styles['label']}>
|
||||
{
|
||||
selected.length > 0 ?
|
||||
options.reduce((labels, { label, value }) => {
|
||||
if (selected.includes(value)) {
|
||||
labels.push(typeof label === 'string' ? label : value);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}, []).join(', ')
|
||||
:
|
||||
title
|
||||
}
|
||||
</div>
|
||||
<Icon className={styles['icon']} icon={'ic_arrow_down'} />
|
||||
</React.Fragment>
|
||||
}
|
||||
{children}
|
||||
</Button>
|
||||
)}
|
||||
renderMenu={() => (
|
||||
<div className={styles['menu-container']} onClick={popupMenuOnClick}>
|
||||
{options.map(({ label, value }) => (
|
||||
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
|
||||
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
|
||||
<Icon className={styles['icon']} icon={'ic_check'} />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Multiselect.propTypes = {
|
||||
className: PropTypes.string,
|
||||
direction: PropTypes.any,
|
||||
title: PropTypes.string,
|
||||
renderLabelContent: PropTypes.func,
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
value: PropTypes.string.isRequired,
|
||||
label: PropTypes.string
|
||||
})),
|
||||
selected: PropTypes.arrayOf(PropTypes.string),
|
||||
onOpen: PropTypes.func,
|
||||
onClose: PropTypes.func,
|
||||
onSelect: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Multiselect;
|
||||
3
src/common/Multiselect/index.js
Normal file
3
src/common/Multiselect/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
const Multiselect = require('./Multiselect');
|
||||
|
||||
module.exports = Multiselect;
|
||||
78
src/common/Multiselect/styles.less
Normal file
78
src/common/Multiselect/styles.less
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
:import('~stremio/common/Popup/styles.less') {
|
||||
popup-menu-container: menu-container;
|
||||
}
|
||||
|
||||
.label-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
&:global(.active) {
|
||||
background-color: var(--color-surfacelight);
|
||||
|
||||
.label {
|
||||
color: var(--color-backgrounddarker);
|
||||
}
|
||||
|
||||
.icon {
|
||||
fill: var(--color-backgrounddarker);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
max-height: 2.4em;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 1rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.popup-menu-container {
|
||||
width: 100%;
|
||||
|
||||
.menu-container {
|
||||
.option-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
&:global(.selected) {
|
||||
background-color: var(--color-surfacedarker);
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--color-surfacedark);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
max-height: 4.8em;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
display: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 1rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,17 +13,27 @@ const NavMenu = ({ className }) => {
|
|||
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen();
|
||||
const user = useUser();
|
||||
const popupLabelOnClick = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.togglePopupPrevented) {
|
||||
toggleMenu();
|
||||
}
|
||||
}, [toggleMenu]);
|
||||
const popupMenuOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.togglePopupPrevented = true;
|
||||
}, []);
|
||||
return (
|
||||
<Popup
|
||||
open={menuOpen}
|
||||
direction={'bottom'}
|
||||
onCloseRequest={closeMenu}
|
||||
renderLabel={(ref) => (
|
||||
<Button ref={ref} className={classnames(className, styles['nav-menu-label-container'], { 'active': menuOpen })} tabIndex={-1} onClick={toggleMenu}>
|
||||
renderLabel={({ ref, className: popupLabelClassName, children }) => (
|
||||
<Button ref={ref} className={classnames(className, popupLabelClassName, styles['nav-menu-label-container'], { 'active': menuOpen })} tabIndex={-1} onClick={popupLabelOnClick}>
|
||||
<Icon className={styles['icon']} icon={'ic_more'} />
|
||||
{children}
|
||||
</Button>
|
||||
)}
|
||||
renderMenu={() => (
|
||||
<div className={styles['nav-menu-container']}>
|
||||
<div className={styles['nav-menu-container']} onClick={popupMenuOnClick}>
|
||||
<div className={styles['user-info-container']}>
|
||||
<div
|
||||
className={styles['avatar-container']}
|
||||
|
|
|
|||
|
|
@ -18,101 +18,99 @@
|
|||
height: 50%;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu-container {
|
||||
min-width: 20rem;
|
||||
max-width: 30rem;
|
||||
background-color: var(--color-background);
|
||||
.nav-menu-container {
|
||||
width: 20rem;
|
||||
background-color: var(--color-background);
|
||||
|
||||
.user-info-container {
|
||||
display: grid;
|
||||
height: 7rem;
|
||||
grid-template-columns: 7rem 1fr;
|
||||
grid-template-rows: 50% 50%;
|
||||
grid-template-areas:
|
||||
"avatar-area email-area"
|
||||
"avatar-area logout-button-area";
|
||||
.user-info-container {
|
||||
display: grid;
|
||||
height: 7rem;
|
||||
grid-template-columns: 7rem 1fr;
|
||||
grid-template-rows: 50% 50%;
|
||||
grid-template-areas:
|
||||
"avatar-area email-area"
|
||||
"avatar-area logout-button-area";
|
||||
|
||||
.avatar-container {
|
||||
grid-area: avatar-area;
|
||||
padding: 1rem;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-origin: content-box;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
grid-area: email-area;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 1rem 1rem 0 0;
|
||||
|
||||
.email-label {
|
||||
flex: 1;
|
||||
max-height: 2.4em;
|
||||
color: var(--color-surfacelighter);
|
||||
user-select: text;
|
||||
.avatar-container {
|
||||
grid-area: avatar-area;
|
||||
padding: 1rem;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-origin: content-box;
|
||||
background-clip: content-box;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-button-container {
|
||||
grid-area: logout-button-area;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 1em 1rem 0;
|
||||
.email-container {
|
||||
grid-area: email-area;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 1rem 1rem 0 0;
|
||||
|
||||
&:hover, &:focus {
|
||||
outline: none;
|
||||
|
||||
.logout-label {
|
||||
.email-label {
|
||||
flex: 1;
|
||||
max-height: 2.4em;
|
||||
color: var(--color-surfacelighter);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-label {
|
||||
flex: 1;
|
||||
max-height: 2.4em;
|
||||
color: var(--color-surface);
|
||||
.logout-button-container {
|
||||
grid-area: logout-button-area;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 0 1em 1rem 0;
|
||||
|
||||
&:hover, &:focus {
|
||||
outline: none;
|
||||
|
||||
.logout-label {
|
||||
color: var(--color-surfacelighter);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-label {
|
||||
flex: 1;
|
||||
max-height: 2.4em;
|
||||
color: var(--color-surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu-section {
|
||||
border-top: thin solid var(--color-surfacedark80);
|
||||
.nav-menu-section {
|
||||
border-top: thin solid var(--color-surfacedark80);
|
||||
|
||||
.nav-menu-option-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 4rem;
|
||||
.nav-menu-option-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 4rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surfacedarker60);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-surfacedarker60);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
margin: 1.3rem;
|
||||
fill: var(--color-secondarylight);
|
||||
}
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
margin: 1.3rem;
|
||||
fill: var(--color-secondarylight);
|
||||
}
|
||||
|
||||
.nav-menu-option-label {
|
||||
flex: 1;
|
||||
max-height: 2.4em;
|
||||
padding-right: 1.3rem;
|
||||
color: var(--color-surfacelighter);
|
||||
.nav-menu-option-label {
|
||||
flex: 1;
|
||||
max-height: 2.4em;
|
||||
padding-right: 1.3rem;
|
||||
color: var(--color-surfacelighter);
|
||||
|
||||
&:only-child {
|
||||
padding-left: 1.3rem;
|
||||
&:only-child {
|
||||
padding-left: 1.3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ const NavTabButton = ({ className, icon, label, href, onClick }) => {
|
|||
|
||||
return null;
|
||||
}, [href]);
|
||||
const active = useRouteActive(routeRegexp);
|
||||
const routeActive = useRouteActive(routeRegexp);
|
||||
return (
|
||||
<Button className={classnames(className, styles['nav-tab-button-container'], { 'active': active })} title={label} tabIndex={-1} href={href} onClick={onClick}>
|
||||
<Button className={classnames(className, styles['nav-tab-button-container'], { 'active': routeActive })} title={label} tabIndex={-1} href={href} onClick={onClick}>
|
||||
{
|
||||
typeof icon === 'string' && icon.length > 0 ?
|
||||
<Icon className={styles['icon']} icon={icon} />
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const UrlUtils = require('url');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { useFocusable } = require('stremio-router');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const Button = require('stremio/common/Button');
|
||||
const TextInput = require('stremio/common/TextInput');
|
||||
const routesRegexp = require('stremio/common/routesRegexp');
|
||||
|
|
@ -13,37 +13,37 @@ const styles = require('./styles');
|
|||
|
||||
const SearchBar = ({ className }) => {
|
||||
const locationHash = useLocationHash();
|
||||
const focusable = useFocusable();
|
||||
const routeFocused = useRouteFocused();
|
||||
const routeActive = useRouteActive(routesRegexp.search.regexp);
|
||||
const searchInputRef = React.useRef(null);
|
||||
const active = useRouteActive(routesRegexp.search.regexp);
|
||||
const query = React.useMemo(() => {
|
||||
if (active) {
|
||||
if (routeActive) {
|
||||
const { search: locationSearch } = UrlUtils.parse(locationHash.slice(1));
|
||||
const queryParams = new URLSearchParams(locationSearch);
|
||||
return queryParams.has('q') ? queryParams.get('q') : '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [active, locationHash]);
|
||||
}, [routeActive, locationHash]);
|
||||
const searchBarOnClick = React.useCallback(() => {
|
||||
if (!active) {
|
||||
if (!routeActive) {
|
||||
window.location = '#/search';
|
||||
}
|
||||
}, [active]);
|
||||
}, [routeActive]);
|
||||
const queryInputOnSubmit = React.useCallback(() => {
|
||||
if (active) {
|
||||
if (routeActive) {
|
||||
window.location.replace(`#/search?q=${searchInputRef.current.value}`);
|
||||
}
|
||||
}, [active]);
|
||||
}, [routeActive]);
|
||||
React.useEffect(() => {
|
||||
if (active && focusable) {
|
||||
if (routeActive && routeFocused) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [active, focusable, query]);
|
||||
}, [routeActive, routeFocused, query]);
|
||||
return (
|
||||
<label className={classnames(className, styles['search-bar-container'], { 'active': active })} onClick={searchBarOnClick}>
|
||||
<label className={classnames(className, styles['search-bar-container'], { 'active': routeActive })} onClick={searchBarOnClick}>
|
||||
{
|
||||
active ?
|
||||
routeActive ?
|
||||
<TextInput
|
||||
key={query}
|
||||
ref={searchInputRef}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
align-items: center;
|
||||
height: var(--nav-bar-size);
|
||||
background-color: var(--color-secondarydark);
|
||||
overflow: visible;
|
||||
|
||||
.nav-tab-button {
|
||||
flex: none;
|
||||
|
|
|
|||
|
|
@ -1,133 +1,87 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const { Modal } = require('stremio-router');
|
||||
const classnames = require('classnames');
|
||||
const FocusLock = require('react-focus-lock').default;
|
||||
const useDataset = require('stremio/common/useDataset');
|
||||
const styles = require('./styles');
|
||||
|
||||
// TODO rename to Popover
|
||||
const Popup = ({ open, menuMatchLabelWidth, renderLabel, renderMenu, onCloseRequest }) => {
|
||||
const Popup = ({ open, direction, renderLabel, renderMenu, onCloseRequest, ...props }) => {
|
||||
direction = ['top', 'bottom'].includes(direction) ? direction : null;
|
||||
const dataset = useDataset(props);
|
||||
const labelRef = React.useRef(null);
|
||||
const menuRef = React.useRef(null);
|
||||
const [menuStyles, setMenuStyles] = React.useState({});
|
||||
const [autoDirection, setAutoDirection] = React.useState(null);
|
||||
const menuOnMouseDown = React.useCallback((event) => {
|
||||
event.nativeEvent.closePopupPrevented = true;
|
||||
}, []);
|
||||
const menuOnKeyUp = React.useCallback((event) => {
|
||||
event.nativeEvent.buttonClickPrevented = true;
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const checkCloseEvent = (event) => {
|
||||
switch (event.type) {
|
||||
case 'resize':
|
||||
onCloseRequest(event);
|
||||
break;
|
||||
case 'keydown':
|
||||
if (event.key === 'Escape') {
|
||||
onCloseRequest(event);
|
||||
}
|
||||
break;
|
||||
case 'mousedown':
|
||||
case 'scroll':
|
||||
if (event.target !== document &&
|
||||
event.target !== document.documentElement &&
|
||||
!labelRef.current.contains(event.target) &&
|
||||
!menuRef.current.contains(event.target)) {
|
||||
onCloseRequest(event);
|
||||
}
|
||||
break;
|
||||
const onCloseEvent = (event) => {
|
||||
if (!event.closePopupPrevented && typeof onCloseRequest === 'function') {
|
||||
const closeEvent = {
|
||||
type: 'close',
|
||||
nativeEvent: event,
|
||||
dataset: dataset
|
||||
};
|
||||
switch (event.type) {
|
||||
case 'resize':
|
||||
onCloseRequest(closeEvent);
|
||||
break;
|
||||
case 'keydown':
|
||||
if (event.key === 'Escape') {
|
||||
onCloseRequest(closeEvent);
|
||||
}
|
||||
break;
|
||||
case 'mousedown':
|
||||
if (event.target !== document.documentElement && !labelRef.current.contains(event.target)) {
|
||||
onCloseRequest(closeEvent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
if (open) {
|
||||
window.addEventListener('scroll', checkCloseEvent, true);
|
||||
window.addEventListener('mousedown', checkCloseEvent);
|
||||
window.addEventListener('keydown', checkCloseEvent);
|
||||
window.addEventListener('resize', checkCloseEvent);
|
||||
window.addEventListener('resize', onCloseEvent);
|
||||
window.addEventListener('keydown', onCloseEvent);
|
||||
window.addEventListener('mousedown', onCloseEvent);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('scroll', checkCloseEvent, true);
|
||||
window.removeEventListener('mousedown', checkCloseEvent);
|
||||
window.removeEventListener('keydown', checkCloseEvent);
|
||||
window.removeEventListener('resize', checkCloseEvent);
|
||||
window.removeEventListener('resize', onCloseEvent);
|
||||
window.removeEventListener('keydown', onCloseEvent);
|
||||
window.removeEventListener('mousedown', onCloseEvent);
|
||||
};
|
||||
}, [open, onCloseRequest]);
|
||||
React.useEffect(() => {
|
||||
let menuStyles = {};
|
||||
}, [open, onCloseRequest, dataset]);
|
||||
React.useLayoutEffect(() => {
|
||||
if (open) {
|
||||
const documentRect = document.documentElement.getBoundingClientRect();
|
||||
const labelRect = labelRef.current.getBoundingClientRect();
|
||||
const menuRect = menuRef.current.getBoundingClientRect();
|
||||
const labelPosition = {
|
||||
left: labelRect.left - documentRect.left,
|
||||
top: labelRect.top - documentRect.top,
|
||||
right: (documentRect.width + documentRect.left) - (labelRect.left + labelRect.width),
|
||||
bottom: (documentRect.height + documentRect.top) - (labelRect.top + labelRect.height)
|
||||
};
|
||||
const matchLabelWidthMenuStyles = {
|
||||
width: `${labelRect.width}px`,
|
||||
maxWidth: `${labelRect.width}px`
|
||||
};
|
||||
const bottomMenuStyles = {
|
||||
top: `${labelPosition.top + labelRect.height}px`,
|
||||
maxHeight: `${labelPosition.bottom}px`
|
||||
};
|
||||
const topMenuStyles = {
|
||||
bottom: `${labelPosition.bottom + labelRect.height}px`,
|
||||
maxHeight: `${labelPosition.top}px`
|
||||
};
|
||||
const rightMenuStyles = {
|
||||
left: `${labelPosition.left}px`,
|
||||
maxWidth: `${labelPosition.right + labelRect.width}px`
|
||||
};
|
||||
const leftMenuStyles = {
|
||||
right: `${labelPosition.right}px`,
|
||||
maxWidth: `${labelPosition.left + labelRect.width}px`
|
||||
};
|
||||
|
||||
if (menuRect.height <= labelPosition.bottom) {
|
||||
menuStyles = { ...menuStyles, ...bottomMenuStyles };
|
||||
} else if (menuRect.height <= labelPosition.top) {
|
||||
menuStyles = { ...menuStyles, ...topMenuStyles };
|
||||
} else if (labelPosition.bottom >= labelPosition.top) {
|
||||
menuStyles = { ...menuStyles, ...bottomMenuStyles };
|
||||
} else {
|
||||
menuStyles = { ...menuStyles, ...topMenuStyles };
|
||||
}
|
||||
|
||||
if (menuRect.width <= (labelPosition.right + labelRect.width)) {
|
||||
menuStyles = { ...menuStyles, ...rightMenuStyles };
|
||||
} else if (menuRect.width <= (labelPosition.left + labelRect.width)) {
|
||||
menuStyles = { ...menuStyles, ...leftMenuStyles };
|
||||
} else if (labelPosition.right > labelPosition.left) {
|
||||
menuStyles = { ...menuStyles, ...rightMenuStyles };
|
||||
} else {
|
||||
menuStyles = { ...menuStyles, ...leftMenuStyles };
|
||||
}
|
||||
|
||||
if (menuMatchLabelWidth) {
|
||||
menuStyles = { ...menuStyles, ...matchLabelWidthMenuStyles };
|
||||
}
|
||||
|
||||
menuStyles = { ...menuStyles, visibility: 'visible' };
|
||||
const labelOffsetTop = labelRect.top - documentRect.top;
|
||||
const labelOffsetBottom = (documentRect.height + documentRect.top) - (labelRect.top + labelRect.height);
|
||||
const autoDirection = labelOffsetBottom >= labelOffsetTop ? 'bottom' : 'top';
|
||||
setAutoDirection(autoDirection);
|
||||
} else {
|
||||
setAutoDirection(null);
|
||||
}
|
||||
|
||||
setMenuStyles(menuStyles);
|
||||
}, [open]);
|
||||
return (
|
||||
<React.Fragment>
|
||||
{renderLabel(labelRef)}
|
||||
{
|
||||
open ?
|
||||
<Modal className={styles['popup-modal-container']}>
|
||||
<div ref={menuRef} style={menuStyles} className={styles['menu-container']}>
|
||||
{renderMenu()}
|
||||
</div>
|
||||
</Modal>
|
||||
:
|
||||
null
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
return renderLabel({
|
||||
ref: labelRef,
|
||||
className: styles['label-container'],
|
||||
children: open ?
|
||||
<FocusLock className={classnames(styles['menu-container'], styles[`menu-direction-${typeof direction === 'string' ? direction : autoDirection}`])} autoFocus={false} lockProps={{ onMouseDown: menuOnMouseDown, onKeyUp: menuOnKeyUp }}>
|
||||
{renderMenu()}
|
||||
</FocusLock>
|
||||
:
|
||||
null
|
||||
});
|
||||
}
|
||||
|
||||
Popup.propTypes = {
|
||||
open: PropTypes.bool,
|
||||
menuMatchLabelWidth: PropTypes.bool,
|
||||
direction: PropTypes.oneOf(['top', 'bottom']),
|
||||
renderLabel: PropTypes.func.isRequired,
|
||||
renderMenu: PropTypes.func.isRequired,
|
||||
onCloseRequest: PropTypes.func.isRequired
|
||||
onCloseRequest: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Popup;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
.popup-modal-container {
|
||||
pointer-events: none;
|
||||
.label-container {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
||||
.menu-container {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
visibility: hidden;
|
||||
overflow: auto;
|
||||
box-shadow: 0 1.35rem 2.7rem var(--color-backgrounddarker40),
|
||||
0 1.1rem 0.85rem var(--color-backgrounddarker20);
|
||||
cursor: auto;
|
||||
|
||||
&.menu-direction-bottom {
|
||||
top: 100%;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&.menu-direction-top {
|
||||
bottom: 100%;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,121 +1,128 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useFocusedRoute } = require('stremio-router');
|
||||
const useAnimationFrame = require('stremio/common/useAnimationFrame');
|
||||
const useLiveRef = require('stremio/common/useLiveRef');
|
||||
const styles = require('./styles');
|
||||
|
||||
class Slider extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.sliderContainerRef = React.createRef();
|
||||
this.orientation = props.orientation;
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextProps.value !== this.props.value ||
|
||||
nextProps.minimumValue !== this.props.minimumValue ||
|
||||
nextProps.maximumValue !== this.props.maximumValue ||
|
||||
nextProps.className !== this.props.className;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.releaseThumb();
|
||||
}
|
||||
|
||||
calculateSlidingValue = ({ mouseX, mouseY }) => {
|
||||
const { x: sliderX, y: sliderY, width: sliderWidth, height: sliderHeight } = this.sliderContainerRef.current.getBoundingClientRect();
|
||||
const sliderStart = this.orientation === 'horizontal' ? sliderX : sliderY;
|
||||
const sliderLength = this.orientation === 'horizontal' ? sliderWidth : sliderHeight;
|
||||
const mouseStart = this.orientation === 'horizontal' ? mouseX : mouseY;
|
||||
const thumbStart = Math.min(Math.max(mouseStart - sliderStart, 0), sliderLength);
|
||||
const slidingValueCoef = this.orientation === 'horizontal' ? thumbStart / sliderLength : (sliderLength - thumbStart) / sliderLength;
|
||||
const slidingValue = slidingValueCoef * (this.props.maximumValue - this.props.minimumValue) + this.props.minimumValue;
|
||||
return slidingValue;
|
||||
}
|
||||
|
||||
releaseThumb = () => {
|
||||
window.removeEventListener('blur', this.onBlur);
|
||||
window.removeEventListener('mouseup', this.onMouseUp);
|
||||
window.removeEventListener('mousemove', this.onMouseMove);
|
||||
document.documentElement.style.cursor = 'initial';
|
||||
document.body.style['pointer-events'] = 'initial';
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.releaseThumb();
|
||||
if (typeof this.props.onCancel === 'function') {
|
||||
this.props.onCancel();
|
||||
const Slider = ({ className, value, minimumValue, maximumValue, onSlide, onComplete }) => {
|
||||
minimumValue = minimumValue !== null && !isNaN(minimumValue) && isFinite(minimumValue) ? minimumValue : 0;
|
||||
maximumValue = maximumValue !== null && !isNaN(maximumValue) && isFinite(maximumValue) ? maximumValue : 100;
|
||||
value = value !== null && !isNaN(value) && value >= minimumValue && value <= maximumValue ? value : 0;
|
||||
const onSlideRef = useLiveRef(onSlide, [onSlide]);
|
||||
const onCompleteRef = useLiveRef(onComplete, [onComplete]);
|
||||
const sliderContainerRef = React.useRef(null);
|
||||
const routeFocused = useFocusedRoute();
|
||||
const [requestThumbAnimation, cancelThumbAnimation] = useAnimationFrame();
|
||||
const calculateValueForMouseX = React.useCallback((mouseX) => {
|
||||
if (sliderContainerRef.current === null) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
onMouseUp = ({ clientX: mouseX, clientY: mouseY }) => {
|
||||
this.releaseThumb();
|
||||
const slidingValue = this.calculateSlidingValue({ mouseX, mouseY });
|
||||
if (typeof this.props.onComplete === 'function') {
|
||||
this.props.onComplete(slidingValue);
|
||||
const minimumValue = parseInt(sliderContainerRef.current.getAttribute('aria-valuemin'));
|
||||
const maximumValue = parseInt(sliderContainerRef.current.getAttribute('aria-valuemax'));
|
||||
const { x: sliderX, width: sliderWidth } = sliderContainerRef.current.getBoundingClientRect();
|
||||
const thumbStart = Math.min(Math.max(mouseX - sliderX, 0), sliderWidth);
|
||||
const value = (thumbStart / sliderWidth) * (maximumValue - minimumValue) + minimumValue;
|
||||
return value;
|
||||
}, []);
|
||||
const retainThumb = React.useCallback(() => {
|
||||
window.addEventListener('blur', onBlur);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
document.documentElement.className = classnames(document.documentElement.className, styles['active-slider-within']);
|
||||
}, []);
|
||||
const releaseThumb = React.useCallback(() => {
|
||||
cancelThumbAnimation();
|
||||
window.removeEventListener('blur', onBlur);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
const classList = document.documentElement.className.split(' ');
|
||||
const classIndex = classList.indexOf(styles['active-slider-within']);
|
||||
if (classIndex !== -1) {
|
||||
classList.splice(classIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
onMouseMove = ({ clientX: mouseX, clientY: mouseY }) => {
|
||||
const slidingValue = this.calculateSlidingValue({ mouseX, mouseY });
|
||||
if (typeof this.props.onSlide === 'function') {
|
||||
this.props.onSlide(slidingValue);
|
||||
document.documentElement.className = classnames(classList);
|
||||
}, []);
|
||||
const onBlur = React.useCallback(() => {
|
||||
const value = parseInt(sliderContainerRef.current.getAttribute('aria-valuenow'));
|
||||
if (typeof onSlideRef.current === 'function') {
|
||||
onSlideRef.current(value);
|
||||
}
|
||||
if (typeof onCompleteRef.current === 'function') {
|
||||
onCompleteRef.current(value);
|
||||
}
|
||||
}
|
||||
|
||||
onStartSliding = ({ clientX: mouseX, clientY: mouseY, button }) => {
|
||||
if (button !== 0) {
|
||||
releaseThumb();
|
||||
}, []);
|
||||
const onMouseUp = React.useCallback((event) => {
|
||||
const value = calculateValueForMouseX(event.clientX);
|
||||
if (typeof onCompleteRef.current === 'function') {
|
||||
onCompleteRef.current(value);
|
||||
}
|
||||
|
||||
releaseThumb();
|
||||
}, []);
|
||||
const onMouseMove = React.useCallback((event) => {
|
||||
requestThumbAnimation(() => {
|
||||
const value = calculateValueForMouseX(event.clientX);
|
||||
if (typeof onSlideRef.current === 'function') {
|
||||
onSlideRef.current(value);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
const onMouseDown = React.useCallback((event) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('blur', this.onBlur);
|
||||
window.addEventListener('mouseup', this.onMouseUp);
|
||||
window.addEventListener('mousemove', this.onMouseMove);
|
||||
document.documentElement.style.cursor = 'pointer';
|
||||
document.body.style['pointer-events'] = 'none';
|
||||
this.onMouseMove({ clientX: mouseX, clientY: mouseY });
|
||||
}
|
||||
const value = calculateValueForMouseX(event.clientX);
|
||||
if (typeof onSlideRef.current === 'function') {
|
||||
onSlideRef.current(value);
|
||||
}
|
||||
|
||||
render() {
|
||||
const thumbStartProp = this.orientation === 'horizontal' ? 'left' : 'bottom';
|
||||
const trackBeforeSizeProp = this.orientation === 'horizontal' ? 'width' : 'height';
|
||||
const thumbStart = Math.max(0, Math.min(1, (this.props.value - this.props.minimumValue) / (this.props.maximumValue - this.props.minimumValue)));
|
||||
const disabled = this.props.value === null || isNaN(this.props.value) ||
|
||||
this.props.minimumValue === null || isNaN(this.props.minimumValue) ||
|
||||
this.props.maximumValue === null || isNaN(this.props.maximumValue) ||
|
||||
this.props.minimumValue === this.props.maximumValue;
|
||||
return (
|
||||
<div ref={this.sliderContainerRef} className={classnames(styles['slider-container'], styles[this.orientation], { 'disabled': disabled }, this.props.className)} onMouseDown={this.onStartSliding}>
|
||||
retainThumb();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!routeFocused) {
|
||||
releaseThumb();
|
||||
}
|
||||
}, [routeFocused]);
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
releaseThumb();
|
||||
};
|
||||
}, []);
|
||||
const thumbPosition = React.useMemo(() => {
|
||||
return Math.max(0, Math.min(1, (value - minimumValue) / (maximumValue - minimumValue)));
|
||||
}, [value, minimumValue, maximumValue]);
|
||||
return (
|
||||
<div ref={sliderContainerRef} className={classnames(className, styles['slider-container'])} aria-valuenow={value} aria-valuemin={minimumValue} aria-valuemax={maximumValue} onMouseDown={onMouseDown}>
|
||||
<div className={styles['layer']}>
|
||||
<div className={styles['track']} />
|
||||
{
|
||||
!disabled ?
|
||||
<React.Fragment>
|
||||
<div className={styles['track-before']} style={{ [trackBeforeSizeProp]: `calc(100% * ${thumbStart})` }} />
|
||||
<div className={styles['thumb']} style={{ [thumbStartProp]: `calc(100% * ${thumbStart})` }} />
|
||||
</React.Fragment>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
<div className={styles['layer']}>
|
||||
<div className={styles['track-before']} style={{ width: `calc(100% * ${thumbPosition})` }} />
|
||||
</div>
|
||||
<div className={styles['layer']}>
|
||||
<svg className={styles['thumb']} style={{ marginLeft: `calc(100% * ${thumbPosition})` }} viewBox={'0 0 10 10'}>
|
||||
<circle cx={'5'} cy={'5'} r={'5'} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Slider.propTypes = {
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.number,
|
||||
minimumValue: PropTypes.number,
|
||||
maximumValue: PropTypes.number,
|
||||
orientation: PropTypes.oneOf(['horizontal', 'vertical']).isRequired,
|
||||
onSlide: PropTypes.func,
|
||||
onComplete: PropTypes.func,
|
||||
onCancel: PropTypes.func
|
||||
};
|
||||
Slider.defaultProps = {
|
||||
value: 0,
|
||||
minimumValue: 0,
|
||||
maximumValue: 100,
|
||||
orientation: 'horizontal'
|
||||
onComplete: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Slider;
|
||||
|
|
|
|||
|
|
@ -1,71 +1,59 @@
|
|||
html.active-slider-within {
|
||||
cursor: grabbing;
|
||||
|
||||
body {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
|
||||
&.horizontal {
|
||||
.track {
|
||||
top: calc(50% - var(--track-size) * 0.5);
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: var(--track-size);
|
||||
}
|
||||
|
||||
.track-before {
|
||||
top: calc(50% - var(--track-size) * 0.5);
|
||||
left: 0;
|
||||
height: var(--track-size);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
top: calc(50% - var(--thumb-size) * 0.5);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
.track {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(50% - var(--track-size) * 0.1);
|
||||
width: var(--track-size);
|
||||
}
|
||||
|
||||
.track-before {
|
||||
bottom: 0;
|
||||
left: calc(50% - var(--track-size) * 0.1);
|
||||
width: var(--track-size);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
left: calc(50% - var(--thumb-size) * 0.5);
|
||||
transform: translateY(50%);
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.disabled) {
|
||||
pointer-events: none;
|
||||
|
||||
.track, .track-before {
|
||||
background-color: var(--color-surfacedark);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
fill: var(--color-surfacedark);
|
||||
}
|
||||
}
|
||||
|
||||
.layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background-color: var(--track-color);
|
||||
flex: 1;
|
||||
height: var(--track-size, 0.5rem);
|
||||
background-color: var(--color-backgroundlighter);
|
||||
}
|
||||
|
||||
.track-before {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
background-color: var(--track-before-color);
|
||||
flex: none;
|
||||
height: var(--track-size, 0.5rem);
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
width: var(--thumb-size);
|
||||
height: var(--thumb-size);
|
||||
border-radius: 50%;
|
||||
background-color: var(--thumb-color);
|
||||
flex: none;
|
||||
width: var(--thumb-size, 1.5rem);
|
||||
height: var(--thumb-size, 1.5rem);
|
||||
transform: translateX(-50%);
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
const React = require('react');
|
||||
const classnames = require('classnames');
|
||||
const useTabIndex = require('stremio/common/useTabIndex');
|
||||
const styles = require('./styles');
|
||||
|
||||
const TextInput = React.forwardRef((props, ref) => {
|
||||
const tabIndex = useTabIndex(props.tabIndex, props.disabled);
|
||||
const onKeyUp = React.useCallback((event) => {
|
||||
if (typeof props.onKeyUp === 'function') {
|
||||
props.onKeyUp(event);
|
||||
|
|
@ -21,10 +19,10 @@ const TextInput = React.forwardRef((props, ref) => {
|
|||
autoCapitalize={'off'}
|
||||
autoComplete={'off'}
|
||||
spellCheck={false}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={classnames(props.className, styles['text-input-container'], { 'disabled': props.disabled })}
|
||||
tabIndex={tabIndex}
|
||||
className={classnames(props.className, styles['text-input'], { 'disabled': props.disabled })}
|
||||
onKeyUp={onKeyUp}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
.text-input-container {
|
||||
.text-input {
|
||||
user-select: text;
|
||||
|
||||
&::-moz-focus-inner {
|
||||
|
|
|
|||
|
|
@ -1,49 +1,49 @@
|
|||
const Button = require('./Button');
|
||||
const Checkbox = require('./Checkbox');
|
||||
const ColorPicker = require('./ColorPicker');
|
||||
const ColorInput = require('./ColorInput');
|
||||
const Dropdown = require('./Dropdown');
|
||||
const Image = require('./Image');
|
||||
const MainNavBar = require('./MainNavBar');
|
||||
const MetaItem = require('./MetaItem');
|
||||
const MetaPreview = require('./MetaPreview');
|
||||
const MetaPreviewPlaceholder = require('./MetaPreviewPlaceholder');
|
||||
const MetaRow = require('./MetaRow');
|
||||
const MetaRowPlaceholder = require('./MetaRowPlaceholder');
|
||||
const Multiselect = require('./Multiselect');
|
||||
const NavBar = require('./NavBar');
|
||||
const PlayIconCircleCentered = require('./PlayIconCircleCentered');
|
||||
const Popup = require('./Popup');
|
||||
const SharePrompt = require('./SharePrompt');
|
||||
const Slider = require('./Slider');
|
||||
const TextInput = require('./TextInput');
|
||||
const routesRegexp = require('./routesRegexp');
|
||||
const useAnimationFrame = require('./useAnimationFrame');
|
||||
const useBinaryState = require('./useBinaryState');
|
||||
const useDataset = require('./useDataset');
|
||||
const useFullscreen = require('./useFullscreen');
|
||||
const useLiveRef = require('./useLiveRef');
|
||||
const useLocationHash = require('./useLocationHash');
|
||||
const useRouteActive = require('./useRouteActive');
|
||||
const useTabIndex = require('./useTabIndex');
|
||||
const useSpreadState = require('./useSpreadState');
|
||||
|
||||
module.exports = {
|
||||
Button,
|
||||
Checkbox,
|
||||
ColorPicker,
|
||||
ColorInput,
|
||||
Dropdown,
|
||||
Image,
|
||||
MainNavBar,
|
||||
MetaItem,
|
||||
MetaPreview,
|
||||
MetaPreviewPlaceholder,
|
||||
MetaRow,
|
||||
MetaRowPlaceholder,
|
||||
Multiselect,
|
||||
NavBar,
|
||||
PlayIconCircleCentered,
|
||||
Popup,
|
||||
SharePrompt,
|
||||
Slider,
|
||||
TextInput,
|
||||
routesRegexp,
|
||||
useAnimationFrame,
|
||||
useBinaryState,
|
||||
useDataset,
|
||||
useFullscreen,
|
||||
useLiveRef,
|
||||
useLocationHash,
|
||||
useRouteActive,
|
||||
useTabIndex
|
||||
useSpreadState
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ const routesRegexp = {
|
|||
urlParamsNames: []
|
||||
},
|
||||
player: {
|
||||
regexp: /^\/player\/?$/i,
|
||||
urlParamsNames: []
|
||||
regexp: /^\/player\/(?:([^\/]+?))\/(?:([^\/]+?))\/(?:([^\/]+?))\/(?:([^\/]+?))\/?$/i,
|
||||
urlParamsNames: ['type', 'id', 'videoId', 'stream']
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
20
src/common/useAnimationFrame.js
Normal file
20
src/common/useAnimationFrame.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const React = require('react');
|
||||
|
||||
const useAnimationFrame = () => {
|
||||
const animationFrameId = React.useRef(null);
|
||||
const request = React.useCallback((cb) => {
|
||||
if (animationFrameId.current === null) {
|
||||
animationFrameId.current = requestAnimationFrame(() => {
|
||||
cb();
|
||||
animationFrameId.current = null;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const cancel = React.useCallback(() => {
|
||||
cancelAnimationFrame(animationFrameId.current);
|
||||
animationFrameId.current = null;
|
||||
}, []);
|
||||
return [request, cancel];
|
||||
};
|
||||
|
||||
module.exports = useAnimationFrame;
|
||||
20
src/common/useDataset.js
Normal file
20
src/common/useDataset.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const React = require('react');
|
||||
|
||||
const toCamelCase = (value) => {
|
||||
return value.replace(/-([a-z])/gi, (_, letter) => letter.toUpperCase());
|
||||
};
|
||||
|
||||
const useDataset = (props) => {
|
||||
props = typeof props === 'object' && props !== null ? props : {};
|
||||
const dataPropNames = Object.keys(props).filter(propsName => propsName.startsWith('data-'));
|
||||
const dataset = React.useMemo(() => {
|
||||
return dataPropNames.reduce((dataset, dataPropName) => {
|
||||
const datasetPropName = toCamelCase(dataPropName.slice(5));
|
||||
dataset[datasetPropName] = String(props[dataPropName]);
|
||||
return dataset;
|
||||
}, {});
|
||||
}, [dataPropNames.join('')]);
|
||||
return dataset;
|
||||
};
|
||||
|
||||
module.exports = useDataset;
|
||||
11
src/common/useLiveRef.js
Normal file
11
src/common/useLiveRef.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const React = require('react');
|
||||
|
||||
const useLiveRef = (value, dependencies) => {
|
||||
const ref = React.useRef(value);
|
||||
React.useEffect(() => {
|
||||
ref.current = value;
|
||||
}, dependencies);
|
||||
return ref;
|
||||
};
|
||||
|
||||
module.exports = useLiveRef;
|
||||
11
src/common/useSpreadState.js
Normal file
11
src/common/useSpreadState.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
const React = require('react');
|
||||
|
||||
const useSpreadState = (initialState) => {
|
||||
const [state, setState] = React.useReducer(
|
||||
(state, nextState) => ({ ...state, ...nextState }),
|
||||
initialState
|
||||
);
|
||||
return [state, setState];
|
||||
};
|
||||
|
||||
module.exports = useSpreadState;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
const { useFocusable } = require('stremio-router');
|
||||
|
||||
const useTabIndex = (tabIndex, disabled) => {
|
||||
const focusable = useFocusable();
|
||||
return (tabIndex === null || isNaN(tabIndex)) ?
|
||||
(focusable && !disabled ? 0 : -1)
|
||||
:
|
||||
tabIndex;
|
||||
};
|
||||
|
||||
module.exports = useTabIndex;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
const React = require('react');
|
||||
|
||||
const FocusableContext = React.createContext(false);
|
||||
|
||||
FocusableContext.displayName = 'FocusableContext';
|
||||
|
||||
module.exports = FocusableContext;
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const { useModalsContainer } = require('../ModalsContainerContext');
|
||||
const { useRoutesContainer } = require('../RoutesContainerContext');
|
||||
const FocusableContext = require('./FocusableContext');
|
||||
|
||||
const FocusableProvider = ({ children, onRoutesContainerChildrenChange, onModalsContainerChildrenChange }) => {
|
||||
const routesContainer = useRoutesContainer();
|
||||
const modalsContainer = useModalsContainer();
|
||||
const contentContainerRef = React.useRef(null);
|
||||
const [focusable, setFocusable] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const onContainerChildrenChange = () => {
|
||||
setFocusable(
|
||||
onRoutesContainerChildrenChange({
|
||||
routesContainer: routesContainer,
|
||||
contentContainer: contentContainerRef.current
|
||||
})
|
||||
&&
|
||||
onModalsContainerChildrenChange({
|
||||
modalsContainer: modalsContainer,
|
||||
contentContainer: contentContainerRef.current
|
||||
})
|
||||
);
|
||||
};
|
||||
const routesContainerChildrenObserver = new MutationObserver(onContainerChildrenChange);
|
||||
const modalsContainerChildrenObserver = new MutationObserver(onContainerChildrenChange);
|
||||
routesContainerChildrenObserver.observe(routesContainer, { childList: true });
|
||||
modalsContainerChildrenObserver.observe(modalsContainer, { childList: true });
|
||||
onContainerChildrenChange();
|
||||
return () => {
|
||||
routesContainerChildrenObserver.disconnect();
|
||||
modalsContainerChildrenObserver.disconnect();
|
||||
};
|
||||
}, [routesContainer, modalsContainer, onRoutesContainerChildrenChange, onModalsContainerChildrenChange]);
|
||||
React.useEffect(() => {
|
||||
if (focusable && !contentContainerRef.current.contains(document.activeElement)) {
|
||||
contentContainerRef.current.focus();
|
||||
}
|
||||
}, [focusable]);
|
||||
return (
|
||||
<FocusableContext.Provider value={focusable}>
|
||||
{React.cloneElement(React.Children.only(children), {
|
||||
ref: contentContainerRef,
|
||||
tabIndex: -1
|
||||
})}
|
||||
</FocusableContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
FocusableProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
onRoutesContainerChildrenChange: PropTypes.func.isRequired,
|
||||
onModalsContainerChildrenChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
module.exports = FocusableProvider;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
const FocusableProvider = require('./FocusableProvider');
|
||||
const useFocusable = require('./useFocusable');
|
||||
|
||||
module.exports = {
|
||||
FocusableProvider,
|
||||
useFocusable
|
||||
};
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
const React = require('react');
|
||||
const FocusableContext = require('./FocusableContext');
|
||||
|
||||
const useFocusable = () => {
|
||||
return React.useContext(FocusableContext);
|
||||
};
|
||||
|
||||
module.exports = useFocusable;
|
||||
|
|
@ -1,23 +1,28 @@
|
|||
const React = require('react');
|
||||
const ReactDOM = require('react-dom');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { FocusableProvider } = require('../FocusableContext');
|
||||
const FocusLock = require('react-focus-lock').default;
|
||||
const { useModalsContainer } = require('../ModalsContainerContext');
|
||||
|
||||
const Modal = (props) => {
|
||||
const Modal = ({ className, autoFocus, disabled, children, ...props }) => {
|
||||
const modalsContainer = useModalsContainer();
|
||||
const onRoutesContainerChildrenChange = React.useCallback(({ routesContainer, contentContainer }) => {
|
||||
return routesContainer.lastElementChild.contains(contentContainer);
|
||||
}, []);
|
||||
const onModalsContainerChildrenChange = React.useCallback(({ modalsContainer, contentContainer }) => {
|
||||
return modalsContainer.lastElementChild === contentContainer;
|
||||
}, []);
|
||||
return ReactDOM.createPortal(
|
||||
<FocusableProvider onRoutesContainerChildrenChange={onRoutesContainerChildrenChange} onModalsContainerChildrenChange={onModalsContainerChildrenChange}>
|
||||
<div {...props} className={classnames(props.className, 'modal-container')} />
|
||||
</FocusableProvider>,
|
||||
<FocusLock className={classnames(className, 'modal-container')} autoFocus={typeof autoFocus === 'boolean' ? autoFocus : false} disabled={disabled} lockProps={props}>
|
||||
{children}
|
||||
</FocusLock>,
|
||||
modalsContainer
|
||||
);
|
||||
};
|
||||
|
||||
Modal.propTypes = {
|
||||
className: PropTypes.string,
|
||||
autoFocus: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
])
|
||||
};
|
||||
|
||||
module.exports = Modal;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,14 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const { FocusableProvider } = require('../FocusableContext');
|
||||
const { ModalsContainerProvider } = require('../ModalsContainerContext');
|
||||
|
||||
const Route = ({ children }) => {
|
||||
const onRoutesContainerChildrenChange = React.useCallback(({ routesContainer, contentContainer }) => {
|
||||
return routesContainer.lastElementChild.contains(contentContainer);
|
||||
}, []);
|
||||
const onModalsContainerChildrenChange = React.useCallback(({ modalsContainer }) => {
|
||||
return modalsContainer.childElementCount === 0;
|
||||
}, []);
|
||||
return (
|
||||
<div className={'route-container'}>
|
||||
<ModalsContainerProvider>
|
||||
<FocusableProvider onRoutesContainerChildrenChange={onRoutesContainerChildrenChange} onModalsContainerChildrenChange={onModalsContainerChildrenChange}>
|
||||
<div className={'route-content'}>{children}</div>
|
||||
</FocusableProvider>
|
||||
<div className={'route-content'}>
|
||||
{children}
|
||||
</div>
|
||||
</ModalsContainerProvider>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
7
src/router/RouteFocusedContext/RouteFocusedContext.js
Normal file
7
src/router/RouteFocusedContext/RouteFocusedContext.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const React = require('react');
|
||||
|
||||
const RouteFocusedContext = React.createContext(false);
|
||||
|
||||
RouteFocusedContext.displayName = 'RouteFocusedContext';
|
||||
|
||||
module.exports = RouteFocusedContext;
|
||||
7
src/router/RouteFocusedContext/index.js
Normal file
7
src/router/RouteFocusedContext/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const RouteFocusedContext = require('./RouteFocusedContext');
|
||||
const useRouteFocused = require('./useRouteFocused');
|
||||
|
||||
module.exports = {
|
||||
RouteFocusedProvider: RouteFocusedContext.Provider,
|
||||
useRouteFocused
|
||||
};
|
||||
8
src/router/RouteFocusedContext/useRouteFocused.js
Normal file
8
src/router/RouteFocusedContext/useRouteFocused.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
const React = require('react');
|
||||
const RouteFocusedContext = require('./RouteFocusedContext');
|
||||
|
||||
const useRouteFocused = () => {
|
||||
return React.useContext(RouteFocusedContext);
|
||||
};
|
||||
|
||||
module.exports = useRouteFocused;
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
const React = require('react');
|
||||
const ReactIs = require('react-is');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const UrlUtils = require('url');
|
||||
const { RouteFocusedProvider } = require('../RouteFocusedContext');
|
||||
const Route = require('../Route');
|
||||
const { RoutesContainerProvider } = require('../RoutesContainerContext');
|
||||
|
||||
const Router = ({ className, onPathNotMatch, ...props }) => {
|
||||
const [{ homePath, viewsConfig }] = React.useState(() => ({
|
||||
|
|
@ -96,17 +97,19 @@ const Router = ({ className, onPathNotMatch, ...props }) => {
|
|||
};
|
||||
}, [onPathNotMatch]);
|
||||
return (
|
||||
<RoutesContainerProvider className={className}>
|
||||
<div className={classnames(className, 'routes-container')}>
|
||||
{
|
||||
views
|
||||
.filter(view => view !== null)
|
||||
.map(({ key, component, urlParams, queryParams }) => (
|
||||
<Route key={key}>
|
||||
{React.createElement(component, { urlParams, queryParams })}
|
||||
</Route>
|
||||
.map(({ key, component, urlParams, queryParams }, index, views) => (
|
||||
<RouteFocusedProvider key={key} value={index === views.length - 1}>
|
||||
<Route>
|
||||
{React.createElement(component, { urlParams, queryParams })}
|
||||
</Route>
|
||||
</RouteFocusedProvider>
|
||||
))
|
||||
}
|
||||
</RoutesContainerProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
const React = require('react');
|
||||
|
||||
const RoutesContainerContext = React.createContext(null);
|
||||
|
||||
RoutesContainerContext.displayName = 'RoutesContainerContext';
|
||||
|
||||
module.exports = RoutesContainerContext;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const RoutesContainerContext = require('./RoutesContainerContext');
|
||||
|
||||
const RoutesContainerProvider = ({ className, children }) => {
|
||||
const [container, setContainer] = React.useState(null);
|
||||
return (
|
||||
<RoutesContainerContext.Provider value={container}>
|
||||
<div ref={setContainer} className={classnames(className, 'routes-container')}>
|
||||
{container instanceof HTMLElement ? children : null}
|
||||
</div>
|
||||
</RoutesContainerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
RoutesContainerProvider.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node
|
||||
])
|
||||
};
|
||||
|
||||
module.exports = RoutesContainerProvider;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
const RoutesContainerProvider = require('./RoutesContainerProvider');
|
||||
const useRoutesContainer = require('./useRoutesContainer');
|
||||
|
||||
module.exports = {
|
||||
RoutesContainerProvider,
|
||||
useRoutesContainer
|
||||
};
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
const React = require('react');
|
||||
const RoutesContainerContext = require('./RoutesContainerContext');
|
||||
|
||||
const useRoutesContainer = () => {
|
||||
return React.useContext(RoutesContainerContext);
|
||||
};
|
||||
|
||||
module.exports = useRoutesContainer;
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
const { useFocusable } = require('./FocusableContext');
|
||||
const { useRouteFocused } = require('./RouteFocusedContext');
|
||||
const Modal = require('./Modal');
|
||||
const Router = require('./Router');
|
||||
|
||||
module.exports = {
|
||||
useFocusable,
|
||||
useRouteFocused,
|
||||
Modal,
|
||||
Router
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const React = require('react');
|
||||
const { MainNavBar, MetaRow, MetaRowPlaceholder } = require('stremio/common');
|
||||
const { MainNavBar, MetaRow } = require('stremio/common');
|
||||
const useCatalogs = require('./useCatalogs');
|
||||
const styles = require('./styles');
|
||||
|
||||
|
|
@ -42,9 +42,10 @@ const Board = () => {
|
|||
);
|
||||
case 'Loading':
|
||||
return (
|
||||
<MetaRowPlaceholder
|
||||
<MetaRow.Placeholder
|
||||
key={`${index}${req.base}${content.type}`}
|
||||
className={styles['board-row-placeholder']}
|
||||
title={`${req.path.id} - ${req.path.type_name}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
meta-item: meta-item;
|
||||
}
|
||||
|
||||
:import('~stremio/common/MetaRowPlaceholder/styles.less') {
|
||||
:import('~stremio/common/MetaRow/MetaRowPlaceholder/styles.less') {
|
||||
meta-item-placeholder: meta-item;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const React = require('react');
|
||||
const { NavBar, MetaPreview, MetaPreviewPlaceholder } = require('stremio/common');
|
||||
const { NavBar, MetaPreview } = require('stremio/common');
|
||||
const VideosList = require('./VideosList');
|
||||
const StreamsList = require('./StreamsList');
|
||||
const useMetaItem = require('./useMetaItem');
|
||||
|
|
@ -32,7 +32,7 @@ const Detail = ({ urlParams }) => {
|
|||
/>
|
||||
</React.Fragment>
|
||||
:
|
||||
<MetaPreviewPlaceholder className={styles['meta-preview']} />
|
||||
<MetaPreview.Placeholder className={styles['meta-preview']} />
|
||||
}
|
||||
{
|
||||
typeof urlParams.videoId === 'string' && urlParams.videoId.length > 0 ?
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const React = require('react');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { useFocusable } = require('stremio-router');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const { Button } = require('stremio/common');
|
||||
const CredentialsTextInput = require('./CredentialsTextInput');
|
||||
const ConsentCheckbox = require('./ConsentCheckbox');
|
||||
|
|
@ -11,7 +11,7 @@ const LOGIN_FORM = 'LOGIN_FORM';
|
|||
const SIGNUP_FORM = 'SIGNUP_FORM';
|
||||
|
||||
const Intro = () => {
|
||||
const focusable = useFocusable();
|
||||
const routeFocused = useRouteFocused();
|
||||
const emailRef = React.useRef();
|
||||
const passwordRef = React.useRef();
|
||||
const confirmPasswordRef = React.useRef();
|
||||
|
|
@ -36,11 +36,13 @@ const Intro = () => {
|
|||
case 'change-credentials':
|
||||
return {
|
||||
...state,
|
||||
error: '',
|
||||
[action.name]: action.value
|
||||
};
|
||||
case 'toggle-checkbox':
|
||||
return {
|
||||
...state,
|
||||
error: '',
|
||||
[action.name]: !state[action.name]
|
||||
};
|
||||
case 'error':
|
||||
|
|
@ -137,10 +139,10 @@ const Intro = () => {
|
|||
}
|
||||
}, [state.error]);
|
||||
React.useEffect(() => {
|
||||
if (focusable) {
|
||||
if (routeFocused) {
|
||||
emailRef.current.focus();
|
||||
}
|
||||
}, [state.form, focusable]);
|
||||
}, [state.form, routeFocused]);
|
||||
return (
|
||||
<div className={styles['intro-container']}>
|
||||
<div className={styles['form-container']}>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,25 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { Loader } = require('stremio/common');
|
||||
const colors = require('stremio-colors');
|
||||
const { Image } = require('stremio/common');
|
||||
const styles = require('./styles');
|
||||
|
||||
class BufferingLoader extends React.PureComponent {
|
||||
render() {
|
||||
if (!this.props.buffering) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classnames(this.props.className, styles['buffering-loader-container'])}>
|
||||
<Loader className={styles['bufferring-loader']} fill={colors.surfacelighter80} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
const BufferingLoader = ({ className, logo }) => {
|
||||
return (
|
||||
<div className={classnames(className, styles['buffering-loader-container'])}>
|
||||
<Image
|
||||
className={styles['buffering-loader']}
|
||||
src={logo}
|
||||
alt={' '}
|
||||
fallbackSrc={'/images/stremio_symbol.png'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BufferingLoader.propTypes = {
|
||||
className: PropTypes.string,
|
||||
buffering: PropTypes.bool
|
||||
logo: PropTypes.string
|
||||
};
|
||||
|
||||
module.exports = BufferingLoader;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.bufferring-loader {
|
||||
width: calc(var(--control-bar-button-size) * 3);
|
||||
height: calc(var(--control-bar-button-size) * 3);
|
||||
.buffering-loader {
|
||||
flex: none;
|
||||
width: 17rem;
|
||||
height: 17rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,55 +9,56 @@ const SubtitlesButton = require('./SubtitlesButton');
|
|||
const ShareButton = require('./ShareButton');
|
||||
const styles = require('./styles');
|
||||
|
||||
const ControlBar = (props) => (
|
||||
<div className={classnames(props.className, styles['control-bar-container'])}>
|
||||
<SeekBar
|
||||
className={styles['seek-bar']}
|
||||
time={props.time}
|
||||
duration={props.duration}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
<div className={styles['control-bar-buttons-container']}>
|
||||
<PlayPauseButton
|
||||
className={styles['control-bar-button']}
|
||||
paused={props.paused}
|
||||
const ControlBar = (props) => {
|
||||
return (
|
||||
<div className={classnames(props.className, styles['control-bar-container'])}>
|
||||
<SeekBar
|
||||
className={styles['seek-bar']}
|
||||
time={props.time}
|
||||
duration={props.duration}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
<MuteButton
|
||||
className={styles['control-bar-button']}
|
||||
volume={props.volume}
|
||||
muted={props.muted}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
<VolumeSlider
|
||||
className={styles['volume-slider']}
|
||||
volume={props.volume}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
<div className={styles['spacing']} />
|
||||
<SubtitlesButton
|
||||
className={styles['control-bar-button']}
|
||||
modalContainerClassName={classnames(styles['modal-container'], props.modalContainerClassName)}
|
||||
subtitlesTracks={props.subtitlesTracks}
|
||||
selectedSubtitlesTrackId={props.selectedSubtitlesTrackId}
|
||||
subtitlesSize={props.subtitlesSize}
|
||||
subtitlesDelay={props.subtitlesDelay}
|
||||
subtitlesTextColor={props.subtitlesTextColor}
|
||||
subtitlesBackgroundColor={props.subtitlesBackgroundColor}
|
||||
subtitlesOutlineColor={props.subtitlesOutlineColor}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
<ShareButton
|
||||
className={styles['control-bar-button']}
|
||||
modalContainerClassName={classnames(styles['modal-container'], props.modalContainerClassName)}
|
||||
/>
|
||||
<div className={styles['control-bar-buttons-container']}>
|
||||
<PlayPauseButton
|
||||
className={styles['control-bar-button']}
|
||||
paused={props.paused}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
<MuteButton
|
||||
className={styles['control-bar-button']}
|
||||
volume={props.volume}
|
||||
muted={props.muted}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
<VolumeSlider
|
||||
className={styles['volume-slider']}
|
||||
volume={props.volume}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
<div className={styles['spacing']} />
|
||||
<SubtitlesButton
|
||||
className={styles['control-bar-button']}
|
||||
modalContainerClassName={styles['modal-container']}
|
||||
subtitlesTracks={props.subtitlesTracks}
|
||||
selectedSubtitlesTrackId={props.selectedSubtitlesTrackId}
|
||||
subtitlesSize={props.subtitlesSize}
|
||||
subtitlesDelay={props.subtitlesDelay}
|
||||
subtitlesTextColor={props.subtitlesTextColor}
|
||||
subtitlesBackgroundColor={props.subtitlesBackgroundColor}
|
||||
subtitlesOutlineColor={props.subtitlesOutlineColor}
|
||||
dispatch={props.dispatch}
|
||||
/>
|
||||
<ShareButton
|
||||
className={styles['control-bar-button']}
|
||||
modalContainerClassName={styles['modal-container']}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
ControlBar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
modalContainerClassName: PropTypes.string,
|
||||
paused: PropTypes.bool,
|
||||
time: PropTypes.number,
|
||||
duration: PropTypes.number,
|
||||
|
|
@ -67,7 +68,7 @@ ControlBar.propTypes = {
|
|||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
origin: PropTypes.string.isRequired
|
||||
})).isRequired,
|
||||
})),
|
||||
selectedSubtitlesTrackId: PropTypes.string,
|
||||
subtitlesSize: PropTypes.number,
|
||||
subtitlesDelay: PropTypes.number,
|
||||
|
|
@ -77,8 +78,4 @@ ControlBar.propTypes = {
|
|||
dispatch: PropTypes.func
|
||||
};
|
||||
|
||||
ControlBar.defaultProps = {
|
||||
subtitlesTracks: Object.freeze([])
|
||||
};
|
||||
|
||||
module.exports = ControlBar;
|
||||
|
|
|
|||
|
|
@ -2,37 +2,29 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Button } = require('stremio/common');
|
||||
|
||||
class MuteButton extends React.Component {
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextProps.className !== this.props.className ||
|
||||
nextProps.volume !== this.props.volume ||
|
||||
nextProps.muted !== this.props.muted;
|
||||
}
|
||||
|
||||
toggleMuted = () => {
|
||||
this.props.dispatch({ propName: 'muted', propValue: !this.props.muted });
|
||||
}
|
||||
|
||||
render() {
|
||||
const icon = this.props.muted ? 'ic_volume0' :
|
||||
(this.props.volume === null || isNaN(this.props.volume)) ? 'ic_volume3' :
|
||||
this.props.volume < 30 ? 'ic_volume1' :
|
||||
this.props.volume < 70 ? 'ic_volume2' :
|
||||
'ic_volume3';
|
||||
return (
|
||||
<div className={classnames(this.props.className, { 'disabled': this.props.muted === null })} onClick={this.toggleMuted}>
|
||||
<Icon className={'icon'} icon={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const MuteButton = ({ className, muted, volume, dispatch }) => {
|
||||
const toggleMuted = React.useCallback(() => {
|
||||
dispatch({ propName: 'muted', propValue: !muted });
|
||||
}, [muted, dispatch]);
|
||||
const icon = muted ? 'ic_volume0' :
|
||||
(volume === null || isNaN(volume)) ? 'ic_volume3' :
|
||||
volume < 30 ? 'ic_volume1' :
|
||||
volume < 70 ? 'ic_volume2' :
|
||||
'ic_volume3';
|
||||
return (
|
||||
<Button className={classnames(className, { 'disabled': typeof muted !== 'boolean' })} tabIndex={-1} onClick={toggleMuted}>
|
||||
<Icon className={'icon'} icon={icon} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
MuteButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
muted: PropTypes.bool,
|
||||
volume: PropTypes.number,
|
||||
dispatch: PropTypes.func.isRequired
|
||||
dispatch: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = MuteButton;
|
||||
|
|
|
|||
|
|
@ -2,31 +2,28 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Button } = require('stremio/common');
|
||||
|
||||
class PlayPauseButton extends React.Component {
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextProps.className !== this.props.className ||
|
||||
nextProps.paused !== this.props.paused;
|
||||
}
|
||||
|
||||
togglePaused = () => {
|
||||
this.props.dispatch({ propName: 'paused', propValue: !this.props.paused });
|
||||
}
|
||||
|
||||
render() {
|
||||
const icon = this.props.paused === null || this.props.paused ? 'ic_play' : 'ic_pause';
|
||||
return (
|
||||
<div className={classnames(this.props.className, { 'disabled': this.props.paused === null })} onClick={this.togglePaused}>
|
||||
<Icon className={'icon'} icon={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
const PlayPauseButton = ({ className, paused, dispatch }) => {
|
||||
const togglePaused = React.useCallback(() => {
|
||||
if (typeof dispatch === 'function') {
|
||||
dispatch({ propName: 'paused', propValue: !paused });
|
||||
}
|
||||
}, [paused, dispatch]);
|
||||
return (
|
||||
<Button className={classnames(className, { 'disabled': typeof paused !== 'boolean' })} tabIndex={-1} onClick={togglePaused}>
|
||||
<Icon
|
||||
className={'icon'}
|
||||
icon={typeof paused !== 'boolean' || paused ? 'ic_play' : 'ic_pause'}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
PlayPauseButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
paused: PropTypes.bool,
|
||||
dispatch: PropTypes.func.isRequired
|
||||
dispatch: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = PlayPauseButton;
|
||||
|
|
|
|||
|
|
@ -3,85 +3,51 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const debounce = require('lodash.debounce');
|
||||
const { Slider } = require('stremio/common');
|
||||
const formatTime = require('./formatTime');
|
||||
const styles = require('./styles');
|
||||
|
||||
class SeekBar extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
time: null
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextState.time !== this.state.time ||
|
||||
nextProps.className !== this.props.className ||
|
||||
nextProps.time !== this.props.time ||
|
||||
nextProps.duration !== this.props.duration;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.resetTimeDebounced.cancel();
|
||||
}
|
||||
|
||||
resetTimeDebounced = debounce(() => {
|
||||
this.setState({ time: null });
|
||||
}, 1500)
|
||||
|
||||
onSlide = (time) => {
|
||||
this.resetTimeDebounced.cancel();
|
||||
this.setState({ time });
|
||||
}
|
||||
|
||||
onComplete = (time) => {
|
||||
this.resetTimeDebounced();
|
||||
this.setState({ time });
|
||||
this.props.dispatch({ propName: 'time', propValue: time });
|
||||
}
|
||||
|
||||
onCancel = () => {
|
||||
this.resetTimeDebounced.cancel();
|
||||
this.setState({ time: null });
|
||||
}
|
||||
|
||||
formatTime = (time) => {
|
||||
if (time === null || isNaN(time)) {
|
||||
return '--:--:--';
|
||||
const SeekBar = ({ className, time, duration, dispatch }) => {
|
||||
const [seekTime, setSeekTime] = React.useState(null);
|
||||
const resetTimeDebounced = React.useCallback(debounce(() => {
|
||||
setSeekTime(null);
|
||||
}, 1500), []);
|
||||
const onSlide = React.useCallback((time) => {
|
||||
resetTimeDebounced.cancel();
|
||||
setSeekTime(time);
|
||||
}, []);
|
||||
const onComplete = React.useCallback((time) => {
|
||||
resetTimeDebounced();
|
||||
setSeekTime(time);
|
||||
if (typeof dispatch === 'function') {
|
||||
dispatch({ propName: 'time', propValue: time });
|
||||
}
|
||||
|
||||
const hours = ('0' + Math.floor((time / (1000 * 60 * 60)) % 24)).slice(-2);
|
||||
const minutes = ('0' + Math.floor((time / (1000 * 60)) % 60)).slice(-2);
|
||||
const seconds = ('0' + Math.floor((time / 1000) % 60)).slice(-2);
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const time = this.state.time !== null ? this.state.time : this.props.time;
|
||||
return (
|
||||
<div className={classnames(this.props.className, styles['seek-bar-container'], { 'active': this.state.time !== null })}>
|
||||
<div className={styles['label']}>{this.formatTime(time)}</div>
|
||||
<Slider
|
||||
className={styles['slider']}
|
||||
value={time}
|
||||
minimumValue={0}
|
||||
maximumValue={this.props.duration}
|
||||
orientation={'horizontal'}
|
||||
onSlide={this.onSlide}
|
||||
onComplete={this.onComplete}
|
||||
onCancel={this.onCancel}
|
||||
/>
|
||||
<div className={styles['label']}>{this.formatTime(this.props.duration)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
resetTimeDebounced.cancel();
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div className={classnames(className, styles['seek-bar-container'], { 'active': seekTime !== null })}>
|
||||
<div className={styles['label']}>{formatTime(seekTime !== null ? seekTime : time)}</div>
|
||||
<Slider
|
||||
className={classnames(styles['slider'], { 'disabled': time === null || isNaN(time) })}
|
||||
value={seekTime !== null ? seekTime : time}
|
||||
minimumValue={0}
|
||||
maximumValue={duration}
|
||||
onSlide={onSlide}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
<div className={styles['label']}>{formatTime(duration)}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SeekBar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
time: PropTypes.number,
|
||||
duration: PropTypes.number,
|
||||
dispatch: PropTypes.func.isRequired
|
||||
dispatch: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = SeekBar;
|
||||
|
|
|
|||
16
src/routes/Player/ControlBar/SeekBar/formatTime.js
Normal file
16
src/routes/Player/ControlBar/SeekBar/formatTime.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
const formatUnit = (value) => {
|
||||
return ('0' + value).slice(-1 * Math.max(value.toString().length, 2));
|
||||
};
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (time === null || isNaN(time)) {
|
||||
return '--:--:--';
|
||||
}
|
||||
|
||||
const hours = Math.floor(time / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((time / (1000 * 60)) % 60);
|
||||
const seconds = Math.floor((time / 1000) % 60);
|
||||
return `${formatUnit(hours)}:${formatUnit(minutes)}:${formatUnit(seconds)}`;
|
||||
};
|
||||
|
||||
module.exports = formatTime;
|
||||
|
|
@ -1,35 +1,52 @@
|
|||
:import('~stremio/common/Slider/styles.less') {
|
||||
slider-track: track;
|
||||
slider-track-before: track-before;
|
||||
slider-thumb: thumb;
|
||||
}
|
||||
|
||||
.seek-bar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
&:hover, &:global(.active) {
|
||||
.slider:not(:global(.disabled)) {
|
||||
.slider-track-before {
|
||||
background-color: var(--color-primarylight);
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.slider:not(:global(.disabled)) {
|
||||
.slider-thumb {
|
||||
transition-delay: 100ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-surfacelight);
|
||||
flex: none;
|
||||
max-width: 5rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.slider {
|
||||
--thumb-size: var(--seek-bar-thumb-size);
|
||||
--track-size: var(--seek-bar-track-size);
|
||||
--track-before-color: var(--color-primary);
|
||||
--track-color: var(--color-backgroundlighter);
|
||||
--thumb-color: transparent;
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
margin: 0 var(--seek-bar-thumb-size);
|
||||
margin: 0 var(--thumb-size);
|
||||
|
||||
&:global(.disabled) {
|
||||
--track-color: var(--color-surfacedark);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover, &:global(.active) {
|
||||
.label {
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.slider {
|
||||
--track-before-color: var(--color-primarylight);
|
||||
--thumb-color: var(--color-surfacelighter);
|
||||
.slider-thumb {
|
||||
transition: 0s fill;
|
||||
fill: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,47 +2,28 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Popup } = require('stremio/common');
|
||||
const { Button, Popup, useBinaryState } = require('stremio/common');
|
||||
const styles = require('./styles');
|
||||
|
||||
class ShareButton extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
popupOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextState.popupOpen !== this.state.popupOpen ||
|
||||
nextProps.className !== this.props.className ||
|
||||
nextProps.modalContainerClassName !== this.props.modalContainerClassName;
|
||||
}
|
||||
|
||||
onPopupOpen = () => {
|
||||
this.setState({ popupOpen: true });
|
||||
}
|
||||
|
||||
onPopupClose = () => {
|
||||
this.setState({ popupOpen: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Popup onOpen={this.onPopupOpen} onClose={this.onPopupClose}>
|
||||
<Popup.Label>
|
||||
<div className={classnames(this.props.className, { 'active': this.state.popupOpen })}>
|
||||
<Icon className={'icon'} icon={'ic_share'} />
|
||||
</div>
|
||||
</Popup.Label>
|
||||
<Popup.Menu className={this.props.modalContainerClassName}>
|
||||
<div className={styles['share-dialog-container']} />
|
||||
</Popup.Menu>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
}
|
||||
const ShareButton = ({ className, modalContainerClassName }) => {
|
||||
const [popupOpen, openPopup, closePopup, togglePopup] = useBinaryState(false);
|
||||
return (
|
||||
<Popup
|
||||
open={popupOpen}
|
||||
menuModalClassName={classnames(modalContainerClassName, styles['share-modal-container'])}
|
||||
menuRelativePosition={false}
|
||||
renderLabel={(ref) => (
|
||||
<Button ref={ref} className={classnames(className, { 'active': popupOpen })} tabIndex={-1} onClick={togglePopup}>
|
||||
<Icon className={'icon'} icon={'ic_share'} />
|
||||
</Button>
|
||||
)}
|
||||
renderMenu={() => (
|
||||
<div className={styles['share-dialog-container']} />
|
||||
)}
|
||||
onCloseRequest={closePopup}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
ShareButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
.share-dialog-container {
|
||||
width: calc(var(--control-bar-button-size) * 5);
|
||||
height: calc(var(--control-bar-button-size) * 3);
|
||||
background-color: var(--color-backgrounddark);
|
||||
.share-modal-container {
|
||||
.share-dialog-container {
|
||||
width: 10rem;
|
||||
height: 5rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,65 +2,32 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Popup } = require('stremio/common');
|
||||
const { Button, Popup, useBinaryState } = require('stremio/common');
|
||||
const SubtitlesPicker = require('./SubtitlesPicker');
|
||||
const styles = require('./styles');
|
||||
|
||||
class SubtitlesButton extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
popupOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextState.popupOpen !== this.state.popupOpen ||
|
||||
nextProps.className !== this.props.className ||
|
||||
nextProps.modalContainerClassName !== this.props.modalContainerClassName ||
|
||||
nextProps.subtitlesTracks !== this.props.subtitlesTracks ||
|
||||
nextProps.selectedSubtitlesTrackId !== this.props.selectedSubtitlesTrackId ||
|
||||
nextProps.subtitlesSize !== this.props.subtitlesSize ||
|
||||
nextProps.subtitlesDelay !== this.props.subtitlesDelay ||
|
||||
nextProps.subtitlesTextColor !== this.props.subtitlesTextColor ||
|
||||
nextProps.subtitlesBackgroundColor !== this.props.subtitlesBackgroundColor ||
|
||||
nextProps.subtitlesOutlineColor !== this.props.subtitlesOutlineColor;
|
||||
}
|
||||
|
||||
onPopupOpen = () => {
|
||||
this.setState({ popupOpen: true });
|
||||
}
|
||||
|
||||
onPopupClose = () => {
|
||||
this.setState({ popupOpen: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Popup onOpen={this.onPopupOpen} onClose={this.onPopupClose}>
|
||||
<Popup.Label>
|
||||
<div className={classnames(this.props.className, { 'active': this.state.popupOpen }, { 'disabled': this.props.subtitlesTracks.length === 0 })}>
|
||||
<Icon className={'icon'} icon={'ic_sub'} />
|
||||
</div>
|
||||
</Popup.Label>
|
||||
<Popup.Menu className={this.props.modalContainerClassName}>
|
||||
<SubtitlesPicker
|
||||
className={styles['subtitles-picker-container']}
|
||||
subtitlesTracks={this.props.subtitlesTracks}
|
||||
selectedSubtitlesTrackId={this.props.selectedSubtitlesTrackId}
|
||||
subtitlesSize={this.props.subtitlesSize}
|
||||
subtitlesDelay={this.props.subtitlesDelay}
|
||||
subtitlesTextColor={this.props.subtitlesTextColor}
|
||||
subtitlesBackgroundColor={this.props.subtitlesBackgroundColor}
|
||||
subtitlesOutlineColor={this.props.subtitlesOutlineColor}
|
||||
dispatch={this.props.dispatch}
|
||||
/>
|
||||
</Popup.Menu>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
}
|
||||
const SubtitlesButton = (props) => {
|
||||
const [popupOpen, openPopup, closePopup, togglePopup] = useBinaryState(false);
|
||||
return (
|
||||
<Popup
|
||||
open={popupOpen}
|
||||
menuModalClassName={classnames(props.modalContainerClassName, styles['subtitles-modal-container'])}
|
||||
menuRelativePosition={false}
|
||||
renderLabel={(ref) => (
|
||||
<Button ref={ref} className={classnames(props.className, { 'active': popupOpen }, { 'disabled': !Array.isArray(props.subtitlesTracks) || props.subtitlesTracks.length === 0 })} tabIndex={-1} onClick={togglePopup}>
|
||||
<Icon className={'icon'} icon={'ic_sub'} />
|
||||
</Button>
|
||||
)}
|
||||
renderMenu={() => (
|
||||
<SubtitlesPicker
|
||||
{...props}
|
||||
className={styles['subtitles-picker-container']}
|
||||
/>
|
||||
)}
|
||||
onCloseRequest={closePopup}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
SubtitlesButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
|
|
@ -69,7 +36,7 @@ SubtitlesButton.propTypes = {
|
|||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
origin: PropTypes.string.isRequired
|
||||
})).isRequired,
|
||||
})),
|
||||
selectedSubtitlesTrackId: PropTypes.string,
|
||||
subtitlesSize: PropTypes.number,
|
||||
subtitlesDelay: PropTypes.number,
|
||||
|
|
@ -78,8 +45,5 @@ SubtitlesButton.propTypes = {
|
|||
subtitlesOutlineColor: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired
|
||||
};
|
||||
SubtitlesButton.defaultProps = {
|
||||
subtitlesTracks: Object.freeze([])
|
||||
};
|
||||
|
||||
module.exports = SubtitlesButton;
|
||||
|
|
|
|||
|
|
@ -3,13 +3,16 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const Icon = require('stremio-icons/dom');
|
||||
const { Modal } = require('stremio-router');
|
||||
const { ColorPicker } = require('stremio/common');
|
||||
const { ColorInput } = require('stremio/common');
|
||||
const styles = require('./styles');
|
||||
|
||||
const ORIGIN_PRIORITIES = Object.freeze({
|
||||
'LOCAL': 1,
|
||||
'EMBEDDED': 2
|
||||
});
|
||||
const LANGUAGE_PRIORITIES = Object.freeze({
|
||||
'English': 1
|
||||
});
|
||||
const SUBTITLES_SIZE_LABELS = Object.freeze({
|
||||
1: '75%',
|
||||
2: '100%',
|
||||
|
|
@ -27,13 +30,9 @@ const comparatorWithPriorities = (priorities) => {
|
|||
if (!isNaN(valueB)) return 1;
|
||||
return a - b;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const NumberInput = ({ value, label, delta, onChange }) => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['number-input-container']}>
|
||||
<div className={styles['number-input-button']} data-value={value - delta} onClick={onChange}>
|
||||
|
|
@ -47,100 +46,55 @@ const NumberInput = ({ value, label, delta, onChange }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const SubtitlesColorPicker = ({ label, value, onChange }) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const onOpen = () => setOpen(true);
|
||||
const onClose = () => setOpen(false);
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className={styles['color-picker-button-container']}>
|
||||
<div style={{ backgroundColor: value }} className={styles['color-picker-indicator']} onClick={onOpen} />
|
||||
<div className={styles['color-picker-label']}>{label}</div>
|
||||
</div>
|
||||
{
|
||||
open ?
|
||||
<Modal>
|
||||
<div className={styles['color-picker-modal-container']} onClick={onClose}>
|
||||
<ColorPicker className={styles['color-picker-container']} value={value} onChange={onChange} />
|
||||
</div>
|
||||
</Modal>
|
||||
:
|
||||
null
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
class SubtitlesPicker extends React.Component {
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextProps.className !== this.props.className ||
|
||||
nextProps.subtitlesTracks !== this.props.subtitlesTracks ||
|
||||
nextProps.selectedSubtitlesTrackId !== this.props.selectedSubtitlesTrackId ||
|
||||
nextProps.subtitlesSize !== this.props.subtitlesSize ||
|
||||
nextProps.subtitlesDelay !== this.props.subtitlesDelay ||
|
||||
nextProps.subtitlesTextColor !== this.props.subtitlesTextColor ||
|
||||
nextProps.subtitlesBackgroundColor !== this.props.subtitlesBackgroundColor ||
|
||||
nextProps.subtitlesOutlineColor !== this.props.subtitlesOutlineColor;
|
||||
}
|
||||
|
||||
toggleSubtitleEnabled = () => {
|
||||
const selectedSubtitlesTrackId = this.props.selectedSubtitlesTrackId === null && this.props.subtitlesTracks.length > 0 ?
|
||||
this.props.subtitlesTracks[0].id
|
||||
const SubtitlesPicker = (props) => {
|
||||
const toggleSubtitleEnabled = React.useCallback(() => {
|
||||
const selectedSubtitlesTrackId = props.selectedSubtitlesTrackId === null && props.subtitlesTracks.length > 0 ?
|
||||
props.subtitlesTracks[0].id
|
||||
:
|
||||
null;
|
||||
this.props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: selectedSubtitlesTrackId });
|
||||
}
|
||||
|
||||
labelOnClick = (event) => {
|
||||
const subtitleTrack = this.props.subtitlesTracks.find(({ label, origin }) => {
|
||||
props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: selectedSubtitlesTrackId });
|
||||
}, [props.selectedSubtitlesTrackId, props.subtitlesTracks, props.dispatch]);
|
||||
const labelOnClick = React.useCallback((event) => {
|
||||
const subtitleTrack = props.subtitlesTracks.find(({ label, origin }) => {
|
||||
return label === event.currentTarget.dataset.label &&
|
||||
origin === event.currentTarget.dataset.origin;
|
||||
});
|
||||
if (subtitleTrack) {
|
||||
this.props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: subtitleTrack.id });
|
||||
props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: subtitleTrack.id });
|
||||
}
|
||||
}
|
||||
|
||||
variantOnClick = (event) => {
|
||||
this.props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: event.currentTarget.dataset.trackId });
|
||||
}
|
||||
|
||||
setsubtitlesSize = (event) => {
|
||||
this.props.dispatch({ propName: 'subtitlesSize', propValue: event.currentTarget.dataset.value });
|
||||
}
|
||||
|
||||
setSubtitlesDelay = (event) => {
|
||||
this.props.dispatch({ propName: 'subtitlesDelay', propValue: event.currentTarget.dataset.value });
|
||||
}
|
||||
|
||||
setSubtitlesTextColor = (color) => {
|
||||
this.props.dispatch({ propName: 'subtitlesTextColor', propValue: color });
|
||||
}
|
||||
|
||||
setSubtitlesBackgroundColor = (color) => {
|
||||
this.props.dispatch({ propName: 'subtitlesBackgroundColor', propValue: color });
|
||||
}
|
||||
|
||||
setSubtitlesOutlineColor = (color) => {
|
||||
this.props.dispatch({ propName: 'subtitlesOutlineColor', propValue: color });
|
||||
}
|
||||
|
||||
renderToggleButton({ selectedTrack }) {
|
||||
return (
|
||||
<div className={styles['toggle-button-container']} onClick={this.toggleSubtitleEnabled}>
|
||||
}, [props.subtitlesTracks, props.dispatch]);
|
||||
const variantOnClick = React.useCallback((event) => {
|
||||
props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: event.currentTarget.dataset.trackId });
|
||||
}, [props.dispatch]);
|
||||
const setsubtitlesSize = React.useCallback((event) => {
|
||||
props.dispatch({ propName: 'subtitlesSize', propValue: event.currentTarget.dataset.value });
|
||||
}, [props.dispatch]);
|
||||
const setSubtitlesDelay = React.useCallback((event) => {
|
||||
props.dispatch({ propName: 'subtitlesDelay', propValue: event.currentTarget.dataset.value });
|
||||
}, [props.dispatch]);
|
||||
const setSubtitlesTextColor = React.useCallback((event) => {
|
||||
props.dispatch({ propName: 'subtitlesTextColor', propValue: event.nativeEvent.value });
|
||||
}, [props.dispatch]);
|
||||
const setSubtitlesBackgroundColor = React.useCallback((color) => {
|
||||
props.dispatch({ propName: 'subtitlesBackgroundColor', propValue: color });
|
||||
}, [props.dispatch]);
|
||||
const setSubtitlesOutlineColor = React.useCallback((color) => {
|
||||
props.dispatch({ propName: 'subtitlesOutlineColor', propValue: color });
|
||||
}, [props.dispatch]);
|
||||
const selectedTrack = props.subtitlesTracks.find(({ id }) => id === props.selectedSubtitlesTrackId);
|
||||
const groupedTracks = props.subtitlesTracks.reduce((result, track) => {
|
||||
result[track.origin] = result[track.origin] || {};
|
||||
result[track.origin][track.label] = result[track.origin][track.label] || [];
|
||||
result[track.origin][track.label].push(track);
|
||||
return result;
|
||||
}, {});
|
||||
return (
|
||||
<div className={classnames(props.className, styles['subtitles-picker-container'])}>
|
||||
<div className={styles['toggle-button-container']} onClick={toggleSubtitleEnabled}>
|
||||
<div className={styles['toggle-label']}>ON</div>
|
||||
<div className={styles['toggle-label']}>OFF</div>
|
||||
<div className={classnames(styles['toggle-thumb'], { [styles['on']]: !!selectedTrack })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderLabelsList({ groupedTracks, selectedTrack }) {
|
||||
return (
|
||||
<div className={styles['labels-list-container']}>
|
||||
{
|
||||
Object.keys(groupedTracks)
|
||||
|
|
@ -150,13 +104,13 @@ class SubtitlesPicker extends React.Component {
|
|||
<div className={styles['track-origin']}>{origin}</div>
|
||||
{
|
||||
Object.keys(groupedTracks[origin])
|
||||
.sort(comparatorWithPriorities(this.props.languagePriorities))
|
||||
.sort(comparatorWithPriorities(LANGUAGE_PRIORITIES))
|
||||
.map((label) => {
|
||||
const selected = selectedTrack && selectedTrack.label === label && selectedTrack.origin === origin;
|
||||
return (
|
||||
<div key={label}
|
||||
className={classnames(styles['language-label'], { [styles['selected']]: selected })}
|
||||
onClick={this.labelOnClick}
|
||||
onClick={labelOnClick}
|
||||
data-label={label}
|
||||
data-origin={origin}
|
||||
children={label}
|
||||
|
|
@ -168,115 +122,83 @@ class SubtitlesPicker extends React.Component {
|
|||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderVariantsList({ groupedTracks, selectedTrack }) {
|
||||
if (groupedTracks[selectedTrack.origin][selectedTrack.label].length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['variants-container']}>
|
||||
{
|
||||
groupedTracks[selectedTrack.origin][selectedTrack.label].map((track, index) => (
|
||||
<div key={track.id}
|
||||
className={classnames(styles['variant-button'], { [styles['selected']]: track.id === selectedTrack.id })}
|
||||
title={track.id}
|
||||
onClick={this.variantOnClick}
|
||||
data-track-id={track.id}
|
||||
children={index + 1}
|
||||
{
|
||||
!selectedTrack ?
|
||||
<div className={styles['preferences-container']}>
|
||||
<div className={styles['subtitles-disabled-label']}>Subtitles are disabled</div>
|
||||
</div>
|
||||
:
|
||||
<div className={styles['preferences-container']}>
|
||||
<div className={styles['preferences-title']}>Preferences</div>
|
||||
{
|
||||
groupedTracks[selectedTrack.origin][selectedTrack.label].length > 1 ?
|
||||
<div className={styles['variants-container']}>
|
||||
{
|
||||
groupedTracks[selectedTrack.origin][selectedTrack.label].map((track, index) => (
|
||||
<div key={track.id}
|
||||
className={classnames(styles['variant-button'], { [styles['selected']]: track.id === selectedTrack.id })}
|
||||
title={track.id}
|
||||
onClick={variantOnClick}
|
||||
data-track-id={track.id}
|
||||
children={index + 1}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className={styles['color-picker-button-container']}>
|
||||
<ColorInput
|
||||
className={styles['color-picker-indicator']}
|
||||
value={props.subtitlesTextColor}
|
||||
onChange={setSubtitlesTextColor}
|
||||
/>
|
||||
<div className={styles['color-picker-label']}>Text color</div>
|
||||
</div>
|
||||
{/* <SubtitlesColorPicker
|
||||
label={'Background color'}
|
||||
value={props.subtitlesBackgroundColor}
|
||||
onChange={setSubtitlesBackgroundColor}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderPreferences({ groupedTracks, selectedTrack }) {
|
||||
if (!selectedTrack) {
|
||||
return (
|
||||
<div className={styles['preferences-container']}>
|
||||
<div className={styles['subtitles-disabled-label']}>Subtitles are disabled</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['preferences-container']}>
|
||||
<div className={styles['preferences-title']}>Preferences</div>
|
||||
{this.renderVariantsList({ groupedTracks, selectedTrack })}
|
||||
<SubtitlesColorPicker
|
||||
label={'Text color'}
|
||||
value={this.props.subtitlesTextColor}
|
||||
onChange={this.setSubtitlesTextColor}
|
||||
/>
|
||||
<SubtitlesColorPicker
|
||||
label={'Background color'}
|
||||
value={this.props.subtitlesBackgroundColor}
|
||||
onChange={this.setSubtitlesBackgroundColor}
|
||||
/>
|
||||
<SubtitlesColorPicker
|
||||
label={'Outline color'}
|
||||
value={this.props.subtitlesOutlineColor}
|
||||
onChange={this.setSubtitlesOutlineColor}
|
||||
/>
|
||||
<NumberInput
|
||||
label={SUBTITLES_SIZE_LABELS[this.props.subtitlesSize]}
|
||||
value={this.props.subtitlesSize}
|
||||
delta={1}
|
||||
onChange={this.setsubtitlesSize}
|
||||
/>
|
||||
<NumberInput
|
||||
label={`${(this.props.subtitlesDelay / 1000).toFixed(2)}s`}
|
||||
value={this.props.subtitlesDelay}
|
||||
delta={100}
|
||||
onChange={this.setSubtitlesDelay}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const selectedTrack = this.props.subtitlesTracks.find(({ id }) => id === this.props.selectedSubtitlesTrackId);
|
||||
const groupedTracks = this.props.subtitlesTracks.reduce((result, track) => {
|
||||
result[track.origin] = result[track.origin] || {};
|
||||
result[track.origin][track.label] = result[track.origin][track.label] || [];
|
||||
result[track.origin][track.label].push(track);
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className={classnames(this.props.className, styles['subtitles-picker-container'])}>
|
||||
{this.renderToggleButton({ selectedTrack })}
|
||||
{this.renderLabelsList({ groupedTracks, selectedTrack })}
|
||||
{this.renderPreferences({ groupedTracks, selectedTrack })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
<SubtitlesColorPicker
|
||||
label={'Outline color'}
|
||||
value={props.subtitlesOutlineColor}
|
||||
onChange={setSubtitlesOutlineColor}
|
||||
/> */}
|
||||
<NumberInput
|
||||
label={SUBTITLES_SIZE_LABELS[props.subtitlesSize]}
|
||||
value={props.subtitlesSize}
|
||||
delta={1}
|
||||
onChange={setsubtitlesSize}
|
||||
/>
|
||||
<NumberInput
|
||||
label={`${(props.subtitlesDelay / 1000).toFixed(2)}s`}
|
||||
value={props.subtitlesDelay}
|
||||
delta={100}
|
||||
onChange={setSubtitlesDelay}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SubtitlesPicker.propTypes = {
|
||||
className: PropTypes.string,
|
||||
languagePriorities: PropTypes.objectOf(PropTypes.number).isRequired,
|
||||
languagePriorities: PropTypes.objectOf(PropTypes.number),
|
||||
subtitlesTracks: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
origin: PropTypes.string.isRequired
|
||||
})).isRequired,
|
||||
})),
|
||||
selectedSubtitlesTrackId: PropTypes.string,
|
||||
subtitlesSize: PropTypes.number,
|
||||
subtitlesDelay: PropTypes.number,
|
||||
subtitlesTextColor: PropTypes.string,
|
||||
subtitlesBackgroundColor: PropTypes.string,
|
||||
subtitlesOutlineColor: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired
|
||||
};
|
||||
SubtitlesPicker.defaultProps = {
|
||||
subtitlesTracks: Object.freeze([]),
|
||||
languagePriorities: Object.freeze({
|
||||
English: 1
|
||||
})
|
||||
dispatch: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = SubtitlesPicker;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
.subtitles-picker-container {
|
||||
width: calc(var(--subtitles-picker-button-size) * 14);
|
||||
height: calc(var(--subtitles-picker-button-size) * 9);
|
||||
--subtitles-picker-button-size: 2.5rem;
|
||||
font-size: calc(var(--subtitles-picker-button-size) * 0.45);
|
||||
padding: calc(var(--subtitles-picker-button-size) * 0.3);
|
||||
gap: calc(var(--subtitles-picker-button-size) * 0.3);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
.subtitles-picker-container {
|
||||
--subtitles-picker-button-size: calc(var(--control-bar-button-size) * 0.6);
|
||||
background-color: var(--color-backgrounddark);
|
||||
.subtitles-modal-container {
|
||||
.subtitles-picker-container {
|
||||
width: 35rem;
|
||||
height: 25rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,68 +5,43 @@ const debounce = require('lodash.debounce');
|
|||
const { Slider } = require('stremio/common');
|
||||
const styles = require('./styles');
|
||||
|
||||
class VolumeSlider extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
volume: null
|
||||
const VolumeSlider = ({ className, volume, dispatch }) => {
|
||||
const [slidingVolume, setSlidingVolume] = React.useState(null);
|
||||
const resetVolumeDebounced = React.useCallback(debounce(() => {
|
||||
setSlidingVolume(null);
|
||||
}, 100), []);
|
||||
const onSlide = React.useCallback((volume) => {
|
||||
resetVolumeDebounced.cancel();
|
||||
setSlidingVolume(volume);
|
||||
}, []);
|
||||
const onComplete = React.useCallback((volume) => {
|
||||
resetVolumeDebounced();
|
||||
setSlidingVolume(volume);
|
||||
if (typeof dispatch === 'function') {
|
||||
dispatch({ propName: 'volume', propValue: volume });
|
||||
}
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
resetVolumeDebounced.cancel();
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextState.volume !== this.state.volume ||
|
||||
nextProps.className !== this.props.className ||
|
||||
nextProps.volume !== this.props.volume;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.resetVolumeDebounced.cancel();
|
||||
}
|
||||
|
||||
resetVolumeDebounced = debounce(() => {
|
||||
this.setState({ volume: null });
|
||||
}, 100)
|
||||
|
||||
onSlide = (volume) => {
|
||||
this.resetVolumeDebounced.cancel();
|
||||
this.setState({ volume });
|
||||
}
|
||||
|
||||
onComplete = (volume) => {
|
||||
this.resetVolumeDebounced();
|
||||
this.setState({ volume });
|
||||
this.props.dispatch({ propName: 'volume', propValue: volume });
|
||||
}
|
||||
|
||||
onCancel = () => {
|
||||
this.resetVolumeDebounced.cancel();
|
||||
this.setState({ volume: null });
|
||||
}
|
||||
|
||||
render() {
|
||||
const volume = this.state.volume !== null ? this.state.volume : this.props.volume;
|
||||
return (
|
||||
<div className={classnames(this.props.className, styles['volume-slider-container'], { 'active': this.state.volume !== null })}>
|
||||
<Slider
|
||||
className={styles['volume-slider']}
|
||||
value={volume}
|
||||
minimumValue={0}
|
||||
maximumValue={100}
|
||||
orientation={'horizontal'}
|
||||
onSlide={this.onSlide}
|
||||
onComplete={this.onComplete}
|
||||
onCancel={this.onCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<Slider
|
||||
className={classnames(className, styles['volume-slider'], { 'active': slidingVolume !== null }, { 'disabled': volume === null || isNaN(volume) })}
|
||||
value={slidingVolume !== null ? slidingVolume : volume !== null ? volume : 100}
|
||||
minimumValue={0}
|
||||
maximumValue={100}
|
||||
onSlide={onSlide}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
VolumeSlider.propTypes = {
|
||||
className: PropTypes.string,
|
||||
volume: PropTypes.number,
|
||||
dispatch: PropTypes.func.isRequired
|
||||
dispatch: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = VolumeSlider;
|
||||
|
|
|
|||
|
|
@ -1,24 +1,11 @@
|
|||
.volume-slider-container {
|
||||
padding: 0 calc(var(--volume-slider-thumb-size) / 2);
|
||||
|
||||
.volume-slider {
|
||||
--thumb-size: var(--volume-slider-thumb-size);
|
||||
--track-size: var(--volume-slider-track-size);
|
||||
--track-before-color: var(--color-primary);
|
||||
--track-color: var(--color-backgroundlighter);
|
||||
--thumb-color: transparent;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&:global(.disabled) {
|
||||
--track-color: var(--color-surfacedark);
|
||||
}
|
||||
}
|
||||
:import('~stremio/common/Slider/styles.less') {
|
||||
slider-track-before: track-before;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
&:hover, &:global(.active) {
|
||||
.volume-slider {
|
||||
--track-before-color: var(--color-primarylight);
|
||||
--thumb-color: var(--color-surfacelighter);
|
||||
.slider-track-before {
|
||||
background-color: var(--color-primarylight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +1,16 @@
|
|||
:import('./ShareButton/styles.less') {
|
||||
share-dialog-container: share-dialog-container;
|
||||
}
|
||||
|
||||
:import('./SubtitlesButton/styles.less') {
|
||||
subtitles-picker-container: subtitles-picker-container;
|
||||
}
|
||||
|
||||
.control-bar-container {
|
||||
padding: 0 calc(var(--control-bar-button-size) * 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.seek-bar {
|
||||
--seek-bar-thumb-size: calc(var(--control-bar-button-size) * 0.40);
|
||||
--seek-bar-track-size: calc(var(--control-bar-button-size) * 0.12);
|
||||
height: calc(var(--control-bar-button-size) * 0.6);
|
||||
font-size: calc(var(--control-bar-button-size) * 0.35);
|
||||
}
|
||||
|
||||
.control-bar-buttons-container {
|
||||
height: var(--control-bar-button-size);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.control-bar-button {
|
||||
width: var(--control-bar-button-size);
|
||||
height: var(--control-bar-button-size);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
:global(.icon) {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
fill: var(--color-surfacelight);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
&:global(.active) {
|
||||
background-color: var(--color-backgrounddarker);
|
||||
|
||||
:global(.icon) {
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.disabled) {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
|
||||
:global(.icon) {
|
||||
fill: var(--color-surfacedark);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(:global(.disabled)) {
|
||||
:global(.icon) {
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
--volume-slider-thumb-size: calc(var(--control-bar-button-size) * 0.36);
|
||||
--volume-slider-track-size: calc(var(--control-bar-button-size) * 0.10);
|
||||
width: calc(var(--control-bar-button-size) * 4);
|
||||
height: var(--control-bar-button-size);
|
||||
}
|
||||
|
||||
.spacing {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
padding: 0 1.5rem;
|
||||
overflow: visible;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
|
|
@ -74,11 +18,69 @@
|
|||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
box-shadow: 0 0 calc(var(--control-bar-button-size) * 2) calc(var(--control-bar-button-size) * 2.3) var(--color-backgrounddarker);
|
||||
box-shadow: 0 0 8rem 8rem var(--color-backgrounddarker);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.seek-bar {
|
||||
--thumb-size: 1.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.control-bar-buttons-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.control-bar-button {
|
||||
flex: none;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&:global(.active) {
|
||||
background-color: var(--color-backgrounddarker);
|
||||
}
|
||||
|
||||
&:global(.disabled) {
|
||||
:global(.icon) {
|
||||
fill: var(--color-surfacedark);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.icon) {
|
||||
flex: none;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
--thumb-size: 1.25rem;
|
||||
flex: none;
|
||||
width: 16rem;
|
||||
height: 4rem;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.spacing {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
--border-color: var(--color-surfacelighter);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
padding: 8rem 1rem;
|
||||
|
||||
.share-dialog-container, .subtitles-picker-container {
|
||||
flex: none;
|
||||
background-color: var(--color-backgroundlighter);
|
||||
border: thin solid var(--color-surfacelighter20);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +1,64 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useSpreadState } = require('stremio/common');
|
||||
const Video = require('./Video');
|
||||
const BufferingLoader = require('./BufferingLoader');
|
||||
const ControlBar = require('./ControlBar');
|
||||
const styles = require('./styles');
|
||||
|
||||
class Player extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.videoRef = React.createRef();
|
||||
|
||||
this.state = {
|
||||
paused: null,
|
||||
time: null,
|
||||
duration: null,
|
||||
buffering: null,
|
||||
volume: null,
|
||||
muted: null,
|
||||
subtitlesTracks: [],
|
||||
selectedSubtitlesTrackId: null,
|
||||
subtitlesSize: null,
|
||||
subtitlesDelay: null,
|
||||
subtitlesTextColor: null,
|
||||
subtitlesBackgroundColor: null,
|
||||
subtitlesOutlineColor: null
|
||||
const Player = ({ urlParams }) => {
|
||||
const videoRef = React.useRef(null);
|
||||
const stream = React.useMemo(() => {
|
||||
return {
|
||||
// ytId: 'E4A0bcCQke0',
|
||||
url: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextState.paused !== this.state.paused ||
|
||||
nextState.time !== this.state.time ||
|
||||
nextState.duration !== this.state.duration ||
|
||||
nextState.buffering !== this.state.buffering ||
|
||||
nextState.volume !== this.state.volume ||
|
||||
nextState.muted !== this.state.muted ||
|
||||
nextState.subtitlesTracks !== this.state.subtitlesTracks ||
|
||||
nextState.selectedSubtitlesTrackId !== this.state.selectedSubtitlesTrackId ||
|
||||
nextState.subtitlesSize !== this.state.subtitlesSize ||
|
||||
nextState.subtitlesDelay !== this.state.subtitlesDelay ||
|
||||
nextState.subtitlesTextColor !== this.state.subtitlesTextColor ||
|
||||
nextState.subtitlesBackgroundColor !== this.state.subtitlesBackgroundColor ||
|
||||
nextState.subtitlesOutlineColor !== this.state.subtitlesOutlineColor;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.dispatch({
|
||||
commandName: 'load',
|
||||
commandArgs: {
|
||||
stream: this.props.stream,
|
||||
ipc: window.shell
|
||||
}
|
||||
}, [urlParams.stream]);
|
||||
const [state, setState] = useSpreadState({
|
||||
paused: null,
|
||||
time: null,
|
||||
duration: null,
|
||||
buffering: null,
|
||||
volume: null,
|
||||
muted: null,
|
||||
subtitlesTracks: [],
|
||||
selectedSubtitlesTrackId: null,
|
||||
subtitlesSize: null,
|
||||
subtitlesDelay: null,
|
||||
subtitlesTextColor: null,
|
||||
subtitlesBackgroundColor: null,
|
||||
subtitlesOutlineColor: null
|
||||
});
|
||||
const dispatch = React.useCallback((args) => {
|
||||
if (videoRef.current !== null) {
|
||||
videoRef.current.dispatch(args);
|
||||
}
|
||||
}, []);
|
||||
const onImplementationChanged = React.useCallback((manifest) => {
|
||||
manifest.props.forEach((propName) => {
|
||||
dispatch({ observedPropName: propName });
|
||||
});
|
||||
this.dispatch({
|
||||
}, []);
|
||||
const onEnded = React.useCallback(() => {
|
||||
alert('ended');
|
||||
}, []);
|
||||
const onError = React.useCallback((error) => {
|
||||
alert(error.message);
|
||||
console.error(error);
|
||||
}, []);
|
||||
const onPropChanged = React.useCallback((propName, propValue) => {
|
||||
setState({ [propName]: propValue });
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
dispatch({
|
||||
commandName: 'load',
|
||||
commandArgs: { stream }
|
||||
});
|
||||
dispatch({
|
||||
propName: 'subtitlesOffset',
|
||||
propValue: 18
|
||||
});
|
||||
this.dispatch({
|
||||
dispatch({
|
||||
commandName: 'addSubtitlesTracks',
|
||||
commandArgs: {
|
||||
tracks: [
|
||||
|
|
@ -69,86 +70,47 @@ class Player extends React.Component {
|
|||
]
|
||||
}
|
||||
});
|
||||
this.dispatch({
|
||||
dispatch({
|
||||
propName: 'selectedSubtitlesTrackId',
|
||||
propValue: 'https://raw.githubusercontent.com/caitp/ng-media/master/example/assets/captions/bunny-en.vtt'
|
||||
});
|
||||
}
|
||||
|
||||
onEnded = () => {
|
||||
alert('ended');
|
||||
}
|
||||
|
||||
onError = (error) => {
|
||||
alert(error.message);
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
onPropValue = (propName, propValue) => {
|
||||
this.setState({ [propName]: propValue });
|
||||
}
|
||||
|
||||
onPropChanged = (propName, propValue) => {
|
||||
this.setState({ [propName]: propValue });
|
||||
}
|
||||
|
||||
onImplementationChanged = (manifest) => {
|
||||
manifest.props.forEach((propName) => {
|
||||
this.dispatch({ observedPropName: propName });
|
||||
});
|
||||
}
|
||||
|
||||
dispatch = (args) => {
|
||||
this.videoRef.current && this.videoRef.current.dispatch(args);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={styles['player-container']}>
|
||||
<Video
|
||||
ref={this.videoRef}
|
||||
className={styles['layer']}
|
||||
onEnded={this.onEnded}
|
||||
onError={this.onError}
|
||||
onPropValue={this.onPropValue}
|
||||
onPropChanged={this.onPropChanged}
|
||||
onImplementationChanged={this.onImplementationChanged}
|
||||
/>
|
||||
<div className={styles['layer']} />
|
||||
<BufferingLoader
|
||||
className={styles['layer']}
|
||||
buffering={this.state.buffering}
|
||||
/>
|
||||
<ControlBar
|
||||
className={classnames(styles['layer'], styles['control-bar-layer'])}
|
||||
modalContainerClassName={styles['modal-container']}
|
||||
paused={this.state.paused}
|
||||
time={this.state.time}
|
||||
duration={this.state.duration}
|
||||
volume={this.state.volume}
|
||||
muted={this.state.muted}
|
||||
subtitlesTracks={this.state.subtitlesTracks}
|
||||
selectedSubtitlesTrackId={this.state.selectedSubtitlesTrackId}
|
||||
subtitlesSize={this.state.subtitlesSize}
|
||||
subtitlesDelay={this.state.subtitlesDelay}
|
||||
subtitlesTextColor={this.state.subtitlesTextColor}
|
||||
subtitlesBackgroundColor={this.state.subtitlesBackgroundColor}
|
||||
subtitlesOutlineColor={this.state.subtitlesOutlineColor}
|
||||
dispatch={this.dispatch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Player.propTypes = {
|
||||
stream: PropTypes.object.isRequired
|
||||
};
|
||||
Player.defaultProps = {
|
||||
stream: Object.freeze({
|
||||
// ytId: 'E4A0bcCQke0',
|
||||
url: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'
|
||||
})
|
||||
}, [stream]);
|
||||
return (
|
||||
<div className={styles['player-container']}>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
className={styles['layer']}
|
||||
onEnded={onEnded}
|
||||
onError={onError}
|
||||
onPropValue={onPropChanged}
|
||||
onPropChanged={onPropChanged}
|
||||
onImplementationChanged={onImplementationChanged}
|
||||
/>
|
||||
<div className={styles['layer']} />
|
||||
{
|
||||
state.buffering ?
|
||||
<BufferingLoader className={styles['layer']} />
|
||||
:
|
||||
null
|
||||
}
|
||||
<ControlBar
|
||||
className={classnames(styles['layer'], styles['control-bar-layer'])}
|
||||
paused={state.paused}
|
||||
time={state.time}
|
||||
duration={state.duration}
|
||||
volume={state.volume}
|
||||
muted={state.muted}
|
||||
subtitlesTracks={state.subtitlesTracks}
|
||||
selectedSubtitlesTrackId={state.selectedSubtitlesTrackId}
|
||||
subtitlesSize={state.subtitlesSize}
|
||||
subtitlesDelay={state.subtitlesDelay}
|
||||
subtitlesTextColor={state.subtitlesTextColor}
|
||||
subtitlesBackgroundColor={state.subtitlesBackgroundColor}
|
||||
subtitlesOutlineColor={state.subtitlesOutlineColor}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = Player;
|
||||
|
|
|
|||
|
|
@ -1,79 +1,67 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const hat = require('hat');
|
||||
const HTMLVideo = require('./stremio-video/HTMLVideo');
|
||||
const YouTubeVideo = require('./stremio-video/YouTubeVideo');
|
||||
const MPVVideo = require('./stremio-video/MPVVideo');
|
||||
const useVideoImplementation = require('./useVideoImplementation');
|
||||
|
||||
class Video extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.containerRef = React.createRef();
|
||||
this.id = `video-${hat()}`;
|
||||
this.video = null;
|
||||
}
|
||||
|
||||
shouldComponentUpdate() {
|
||||
return false;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.dispatch({ commandName: 'destroy' });
|
||||
}
|
||||
|
||||
selectVideoImplementation = (args) => {
|
||||
if (args.ipc) {
|
||||
return MPVVideo;
|
||||
} else if (args.stream.ytId) {
|
||||
return YouTubeVideo;
|
||||
} else {
|
||||
return HTMLVideo;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch = (args = {}) => {
|
||||
if (args.commandName === 'load') {
|
||||
const { commandArgs = {} } = args;
|
||||
const Video = this.selectVideoImplementation(commandArgs);
|
||||
if (this.video === null || this.video.constructor !== Video) {
|
||||
this.dispatch({ commandName: 'destroy' });
|
||||
this.video = new Video({
|
||||
id: this.id,
|
||||
containerElement: this.containerRef.current,
|
||||
ipc: commandArgs.ipc
|
||||
const Video = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const [onEnded, onError, onPropValue, onPropChanged, onImplementationChanged] = React.useMemo(() => [
|
||||
props.onEnded,
|
||||
props.onError,
|
||||
props.onPropValue,
|
||||
props.onPropChanged,
|
||||
props.onImplementationChanged
|
||||
], []);
|
||||
const containerElementRef = React.useRef(null);
|
||||
const videoRef = React.useRef(null);
|
||||
const id = React.useMemo(() => `video-${hat()}`, []);
|
||||
const dispatch = React.useCallback((args) => {
|
||||
if (args && args.commandName === 'load' && args.commandArgs) {
|
||||
const Video = useVideoImplementation(args.commandArgs.shell, args.commandArgs.stream);
|
||||
if (typeof Video !== 'function') {
|
||||
videoRef.current = null;
|
||||
} else if (videoRef.current === null || videoRef.current.constructor !== Video) {
|
||||
dispatch({ commandName: 'destroy' });
|
||||
videoRef.current = new Video({
|
||||
id: id,
|
||||
containerElement: containerElementRef.current,
|
||||
shell: args.commandArgs.shell
|
||||
});
|
||||
this.video.on('ended', this.props.onEnded);
|
||||
this.video.on('error', this.props.onError);
|
||||
this.video.on('propValue', this.props.onPropValue);
|
||||
this.video.on('propChanged', this.props.onPropChanged);
|
||||
this.props.onImplementationChanged(this.video.constructor.manifest);
|
||||
videoRef.current.on('ended', onEnded);
|
||||
videoRef.current.on('error', onError);
|
||||
videoRef.current.on('propValue', onPropValue);
|
||||
videoRef.current.on('propChanged', onPropChanged);
|
||||
onImplementationChanged(videoRef.current.constructor.manifest);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.video !== null) {
|
||||
if (videoRef.current !== null) {
|
||||
try {
|
||||
this.video.dispatch(args);
|
||||
videoRef.current.dispatch(args);
|
||||
} catch (e) {
|
||||
console.error(this.video.constructor.manifest.name, e);
|
||||
console.error(videoRef.current.constructor.manifest.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
React.useImperativeHandle(ref, () => ({ dispatch }));
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
dispatch({ commandName: 'destroy' });
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div ref={containerElementRef} id={id} className={className} />
|
||||
);
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div ref={this.containerRef} id={this.id} className={this.props.className} />
|
||||
);
|
||||
}
|
||||
}
|
||||
Video.displayName = 'Video';
|
||||
|
||||
Video.propTypes = {
|
||||
className: PropTypes.string,
|
||||
onEnded: PropTypes.func.isRequired,
|
||||
onError: PropTypes.func.isRequired,
|
||||
onPropValue: PropTypes.func.isRequired,
|
||||
onPropChanged: PropTypes.func.isRequired,
|
||||
onImplementationChanged: PropTypes.func.isRequired
|
||||
onEnded: PropTypes.func,
|
||||
onError: PropTypes.func,
|
||||
onPropValue: PropTypes.func,
|
||||
onPropChanged: PropTypes.func,
|
||||
onImplementationChanged: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Video;
|
||||
|
|
|
|||
19
src/routes/Player/Video/useVideoImplementation.js
Normal file
19
src/routes/Player/Video/useVideoImplementation.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const { HTMLVideo, YouTubeVideo, MPVVideo } = require('stremio-video');
|
||||
|
||||
const useVideoImplementation = (shell, stream) => {
|
||||
if (shell) {
|
||||
return MPVVideo;
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
if (stream.ytId) {
|
||||
return YouTubeVideo;
|
||||
} else {
|
||||
return HTMLVideo;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
module.exports = useVideoImplementation;
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
.player-container, .modal-container {
|
||||
--control-bar-button-size: 60px;
|
||||
}
|
||||
|
||||
.player-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-backgrounddarker);
|
||||
|
||||
.layer {
|
||||
position: absolute;
|
||||
|
|
|
|||
9
src/video/index.js
Normal file
9
src/video/index.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const HTMLVideo = require('./HTMLVideo');
|
||||
const MPVVideo = require('./MPVVideo');
|
||||
const YouTubeVideo = require('./YouTubeVideo');
|
||||
|
||||
module.exports = {
|
||||
HTMLVideo,
|
||||
MPVVideo,
|
||||
YouTubeVideo
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue