Merge branch 'master' of github.com:Stremio/stremio-web into user-notifications-menu

This commit is contained in:
svetlagasheva 2019-10-16 10:38:54 +03:00
commit b34746ce7e
106 changed files with 1749 additions and 1891 deletions

BIN
images/stremio_symbol.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View file

@ -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"
}
}
}

View file

@ -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
},

View file

@ -7,6 +7,8 @@
.icon {
display: block;
width: 1rem;
height: 1rem;
fill: var(--color-surfacelighter);
}
}

View file

@ -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
};

View file

@ -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;

View 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;
}
}

View file

@ -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);
}
}
}
}

View file

@ -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;

View file

@ -1,3 +0,0 @@
const Dropdown = require('./Dropdown');
module.exports = Dropdown;

View file

@ -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
View 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;

View file

@ -0,0 +1,3 @@
const Image = require('./Image');
module.exports = Image;

View file

@ -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
};

View file

@ -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);
}
}
}

View file

@ -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
}

View file

@ -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 {

View file

@ -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,

View file

@ -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;

View 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;

View file

@ -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);
}
}

View file

@ -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);
}
}
}

View file

@ -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;

View 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;

View file

@ -0,0 +1,3 @@
const Multiselect = require('./Multiselect');
module.exports = Multiselect;

View 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);
}
}
}
}
}

View file

@ -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']}

View file

@ -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;
}
}
}
}

View file

@ -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} />

View file

@ -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}

View file

@ -5,6 +5,7 @@
align-items: center;
height: var(--nav-bar-size);
background-color: var(--color-secondarydark);
overflow: visible;
.nav-tab-button {
flex: none;

View file

@ -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;

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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}
/>
);

View file

@ -1,4 +1,4 @@
.text-input-container {
.text-input {
user-select: text;
&::-moz-focus-inner {

View file

@ -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
};

View file

@ -32,8 +32,8 @@ const routesRegexp = {
urlParamsNames: []
},
player: {
regexp: /^\/player\/?$/i,
urlParamsNames: []
regexp: /^\/player\/(?:([^\/]+?))\/(?:([^\/]+?))\/(?:([^\/]+?))\/(?:([^\/]+?))\/?$/i,
urlParamsNames: ['type', 'id', 'videoId', 'stream']
}
};

View 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
View 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
View 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;

View 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;

View file

@ -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;

View file

@ -1,7 +0,0 @@
const React = require('react');
const FocusableContext = React.createContext(false);
FocusableContext.displayName = 'FocusableContext';
module.exports = FocusableContext;

View file

@ -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;

View file

@ -1,7 +0,0 @@
const FocusableProvider = require('./FocusableProvider');
const useFocusable = require('./useFocusable');
module.exports = {
FocusableProvider,
useFocusable
};

View file

@ -1,8 +0,0 @@
const React = require('react');
const FocusableContext = require('./FocusableContext');
const useFocusable = () => {
return React.useContext(FocusableContext);
};
module.exports = useFocusable;

View file

@ -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;

View file

@ -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>
);

View file

@ -0,0 +1,7 @@
const React = require('react');
const RouteFocusedContext = React.createContext(false);
RouteFocusedContext.displayName = 'RouteFocusedContext';
module.exports = RouteFocusedContext;

View file

@ -0,0 +1,7 @@
const RouteFocusedContext = require('./RouteFocusedContext');
const useRouteFocused = require('./useRouteFocused');
module.exports = {
RouteFocusedProvider: RouteFocusedContext.Provider,
useRouteFocused
};

View file

@ -0,0 +1,8 @@
const React = require('react');
const RouteFocusedContext = require('./RouteFocusedContext');
const useRouteFocused = () => {
return React.useContext(RouteFocusedContext);
};
module.exports = useRouteFocused;

View file

@ -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>
);
};

View file

@ -1,7 +0,0 @@
const React = require('react');
const RoutesContainerContext = React.createContext(null);
RoutesContainerContext.displayName = 'RoutesContainerContext';
module.exports = RoutesContainerContext;

View file

@ -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;

View file

@ -1,7 +0,0 @@
const RoutesContainerProvider = require('./RoutesContainerProvider');
const useRoutesContainer = require('./useRoutesContainer');
module.exports = {
RoutesContainerProvider,
useRoutesContainer
};

View file

@ -1,8 +0,0 @@
const React = require('react');
const RoutesContainerContext = require('./RoutesContainerContext');
const useRoutesContainer = () => {
return React.useContext(RoutesContainerContext);
};
module.exports = useRoutesContainer;

View file

@ -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
};

View file

@ -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}`}
/>
);
}

View file

@ -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;
}

View file

@ -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 ?

View file

@ -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']}>

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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;

View file

@ -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;
}
}
}

View file

@ -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,

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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;

View 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;

View file

@ -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
View 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