conflict resolved

This commit is contained in:
svetlagasheva 2020-01-07 14:42:37 +02:00
commit 8c39721c3c
202 changed files with 9041 additions and 3865 deletions

85
.eslintrc Normal file
View file

@ -0,0 +1,85 @@
{
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"settings": {
"react": {
"version": "detect"
}
},
"globals": {
"YT": "readonly",
"FB": "readonly"
},
"env": {
"commonjs": true,
"browser": true,
"es6": true
},
"parserOptions": {
"ecmaVersion": 9,
"ecmaFeatures": {
"jsx": true
}
},
"ignorePatterns": [
"/*",
"!/src",
"src/routes/Settings",
"src/routes/Player",
"src/video"
],
"rules": {
"arrow-parens": "error",
"arrow-spacing": "error",
"block-spacing": "error",
"comma-spacing": "error",
"eol-last": "error",
"eqeqeq": "error",
"func-call-spacing": "error",
"indent": [
"error",
4,
{
"SwitchCase": 1
}
],
"no-console": "error",
"no-extra-semi": "error",
"no-eq-null": "error",
"no-multi-spaces": "error",
"no-multiple-empty-lines": [
"error",
{
"max": 1
}
],
"no-template-curly-in-string": "error",
"no-trailing-spaces": "error",
"no-useless-concat": "error",
"no-unreachable": "error",
"no-unused-vars": "error",
"prefer-const": "error",
"quotes": [
"error",
"single"
],
"quote-props": [
"error",
"as-needed",
{
"unnecessary": false
}
],
"semi": "error",
"semi-spacing": "error",
"space-before-blocks": "error",
"valid-typeof": [
"error",
{
"requireStringLiterals": true
}
]
}
}

View file

@ -9,50 +9,57 @@
"scripts": {
"start": "webpack-dev-server --mode development",
"build": "webpack --mode production",
"storybook": "start-storybook --ci --config-dir ./storybook --static-dir ./ --port 6060"
"storybook": "start-storybook --ci --config-dir ./storybook --static-dir ./ --port 6060",
"test": "jest",
"lint": "eslint src"
},
"dependencies": {
"a-color-picker": "1.1.9",
"a-color-picker": "1.2.1",
"classnames": "2.2.6",
"events": "1.1.1",
"hat": "0.0.3",
"lodash.debounce": "4.0.8",
"lodash.isequal": "4.5.0",
"lodash.throttle": "4.1.1",
"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",
"react": "16.12.0",
"react-dom": "16.12.0",
"react-focus-lock": "2.2.1",
"spatial-navigation-polyfill": "git+ssh://git@github.com/Stremio/spatial-navigation.git#381b4f37d138e66ae8ea025e240af3704245e5dc",
"stremio-colors": "git+ssh://git@github.com/Stremio/stremio-colors.git#v2.0.4",
"stremio-core-web": "git+ssh://git@github.com/stremio/stremio-core-web.git#v0.6.0",
"stremio-core-web": "git+ssh://git@github.com/stremio/stremio-core-web.git#da5b37865004d0ae140518c4f276d1ed1a1483d9",
"stremio-icons": "git+ssh://git@github.com/Stremio/stremio-icons.git#v1.0.11",
"vtt.js": "0.13.0"
},
"devDependencies": {
"@babel/core": "7.6.2",
"@babel/plugin-proposal-class-properties": "7.5.5",
"@babel/plugin-proposal-object-rest-spread": "7.6.2",
"@babel/preset-env": "7.6.2",
"@babel/preset-react": "7.0.0",
"@babel/runtime": "7.6.2",
"@storybook/addon-actions": "5.2.1",
"@babel/core": "7.7.5",
"@babel/plugin-proposal-class-properties": "7.7.4",
"@babel/plugin-proposal-object-rest-spread": "7.7.4",
"@babel/preset-env": "7.7.6",
"@babel/preset-react": "7.7.4",
"@babel/runtime": "7.7.6",
"@storybook/addon-actions": "5.2.8",
"@storybook/addon-console": "1.2.1",
"@storybook/addons": "5.2.1",
"@storybook/react": "5.2.1",
"@storybook/addons": "5.2.8",
"@storybook/react": "5.2.8",
"babel-loader": "8.0.6",
"clean-webpack-plugin": "3.0.0",
"copy-webpack-plugin": "5.0.4",
"css-loader": "3.2.0",
"copy-webpack-plugin": "5.1.1",
"css-loader": "3.3.2",
"cssnano": "4.1.10",
"cssnano-preset-advanced": "4.0.7",
"eslint": "6.7.2",
"eslint-plugin-react": "7.17.0",
"html-webpack-plugin": "3.2.0",
"jest": "24.9.0",
"less": "3.10.3",
"less-loader": "5.0.0",
"mini-css-extract-plugin": "0.8.0",
"postcss-loader": "3.0.0",
"storybook-addon-jsx": "7.1.6",
"terser-webpack-plugin": "2.1.0",
"webpack": "4.41.0",
"webpack-cli": "3.3.9",
"webpack-dev-server": "3.8.1"
"storybook-addon-jsx": "7.1.13",
"terser-webpack-plugin": "2.3.0",
"webpack": "4.41.3",
"webpack-cli": "3.3.10",
"webpack-dev-server": "3.9.0"
}
}

View file

@ -24,6 +24,7 @@ const App = () => {
setCoreInitialized(services.core.active || services.core.error instanceof Error);
if (services.core.active) {
services.core.dispatch({ action: 'LoadCtx' });
window.core = services.core;
}
};
services.shell.on('stateChanged', onShellStateChanged);

View file

@ -3,13 +3,15 @@ const { routesRegexp } = require('stremio/common');
const routerViewsConfig = [
[
{
...routesRegexp.intro,
component: routes.Intro
},
{
...routesRegexp.board,
component: routes.Board
}
],
[
{
...routesRegexp.intro,
component: routes.Intro
},
{
...routesRegexp.discover,
@ -26,9 +28,11 @@ const routerViewsConfig = [
],
[
{
...routesRegexp.detail,
component: routes.Detail
},
...routesRegexp.metadetails,
component: routes.MetaDetails
}
],
[
{
...routesRegexp.addons,
component: routes.Addons

View file

@ -1,17 +1,18 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const styles = require('./styles');
const Button = React.forwardRef(({ children, ...props }, ref) => {
const onKeyUp = React.useCallback((event) => {
if (typeof props.onKeyUp === 'function') {
props.onKeyUp(event);
const Button = React.forwardRef(({ className, href, disabled, children, ...props }, ref) => {
const onKeyDown = React.useCallback((event) => {
if (typeof props.onKeyDown === 'function') {
props.onKeyDown(event);
}
if (event.key === 'Enter' && !event.nativeEvent.buttonClickPrevented) {
event.currentTarget.click();
}
}, [props.onKeyUp]);
}, [props.onKeyDown]);
const onMouseDown = React.useCallback((event) => {
if (typeof props.onMouseDown === 'function') {
props.onMouseDown(event);
@ -25,13 +26,14 @@ const Button = React.forwardRef(({ children, ...props }, ref) => {
}
}, [props.onMouseDown]);
return React.createElement(
typeof props.href === 'string' && props.href.length > 0 ? 'a' : 'div',
typeof href === 'string' && href.length > 0 ? 'a' : 'div',
{
tabIndex: 0,
...props,
ref,
className: classnames(props.className, styles['button-container'], { 'disabled': props.disabled }),
onKeyUp,
className: classnames(className, styles['button-container'], { 'disabled': disabled }),
href,
onKeyDown,
onMouseDown
},
children
@ -40,4 +42,13 @@ const Button = React.forwardRef(({ children, ...props }, ref) => {
Button.displayName = 'Button';
Button.propTypes = {
className: PropTypes.string,
href: PropTypes.string,
disabled: PropTypes.bool,
children: PropTypes.node,
onKeyDown: PropTypes.func,
onMouseDown: PropTypes.func
};
module.exports = Button;

View file

@ -1,5 +1,4 @@
.button-container {
--spatial-navigation-action: focus;
outline-width: var(--focus-outline-size);
outline-color: var(--color-surfacelighter);
outline-offset: calc(-1 * var(--focus-outline-size));

View file

@ -1,25 +1,32 @@
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 styles = require('./styles');
const Checkbox = React.forwardRef((props, ref) => {
const Checkbox = React.forwardRef(({ className, checked, children, ...props }, ref) => {
return (
<Button {...props} ref={ref} className={classnames(props.className, styles['checkbox-container'], { 'checked': props.checked })}>
<Button {...props} ref={ref} className={classnames(className, styles['checkbox-container'], { 'checked': checked })}>
{
props.checked ?
checked ?
<svg className={styles['icon']} viewBox={'0 0 100 100'}>
<Icon x={'10'} y={'10'} width={'80'} height={'80'} icon={'ic_check'} />
</svg>
:
<Icon className={styles['icon']} icon={'ic_box_empty'} />
}
{props.children}
{children}
</Button>
);
});
Checkbox.displayName = 'Checkbox';
Checkbox.propTypes = {
className: PropTypes.string,
checked: PropTypes.bool,
children: PropTypes.node
};
module.exports = Checkbox;

View file

@ -1,74 +1,72 @@
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 ModalDialog = require('stremio/common/ModalDialog');
const useBinaryState = require('stremio/common/useBinaryState');
const useDataset = require('stremio/common/useDataset');
const ColorPicker = require('./ColorPicker');
const styles = require('./styles');
const COLOR_FORMAT = 'hexcss4';
const parseColor = (value) => {
return AColorPicker.parseColor(value, 'hexcss4');
};
const ColorInput = ({ className, value, onChange, ...props }) => {
value = AColorPicker.parseColor(value, COLOR_FORMAT);
const dataset = useDataset(props);
const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
const [modalOpen, openModal, closeModal] = useBinaryState(false);
const [tempValue, setTempValue] = React.useState(value);
const pickerLabelOnClick = React.useCallback((event) => {
const [tempValue, setTempValue] = React.useState(() => {
return parseColor(value);
});
const labelButtonStyle = React.useMemo(() => ({
backgroundColor: value
}), [value]);
const labelButtonOnClick = React.useCallback((event) => {
if (typeof props.onClick === 'function') {
props.onClick(event);
}
if (!event.nativeEvent.openModalPrevented) {
openModal();
}
}, []);
const modalContainerOnClick = React.useCallback((event) => {
}, [props.onClick]);
const modalDialogOnClick = 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
});
}
const modalButtons = React.useMemo(() => {
const selectButtonOnClick = (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);
closeModal();
};
return [
{
label: 'Select',
props: {
'data-autofocus': true,
onClick: selectButtonOnClick
}
}
];
}, [tempValue, dataset, onChange]);
const colorPickerOnInput = React.useCallback((event) => {
setTempValue(parseColor(event.value));
}, []);
React.useLayoutEffect(() => {
setTempValue(parseColor(value));
}, [value, modalOpen]);
return (
<Button style={{ backgroundColor: value }} className={className} title={value} onClick={pickerLabelOnClick}>
<Button title={value} {...props} style={labelButtonStyle} className={className} onClick={labelButtonOnClick}>
{
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>
</div>
</Modal>
<ModalDialog title={'Choose a color:'} buttons={modalButtons} onCloseRequest={closeModal} onClick={modalDialogOnClick}>
<ColorPicker value={tempValue} onInput={colorPickerOnInput} />
</ModalDialog>
:
null
}
@ -77,8 +75,11 @@ const ColorInput = ({ className, value, onChange, ...props }) => {
};
ColorInput.propTypes = {
className: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func
dataset: PropTypes.objectOf(PropTypes.string),
onChange: PropTypes.func,
onClick: PropTypes.func
};
module.exports = ColorInput;

View file

@ -4,42 +4,43 @@ const classnames = require('classnames');
const AColorPicker = require('a-color-picker');
const styles = require('./styles');
const COLOR_FORMAT = 'hexcss4';
const parseColor = (value) => {
return AColorPicker.parseColor(value, 'hexcss4');
};
// TODO implement custom picker which is keyboard accessible
const ColorPicker = ({ className, value, onInput }) => {
value = AColorPicker.parseColor(value, COLOR_FORMAT);
const pickerRef = React.useRef(null);
const pickerElementRef = React.useRef(null);
React.useEffect(() => {
React.useLayoutEffect(() => {
pickerRef.current = AColorPicker.createPicker(pickerElementRef.current, {
color: value,
color: parseColor(value),
showHSL: false,
showHEX: false,
showRGB: false,
showAlpha: true
});
const clipboardPicker = pickerElementRef.current.querySelector('.a-color-picker-clipbaord');
if (clipboardPicker instanceof HTMLElement) {
clipboardPicker.tabIndex = -1;
const pickerClipboard = pickerElementRef.current.querySelector('.a-color-picker-clipbaord');
if (pickerClipboard instanceof HTMLElement) {
pickerClipboard.tabIndex = -1;
}
}, []);
React.useEffect(() => {
pickerRef.current.on('change', (picker, color) => {
if (typeof onInput === 'function') {
React.useLayoutEffect(() => {
if (typeof onInput === 'function') {
pickerRef.current.on('change', (picker, value) => {
onInput({
type: 'input',
value: AColorPicker.parseColor(color, COLOR_FORMAT)
value: parseColor(value)
});
}
});
});
}
return () => {
pickerRef.current.off('change');
};
}, [onInput]);
React.useEffect(() => {
if (AColorPicker.parseColor(pickerRef.current.color, COLOR_FORMAT) !== value) {
pickerRef.current.color = value;
React.useLayoutEffect(() => {
const nextValue = parseColor(value);
if (nextValue !== parseColor(pickerRef.current.color)) {
pickerRef.current.color = nextValue;
}
}, [value]);
return (

View file

@ -1,81 +0,0 @@
.color-input-modal-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
pointer-events: auto;
background-color: var(--color-backgrounddarker40);
.color-input-container {
flex: none;
display: flex;
flex-direction: column;
align-items: center;
max-width: 25rem;
padding: 1rem;
background-color: var(--color-surfacelighter);
.header-container {
flex: none;
align-self: stretch;
display: flex;
flex-direction: row;
align-items: flex-start;
.title {
flex: 1;
margin-right: 1rem;
font-size: 1.2rem;
max-height: 2.4em;
}
.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);
}
}
}
.color-picker {
flex: none;
margin: 1rem;
}
.submit-button-container {
flex: none;
align-self: stretch;
padding: 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,11 +1,15 @@
const React = require('react');
const PropTypes = require('prop-types');
const Image = ({ className, src, alt, fallbackSrc, renderFallback }) => {
const Image = ({ className, src, alt, fallbackSrc, renderFallback, ...props }) => {
const [broken, setBroken] = React.useState(false);
const onError = React.useCallback(() => {
const onError = React.useCallback((event) => {
if (typeof props.onError === 'function') {
props.onError(event);
}
setBroken(true);
}, []);
}, [props.onError]);
React.useLayoutEffect(() => {
setBroken(false);
}, [src]);
@ -13,9 +17,9 @@ const Image = ({ className, src, alt, fallbackSrc, renderFallback }) => {
typeof renderFallback === 'function' ?
renderFallback()
:
<img className={className} src={fallbackSrc} alt={alt} />
<img {...props} className={className} src={fallbackSrc} alt={alt} />
:
<img className={className} src={src} alt={alt} onError={onError} />;
<img {...props} className={className} src={src} alt={alt} onError={onError} />;
};
Image.propTypes = {
@ -23,7 +27,8 @@ Image.propTypes = {
src: PropTypes.string,
alt: PropTypes.string,
fallbackSrc: PropTypes.string,
renderFallback: PropTypes.func
renderFallback: PropTypes.func,
onError: PropTypes.func
};
module.exports = Image;

View file

@ -8,7 +8,7 @@ const TABS = [
{ label: 'Library', icon: 'ic_library', href: '#/library' }
];
const MainNavBar = ({ className }) => {
const MainNavBar = React.memo(({ className }) => {
return (
<NavBar
className={className}
@ -21,7 +21,9 @@ const MainNavBar = ({ className }) => {
navMenu={true}
/>
);
};
});
MainNavBar.displayName = 'MainNavBar';
MainNavBar.propTypes = {
className: PropTypes.string

View file

@ -7,57 +7,59 @@ const Image = require('stremio/common/Image');
const Multiselect = require('stremio/common/Multiselect');
const PlayIconCircleCentered = require('stremio/common/PlayIconCircleCentered');
const useBinaryState = require('stremio/common/useBinaryState');
const useDataset = require('stremio/common/useDataset');
const styles = require('./styles');
const ICON_FOR_TYPE = Object.assign(Object.create(null), {
'movie': 'ic_movies',
'series': 'ic_series',
'channel': 'ic_channels',
'tv': 'ic_tv',
'other': 'ic_movies'
});
const ICON_FOR_TYPE = new Map([
['movie', 'ic_movies'],
['series', 'ic_series'],
['channel', 'ic_channels'],
['tv', 'ic_tv'],
['other', 'ic_movies']
]);
const MetaItem = React.memo(({ className, type, name, poster, posterShape, playIcon, progress, menuOptions, onSelect, menuOptionOnSelect, ...props }) => {
const dataset = useDataset(props);
const MetaItem = React.memo(({ className, type, name, poster, posterShape, playIcon, progress, options, dataset, optionOnSelect, ...props }) => {
const [menuOpen, onMenuOpen, onMenuClose] = useBinaryState(false);
const metaItemOnClick = React.useCallback((event) => {
if (!event.nativeEvent.selectMetaItemPrevented && typeof onSelect === 'function') {
onSelect({
type: 'select',
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
if (typeof props.onClick === 'function') {
props.onClick(event);
}
}, [onSelect, dataset]);
const multiselectOnClick = React.useCallback((event) => {
event.nativeEvent.selectMetaItemPrevented = true;
if (event.nativeEvent.selectPrevented) {
event.preventDefault();
}
}, [props.onClick]);
const menuOnClick = React.useCallback((event) => {
event.nativeEvent.selectPrevented = true;
}, []);
const multiselectOnSelect = React.useCallback((event) => {
if (typeof menuOptionOnSelect === 'function') {
menuOptionOnSelect({
const menuOnSelect = React.useCallback((event) => {
if (typeof optionOnSelect === 'function') {
optionOnSelect({
type: 'select-option',
value: event.value,
dataset: dataset,
reactEvent: event.reactEvent,
nativeEvent: event.nativeEvent
});
}
}, [menuOptionOnSelect, dataset]);
}, [dataset, optionOnSelect]);
const renderPosterFallback = React.useMemo(() => () => (
<Icon
className={styles['placeholder-icon']}
icon={ICON_FOR_TYPE.has(type) ? ICON_FOR_TYPE.get(type) : ICON_FOR_TYPE.get('other')}
/>
), [type]);
const renderMenuLabelContent = React.useMemo(() => () => (
<Icon className={styles['icon']} icon={'ic_more'} />
), []);
return (
<Button className={classnames(className, styles['meta-item-container'], styles['poster-shape-poster'], styles[`poster-shape-${posterShape}`], { 'active': menuOpen })} title={name} onClick={metaItemOnClick}>
<Button title={name} {...props} className={classnames(className, styles['meta-item-container'], styles['poster-shape-poster'], styles[`poster-shape-${posterShape}`], { 'active': menuOpen })} 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']}
/>
)}
renderFallback={renderPosterFallback}
/>
</div>
{
@ -78,7 +80,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
}
</div>
{
(typeof name === 'string' && name.length > 0) || (Array.isArray(menuOptions) && menuOptions.length > 0) ?
(typeof name === 'string' && name.length > 0) || (Array.isArray(options) && options.length > 0) ?
<div className={styles['title-bar-container']}>
{
typeof name === 'string' && name.length > 0 ?
@ -87,19 +89,16 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
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>
Array.isArray(options) && options.length > 0 ?
<Multiselect
className={styles['menu-label-container']}
renderLabelContent={renderMenuLabelContent}
options={options}
onOpen={onMenuOpen}
onClose={onMenuClose}
onSelect={menuOnSelect}
onClick={menuOnClick}
/>
:
null
}
@ -121,9 +120,10 @@ MetaItem.propTypes = {
posterShape: PropTypes.oneOf(['poster', 'landscape', 'square']),
playIcon: PropTypes.bool,
progress: PropTypes.number,
menuOptions: PropTypes.array,
onSelect: PropTypes.func,
menuOptionOnSelect: PropTypes.func
options: PropTypes.array,
dataset: PropTypes.objectOf(PropTypes.string),
optionOnSelect: PropTypes.func,
onClick: PropTypes.func
};
module.exports = MetaItem;

View file

@ -14,11 +14,25 @@
}
.meta-item-container {
position: relative;
overflow: visible;
&:hover, &:focus, &:global(.active) {
outline-style: solid;
outline-offset: 0;
&:hover, &:focus, &:global(.active), &:global(.selected) {
outline-style: none;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
outline-style: solid;
outline-width: var(--focus-outline-size);
outline-color: var(--color-surfacelighter);
outline-offset: calc(-1 * var(--focus-outline-size));
pointer-events: none;
content: "";
}
.title-bar-container {
background-color: var(--color-surfacelight);
@ -27,11 +41,9 @@
color: var(--color-backgrounddarker);
}
.multiselect-container {
.multiselect-label-container {
.icon {
fill: var(--color-backgrounddarker);
}
.menu-label-container {
.icon {
fill: var(--color-backgrounddarker);
}
}
}
@ -148,50 +160,43 @@
}
}
.multiselect-container {
.menu-label-container {
flex: none;
width: 2.8rem;
height: 2.8rem;
overflow: visible;
padding: 0.75rem;
background-color: transparent;
.multiselect-label-container {
&:hover, &:global(.active) {
background-color: var(--color-surface80);
}
.icon {
display: block;
width: 100%;
height: 100%;
padding: 0.75rem;
background-color: transparent;
fill: var(--color-surfacelighter);
}
&:hover, &:global(.active) {
background-color: var(--color-surface80);
}
.popup-menu-container {
width: auto;
.icon {
display: block;
width: 100%;
height: 100%;
fill: var(--color-surfacelighter);
}
.multiselect-menu-container {
min-width: 8rem;
max-width: 12rem;
.popup-menu-container {
width: auto;
right: initial;
left: 0;
.multiselect-option-container {
padding: 0.5rem;
background-color: var(--color-surfacelighter);
.multiselect-menu-container {
min-width: 8rem;
max-width: 12rem;
&:hover, &:focus {
outline: none;
background-color: var(--color-surfacelight);
}
.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);
}
.multiselect-option-label {
color: var(--color-backgrounddarker);
}
}
}

View file

@ -17,7 +17,7 @@ const MetaLinks = ({ className, label, links }) => {
Array.isArray(links) && links.length > 0 ?
<div className={styles['links-container']}>
{links.map(({ label, href }, index) => (
<Button key={`${label}-${href}-${index}`} className={styles['link-container']} title={label} tabIndex={-1} href={href}>
<Button key={index} className={styles['link-container']} title={label} tabIndex={-1} href={href}>
{label}
{index < links.length - 1 ? ',' : null}
</Button>
@ -34,8 +34,8 @@ MetaLinks.propTypes = {
className: PropTypes.string,
label: PropTypes.string,
links: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string.isRequired,
href: PropTypes.string.isRequired
label: PropTypes.string,
href: PropTypes.string
}))
};

View file

@ -1,69 +1,80 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { Modal } = require('stremio-router');
const UrlUtils = require('url');
const Icon = require('stremio-icons/dom');
const Image = require('stremio/common/Image');
const ModalDialog = require('stremio/common/ModalDialog');
const SharePrompt = require('stremio/common/SharePrompt');
const routesRegexp = require('stremio/common/routesRegexp');
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 }) => {
const IMDB_LINK_CATEGORY = 'imdb';
const SHARE_LINK_CATEGORY = 'share';
const ALLOWED_LINK_REDIRECTS = [
routesRegexp.search.regexp,
routesRegexp.discover.regexp,
routesRegexp.metadetails.regexp
];
const MetaPreview = ({ className, compact, name, logo, background, runtime, releaseInfo, released, description, links, trailer, inLibrary, toggleInLibrary }) => {
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
const genresLinks = React.useMemo(() => {
return Array.isArray(genres) ?
genres.filter(genre => typeof genre === 'string')
.map((genre) => ({
label: genre,
href: `#/discover/${type}//?genre=${genre}`
}))
const linksGroups = React.useMemo(() => {
return Array.isArray(links) ?
links
.filter((link) => {
return link &&
typeof link.category === 'string' &&
typeof link.url === 'string';
})
.reduce((linksGroups, { category, name, url }) => {
if (category === IMDB_LINK_CATEGORY) {
linksGroups[category] = {
label: name,
href: `https://www.stremio.com/warning#${encodeURIComponent(`https://www.imdb.com/title/${url}`)}`
};
} else if (category === SHARE_LINK_CATEGORY) {
linksGroups[category] = {
label: name,
href: url
};
} else {
const { protocol, host, path, pathname } = UrlUtils.parse(url);
if (protocol === 'stremio:') {
if (ALLOWED_LINK_REDIRECTS.some((regexp) => pathname.match(regexp))) {
linksGroups[category] = linksGroups[category] || [];
linksGroups[category].push({
label: name,
href: `#${path}`
});
}
} else {
if (typeof host === 'string' && host.length > 0) {
linksGroups[category] = linksGroups[category] || [];
linksGroups[category].push({
label: name,
href: `https://www.stremio.com/warning#${encodeURIComponent(url)}`
});
}
}
}
return linksGroups;
}, {})
:
[];
}, [type, genres]);
const writersLinks = React.useMemo(() => {
return Array.isArray(writers) ?
writers.filter(writer => typeof writer === 'string')
.map((writer) => ({
label: writer,
href: `#/search?q=${writer}`
}))
:
[];
}, [writers]);
const directorsLinks = React.useMemo(() => {
return Array.isArray(directors) ?
directors.filter(director => typeof director === 'string')
.map((director) => ({
label: director,
href: `#/search?q=${director}`
}))
:
[];
}, [directors]);
const castLinks = React.useMemo(() => {
return Array.isArray(cast) ?
cast.filter(name => typeof name === 'string')
.map((name) => ({
label: name,
href: `#/search?q=${name}`
}))
:
[];
}, [cast]);
const shareModalBackgroundOnClick = React.useCallback((event) => {
if (!event.nativeEvent.closeShareModalPrevented) {
closeShareModal();
}
}, []);
const shareModalContentOnClick = React.useCallback((event) => {
event.nativeEvent.closeShareModalPrevented = true;
}, []);
}, [links]);
return (
<div className={classnames(className, styles['meta-preview-container'], { [styles['compact']]: compact })}>
{
typeof background === 'string' && background.length > 0 ?
<div className={styles['background-image-layer']}>
<img
key={background}
className={styles['background-image']}
src={background}
alt={' '}
@ -75,18 +86,24 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
<div className={styles['meta-info-container']}>
{
typeof logo === 'string' && logo.length > 0 ?
<img
<Image
key={logo}
className={styles['logo']}
src={logo}
alt={' '}
renderFallback={() => (
<Icon
className={styles['logo-placeholder-icon']}
icon={'ic_broken_link'}
/>
)}
/>
:
null
}
{
(typeof releaseInfo === 'string' && releaseInfo.length > 0) || (released instanceof Date && !isNaN(released.getTime())) || (typeof duration === 'string' && duration.length > 0) ?
<div className={styles['duration-release-info-container']}>
(typeof releaseInfo === 'string' && releaseInfo.length > 0) || (released instanceof Date && !isNaN(released.getTime())) || (typeof runtime === 'string' && runtime.length > 0) ?
<div className={styles['runtime-release-info-container']}>
{
typeof releaseInfo === 'string' && releaseInfo.length > 0 ?
<div className={styles['release-info-label']}>{releaseInfo}</div>
@ -97,8 +114,8 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
null
}
{
typeof duration === 'string' && duration.length > 0 ?
<div className={styles['duration-label']}>{duration}</div>
typeof runtime === 'string' && runtime.length > 0 ?
<div className={styles['runtime-label']}>{runtime}</div>
:
null
}
@ -106,9 +123,14 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
:
null
}
<div className={styles['name-container']}>
{typeof name === 'string' && name.length > 0 ? name : id}
</div>
{
typeof name === 'string' && name.length > 0 ?
<div className={styles['name-container']}>
{name}
</div>
:
null
}
{
typeof description === 'string' && description.length > 0 ?
<div className={styles['description-container']}>{description}</div>
@ -116,28 +138,19 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
null
}
{
genresLinks.length > 0 ?
<MetaLinks className={styles['meta-links']} label={'Genres'} links={genresLinks} />
:
null
}
{
writersLinks.length > 0 ?
<MetaLinks className={styles['meta-links']} label={'Writers'} links={writersLinks} />
:
null
}
{
directorsLinks.length > 0 ?
<MetaLinks className={styles['meta-links']} label={'Directors'} links={directorsLinks} />
:
null
}
{
castLinks.length > 0 ?
<MetaLinks className={styles['meta-links']} label={'Cast'} links={castLinks} />
:
null
Object.keys(linksGroups)
.filter((category) => {
return category !== IMDB_LINK_CATEGORY &&
category !== SHARE_LINK_CATEGORY;
})
.map((category, index) => (
<MetaLinks
key={index}
className={styles['meta-links']}
label={category}
links={linksGroups[category]}
/>
))
}
</div>
<div className={styles['action-buttons-container']}>
@ -147,32 +160,30 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
className={styles['action-button']}
icon={inLibrary ? 'ic_removelib' : 'ic_addlib'}
label={inLibrary ? 'Remove from Library' : 'Add to library'}
data-id={id}
onClick={toggleInLibrary}
{...(!compact ? { tabIndex: -1 } : null)}
/>
:
null
}
{
typeof trailer === 'string' && trailer.length > 0 ?
<ActionButton
className={styles['action-button']}
icon={'ic_movies'}
label={'Trailer'}
href={`#/player?stream=${trailer}`}
{...(compact ? { tabIndex: -1 } : null)}
/>
:
null
}
{
typeof imdbId === 'string' && imdbId.length > 0 ?
typeof trailer === 'object' && trailer !== null ?
<ActionButton
className={styles['action-button']}
icon={'ic_movies'}
label={'Trailer'}
href={`#/player?stream=${JSON.stringify(trailer)}`}
{...(compact ? { tabIndex: -1 } : null)}
/>
:
null
}
{
typeof linksGroups[IMDB_LINK_CATEGORY] === 'object' ?
<ActionButton
{...linksGroups[IMDB_LINK_CATEGORY]}
className={styles['action-button']}
icon={'ic_imdb'}
label={typeof imdbRating === 'string' && imdbRating.length > 0 ? `${imdbRating} / 10` : null}
href={`https://imdb.com/title/${imdbId}`}
target={'_blank'}
{...(compact ? { tabIndex: -1 } : null)}
/>
@ -180,7 +191,7 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
null
}
{
!compact && typeof share === 'string' && share.length > 0 ?
!compact && typeof linksGroups[SHARE_LINK_CATEGORY] === 'object' ?
<React.Fragment>
<ActionButton
className={styles['action-button']}
@ -191,14 +202,12 @@ const MetaPreview = ({ className, compact, id, type, name, logo, background, dur
/>
{
shareModalOpen ?
<Modal>
<div style={{ width: '100%', height: '100%' }} onClick={shareModalBackgroundOnClick}>
<div
style={{ width: '50%', height: '50%', backgroundColor: 'red' }}
onClick={shareModalContentOnClick}
/>
</div>
</Modal>
<ModalDialog title={'Share'} onCloseRequest={closeShareModal}>
<SharePrompt
className={styles['share-prompt']}
url={linksGroups[SHARE_LINK_CATEGORY].href}
/>
</ModalDialog>
:
null
}
@ -216,23 +225,19 @@ MetaPreview.Placeholder = MetaPreviewPlaceholder;
MetaPreview.propTypes = {
className: PropTypes.string,
compact: PropTypes.bool,
id: PropTypes.string,
type: PropTypes.string,
name: PropTypes.string,
logo: PropTypes.string,
background: PropTypes.string,
duration: PropTypes.string,
runtime: PropTypes.string,
releaseInfo: PropTypes.string,
released: PropTypes.instanceOf(Date),
description: PropTypes.string,
genres: PropTypes.arrayOf(PropTypes.string),
writers: PropTypes.arrayOf(PropTypes.string),
directors: PropTypes.arrayOf(PropTypes.string),
cast: PropTypes.arrayOf(PropTypes.string),
imdbId: PropTypes.string,
imdbRating: PropTypes.string,
trailer: PropTypes.string,
share: PropTypes.string,
links: PropTypes.arrayOf(PropTypes.shape({
category: PropTypes.string,
name: PropTypes.string,
url: PropTypes.string
})),
trailer: PropTypes.object,
inLibrary: PropTypes.bool,
toggleInLibrary: PropTypes.func
};

View file

@ -6,15 +6,18 @@
&.compact {
.meta-info-container {
.logo {
.logo, .logo-placeholder-icon {
width: 100%;
}
.logo {
object-position: center;
}
.duration-release-info-container {
.runtime-release-info-container {
justify-content: space-evenly;
.duration-label, .release-info-label {
.runtime-label, .release-info-label {
margin: 1rem 0.4rem;
}
}
@ -40,7 +43,7 @@
height: 100%;
object-fit: cover;
object-position: top left;
filter: blur(5px) brightness(80%);
filter: blur(5px) brightness(50%);
}
}
@ -50,23 +53,31 @@
padding: 0 1rem;
overflow-y: auto;
.logo {
.logo, .logo-placeholder-icon {
display: block;
max-width: 100%;
height: 7rem;
height: 8rem;
margin-top: 1rem;
object-fit: contain;
object-position: left center;
padding: 1rem;
background-color: var(--color-surfacedarker20);
}
.duration-release-info-container {
.logo {
object-fit: contain;
object-position: left center;
}
.logo-placeholder-icon {
fill: var(--color-surfacelight);
}
.runtime-release-info-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 1rem;
.duration-label, .release-info-label {
.runtime-label, .release-info-label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
@ -108,4 +119,8 @@
margin: 1rem 0;
}
}
}
.share-prompt {
width: 24rem;
}

View file

@ -7,9 +7,8 @@ const MetaItem = require('stremio/common/MetaItem');
const MetaRowPlaceholder = require('./MetaRowPlaceholder');
const styles = require('./styles');
const MetaRow = ({ className, title, message, items, maximumItemsCount, itemMenuOptions }) => {
maximumItemsCount = maximumItemsCount !== null && isFinite(maximumItemsCount) ? maximumItemsCount : 20;
items = Array.isArray(items) ? items.slice(0, maximumItemsCount) : [];
const MetaRow = ({ className, title, message, items, limit, href }) => {
items = Array.isArray(items) ? items.slice(0, limit) : [];
return (
<div className={classnames(className, styles['meta-row-container'])}>
{
@ -22,27 +21,29 @@ const MetaRow = ({ className, title, message, items, maximumItemsCount, itemMenu
typeof message === 'string' && message.length > 0 ?
<div className={styles['message-container']} title={message}>{message}</div>
:
<React.Fragment>
<div className={styles['content-container']}>
<div className={styles['meta-items-container']}>
{items.map((item, index) => (
<MetaItem
{...item}
key={index}
data-id={item.id}
data-type={item.type}
className={classnames(styles['meta-item'], styles['poster-shape-poster'], styles[`poster-shape-${item.posterShape}`])}
menuOptions={itemMenuOptions}
/>
))}
{Array(Math.max(maximumItemsCount - items.length, 0)).fill(null).map((_, index) => (
{Array(limit - items.length).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['label']}>SEE ALL</div>
<Icon className={styles['icon']} icon={'ic_arrow_thin_right'} />
</Button>
</React.Fragment>
{
typeof href === 'string' && href.length > 0 ?
<Button className={styles['see-all-container']} title={'SEE ALL'} href={href}>
<div className={styles['label']}>SEE ALL</div>
<Icon className={styles['icon']} icon={'ic_arrow_thin_right'} />
</Button>
:
null
}
</div>
}
</div>
);
@ -57,8 +58,8 @@ MetaRow.propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
posterShape: PropTypes.string
})),
maximumItemsCount: PropTypes.number,
itemMenuOptions: PropTypes.any
limit: PropTypes.number.isRequired,
href: PropTypes.string
};
module.exports = MetaRow;

View file

@ -3,20 +3,21 @@ 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;
const MetaRowPlaceholder = ({ className, title, limit }) => {
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 className={styles['content-container']}>
<div className={styles['meta-items-container']}>
{Array(limit).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>
<div className={styles['see-all-container']} />
</div>
);
};
@ -24,7 +25,7 @@ const MetaRowPlaceholder = ({ className, title, maximumItemsCount }) => {
MetaRowPlaceholder.propTypes = {
className: PropTypes.string,
title: PropTypes.string,
maximumItemsCount: PropTypes.number
limit: PropTypes.number.isRequired
};
module.exports = MetaRowPlaceholder;

View file

@ -1,12 +1,5 @@
.meta-row-placeholder-container {
display: grid;
grid-template-columns: 6fr minmax(12rem, 1fr);
grid-template-areas:
"title-area title-area"
"meta-items-area see-all-area";
.title-container {
grid-area: title-area;
max-height: 2.4em;
margin-bottom: 2rem;
font-size: 1.5rem;
@ -18,29 +11,36 @@
}
}
.meta-items-container {
grid-area: meta-items-area;
.content-container {
display: flex;
flex-direction: row;
align-items: stretch;
.meta-item {
.meta-items-container {
flex: 1;
margin-right: 2rem;
background-color: var(--color-placeholder);
display: flex;
flex-direction: row;
align-items: stretch;
.poster-container {
padding-bottom: calc(100% * var(--poster-shape-ratio));
}
.title-bar-container {
height: 2.8rem;
.meta-item {
flex: 1;
margin-right: 2rem;
background-color: var(--color-placeholder);
.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);
.see-all-container {
flex: none;
background-color: var(--color-placeholder);
}
}
}

View file

@ -1,14 +1,7 @@
.meta-row-container {
display: grid;
grid-template-columns: 6fr minmax(12rem, 1fr);
grid-template-areas:
"title-area title-area"
"message-area message-area"
"meta-items-area see-all-area";
overflow: visible;
.title-container {
grid-area: title-area;
max-height: 2.4em;
margin-bottom: 2rem;
font-size: 1.5rem;
@ -16,66 +9,71 @@
}
.message-container {
grid-area: message-area;
max-height: 3.6em;
font-size: 1.3rem;
color: var(--color-surfacelighter);
}
.meta-items-container {
grid-area: meta-items-area;
.content-container {
display: flex;
flex-direction: row;
align-items: stretch;
overflow: visible;
.meta-item {
margin-right: 2rem;
.meta-items-container {
flex: 1;
display: flex;
flex-direction: row;
align-items: stretch;
overflow: visible;
&.poster-shape-poster {
flex: calc(1 / var(--poster-shape-ratio));
}
.meta-item {
margin-right: 2rem;
&.poster-shape-square {
flex: 1;
}
&.poster-shape-poster {
flex: calc(1 / var(--poster-shape-ratio));
}
&.poster-shape-landscape {
flex: calc(1 / var(--landscape-shape-ratio));
&.poster-shape-square {
flex: 1;
}
&.poster-shape-landscape {
flex: calc(1 / var(--landscape-shape-ratio));
}
}
}
}
.see-all-container {
grid-area: see-all-area;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: var(--color-backgroundlight);
&:hover, &:focus {
outline-offset: 0;
background-color: var(--color-backgroundlighter);
}
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
max-height: 2.4em;
font-size: 1.2rem;
text-align: center;
color: var(--color-surfacelighter);
}
.icon {
.see-all-container {
flex: none;
width: 1.3rem;
height: 1.3rem;
margin-left: 0.3rem;
fill: var(--color-surfacelighter);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: var(--color-backgroundlight);
&:hover, &:focus {
background-color: var(--color-backgroundlighter);
}
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
max-height: 2.4em;
font-size: 1.2rem;
text-align: center;
color: var(--color-surfacelighter);
}
.icon {
flex: none;
width: 1.3rem;
height: 1.3rem;
margin-left: 0.3rem;
fill: var(--color-surfacelighter);
}
}
}
}

View file

@ -0,0 +1,119 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Button = require('stremio/common/Button');
const Icon = require('stremio-icons/dom');
const { Modal } = require('stremio-router');
const styles = require('./styles');
const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequest, ...props }) => {
const onModalDialogContainerMouseDown = React.useCallback((event) => {
event.nativeEvent.closeModalDialogPrevented = true;
}, []);
const closeButtonOnClick = React.useCallback((event) => {
if (typeof onCloseRequest === 'function') {
onCloseRequest({
type: 'close',
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}, [dataset, onCloseRequest]);
React.useEffect(() => {
const onCloseEvent = (event) => {
if (!event.closeModalDialogPrevented && typeof onCloseRequest === 'function') {
const closeEvent = {
type: 'close',
dataset: dataset,
nativeEvent: event
};
switch (event.type) {
case 'resize':
onCloseRequest(closeEvent);
break;
case 'keydown':
if (event.key === 'Escape') {
onCloseRequest(closeEvent);
}
break;
case 'mousedown':
if (event.target !== document.documentElement) {
onCloseRequest(closeEvent);
}
break;
}
}
};
window.addEventListener('resize', onCloseEvent);
window.addEventListener('keydown', onCloseEvent);
window.addEventListener('mousedown', onCloseEvent);
return () => {
window.removeEventListener('resize', onCloseEvent);
window.removeEventListener('keydown', onCloseEvent);
window.removeEventListener('mousedown', onCloseEvent);
};
}, [dataset, onCloseRequest]);
return (
<Modal {...props} className={classnames(className, styles['modal-container'])}>
<div className={styles['modal-dialog-container']} onMouseDown={onModalDialogContainerMouseDown}>
<div className={styles['header-container']}>
{
typeof title === 'string' && title.length > 0 ?
<div className={styles['title-container']} title={title}>{title}</div>
:
null
}
<Button className={styles['close-button-container']} title={'Close'} onClick={closeButtonOnClick}>
<Icon className={styles['icon']} icon={'ic_x'} />
</Button>
</div>
<div className={styles['modal-dialog-content']}>
{children}
{
Array.isArray(buttons) && buttons.length > 0 ?
<div className={styles['buttons-container']}>
{buttons.map(({ className, label, icon, props }, index) => (
<Button title={label} {...props} key={index} className={classnames(className, styles['action-button'])}>
{
typeof icon === 'string' && icon.length > 0 ?
<Icon className={styles['icon']} icon={icon} />
:
null
}
{
typeof label === 'string' && label.length > 0 ?
<div className={styles['label']}>{label}</div>
:
null
}
</Button>
))}
</div>
:
null
}
</div>
</div>
</Modal>
);
};
ModalDialog.propTypes = {
className: PropTypes.string,
title: PropTypes.string,
buttons: PropTypes.arrayOf(PropTypes.shape({
className: PropTypes.string,
label: PropTypes.string,
icon: PropTypes.string,
props: PropTypes.object
})),
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]),
dataset: PropTypes.objectOf(PropTypes.string),
onCloseRequest: PropTypes.func
};
module.exports = ModalDialog;

View file

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

View file

@ -0,0 +1,117 @@
.modal-container {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--color-backgrounddark40);
.modal-dialog-container {
flex: none;
display: flex;
flex-direction: column;
max-width: 80%;
max-height: 80%;
background-color: var(--color-surfacelighter);
.header-container {
flex: none;
align-self: stretch;
display: flex;
flex-direction: row;
justify-content: flex-end;
padding: 0.2rem 0.2rem 0;
.title-container {
flex: 1;
align-self: center;
max-height: 2.4em;
padding: 0 0.5rem;
font-size: 1.2rem;
color: var(--color-backgrounddarker);
}
.close-button-container {
flex: none;
align-self: flex-start;
width: 2rem;
height: 2rem;
padding: 0.5rem;
.icon {
display: block;
width: 100%;
height: 100%;
fill: var(--color-surfacedark);
}
&:hover, &:focus {
background-color: var(--color-surfacelight);
.icon {
fill: var(--color-surfacedarker);
}
}
&:focus {
outline-color: var(--color-surfacedark);
}
}
}
.modal-dialog-content {
flex: 1;
align-self: stretch;
margin: 2rem 1rem;
padding: 0 1rem;
overflow-y: auto;
.buttons-container {
margin-top: 1rem;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
}
}
}
.action-button {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: var(--color-signal5);
&:focus {
outline-width: calc(1.5 * var(--focus-outline-size));
outline-color: var(--color-surfacelighter);
outline-offset: calc(-2 * var(--focus-outline-size));
}
&:hover {
filter: brightness(1.2);
}
&:not(:last-child) {
margin-right: 1rem;
}
.icon {
flex: none;
width: 1.2rem;
height: 1.2rem;
margin-right: .5rem;
fill: var(--color-surfacelighter);
}
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
max-height: 3.6em;
font-size: 1.1rem;
text-align: center;
color: var(--color-surfacelighter);
}
}

View file

@ -5,32 +5,46 @@ 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 Multiselect = ({ className, direction, title, disabled, dataset, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const options = React.useMemo(() => {
return Array.isArray(props.options) ?
props.options.filter((option) => {
return option && typeof option.value === 'string';
})
:
[];
}, [props.options]);
const selected = React.useMemo(() => {
return Array.isArray(props.selected) ?
props.selected.filter((value) => {
return typeof value === 'string';
})
:
[];
}, [props.selected]);
const popupLabelOnClick = React.useCallback((event) => {
if (typeof props.onClick === 'function') {
props.onClick(event);
}
if (!event.nativeEvent.togglePopupPrevented) {
toggleMenu();
}
}, [toggleMenu]);
}, [props.onClick, toggleMenu]);
const popupMenuOnClick = React.useCallback((event) => {
event.nativeEvent.togglePopupPrevented = true;
}, []);
const popupMenuOnKeyDown = React.useCallback((event) => {
event.nativeEvent.buttonClickPrevented = true;
}, []);
const optionOnClick = React.useCallback((event) => {
if (typeof onSelect === 'function') {
onSelect({
type: 'select',
value: event.currentTarget.dataset.value,
reactEvent: event,
nativeEvent: event.nativeEvent,
dataset: dataset
@ -40,31 +54,36 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
if (!event.nativeEvent.closeMenuPrevented) {
closeMenu();
}
}, [onSelect, dataset]);
}, [dataset, onSelect]);
const mountedRef = React.useRef(false);
React.useLayoutEffect(() => {
if (menuOpen) {
if (typeof onOpen === 'function') {
onOpen({
type: 'open',
dataset: dataset
});
}
} else {
if (typeof onClose === 'function') {
onClose({
type: 'close',
dataset: dataset
});
if (mountedRef.current) {
if (menuOpen) {
if (typeof onOpen === 'function') {
onOpen({
type: 'open',
dataset: dataset
});
}
} else {
if (typeof onClose === 'function') {
onClose({
type: 'close',
dataset: dataset
});
}
}
}
mountedRef.current = true;
}, [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}>
renderLabel={({ children, ...labelProps }) => (
<Button {...labelProps} {...props} className={classnames(className, labelProps.className, styles['label-container'], { 'active': menuOpen })} title={title} disabled={disabled} onClick={popupLabelOnClick}>
{
typeof renderLabelContent === 'function' ?
renderLabelContent()
@ -72,16 +91,19 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
<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(', ')
typeof renderLabelText === 'function' ?
renderLabelText()
:
title
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'} />
@ -91,13 +113,20 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
</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 className={styles['menu-container']} onKeyDown={popupMenuOnKeyDown} onClick={popupMenuOnClick}>
{
options.length > 0 ?
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 className={styles['no-options-container']}>
<div className={styles['label']}>No options available</div>
</div>
}
</div>
)}
/>
@ -108,15 +137,19 @@ 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),
disabled: PropTypes.bool,
dataset: PropTypes.objectOf(PropTypes.string),
renderLabelContent: PropTypes.func,
renderLabelText: PropTypes.func,
onOpen: PropTypes.func,
onClose: PropTypes.func,
onSelect: PropTypes.func
onSelect: PropTypes.func,
onClick: PropTypes.func
};
module.exports = Multiselect;

View file

@ -73,6 +73,24 @@
fill: var(--color-surfacelighter);
}
}
.no-options-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: var(--color-backgroundlighter);
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
font-size: 1.2rem;
text-align: center;
color: var(--color-surfacelighter);
}
}
}
}
}

View file

@ -6,13 +6,13 @@ const Button = require('stremio/common/Button');
const Popup = require('stremio/common/Popup');
const useBinaryState = require('stremio/common/useBinaryState');
const useFullscreen = require('stremio/common/useFullscreen');
const useUser = require('./useUser');
const useUser = require('stremio/common/useUser');
const styles = require('./styles');
const NavMenu = ({ className }) => {
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen();
const user = useUser();
const [user, logout] = useUser();
const popupLabelOnClick = React.useCallback((event) => {
if (!event.nativeEvent.togglePopupPrevented) {
toggleMenu();
@ -21,10 +21,12 @@ const NavMenu = ({ className }) => {
const popupMenuOnClick = React.useCallback((event) => {
event.nativeEvent.togglePopupPrevented = true;
}, []);
const logoutButtonOnClick = React.useCallback(() => {
logout();
}, []);
return (
<Popup
open={menuOpen}
direction={'bottom'}
onCloseRequest={closeMenu}
renderLabel={({ ref, className: popupLabelClassName, children }) => (
<Button ref={ref} className={classnames(className, popupLabelClassName, styles['nav-menu-label-container'], { 'active': menuOpen })} tabIndex={-1} onClick={popupLabelOnClick}>
@ -38,17 +40,17 @@ const NavMenu = ({ className }) => {
<div
className={styles['avatar-container']}
style={{
backgroundImage: user.anonymous ?
`url('/images/anonymous.png')`
backgroundImage: user === null ?
'url(\'/images/anonymous.png\')'
:
`url('${user.avatar}'), url('/images/default_avatar.png')`
}}
/>
<div className={styles['email-container']}>
<div className={styles['email-label']}>{user.anonymous ? 'Anonymous user' : user.email}</div>
<div className={styles['email-label']}>{user === null ? 'Anonymous user' : user.email}</div>
</div>
<Button className={styles['logout-button-container']} title={user.anonymous ? 'Log in / Sign up' : 'Log out'} href={'#/intro'} onClick={user.logout}>
<div className={styles['logout-label']}>{user.anonymous ? 'Log in / Sign up' : 'Log out'}</div>
<Button className={styles['logout-button-container']} title={user === null ? 'Log in / Sign up' : 'Log out'} href={'#/intro'} onClick={logoutButtonOnClick}>
<div className={styles['logout-label']}>{user === null ? 'Log in / Sign up' : 'Log out'}</div>
</Button>
</div>
<div className={styles['nav-menu-section']}>

View file

@ -1,13 +0,0 @@
const React = require('react');
const useUser = () => {
const [user] = React.useState({
email: '',
avatar: '',
anonymous: true,
logout: () => { }
});
return user;
};
module.exports = useUser;

View file

@ -80,7 +80,7 @@ const Notification = ({ className, id, type, name, poster, thumbnail, season, ep
}
</Button>
);
}
};
Notification.propTypes = {
className: PropTypes.string,

View file

@ -1,7 +1,6 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const styles = require('./styles');
const NotificationPlaceholder = ({ className }) => {
@ -20,6 +19,6 @@ const NotificationPlaceholder = ({ className }) => {
NotificationPlaceholder.propTypes = {
className: PropTypes.string
}
};
module.exports = NotificationPlaceholder;

View file

@ -57,7 +57,7 @@ const NotificationsList = ({ className, metaItems }) => {
}
</div>
);
}
};
NotificationsList.propTypes = {
className: PropTypes.string,

View file

@ -5,15 +5,15 @@ const Icon = require('stremio-icons/dom');
const Button = require('stremio/common/Button');
const Popup = require('stremio/common/Popup');
const NotificationsList = require('./NotificationsList');
const useNotifications = require('./useNotifications');
const useCatalogs = require('stremio/routes/Board/useCatalogs');
// const useNotifications = require('./useNotifications');
// const useCatalogs = require('stremio/routes/Board/useCatalogs');
const useBinaryState = require('stremio/common/useBinaryState');
const styles = require('./styles');
const NotificationsMenu = ({ className, onClearButtonClicked }) => {
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
//TODO use useNotifications hook instead of useCatalogs
const metaItems = useCatalogs();
const metaItems = []; //useCatalogs();
return (
<Popup

View file

@ -20,7 +20,7 @@ const SearchBar = ({ className }) => {
if (routeActive) {
const { search: locationSearch } = UrlUtils.parse(locationHash.slice(1));
const queryParams = new URLSearchParams(locationSearch);
return queryParams.has('q') ? queryParams.get('q') : '';
return queryParams.has('search') ? queryParams.get('search') : '';
}
return '';
@ -32,7 +32,7 @@ const SearchBar = ({ className }) => {
}, [routeActive]);
const queryInputOnSubmit = React.useCallback(() => {
if (routeActive) {
window.location.replace(`#/search?q=${searchInputRef.current.value}`);
window.location.replace(`#/search?search=${searchInputRef.current.value}`);
}
}, [routeActive]);
React.useEffect(() => {

View file

@ -0,0 +1,42 @@
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 styles = require('./styles');
const PaginationInput = ({ className, label, dataset, onSelect, ...props }) => {
const prevNextButtonOnClick = React.useCallback((event) => {
if (typeof onSelect === 'function') {
onSelect({
type: 'change-page',
value: event.currentTarget.dataset.value,
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}, [dataset, onSelect]);
return (
<div {...props} className={classnames(className, styles['pagination-input-container'])} >
<Button className={styles['prev-button-container']} title={'Previous page'} data-value={'prev'} onClick={prevNextButtonOnClick}>
<Icon className={styles['icon']} icon={'ic_arrow_left'} />
</Button>
<div className={styles['label-container']} title={label}>
<div className={styles['label']}>{label}</div>
</div>
<Button className={styles['next-button-container']} title={'Next page'} data-value={'next'} onClick={prevNextButtonOnClick}>
<Icon className={styles['icon']} icon={'ic_arrow_right'} />
</Button>
</div>
);
};
PaginationInput.propTypes = {
className: PropTypes.string,
label: PropTypes.string,
dataset: PropTypes.objectOf(PropTypes.string),
onSelect: PropTypes.func
};
module.exports = PaginationInput;

View file

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

View file

@ -0,0 +1,34 @@
.pagination-input-container {
display: flex;
flex-direction: row;
.prev-button-container, .next-button-container {
flex: none;
display: flex;
align-items: center;
justify-content: center;
.icon {
display: block;
fill: var(--color-surfacelighter);
}
}
.label-container {
flex: 1;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
.label {
flex: none;
min-width: 1.2rem;
max-width: 3rem;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
color: var(--color-surfacelighter);
}
}
}

View file

@ -2,20 +2,15 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const FocusLock = require('react-focus-lock').default;
const useDataset = require('stremio/common/useDataset');
const styles = require('./styles');
const Popup = ({ open, direction, renderLabel, renderMenu, onCloseRequest, ...props }) => {
direction = ['top', 'bottom'].includes(direction) ? direction : null;
const dataset = useDataset(props);
const Popup = ({ open, direction, renderLabel, renderMenu, dataset, onCloseRequest, ...props }) => {
const labelRef = React.useRef(null);
const menuRef = React.useRef(null);
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 onCloseEvent = (event) => {
if (!event.closePopupPrevented && typeof onCloseRequest === 'function') {
@ -54,33 +49,61 @@ const Popup = ({ open, direction, renderLabel, renderMenu, onCloseRequest, ...pr
}, [open, onCloseRequest, dataset]);
React.useLayoutEffect(() => {
if (open) {
const autoDirection = [];
const documentRect = document.documentElement.getBoundingClientRect();
const labelRect = labelRef.current.getBoundingClientRect();
const labelOffsetTop = labelRect.top - documentRect.top;
const labelOffsetBottom = (documentRect.height + documentRect.top) - (labelRect.top + labelRect.height);
const autoDirection = labelOffsetBottom >= labelOffsetTop ? 'bottom' : 'top';
setAutoDirection(autoDirection);
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)
};
if (menuRect.height <= labelPosition.bottom) {
autoDirection.push('bottom');
} else if (menuRect.height <= labelPosition.top) {
autoDirection.push('top');
} else if (labelPosition.bottom >= labelPosition.top) {
autoDirection.push('bottom');
} else {
autoDirection.push('top');
}
if (menuRect.width <= (labelPosition.right + labelRect.width)) {
autoDirection.push('right');
} else if (menuRect.width <= (labelPosition.left + labelRect.width)) {
autoDirection.push('left');
} else if (labelPosition.right > labelPosition.left) {
autoDirection.push('right');
} else {
autoDirection.push('left');
}
setAutoDirection(autoDirection.join('-'));
} else {
setAutoDirection(null);
}
}, [open]);
return renderLabel({
...props,
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 }}>
<FocusLock ref={menuRef} className={classnames(styles['menu-container'], styles[`menu-direction-${autoDirection}`], styles[`menu-direction-${direction}`])} autoFocus={false} lockProps={{ onMouseDown: menuOnMouseDown }}>
{renderMenu()}
</FocusLock>
:
null
});
}
};
Popup.propTypes = {
open: PropTypes.bool,
direction: PropTypes.oneOf(['top', 'bottom']),
direction: PropTypes.oneOf(['top-left', 'bottom-left', 'top-right', 'bottom-right']),
renderLabel: PropTypes.func.isRequired,
renderMenu: PropTypes.func.isRequired,
dataset: PropTypes.objectOf(PropTypes.string),
onCloseRequest: PropTypes.func
};

View file

@ -4,7 +4,6 @@
.menu-container {
position: absolute;
right: 0;
z-index: 1;
overflow: visible;
visibility: hidden;
@ -12,13 +11,35 @@
0 1.1rem 0.85rem var(--color-backgrounddarker20);
cursor: auto;
&.menu-direction-bottom {
top: 100%;
&.menu-direction-top-left {
top: initial;
right: 0;
bottom: 100%;
left: initial;
visibility: visible;
}
&.menu-direction-top {
&.menu-direction-bottom-left {
top: 100%;
right: 0;
bottom: initial;
left: initial;
visibility: visible;
}
&.menu-direction-top-right {
top: initial;
right: initial;
bottom: 100%;
left: 0;
visibility: visible;
}
&.menu-direction-bottom-right {
top: 100%;
right: initial;
bottom: initial;
left: 0;
visibility: visible;
}
}

View file

@ -2,55 +2,56 @@ const React = require('react');
const PropTypes = require('prop-types');
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/Button');
const TextInput = require('stremio/common/TextInput');
const styles = require('./styles');
const SharePrompt = ({ className, label, url, close }) => {
const SharePrompt = ({ className, url }) => {
const inputRef = React.useRef(null);
const focusable = useFocusable();
const routeFocused = useRouteFocused();
const selectInputContent = React.useCallback(() => {
if (inputRef.current !== null) {
inputRef.current.select();
}
}, []);
const copyToClipboard = React.useCallback(() => {
inputRef.current.select();
document.execCommand('copy');
if (inputRef.current !== null) {
inputRef.current.select();
document.execCommand('copy');
}
}, []);
React.useEffect(() => {
const onKeyUp = (event) => {
if (event.key === 'Escape' && typeof close === 'function') {
close();
}
};
if (focusable) {
window.addEventListener('keyup', onKeyUp);
if (routeFocused && inputRef.current !== null) {
inputRef.current.select();
}
return () => {
window.removeEventListener('keyup', onKeyUp);
};
}, [close, focusable]);
}, []);
return (
<div className={classnames(className, styles['share-prompt-container'])}>
<Button className={styles['close-button-container']}>
<Icon className={styles['icon']} icon={'ic_x'} onClick={close} />
</Button>
<div className={styles['share-prompt-content']}>
<div className={styles['share-prompt-label']}>{label}</div>
<div className={styles['buttons-container']}>
<Button className={classnames(styles['button-container'], styles['facebook-button'])} href={`https://www.facebook.com/sharer/sharer.php?u=${url}`} target={'_blank'}>
<Icon className={styles['icon']} icon={'ic_facebook'} />
<div className={styles['label']}>FACEBOOK</div>
</Button>
<Button className={classnames(styles['button-container'], styles['twitter-button'])} href={`https://twitter.com/home?status=${url}`} target={'_blank'}>
<Icon className={styles['icon']} icon={'ic_twitter'} />
<div className={styles['label']}>TWITTER</div>
</Button>
</div>
<div className={styles['url-container']}>
<TextInput ref={inputRef} className={styles['url-content']} type={'text'} tabIndex={'-1'} defaultValue={url} readOnly />
<Button className={styles['copy-button']} onClick={copyToClipboard}>
<Icon className={styles['icon']} icon={'ic_link'} />
<div className={styles['label']}>Copy</div>
</Button>
</div>
<div className={styles['buttons-container']}>
<Button className={classnames(styles['button-container'], styles['facebook-button'])} title={'Facebook'} href={`https://www.facebook.com/sharer/sharer.php?u=${url}`} target={'_blank'}>
<Icon className={styles['icon']} icon={'ic_facebook'} />
<div className={styles['label']}>Facebook</div>
</Button>
<Button className={classnames(styles['button-container'], styles['twitter-button'])} title={'Twitter'} href={`https://twitter.com/home?status=${url}`} target={'_blank'}>
<Icon className={styles['icon']} icon={'ic_twitter'} />
<div className={styles['label']}>Twitter</div>
</Button>
</div>
<div className={styles['url-container']}>
<TextInput
ref={inputRef}
className={styles['url-text-input']}
type={'text'}
readOnly={true}
defaultValue={url}
onClick={selectInputContent}
tabIndex={-1}
/>
<Button className={styles['copy-button']} title={'Copy to clipboard'} onClick={copyToClipboard}>
<Icon className={styles['icon']} icon={'ic_link'} />
<div className={styles['label']}>Copy</div>
</Button>
</div>
</div>
);
@ -58,9 +59,7 @@ const SharePrompt = ({ className, label, url, close }) => {
SharePrompt.propTypes = {
className: PropTypes.string,
label: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
close: PropTypes.func
url: PropTypes.string
};
module.exports = SharePrompt;

View file

@ -1,133 +1,110 @@
.share-prompt-container {
position: relative;
z-index: 0;
display: flex;
flex-direction: column;
padding: 2.4rem 0;
background-color: var(--color-surfacelighter);
min-width: 18rem;
background: var(--color-surfacelighter);
.close-button-container {
position: absolute;
top: 0.4rem;
right: 0.4rem;
z-index: 1;
width: 2rem;
height: 2rem;
padding: 0.4rem;
.buttons-container {
display: flex;
flex-direction: row;
&:hover {
background-color: var(--color-surfacelight);
.button-container {
flex-grow: 0;
flex-shrink: 1;
flex-basis: 14rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1rem;
&:not(:last-child) {
margin-right: 1rem;
}
&:hover {
filter: brightness(1.2);
}
&:focus {
outline: calc(1.5 * var(--focus-outline-size)) solid var(--color-surfacelighter);
outline-offset: calc(-2 * var(--focus-outline-size));
}
.icon {
flex: none;
height: 1.2rem;
margin-right: 1rem;
fill: var(--color-surfacelighter);
}
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
max-height: 2.4em;
font-weight: 500;
text-align: center;
color: var(--color-surfacelighter);
}
}
.icon {
display: block;
width: 100%;
height: 100%;
fill: var(--color-backgrounddarker);
.facebook-button {
background-color: var(--color-facebook);
}
.twitter-button {
background-color: var(--color-twitter);
}
}
.share-prompt-content {
padding: 0 2.4rem;
.url-container {
display: flex;
flex-direction: row;
margin-top: 1rem;
border: thin solid var(--color-surface);
.share-prompt-label {
font-size: 1.3rem;
color: var(--color-backgrounddarker);
.url-text-input {
flex: 1;
align-self: stretch;
padding: 1rem;
color: var(--color-backgroundlighter);
text-align: center;
border-right: thin solid var(--color-surface);
}
.buttons-container {
.copy-button {
flex: none;
align-self: stretch;
display: flex;
flex-direction: row;
margin: 1.4rem 0;
align-items: center;
justify-content: center;
width: 8rem;
padding: 0.5rem;
background-color: var(--color-surface);
.button-container {
flex-grow: 0;
flex-shrink: 1;
flex-basis: 14rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 0.6rem 1rem;
.icon {
flex: none;
width: 1.4rem;
height: 1.4rem;
margin-right: 0.6rem;
fill: var(--color-surfacelighter);
}
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
font-size: 0.8rem;
font-weight: 500;
color: var(--color-surfacelighter);
text-align: center;
}
&:hover, &:focus {
filter: brightness(1.2);
}
&:not(:last-child) {
margin-right: 2rem;
}
&:hover {
filter: brightness(1.2);
}
.facebook-button {
background-color: var(--color-facebook);
&:focus {
outline: calc(1.5 * var(--focus-outline-size)) solid var(--color-surfacelighter);
outline-offset: calc(-1.5 * var(--focus-outline-size));
}
.twitter-button {
background-color: var(--color-twitter);
}
}
.url-container {
display: flex;
flex-direction: row;
border: thin solid var(--color-surface);
.url-content {
flex: 1;
min-width: 12rem;
padding: 0.6rem 1rem;
font-size: 0.9rem;
color: var(--color-surfacedark);
text-align: center;
.icon {
flex: none;
width: 1.2rem;
height: 1.2rem;
margin-right: 0.5rem;
fill: var(--color-surfacedarker);
}
.copy-button {
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 0.6rem 1rem;
background-color: var(--color-surface);
.icon {
flex: none;
width: 1.4rem;
height: 1.4rem;
margin-right: 0.6rem;
fill: var(--color-surfacedarker);
}
.label {
color: var(--color-surfacedarker);
}
&:hover, &:focus {
filter: brightness(1.2);
}
max-height: 2.4em;
color: var(--color-surfacedarker);
}
}
}

View file

@ -1,17 +1,18 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const styles = require('./styles');
const TextInput = React.forwardRef((props, ref) => {
const onKeyUp = React.useCallback((event) => {
if (typeof props.onKeyUp === 'function') {
props.onKeyUp(event);
const onKeyDown = React.useCallback((event) => {
if (typeof props.onKeyDown === 'function') {
props.onKeyDown(event);
}
if (event.key === 'Enter' && !event.nativeEvent.submitPrevented && typeof props.onSubmit === 'function') {
props.onSubmit(event);
}
}, [props.onKeyUp, props.onSubmit]);
}, [props.onKeyDown, props.onSubmit]);
return (
<input
size={1}
@ -23,11 +24,18 @@ const TextInput = React.forwardRef((props, ref) => {
{...props}
ref={ref}
className={classnames(props.className, styles['text-input'], { 'disabled': props.disabled })}
onKeyUp={onKeyUp}
onKeyDown={onKeyDown}
/>
);
});
TextInput.displayName = 'TextInput';
TextInput.propTypes = {
className: PropTypes.string,
disabled: PropTypes.bool,
onKeyDown: PropTypes.func,
onSubmit: PropTypes.func
};
module.exports = TextInput;

View file

@ -6,8 +6,10 @@ const MainNavBar = require('./MainNavBar');
const MetaItem = require('./MetaItem');
const MetaPreview = require('./MetaPreview');
const MetaRow = require('./MetaRow');
const ModalDialog = require('./ModalDialog');
const Multiselect = require('./Multiselect');
const NavBar = require('./NavBar');
const PaginationInput = require('./PaginationInput');
const PlayIconCircleCentered = require('./PlayIconCircleCentered');
const Popup = require('./Popup');
const SharePrompt = require('./SharePrompt');
@ -17,12 +19,15 @@ const Toast = require('./Toast');
const routesRegexp = require('./routesRegexp');
const useAnimationFrame = require('./useAnimationFrame');
const useBinaryState = require('./useBinaryState');
const useDataset = require('./useDataset');
const useDeepEqualState = require('./useDeepEqualState');
const useFullscreen = require('./useFullscreen');
const useInLibrary = require('./useInLibrary');
const useLiveRef = require('./useLiveRef');
const useLocationHash = require('./useLocationHash');
const useModelState = require('./useModelState');
const useRouteActive = require('./useRouteActive');
const useSpreadState = require('./useSpreadState');
const useUser = require('./useUser');
module.exports = {
Button,
@ -33,8 +38,10 @@ module.exports = {
MetaItem,
MetaPreview,
MetaRow,
ModalDialog,
Multiselect,
NavBar,
PaginationInput,
PlayIconCircleCentered,
Popup,
SharePrompt,
@ -44,11 +51,13 @@ module.exports = {
routesRegexp,
useAnimationFrame,
useBinaryState,
useDataset,
useDeepEqualState,
useFullscreen,
useInLibrary,
useLiveRef,
useLocationHash,
useModelState,
useRouteActive,
useLiveRef,
useSpreadState
useSpreadState,
useUser
};

View file

@ -1,38 +1,38 @@
const routesRegexp = {
intro: {
regexp: /^\/intro\/?$/i,
regexp: /^\/intro$/,
urlParamsNames: []
},
board: {
regexp: /^\/?$/i,
regexp: /^\/$/,
urlParamsNames: []
},
discover: {
regexp: /^\/discover(?:\/([^\/]*?))?(?:\/([^\/]*?))?\/?$/i,
urlParamsNames: ['type', 'catalog']
regexp: /^\/discover(?:\/([^/]*)\/([^/]*)\/([^/]*))?$/,
urlParamsNames: ['addonTransportUrl', 'type', 'catalogId']
},
library: {
regexp: /^\/library(?:\/([^\/]*?))?\/?$/i,
regexp: /^\/library(?:\/([^/]*))?$/,
urlParamsNames: ['type']
},
search: {
regexp: /^\/search\/?$/i,
regexp: /^\/search$/,
urlParamsNames: []
},
detail: {
regexp: /^\/detail\/(?:([^\/]+?))\/(?:([^\/]+?))(?:\/([^\/]*?))?\/?$/i,
metadetails: {
regexp: /^\/metadetails\/([^/]*)\/([^/]*)(?:\/([^/]*))?$/,
urlParamsNames: ['type', 'id', 'videoId']
},
addons: {
regexp: /^\/addons(?:\/([^\/]*?))?(?:\/([^\/]*?))?\/?$/i,
urlParamsNames: ['category', 'type']
regexp: /^\/addons(?:\/([^/]*)\/([^/]*)\/([^/]*))?$/,
urlParamsNames: ['addonTransportUrl', 'catalogId', 'type']
},
settings: {
regexp: /^\/settings\/?$/i,
regexp: /^\/settings$/,
urlParamsNames: []
},
player: {
regexp: /^\/player\/(?:([^\/]+?))\/(?:([^\/]+?))\/(?:([^\/]+?))\/(?:([^\/]+?))\/?$/i,
regexp: /^\/player\/([^/]*)\/([^/]*)\/([^/]*)\/([^/]*)$/,
urlParamsNames: ['type', 'id', 'videoId', 'stream']
}
};

View file

@ -1,20 +0,0 @@
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;

View file

@ -0,0 +1,22 @@
const React = require('react');
const isEqual = require('lodash.isequal');
const useDeepEqualState = (initialState) => {
return React.useReducer(
(prevState, nextState) => {
return isEqual(prevState, nextState) ?
prevState
:
nextState;
},
undefined,
() => {
return typeof initialState === 'function' ?
initialState()
:
initialState;
}
);
};
module.exports = useDeepEqualState;

View file

@ -0,0 +1,60 @@
const React = require('react');
const { useServices } = require('stremio/services');
const useModelState = require('stremio/common/useModelState');
const useInLibrary = (metaItem) => {
const { core } = useServices();
const initLibraryItemsState = React.useCallback(() => {
return core.getState('library_items');
}, []);
const libraryItems = useModelState({
model: 'library_items',
init: initLibraryItemsState
});
const addToLibrary = React.useCallback((metaItem) => {
core.dispatch({
action: 'UserOp',
args: {
userOp: 'AddToLibrary',
args: {
meta_item: metaItem,
now: new Date()
}
}
});
}, []);
const removeFromLibrary = React.useCallback((id) => {
core.dispatch({
action: 'UserOp',
args: {
userOp: 'RemoveFromLibrary',
args: {
id,
now: new Date()
}
}
});
}, []);
const inLibrary = React.useMemo(() => {
return typeof metaItem === 'object' && metaItem !== null ?
libraryItems.ids.includes(metaItem.id)
:
false;
}, [metaItem, libraryItems]);
const toggleInLibrary = React.useMemo(() => {
if (typeof metaItem !== 'object' || metaItem === null) {
return null;
}
return () => {
if (inLibrary) {
removeFromLibrary(metaItem.id);
} else {
addToLibrary(metaItem);
}
};
}, [metaItem, inLibrary]);
return [inLibrary, toggleInLibrary];
};
module.exports = useInLibrary;

View file

@ -2,7 +2,7 @@ const React = require('react');
const useLiveRef = (value, dependencies) => {
const ref = React.useRef(value);
React.useEffect(() => {
React.useLayoutEffect(() => {
ref.current = value;
}, dependencies);
return ref;

View file

@ -0,0 +1,62 @@
const React = require('react');
const throttle = require('lodash.throttle');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const useDeepEqualState = require('stremio/common/useDeepEqualState');
const UNLOAD_ACTION = {
action: 'Unload',
};
const useModelState = ({ model, action, timeout, onNewState, map, mapWithCtx, init }) => {
const modelRef = React.useRef(model);
const mountedRef = React.useRef(false);
const { core } = useServices();
const routeFocused = useRouteFocused();
const [state, setState] = useDeepEqualState(init);
React.useLayoutEffect(() => {
core.dispatch(action, modelRef.current);
}, [action]);
React.useLayoutEffect(() => {
return () => {
core.dispatch(UNLOAD_ACTION, modelRef.current);
};
}, []);
React.useLayoutEffect(() => {
const onNewStateThrottled = throttle(() => {
const state = core.getState(modelRef.current);
if (typeof onNewState === 'function') {
const action = onNewState(state);
const handled = core.dispatch(action, modelRef.current);
if (handled) {
return;
}
}
if (typeof mapWithCtx === 'function') {
const ctx = core.getState('ctx');
setState(mapWithCtx(state, ctx));
} else if (typeof map === 'function') {
setState(map(state));
} else {
setState(state);
}
}, timeout);
if (routeFocused) {
core.on('NewState', onNewStateThrottled);
if (mountedRef.current) {
onNewStateThrottled.call();
}
}
return () => {
onNewStateThrottled.cancel();
core.off('NewState', onNewStateThrottled);
};
}, [routeFocused]);
React.useLayoutEffect(() => {
mountedRef.current = true;
}, []);
return state;
};
module.exports = useModelState;

View file

@ -5,8 +5,8 @@ const useLocationHash = require('stremio/common/useLocationHash');
const useRouteActive = (routeRegexp) => {
const locationHash = useLocationHash();
const active = React.useMemo(() => {
const { pathname: locationPathname } = UrlUtils.parse(locationHash.slice(1));
return routeRegexp instanceof RegExp && !!locationPathname.match(routeRegexp);
const { pathname } = UrlUtils.parse(locationHash.slice(1));
return typeof pathname === 'string' && routeRegexp instanceof RegExp && !!pathname.match(routeRegexp);
}, [locationHash, routeRegexp]);
return active;
};

31
src/common/useUser.js Normal file
View file

@ -0,0 +1,31 @@
const React = require('react');
const { useServices } = require('stremio/services');
const useModelState = require('stremio/common/useModelState');
const mapUserState = (ctx) => {
return ctx.content.auth ? ctx.content.auth.user : null;
};
const useUser = () => {
const { core } = useServices();
const logout = React.useCallback(() => {
core.dispatch({
action: 'UserOp',
args: {
userOp: 'Logout'
}
});
}, []);
const initUserState = React.useCallback(() => {
const ctx = core.getState('ctx');
return mapUserState(ctx);
}, []);
const user = useModelState({
model: 'ctx',
map: mapUserState,
init: initUserState
});
return [user, logout];
};
module.exports = useUser;

View file

@ -12,6 +12,17 @@
<body>
<div id="app"></div>
<script>
window.fbAsyncInit = function() {
FB.init({
appId : '1537119779906825',
autoLogAppEvents : false,
xfbml : false,
version : 'v2.5'
});
};
</script>
<script async defer src="https://connect.facebook.net/en_US/sdk.js"></script>
<script type="text/javascript">
<%= compilation.assets['main.js'].source() %>
</script>

View file

@ -5,10 +5,10 @@ const classnames = require('classnames');
const FocusLock = require('react-focus-lock').default;
const { useModalsContainer } = require('../ModalsContainerContext');
const Modal = ({ className, autoFocus, disabled, children, ...props }) => {
const Modal = ({ className, autoFocus, children, ...props }) => {
const modalsContainer = useModalsContainer();
return ReactDOM.createPortal(
<FocusLock className={classnames(className, 'modal-container')} autoFocus={typeof autoFocus === 'boolean' ? autoFocus : false} disabled={disabled} lockProps={props}>
<FocusLock className={classnames(className, 'modal-container')} autoFocus={!!autoFocus} lockProps={props}>
{children}
</FocusLock>,
modalsContainer
@ -18,7 +18,6 @@ const Modal = ({ className, autoFocus, disabled, children, ...props }) => {
Modal.propTypes = {
className: PropTypes.string,
autoFocus: PropTypes.bool,
disabled: PropTypes.bool,
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node

View file

@ -3,45 +3,43 @@ const ReactIs = require('react-is');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const UrlUtils = require('url');
const isEqual = require('lodash.isequal');
const { RouteFocusedProvider } = require('../RouteFocusedContext');
const Route = require('../Route');
const routeConfigForPath = require('./routeConfigForPath');
const urlParamsForPath = require('./urlParamsForPath');
const Router = ({ className, onPathNotMatch, ...props }) => {
const [{ homePath, viewsConfig }] = React.useState(() => ({
const { homePath, viewsConfig } = React.useMemo(() => ({
homePath: props.homePath,
viewsConfig: props.viewsConfig
}));
const routeConfigForPath = React.useCallback((path) => {
for (const viewConfig of viewsConfig) {
for (const routeConfig of viewConfig) {
if (typeof path === 'string' && path.match(routeConfig.regexp)) {
return routeConfig;
}
}
}
return null;
}, []);
}), []);
const [views, setViews] = React.useState(() => {
return Array(viewsConfig.length).fill(null);
});
React.useEffect(() => {
React.useLayoutEffect(() => {
if (typeof homePath === 'string') {
const { pathname, path } = UrlUtils.parse(window.location.hash.slice(1));
if (homePath !== path) {
window.location.replace(`#${homePath}`);
const routeConfig = routeConfigForPath(pathname);
const routeConfig = typeof pathname === 'string' ?
routeConfigForPath(viewsConfig, pathname)
:
null;
if (routeConfig) {
window.location = `#${path}`;
}
}
}
}, []);
React.useEffect(() => {
React.useLayoutEffect(() => {
const onLocationHashChange = () => {
const { pathname, query } = UrlUtils.parse(window.location.hash.slice(1));
const queryParams = new URLSearchParams(typeof query === 'string' ? query : '');
const routeConfig = routeConfigForPath(pathname);
const routeConfig = typeof pathname === 'string' ?
routeConfigForPath(viewsConfig, pathname)
:
null;
if (!routeConfig) {
if (typeof onPathNotMatch === 'function') {
const component = onPathNotMatch();
@ -61,16 +59,7 @@ const Router = ({ className, onPathNotMatch, ...props }) => {
return;
}
const matches = pathname.match(routeConfig.regexp);
const urlParams = routeConfig.urlParamsNames.reduce((urlParams, name, index) => {
if (Array.isArray(matches) && typeof matches[index + 1] === 'string') {
urlParams[name] = matches[index + 1];
} else {
urlParams[name] = null;
}
return urlParams;
}, {});
const urlParams = urlParamsForPath(routeConfig, pathname);
const routeViewIndex = viewsConfig.findIndex((vc) => vc.includes(routeConfig));
const routeIndex = viewsConfig[routeViewIndex].findIndex((rc) => rc === routeConfig);
setViews((views) => {
@ -81,8 +70,14 @@ const Router = ({ className, onPathNotMatch, ...props }) => {
return {
key: `${routeViewIndex}${routeIndex}`,
component: routeConfig.component,
urlParams,
queryParams
urlParams: view !== null && isEqual(view.urlParams, urlParams) ?
view.urlParams
:
urlParams,
queryParams: view !== null && isEqual(Array.from(view.queryParams.entries()), Array.from(queryParams.entries())) ?
view.queryParams
:
queryParams
};
} else {
return null;
@ -100,7 +95,7 @@ const Router = ({ className, onPathNotMatch, ...props }) => {
<div className={classnames(className, 'routes-container')}>
{
views
.filter(view => view !== null)
.filter((view) => view !== null)
.map(({ key, component, urlParams, queryParams }, index, views) => (
<RouteFocusedProvider key={key} value={index === views.length - 1}>
<Route>

View file

@ -0,0 +1,13 @@
const routeConfigForPath = (viewsConfig, path) => {
for (const viewConfig of viewsConfig) {
for (const routeConfig of viewConfig) {
if (path.match(routeConfig.regexp)) {
return routeConfig;
}
}
}
return null;
};
module.exports = routeConfigForPath;

View file

@ -0,0 +1,14 @@
const urlParamsForPath = (routeConfig, path) => {
const matches = path.match(routeConfig.regexp);
return routeConfig.urlParamsNames.reduce((urlParams, name, index) => {
if (Array.isArray(matches) && typeof matches[index + 1] === 'string') {
urlParams[name] = decodeURIComponent(matches[index + 1]);
} else {
urlParams[name] = null;
}
return urlParams;
}, {});
};
module.exports = urlParamsForPath;

View file

@ -2,24 +2,54 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const { Button } = require('stremio/common');
const { Button, Image } = require('stremio/common');
const styles = require('./styles');
const Addon = ({ className, id, name, logo, description, types, version, transportUrl, installed, toggle }) => {
const onKeyUp = React.useCallback((event) => {
if (event.key === 'Enter' && typeof toggle === 'function') {
toggle(event);
const Addon = ({ className, id, name, version, logo, description, types, installed, onToggle, onShare, dataset }) => {
const toggleButtonOnClick = React.useCallback((event) => {
if (typeof onToggle === 'function') {
onToggle({
type: 'toggle',
nativeEvent: event.nativeEvent,
reactEvent: event,
dataset: dataset
});
}
}, [toggle]);
}, [onToggle, dataset]);
const shareButtonOnClick = React.useCallback((event) => {
if (typeof onShare === 'function') {
onShare({
type: 'share',
nativeEvent: event.nativeEvent,
reactEvent: event,
dataset: dataset
});
}
}, [onShare, dataset]);
const onKeyDown = React.useCallback((event) => {
if (event.key === 'Enter' && typeof onToggle === 'function') {
onToggle({
type: 'toggle',
nativeEvent: event.nativeEvent,
reactEvent: event,
dataset: dataset
});
}
}, [onToggle, dataset]);
const renderLogoFallback = React.useMemo(() => () => {
return (
<Icon className={styles['icon']} icon={'ic_addons'} />
);
}, []);
return (
<Button className={classnames(styles['addon-container'], className)} data-id={id} onKeyUp={onKeyUp}>
<Button className={classnames(className, styles['addon-container'])} onKeyDown={onKeyDown}>
<div className={styles['logo-container']}>
{
typeof logo === 'string' && logo.length > 0 ?
<img className={styles['logo']} src={logo} alt={' '} />
:
<Icon className={styles['icon']} icon={'ic_addons'} />
}
<Image
className={styles['logo']}
src={logo}
alt={' '}
renderFallback={renderLogoFallback}
/>
</div>
<div className={styles['info-container']}>
<div className={styles['name-container']} title={typeof name === 'string' && name.length > 0 ? name : id}>
@ -32,10 +62,10 @@ const Addon = ({ className, id, name, logo, description, types, version, transpo
null
}
{
Array.isArray(types) ?
Array.isArray(types) && types.length > 0 ?
<div className={styles['types-container']}>
{
types.length <= 1 ?
types.length === 1 ?
types.join('')
:
types.slice(0, -1).join(', ') + ' & ' + types[types.length - 1]
@ -52,10 +82,10 @@ const Addon = ({ className, id, name, logo, description, types, version, transpo
}
</div>
<div className={styles['buttons-container']}>
<Button className={installed ? styles['uninstall-button-container'] : styles['install-button-container']} title={installed ? 'Uninstall' : 'Install'} tabIndex={-1} data-id={id} onClick={toggle}>
<Button className={installed ? styles['uninstall-button-container'] : styles['install-button-container']} title={installed ? 'Uninstall' : 'Install'} tabIndex={-1} onClick={toggleButtonOnClick}>
<div className={styles['label']}>{installed ? 'Uninstall' : 'Install'}</div>
</Button>
<Button className={styles['share-button-container']} title={'Share addon'} tabIndex={-1}>
<Button className={styles['share-button-container']} title={'Share addon'} tabIndex={-1} onClick={shareButtonOnClick}>
<Icon className={styles['icon']} icon={'ic_share'} />
<div className={styles['label']}>Share addon</div>
</Button>
@ -68,13 +98,14 @@ Addon.propTypes = {
className: PropTypes.string,
id: PropTypes.string,
name: PropTypes.string,
version: PropTypes.string,
logo: PropTypes.string,
description: PropTypes.string,
types: PropTypes.arrayOf(PropTypes.string),
version: PropTypes.string,
transportUrl: PropTypes.string,
installed: PropTypes.bool,
toggle: PropTypes.func
onToggle: PropTypes.func,
onShare: PropTypes.func,
dataset: PropTypes.objectOf(PropTypes.string)
};
module.exports = Addon;

View file

@ -1,7 +1,6 @@
.addon-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
padding: 1rem;
background-color: var(--color-backgroundlighter);
@ -17,6 +16,7 @@
display: block;
width: 100%;
height: 100%;
padding: 0.5rem;
object-fit: contain;
object-position: center;
}
@ -31,14 +31,13 @@
}
.info-container {
flex-grow: 1000;
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
min-width: 40rem;
padding: 0 0.5rem;
.name-container {
@ -47,7 +46,7 @@
flex-basis: auto;
padding: 0 0.5rem;
max-height: 3.6em;
font-size: 1.5rem;
font-size: 1.6rem;
color: var(--color-surfacelighter);
}
@ -55,6 +54,7 @@
flex-grow: 1;
flex-shrink: 1;
flex-basis: auto;
margin-top: 0.5rem;
padding: 0 0.5rem;
max-height: 2.4em;
color: var(--color-surfacelight);
@ -83,22 +83,14 @@
}
.buttons-container {
flex-grow: 1;
flex-shrink: 0;
flex-basis: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
min-width: 17rem;
flex: none;
width: 17rem;
.install-button-container, .uninstall-button-container, .share-button-container {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 17rem;
height: 3.5rem;
padding: 0 1rem;
@ -106,13 +98,8 @@
margin-top: 1rem;
}
&:not(:last-child) {
margin-right: 1rem;
}
.icon {
flex: none;
display: block;
width: 1.5rem;
height: 1.5rem;
margin-right: 1rem;

View file

@ -1,116 +1,86 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const { useFocusable } = require('stremio-router');
const { Button } = require('stremio/common');
const styles = require('./styles');
const AddonPrompt = ({ className, id, name, logo, description, types, catalogs, version, transportUrl, installed, official, cancel }) => {
const focusable = useFocusable();
React.useEffect(() => {
const onKeyUp = (event) => {
if (event.key === 'Escape') {
cancel();
}
};
if (focusable) {
window.addEventListener('keyup', onKeyUp);
}
return () => {
window.removeEventListener('keyup', onKeyUp);
};
}, [cancel, focusable]);
const AddonPrompt = ({ className, id, name, logo, description, types, catalogs, version, transportUrl, official }) => {
return (
<div className={classnames(className, styles['addon-prompt-container'])}>
<Button className={styles['close-button-container']} title={'Close'} tabIndex={-1} onClick={cancel}>
<Icon className={styles['icon']} icon={'ic_x'} />
</Button>
<div className={styles['addon-prompt-content']}>
<div className={classnames(styles['title-container'], { [styles['title-with-logo-container']]: typeof logo === 'string' && logo.length > 0 })}>
{
typeof logo === 'string' && logo.length > 0 ?
<div className={styles['logo-container']}>
<img className={styles['logo']} src={logo} alt={' '} />
</div>
:
null
}
{typeof name === 'string' && name.length > 0 ? name : id}
{' '}
{
typeof version === 'string' && version.length > 0 ?
<span className={styles['version-container']}>v.{version}</span>
:
null
}
</div>
<div className={classnames(styles['title-container'], { [styles['title-with-logo-container']]: typeof logo === 'string' && logo.length > 0 })}>
{
typeof description === 'string' && description.length > 0 ?
<div className={styles['section-container']}>
<span className={styles['section-header']}>{description}</span>
typeof logo === 'string' && logo.length > 0 ?
<div className={styles['logo-container']}>
<img className={styles['logo']} src={logo} alt={' '} />
</div>
:
null
}
{typeof name === 'string' && name.length > 0 ? name : id}
{' '}
{
typeof transportUrl === 'string' && transportUrl.length > 0 ?
<div className={styles['section-container']}>
<span className={styles['section-header']}>URL: </span>
<span className={classnames(styles['section-label'], styles['transport-url-label'])}>{transportUrl}</span>
</div>
:
null
}
{
Array.isArray(types) && types.length > 0 ?
<div className={styles['section-container']}>
<span className={styles['section-header']}>Supported types: </span>
<span className={styles['section-label']}>
{
types.length === 1 ?
types[0]
:
types.slice(0, -1).join(', ') + ' & ' + types[types.length - 1]
}
</span>
</div>
:
null
}
{
Array.isArray(catalogs) && catalogs.length > 0 ?
<div className={styles['section-container']}>
<span className={styles['section-header']}>Supported catalogs: </span>
<span className={styles['section-label']}>
{
catalogs.length === 1 ?
catalogs[0].name
:
catalogs.slice(0, -1).map(({ name }) => name).join(', ') + ' & ' + catalogs[catalogs.length - 1].name
}
</span>
</div>
:
null
}
{
!official ?
<div className={styles['section-container']}>
<div className={classnames(styles['section-label'], styles['disclaimer-label'])}>Using third-party add-ons will always be subject to your responsibility and the governing law of the jurisdiction you are located.</div>
</div>
typeof version === 'string' && version.length > 0 ?
<span className={styles['version-container']}>v.{version}</span>
:
null
}
</div>
<div className={styles['buttons-container']}>
<Button className={classnames(styles['button-container'], styles['cancel-button'])} title={'cancel'} onClick={cancel}>
<div className={styles['label']}>Cancel</div>
</Button>
<Button className={classnames(styles['button-container'], installed ? styles['uninstall-button'] : styles['install-button'])} title={installed ? 'Uninstall' : 'Install'}>
<div className={styles['label']}>{installed ? 'Uninstall' : 'Install'}</div>
</Button>
</div>
{
typeof description === 'string' && description.length > 0 ?
<div className={styles['section-container']}>
<span className={styles['section-header']}>{description}</span>
</div>
:
null
}
{
typeof transportUrl === 'string' && transportUrl.length > 0 ?
<div className={styles['section-container']}>
<span className={styles['section-header']}>URL: </span>
<span className={classnames(styles['section-label'], styles['transport-url-label'])}>{transportUrl}</span>
</div>
:
null
}
{
Array.isArray(types) && types.length > 0 ?
<div className={styles['section-container']}>
<span className={styles['section-header']}>Supported types: </span>
<span className={styles['section-label']}>
{
types.length === 1 ?
types[0]
:
types.slice(0, -1).join(', ') + ' & ' + types[types.length - 1]
}
</span>
</div>
:
null
}
{
Array.isArray(catalogs) && catalogs.length > 0 ?
<div className={styles['section-container']}>
<span className={styles['section-header']}>Supported catalogs: </span>
<span className={styles['section-label']}>
{
catalogs.length === 1 ?
catalogs[0].name
:
catalogs.slice(0, -1).map(({ name }) => name).join(', ') + ' & ' + catalogs[catalogs.length - 1].name
}
</span>
</div>
:
null
}
{
!official ?
<div className={styles['section-container']}>
<div className={classnames(styles['section-label'], styles['disclaimer-label'])}>Using third-party add-ons will always be subject to your responsibility and the governing law of the jurisdiction you are located.</div>
</div>
:
null
}
</div>
);
};
@ -127,9 +97,7 @@ AddonPrompt.propTypes = {
})),
version: PropTypes.string,
transportUrl: PropTypes.string,
installed: PropTypes.bool,
official: PropTypes.bool,
cancel: PropTypes.func
official: PropTypes.bool
};
module.exports = AddonPrompt;

View file

@ -1,153 +1,54 @@
.addon-prompt-container {
position: relative;
z-index: 0;
display: flex;
flex-direction: column;
padding: 3rem 0;
background-color: var(--color-surfacelighter);
.title-container {
font-size: 3rem;
font-weight: 300;
word-break: break-all;
.close-button-container {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 1;
width: 2.5rem;
height: 2.5rem;
padding: 0.5rem;
&:hover {
background-color: var(--color-surfacelight);
&.title-with-logo-container {
&::first-line {
line-height: 5rem;
}
}
.icon {
display: block;
width: 100%;
height: 100%;
fill: var(--color-backgrounddarker);
.logo-container {
width: 5rem;
height: 5rem;
margin-right: 0.5rem;
background-color: var(--color-surfacelight20);
float: left;
.logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
}
.version-container {
font-size: 1.5rem;
font-weight: 400;
}
}
.addon-prompt-content {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
align-self: stretch;
padding: 0 3rem;
overflow-y: auto;
.section-container {
margin-top: 1rem;
.title-container {
font-size: 3rem;
.section-header {
font-size: 1.2rem;
}
.section-label {
font-size: 1.2rem;
font-weight: 300;
word-break: break-all;
&.title-with-logo-container {
&::first-line {
line-height: 5rem;
}
&.transport-url-label {
user-select: text;
}
.logo-container {
width: 5rem;
height: 5rem;
margin-right: 0.5rem;
background-color: var(--color-surfacelight20);
float: left;
.logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
}
.version-container {
font-size: 1.5rem;
font-weight: 400;
}
}
.section-container {
margin-top: 1rem;
.section-header {
font-size: 1.2rem;
}
.section-label {
font-size: 1.2rem;
font-weight: 300;
&.transport-url-label {
user-select: text;
}
&.disclaimer-label {
font-style: italic;
}
}
}
}
.buttons-container {
flex: none;
align-self: stretch;
display: flex;
flex-direction: row;
margin-top: 2rem;
padding: 0 3rem;
.button-container {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 4rem;
padding: 0 1rem;
&:first-child {
margin-right: 2rem;
}
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
max-height: 2.4em;
font-size: 1.3rem;
font-weight: 500;
text-align: center;
}
}
.cancel-button, .uninstall-button {
outline-color: var(--color-surfacedark);
outline-style: solid;
&:hover, &:focus {
background-color: var(--color-surfacelight);
}
.label {
color: var(--color-backgrounddarker);
}
}
.install-button {
background-color: var(--color-signal5);
&:hover, &:focus {
filter: brightness(1.2);
}
&:focus {
outline-color: var(--color-surfacedarker);
}
.label {
color: var(--color-surfacelighter);
&.disclaimer-label {
font-style: italic;
}
}
}

View file

@ -1,39 +1,93 @@
const React = require('react');
const PropTypes = require('prop-types');
const Icon = require('stremio-icons/dom');
const { Modal } = require('stremio-router');
const { Button, Dropdown, NavBar, TextInput } = require('stremio/common');
const { Button, Multiselect, NavBar, TextInput, SharePrompt, ModalDialog, useBinaryState } = require('stremio/common');
const Addon = require('./Addon');
const AddonPrompt = require('./AddonPrompt');
const useAddons = require('./useAddons');
const useSelectedAddon = require('./useSelectedAddon');
const useSelectableInputs = require('./useSelectableInputs');
const styles = require('./styles');
const navigateToAddonDetails = (addonsCatalogRequest, transportUrl) => {
const queryParams = new URLSearchParams([['addon', transportUrl]]);
if (addonsCatalogRequest !== null) {
const addonTransportUrl = encodeURIComponent(addonsCatalogRequest.base);
const catalogId = encodeURIComponent(addonsCatalogRequest.path.id);
const type = encodeURIComponent(addonsCatalogRequest.path.type_name);
window.location.replace(`#/addons/${addonTransportUrl}/${catalogId}/${type}?${queryParams}`);
} else {
window.location.replace(`#/addons?${queryParams}`);
}
};
const Addons = ({ urlParams, queryParams }) => {
const [query, setQuery] = React.useState('');
const queryOnChange = React.useCallback((event) => {
setQuery(event.currentTarget.value);
}, []);
const [addons, dropdowns] = useAddons(urlParams.category, urlParams.type);
const [selectedAddon, clearSelectedAddon] = useSelectedAddon(queryParams.get('addon'));
const addonPromptModalBackgroundOnClick = React.useCallback((event) => {
if (!event.nativeEvent.clearSelectedAddonPrevented) {
clearSelectedAddon();
const addons = useAddons(urlParams);
const selectInputs = useSelectableInputs(addons);
const [addAddonModalOpen, openAddAddonModal, closeAddAddonModal] = useBinaryState(false);
const addAddonUrlInputRef = React.useRef(null);
const addAddonOnSubmit = React.useCallback(() => {
if (addAddonUrlInputRef.current !== null) {
const addonsCatalogRequest = addons.catalog_resource !== null ?
addons.catalog_resource.request
:
null;
navigateToAddonDetails(addonsCatalogRequest, addAddonUrlInputRef.current.value);
}
}, [addons]);
const addAddonModalButtons = React.useMemo(() => {
return [
{
className: styles['cancel-button'],
label: 'Cancel',
props: {
onClick: closeAddAddonModal
}
},
{
label: 'Add',
props: {
onClick: addAddonOnSubmit
}
}
];
}, [addAddonOnSubmit]);
const [search, setSearch] = React.useState('');
const searchInputOnChange = React.useCallback((event) => {
setSearch(event.currentTarget.value);
}, []);
const addonPromptOnClick = React.useCallback((event) => {
event.nativeEvent.clearSelectedAddonPrevented = true;
const [sharedTransportUrl, setSharedTransportUrl] = React.useState(null);
const clearSharedTransportUrl = React.useCallback(() => {
setSharedTransportUrl(null);
}, []);
const onAddonShare = React.useCallback((event) => {
setSharedTransportUrl(event.dataset.transportUrl);
}, []);
const onAddonToggle = React.useCallback((event) => {
const addonsCatalogRequest = addons.catalog_resource !== null ?
addons.catalog_resource.request
:
null;
navigateToAddonDetails(addonsCatalogRequest, event.dataset.transportUrl);
}, [addons]);
React.useLayoutEffect(() => {
closeAddAddonModal();
setSearch('');
clearSharedTransportUrl();
}, [urlParams, queryParams]);
return (
<div className={styles['addons-container']}>
<NavBar className={styles['nav-bar']} backButton={true} title={'Addons'} />
<div className={styles['addons-content']}>
<div className={styles['top-bar-container']}>
<Button className={styles['add-button-container']} title={'Add addon'}>
<div className={styles['selectable-inputs-container']}>
<Button className={styles['add-button-container']} title={'Add addon'} onClick={openAddAddonModal}>
<Icon className={styles['icon']} icon={'ic_plus'} />
<div className={styles['add-button-label']}>Add addon</div>
</Button>
{dropdowns.map((dropdown) => (
<Dropdown {...dropdown} key={dropdown.name} className={styles['dropdown']} />
{selectInputs.map((selectInput, index) => (
<Multiselect
{...selectInput}
key={index}
className={styles['select-input-container']}
/>
))}
<label className={styles['search-bar-container']}>
<Icon className={styles['icon']} icon={'ic_search'} />
@ -41,32 +95,105 @@ const Addons = ({ urlParams, queryParams }) => {
className={styles['search-input']}
type={'text'}
placeholder={'Search addons...'}
value={query}
onChange={queryOnChange}
value={search}
onChange={searchInputOnChange}
/>
</label>
</div>
<div className={styles['addons-list-container']} >
{
addons.filter(({ name }) => query.length === 0 || (typeof name === 'string' && name.includes(query)))
.map((addon) => (
<Addon {...addon} key={addon.id} className={styles['addon']} />
))
}
</div>
{
selectedAddon !== null ?
<Modal className={styles['addon-prompt-modal-container']} onClick={addonPromptModalBackgroundOnClick}>
<div className={styles['addon-prompt-container']} onClick={addonPromptOnClick}>
<AddonPrompt {...selectedAddon} className={styles['addon-prompt']} cancel={clearSelectedAddon} />
</div>
</Modal>
addons.selectable.catalogs.length === 0 && addons.catalog_resource === null ?
<div className={styles['message-container']}>
No addons
</div>
:
null
addons.catalog_resource === null ?
<div className={styles['message-container']}>
No select
</div>
:
addons.catalog_resource.content.type === 'Err' ?
<div className={styles['message-container']}>
Addons could not be loaded
</div>
:
addons.catalog_resource.content.type === 'Loading' ?
<div className={styles['message-container']}>
Loading
</div>
:
<div className={styles['addons-list-container']}>
{
addons.catalog_resource.content.content
.filter((addon) => {
return search.length === 0 ||
(
(typeof addon.manifest.name === 'string' && addon.manifest.name.toLowerCase().includes(search.toLowerCase())) ||
(typeof addon.manifest.description === 'string' && addon.manifest.description.toLowerCase().includes(search.toLowerCase()))
);
})
.map((addon, index) => (
<Addon
key={index}
className={styles['addon']}
id={addon.manifest.id}
name={addon.manifest.name}
version={addon.manifest.version}
logo={addon.manifest.logo}
description={addon.manifest.description}
types={addon.manifest.types}
installed={addon.installed}
onToggle={onAddonToggle}
onShare={onAddonShare}
dataset={{ transportUrl: addon.transportUrl }}
/>
))
}
</div>
}
</div>
{
addAddonModalOpen ?
<ModalDialog
className={styles['add-addon-modal-container']}
title={'Add addon'}
buttons={addAddonModalButtons}
onCloseRequest={closeAddAddonModal}>
<TextInput
ref={addAddonUrlInputRef}
className={styles['addon-url-input']}
type={'text'}
placeholder={'Paste url...'}
onSubmit={addAddonOnSubmit}
/>
</ModalDialog>
:
null
}
{
typeof sharedTransportUrl === 'string' ?
<ModalDialog
className={styles['share-modal-container']}
title={'Share addon'}
onCloseRequest={clearSharedTransportUrl}>
<SharePrompt
className={styles['share-prompt-container']}
url={sharedTransportUrl}
/>
</ModalDialog>
:
null
}
</div>
);
};
Addons.propTypes = {
urlParams: PropTypes.exact({
addonTransportUrl: PropTypes.string,
catalogId: PropTypes.string,
type: PropTypes.string
}),
queryParams: PropTypes.instanceOf(URLSearchParams)
};
module.exports = Addons;

View file

@ -1,3 +1,7 @@
:import('~stremio/common/Multiselect/styles.less') {
multiselect-menu-container: menu-container;
}
.addons-container {
display: flex;
flex-direction: column;
@ -16,11 +20,13 @@
display: flex;
flex-direction: column;
.top-bar-container {
.selectable-inputs-container {
flex: none;
align-self: stretch;
display: flex;
flex-direction: row;
margin: 2rem;
padding: 1.5rem;
overflow: visible;
.add-button-container {
flex: none;
@ -29,7 +35,7 @@
align-items: center;
height: 3rem;
max-width: 15rem;
margin-right: 1rem;
margin-right: 1.5rem;
padding: 0 1rem;
background-color: var(--color-signal5);
@ -39,8 +45,8 @@
.icon {
flex: none;
width: 1.5rem;
height: 1.5rem;
width: 1.2rem;
height: 1.2rem;
margin-right: 1rem;
fill: var(--color-surfacelighter);
}
@ -55,12 +61,17 @@
}
}
.dropdown {
.select-input-container {
flex-grow: 0;
flex-shrink: 1;
flex-basis: 15rem;
height: 3rem;
margin-right: 1rem;
margin-right: 1.5rem;
.multiselect-menu-container {
max-height: calc(3.2rem * 7);
overflow: auto;
}
}
.search-bar-container {
@ -71,7 +82,6 @@
flex-direction: row;
align-items: center;
height: 3rem;
margin-right: 1rem;
padding: 0 1rem;
background-color: var(--color-backgroundlighter);
cursor: text;
@ -81,7 +91,7 @@
}
.icon {
display: block;
flex: none;
width: 1.2rem;
height: 1.2rem;
margin-right: 1rem;
@ -90,7 +100,6 @@
.search-input {
flex: 1;
align-self: stretch;
color: var(--color-surfacelighter);
&::placeholder {
@ -102,39 +111,42 @@
}
}
.message-container {
flex: 1;
align-self: stretch;
padding: 0 1.5rem;
font-size: 2rem;
color: var(--color-surfacelighter);
}
.addons-list-container {
flex: 1;
align-self: stretch;
padding: 0 2rem;
padding: 0 1.5rem;
overflow-y: auto;
.addon {
width: 100%;
margin-bottom: 2rem;
margin-bottom: 1.5rem;
}
}
}
}
.addon-prompt-modal-container {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-background60);
.add-addon-modal-container {
.addon-url-input {
width: 25rem;
padding: 0.5rem 1rem;
color: var(--color-surfacedark);
border: thin solid var(--color-surface);
}
.addon-prompt-container {
flex: none;
display: flex;
flex-direction: column;
justify-content: center;
width: 50rem;
height: 80%;
.cancel-button {
background-color: var(--color-surfacedark);
}
}
.addon-prompt {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
align-self: stretch;
}
.share-modal-container {
.share-prompt-container {
width: 25rem;
}
}

View file

@ -0,0 +1,48 @@
const React = require('react');
const { useModelState } = require('stremio/common');
const initAddonDetailsState = () => ({
descriptor: null
});
const mapAddonDetailsStateWithCtx = (addonDetails, ctx) => {
const descriptor = addonDetails.descriptor !== null && addonDetails.descriptor.content.type === 'Ready' ?
{
...addonDetails.descriptor,
content: {
...addonDetails.descriptor.content,
installed: ctx.content.addons.some((addon) => addon.transportUrl === addonDetails.descriptor.transport_url),
}
}
:
addonDetails.descriptor;
return { descriptor };
};
const useAddonDetails = (queryParams) => {
const loadAddonDetailsAction = React.useMemo(() => {
if (queryParams.has('addon')) {
return {
action: 'Load',
args: {
load: 'AddonDetails',
args: {
transport_url: queryParams.get('addon')
}
}
};
} else {
return {
action: 'Unload'
};
}
}, [queryParams]);
return useModelState({
model: 'addon_details',
action: loadAddonDetailsAction,
mapWithCtx: mapAddonDetailsStateWithCtx,
init: initAddonDetailsState,
});
};
module.exports = useAddonDetails;

View file

@ -1,72 +1,100 @@
const React = require('react');
const { useServices } = require('stremio/services');
const { useModelState } = require('stremio/common');
const CATEGORIES = ['official', 'community', 'my'];
const DEFAULT_CATEGORY = 'community';
const DEFAULT_TYPE = 'all';
const initAddonsState = () => ({
selectable: {
types: [],
catalogs: [],
extra: [],
has_next_page: false,
has_prev_page: false
},
catalog_resource: null
});
const useAddons = (category, type) => {
category = CATEGORIES.includes(category) ? category : DEFAULT_CATEGORY;
type = typeof type === 'string' && type.length > 0 ? type : DEFAULT_TYPE;
const addons = React.useMemo(() => {
return [
{
id: 'com.linvo.cinemeta',
name: 'Cinemeta',
description: 'The official add-on for movie and series catalogs',
types: ['movie', 'series'],
version: '2.12.1',
transportUrl: 'https://v3-cinemeta.strem.io/manifest.json',
installed: true,
official: true
},
{
id: 'com.linvo.cinemeta2',
name: 'Cinemeta2',
logo: '/images/intro_background.jpg',
description: 'The official add-on for movie and series catalogs',
types: ['movie', 'series'],
version: '2.12.2',
transportUrl: 'https://v2-cinemeta.strem.io/manifest.json',
installed: false,
official: false
const mapAddonsStateWithCtx = (addons, ctx) => {
const selectable = addons.selectable;
// TODO replace catalog content if resource catalog id is MY
const catalog_resource = addons.catalog_resource !== null && addons.catalog_resource.content.type === 'Ready' ?
{
...addons.catalog_resource,
content: {
...addons.catalog_resource.content,
content: addons.catalog_resource.content.content.map((descriptor) => ({
transportUrl: descriptor.transportUrl,
installed: ctx.content.addons.some((addon) => addon.transportUrl === descriptor.transportUrl),
manifest: {
id: descriptor.manifest.id,
name: descriptor.manifest.name,
version: descriptor.manifest.version,
logo: descriptor.manifest.logo,
description: descriptor.manifest.description,
types: descriptor.manifest.types
}
}))
}
];
}, []);
const onSelect = React.useCallback((event) => {
const { name, value } = event.currentTarget.dataset;
if (name === 'category') {
const nextCategory = CATEGORIES.includes(value) ? value : '';
window.location.replace(`#/addons/${nextCategory}/${type}`);
} else if (name === 'type') {
const nextType = typeof value === 'string' ? value : '';
window.location.replace(`#/addons/${category}/${nextType}`);
}
}, [category, type]);
const categoryDropdown = React.useMemo(() => {
const selected = CATEGORIES.includes(category) ? [category] : [];
const options = CATEGORIES
.map((category) => ({ label: category, value: category }));
:
addons.catalog_resource;
return { selectable, catalog_resource };
};
const onNewAddonsState = (addons) => {
if (addons.catalog_resource === null && addons.selectable.catalogs.length > 0) {
return {
name: 'category',
selected,
options,
onSelect
action: 'Load',
args: {
load: 'CatalogFiltered',
args: addons.selectable.catalogs[0].load_request
}
};
}, [category, onSelect]);
const typeDropdown = React.useMemo(() => {
const selected = typeof type === 'string' && type.length > 0 ? [type] : [];
const options = ['all', 'movie', 'series', 'channel']
.concat(selected)
.filter((type, index, types) => types.indexOf(type) === index)
.map((type) => ({ label: type, value: type }));
return {
name: 'type',
selected,
options,
onSelect
};
}, [type, onSelect]);
return [addons, [categoryDropdown, typeDropdown]];
}
};
const useAddons = (urlParams) => {
const { core } = useServices();
const loadAddonsAction = React.useMemo(() => {
if (typeof urlParams.addonTransportUrl === 'string' && typeof urlParams.catalogId === 'string' && typeof urlParams.type === 'string') {
return {
action: 'Load',
args: {
load: 'CatalogFiltered',
args: {
base: urlParams.addonTransportUrl,
path: {
resource: 'addon_catalog',
type_name: urlParams.type,
id: urlParams.catalogId,
extra: []
}
}
}
};
} else {
const addons = core.getState('addons');
if (addons.selectable.catalogs.length > 0) {
return {
action: 'Load',
args: {
load: 'CatalogFiltered',
args: addons.selectable.catalogs[0].load_request
}
};
} else {
return {
action: 'Unload'
};
}
}
}, [urlParams]);
return useModelState({
model: 'addons',
action: loadAddonsAction,
mapWithCtx: mapAddonsStateWithCtx,
init: initAddonsState,
onNewState: onNewAddonsState
});
};
module.exports = useAddons;

View file

@ -0,0 +1,62 @@
const React = require('react');
const navigateWithLoadRequest = (load_request) => {
const addonTransportUrl = encodeURIComponent(load_request.base);
const catalogId = encodeURIComponent(load_request.path.id);
const type = encodeURIComponent(load_request.path.type_name);
window.location.replace(`#/addons/${addonTransportUrl}/${catalogId}/${type}`);
};
const equalWithouExtra = (request1, request2) => {
return request1.base === request2.base &&
request1.path.resource === request2.path.resource &&
request1.path.type_name === request2.path.type_name &&
request1.path.id === request2.path.id;
};
const mapSelectableInputs = (addons) => {
const catalogSelect = {
title: 'Select catalog',
options: addons.selectable.catalogs
.map(({ name, load_request }) => ({
value: JSON.stringify(load_request),
label: name
})),
selected: addons.selectable.catalogs
.filter(({ load_request: { path: { id } } }) => {
return addons.catalog_resource !== null &&
addons.catalog_resource.request.path.id === id;
})
.map(({ load_request }) => JSON.stringify(load_request)),
onSelect: (event) => {
navigateWithLoadRequest(JSON.parse(event.value));
}
};
const typeSelect = {
title: 'Select type',
options: addons.selectable.types
.map(({ name, load_request }) => ({
value: JSON.stringify(load_request),
label: name
})),
selected: addons.selectable.types
.filter(({ load_request }) => {
return addons.catalog_resource !== null &&
equalWithouExtra(addons.catalog_resource.request, load_request);
})
.map(({ load_request }) => JSON.stringify(load_request)),
onSelect: (event) => {
navigateWithLoadRequest(JSON.parse(event.value));
}
};
return [catalogSelect, typeSelect];
};
const useSelectableInputs = (addons) => {
const selectableInputs = React.useMemo(() => {
return mapSelectableInputs(addons);
}, [addons]);
return selectableInputs;
};
module.exports = useSelectableInputs;

View file

@ -1,30 +0,0 @@
const React = require('react');
const UrlUtils = require('url');
const { routesRegexp, useLocationHash, useRouteActive } = require('stremio/common');
const useSelectedAddon = (transportUrl) => {
const [addon, setAddon] = React.useState(null);
const locationHash = useLocationHash();
const active = useRouteActive(routesRegexp.addons.regexp);
React.useEffect(() => {
if (typeof transportUrl !== 'string') {
setAddon(null);
return;
}
fetch(transportUrl)
.then((resp) => resp.json())
.then((manifest) => setAddon({ ...manifest, transportUrl }));
}, [transportUrl]);
const clear = React.useCallback(() => {
if (active) {
const { pathname, search } = UrlUtils.parse(locationHash.slice(1));
const queryParams = new URLSearchParams(search);
queryParams.delete('addon');
window.location.replace(`#${pathname}?${queryParams.toString()}`);
}
}, [active]);
return [addon, clear];
};
module.exports = useSelectedAddon;

View file

@ -1,53 +1,71 @@
const React = require('react');
const { MainNavBar, MetaRow } = require('stremio/common');
const useCatalogs = require('./useCatalogs');
const useBoard = require('./useBoard');
const useContinueWatching = require('./useContinueWatching');
const useItemOptions = require('./useItemOptions');
const styles = require('./styles');
const CONTINUE_WATCHING_MENU = [
{
label: 'Play',
value: 'play'
},
{
label: 'Dismiss',
value: 'dismiss'
}
];
const Board = () => {
const catalogs = useCatalogs();
const board = useBoard();
const continueWatching = useContinueWatching();
const [options, optionOnSelect] = useItemOptions();
return (
<div className={styles['board-container']}>
<MainNavBar className={styles['nav-bar']} />
<div className={styles['board-content']}>
{catalogs.map(({ req, content }, index) => {
switch (content.type) {
case 'Ready':
{
continueWatching.lib_items.length > 0 ?
<MetaRow
className={styles['board-row']}
title={'Continue Watching'}
items={continueWatching.lib_items.map(({ id, videoId, ...libItem }) => ({
...libItem,
dataset: { id, videoId, type: libItem.type },
options,
optionOnSelect
}))}
limit={10}
/>
:
null
}
{board.catalog_resources.map((catalog_resource, index) => {
const title = `${catalog_resource.addon_name} - ${catalog_resource.request.path.id} ${catalog_resource.request.path.type_name}`;
switch (catalog_resource.content.type) {
case 'Ready': {
return (
<MetaRow
key={`${index}${req.base}${content.type}`}
key={index}
className={styles['board-row']}
title={`${req.path.id} - ${req.path.type_name}`}
items={content.content}
title={title}
items={catalog_resource.content.content}
href={catalog_resource.href}
limit={10}
/>
);
case 'Message':
}
case 'Err': {
const message = `Error(${catalog_resource.content.content.type})${typeof catalog_resource.content.content.content === 'string' ? ` - ${catalog_resource.content.content.content}` : ''}`;
return (
<MetaRow
key={`${index}${req.base}${content.type}`}
key={index}
className={styles['board-row']}
title={`${req.path.id} - ${req.path.type_name}`}
message={content.content}
title={title}
message={message}
limit={10}
/>
);
case 'Loading':
}
case 'Loading': {
return (
<MetaRow.Placeholder
key={`${index}${req.base}${content.type}`}
key={index}
className={styles['board-row-placeholder']}
title={`${req.path.id} - ${req.path.type_name}`}
title={title}
limit={10}
/>
);
}
}
})}
</div>

View file

@ -2,10 +2,12 @@
:import('~stremio/common/MetaRow/styles.less') {
meta-item: meta-item;
see-all-container: see-all-container;
}
:import('~stremio/common/MetaRow/MetaRowPlaceholder/styles.less') {
meta-item-placeholder: meta-item;
see-all-container-placeholder: see-all-container;
}
.board-container {
@ -35,6 +37,10 @@
&:last-child {
margin-bottom: 2rem;
}
.see-all-container, .see-all-container-placeholder {
width: 12rem;
}
}
}
}

View file

@ -0,0 +1,51 @@
const React = require('react');
const { useModelState } = require('stremio/common');
const initBoardState = () => ({
selected: null,
catalog_resources: []
});
const mapBoardStateWithCtx = (board, ctx) => {
const selected = board.selected;
const catalog_resources = board.catalog_resources.map((catalog_resource) => {
catalog_resource.addon_name = ctx.content.addons.reduce((addon_name, addon) => {
if (addon.transportUrl === catalog_resource.request.base) {
return addon.manifest.name;
}
return addon_name;
}, catalog_resource.request.base);
if (catalog_resource.content.type === 'Ready') {
catalog_resource.content.content = catalog_resource.content.content.map((metaItem) => ({
type: metaItem.type,
name: metaItem.name,
poster: metaItem.poster,
posterShape: metaItem.posterShape,
href: `#/metadetails/${encodeURIComponent(metaItem.type)}/${encodeURIComponent(metaItem.id)}` // TODO this should redirect with videoId at some cases
}));
}
catalog_resource.href = `#/discover/${encodeURIComponent(catalog_resource.request.base)}/${encodeURIComponent(catalog_resource.request.path.type_name)}/${encodeURIComponent(catalog_resource.request.path.id)}`;
return catalog_resource;
});
return { selected, catalog_resources };
};
const useBoard = () => {
const loadBoardAction = React.useMemo(() => ({
action: 'Load',
args: {
load: 'CatalogsWithExtra',
args: { extra: [] }
}
}), []);
return useModelState({
model: 'board',
action: loadBoardAction,
mapWithCtx: mapBoardStateWithCtx,
init: initBoardState,
timeout: 1000
});
};
module.exports = useBoard;

View file

@ -1,27 +0,0 @@
const React = require('react');
const { useServices } = require('stremio/services');
const useCatalogs = () => {
const [catalogs, setCatalogs] = React.useState([]);
const { core } = useServices();
React.useEffect(() => {
const onNewState = () => {
const state = core.getState();
setCatalogs(state.catalogs.groups);
};
core.on('NewModel', onNewState);
core.dispatch({
action: 'Load',
args: {
load: 'CatalogGrouped',
args: { extra: [] }
}
});
return () => {
core.off('NewModel', onNewState);
};
}, []);
return catalogs;
};
module.exports = useCatalogs;

View file

@ -0,0 +1,33 @@
const { useModelState } = require('stremio/common');
const initContinueWatchingState = () => ({
lib_items: []
});
const mapContinueWatchingState = (continue_watching) => {
const lib_items = continue_watching.lib_items.map((lib_item) => ({
id: lib_item._id,
type: lib_item.type,
name: lib_item.name,
poster: lib_item.poster,
posterShape: lib_item.posterShape,
progress: lib_item.state.timeOffset > 0 && lib_item.state.duration > 0 ?
lib_item.state.timeOffset / lib_item.state.duration
:
null,
videoId: lib_item.state.video_id,
href: `#/metadetails/${encodeURIComponent(lib_item.type)}/${encodeURIComponent(lib_item._id)}${lib_item.state.video_id !== null ? `/${encodeURIComponent(lib_item.state.video_id)}` : ''}`
}));
return { lib_items };
};
const useContinueWatching = () => {
return useModelState({
model: 'continue_watching',
map: mapContinueWatchingState,
init: initContinueWatchingState,
timeout: 5000
});
};
module.exports = useContinueWatching;

View file

@ -0,0 +1,25 @@
const React = require('react');
const PLAY_OPTION = {
label: 'Play',
value: 'play'
};
const DISMISS_OPTION = {
label: 'Dismiss',
value: 'dismiss'
};
const onSelect = () => {
// TODO {{event.value}} {{event.dataset}}
};
const useItemOptions = () => {
const options = React.useMemo(() => ([
PLAY_OPTION,
DISMISS_OPTION
]), []);
return [options, onSelect];
};
module.exports = useItemOptions;

View file

@ -1,48 +0,0 @@
const React = require('react');
const { NavBar, MetaPreview } = require('stremio/common');
const VideosList = require('./VideosList');
const StreamsList = require('./StreamsList');
const useMetaItem = require('./useMetaItem');
const useInLibrary = require('./useInLibrary');
const styles = require('./styles');
const Detail = ({ urlParams }) => {
const metaItem = useMetaItem(urlParams.type, urlParams.id, urlParams.videoId);
const [inLibrary, addToLibrary, removeFromLibrary, toggleInLibrary] = useInLibrary(urlParams.id);
return (
<div className={styles['detail-container']}>
<NavBar
className={styles['nav-bar']}
backButton={true}
title={metaItem !== null ? metaItem.name : null}
/>
<div className={styles['detail-content']}>
{
metaItem !== null ?
<React.Fragment>
<div className={styles['background-image-layer']}>
<img className={styles['background-image']} src={metaItem.background} alt={' '} />
</div>
<MetaPreview
{...metaItem}
className={styles['meta-preview']}
background={null}
inLibrary={inLibrary}
toggleInLibrary={toggleInLibrary}
/>
</React.Fragment>
:
<MetaPreview.Placeholder className={styles['meta-preview']} />
}
{
typeof urlParams.videoId === 'string' && urlParams.videoId.length > 0 ?
<StreamsList className={styles['streams-list']} metaItem={metaItem} />
:
<VideosList className={styles['videos-list']} metaItem={metaItem} />
}
</div>
</div>
);
};
module.exports = Detail;

View file

@ -1,48 +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');
const styles = require('./styles');
const Stream = ({ className, id, addon, description, progress, onClick }) => {
return (
<Button className={classnames(className, styles['stream-container'])} title={typeof description === 'string' && description.length > 0 ? description : id} data-id={id} onClick={onClick}>
{
typeof addon === 'string' && addon.length > 0 ?
<div className={styles['addon-container']}>
<div className={styles['addon-name']}>{addon}</div>
</div>
:
null
}
<div className={styles['info-container']}>
<div className={styles['description-label']}>
{typeof description === 'string' && description.length > 0 ? description : id}
</div>
</div>
<div className={styles['play-icon-container']}>
<Icon className={styles['play-icon']} icon={'ic_play'} />
</div>
{
progress !== null && !isNaN(progress) && progress > 0 ?
<div className={styles['progress-bar-container']}>
<div className={styles['progress-bar']} style={{ width: `${Math.min(progress, 1) * 100}%` }} />
</div>
:
null
}
</Button>
);
}
Stream.propTypes = {
className: PropTypes.string,
id: PropTypes.string,
addon: PropTypes.string,
description: PropTypes.string,
progress: PropTypes.number,
onClick: PropTypes.func
};
module.exports = Stream;

View file

@ -1,45 +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');
const Stream = require('./Stream');
const StreamPlaceholder = require('./StreamPlaceholder');
const useStreams = require('./useStreams');
const styles = require('./styles');
const StreamsList = ({ className, metaItem }) => {
const streams = useStreams(metaItem);
return (
<div className={classnames(className, styles['streams-list-container'])}>
<div className={styles['streams-scroll-container']}>
{
streams.length > 0 ?
streams.map((stream) => (
<Stream {...stream} key={stream.id} className={styles['stream']} />
))
:
<React.Fragment>
<StreamPlaceholder className={styles['stream']} />
<StreamPlaceholder className={styles['stream']} />
<StreamPlaceholder className={styles['stream']} />
<StreamPlaceholder className={styles['stream']} />
<StreamPlaceholder className={styles['stream']} />
<StreamPlaceholder className={styles['stream']} />
</React.Fragment>
}
</div>
<Button className={styles['install-addons-container']} title={'Install addons'} href={'#/addons'}>
<Icon className={styles['icon']} icon={'ic_addons'} />
<div className={styles['label']}>Install addons</div>
</Button>
</div>
);
}
StreamsList.propTypes = {
className: PropTypes.string,
metaItem: PropTypes.object
};
module.exports = StreamsList;

View file

@ -1,25 +0,0 @@
const React = require('react');
const useStreams = (metaItem) => {
const streams = React.useMemo(() => {
return metaItem !== null ?
[
{
id: '1',
addon: 'Google',
description: 'Google sample videos'
},
{
id: '2',
addon: 'Stremio',
description: 'Stremio demo videos',
progress: 0.3
}
]
:
[];
}, [metaItem]);
return streams;
};
module.exports = useStreams;

View file

@ -1,88 +0,0 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const { Button, Popup, useBinaryState } = require('stremio/common');
const styles = require('./styles');
const SeasonsBar = ({ className, season, seasons, onSeasonChange }) => {
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
const setPrevSeason = React.useCallback(() => {
if (Array.isArray(seasons) && typeof onSeasonChange === 'function') {
const seasonIndex = seasons.indexOf(season);
if (seasonIndex > 0) {
onSeasonChange(seasons[seasonIndex - 1]);
}
}
}, [season, seasons, onSeasonChange]);
const setNextSeason = React.useCallback(() => {
if (Array.isArray(seasons) && typeof onSeasonChange === 'function') {
const seasonIndex = seasons.indexOf(season);
if (seasonIndex < seasons.length - 1) {
onSeasonChange(seasons[seasonIndex + 1]);
}
}
}, [season, seasons, onSeasonChange]);
const seasonOnClick = React.useCallback((event) => {
closeMenu();
const season = parseInt(event.currentTarget.dataset.season);
if (!isNaN(season) && typeof onSeasonChange === 'function') {
onSeasonChange(season);
}
}, [onSeasonChange]);
return (
<div className={classnames(className, styles['seasons-bar-container'])}>
<Button className={styles['prev-season-button']} onClick={setPrevSeason}>
<Icon className={styles['icon']} icon={'ic_arrow_left'} />
</Button>
<Popup
open={menuOpen}
menuMatchLabelWidth={true}
onCloseRequest={closeMenu}
renderLabel={(ref) => (
<Button ref={ref} className={classnames(styles['seasons-popup-label-container'], { 'active': menuOpen })} title={season !== null && !isNaN(season) ? `Season ${season}` : 'Season'} onClick={toggleMenu}>
<div className={styles['season-label']}>Season</div>
{
season !== null && !isNaN(season) ?
<div className={styles['number-label']}>{season}</div>
:
null
}
</Button>
)}
renderMenu={() => (
<div className={styles['seasons-menu-container']}>
{
Array.isArray(seasons) ?
seasons.map((season) => (
<Button key={season} className={styles['season-option-container']} data-season={season} title={season !== null && !isNaN(season) ? `Season ${season}` : 'Season'} onClick={seasonOnClick}>
<div className={styles['season-label']}>Season</div>
{
season !== null && !isNaN(season) ?
<div className={styles['number-label']}>{season}</div>
:
null
}
</Button>
))
:
null
}
</div>
)}
/>
<Button className={styles['next-season-button']} onClick={setNextSeason}>
<Icon className={styles['icon']} icon={'ic_arrow_right'} />
</Button>
</div>
);
};
SeasonsBar.propTypes = {
className: PropTypes.string,
season: PropTypes.number,
seasons: PropTypes.arrayOf(PropTypes.number),
onSeasonChange: PropTypes.func
};
module.exports = SeasonsBar;

View file

@ -1,67 +0,0 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const SeasonsBar = require('./SeasonsBar');
const SeasonsBarPlaceholder = require('./SeasonsBarPlaceholder');
const Video = require('./Video');
const VideoPlaceholder = require('./VideoPlaceholder');
const useSeasons = require('./useSeasons');
const styles = require('./styles');
const VideosList = ({ className, metaItem }) => {
const [season, seasons, setSeason] = useSeasons(metaItem);
return (
<div className={classnames(className, styles['videos-list-container'])}>
{
metaItem !== null ?
<React.Fragment>
{
seasons.length > 1 ?
<SeasonsBar
className={styles['seasons-bar']}
season={season}
seasons={seasons}
onSeasonChange={setSeason}
/>
:
null
}
<div className={styles['videos-scroll-container']}>
{
metaItem.videos
.filter((video) => isNaN(season) || video.season === season)
.map((video) => (
<Video
{...video}
key={video.id}
className={styles['video']}
/>
))
}
</div>
</React.Fragment>
:
<React.Fragment>
<SeasonsBarPlaceholder className={styles['seasons-bar']} />
<div className={styles['videos-scroll-container']}>
<VideoPlaceholder className={styles['video']} />
<VideoPlaceholder className={styles['video']} />
<VideoPlaceholder className={styles['video']} />
<VideoPlaceholder className={styles['video']} />
<VideoPlaceholder className={styles['video']} />
<VideoPlaceholder className={styles['video']} />
<VideoPlaceholder className={styles['video']} />
<VideoPlaceholder className={styles['video']} />
</div>
</React.Fragment>
}
</div>
);
};
VideosList.propTypes = {
className: PropTypes.string,
metaItem: PropTypes.object
};
module.exports = VideosList;

View file

@ -1,23 +0,0 @@
const React = require('react');
const useSeasons = (metaItem) => {
const seasons = React.useMemo(() => {
return metaItem !== null ?
metaItem.videos
.map(({ season }) => season)
.filter((season, index, seasons) => {
return season !== null &&
!isNaN(season) &&
seasons.indexOf(season) === index;
})
:
[];
}, [metaItem]);
const [season, setSeason] = React.useState(seasons[0]);
React.useEffect(() => {
setSeason(seasons[0]);
}, [seasons]);
return [season, seasons, setSeason];
};
module.exports = useSeasons;

View file

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

View file

@ -1,8 +0,0 @@
const { useBinaryState } = require('stremio/common');
const useInLibrary = (id = '') => {
const [inLibrary, addToLibrary, removeFromLibrary, toggleInLibrary] = useBinaryState(false);
return [inLibrary, addToLibrary, removeFromLibrary, toggleInLibrary];
};
module.exports = useInLibrary;

View file

@ -1,46 +0,0 @@
const React = require('react');
const useMetaItem = (type = '', id = '', videoId = '') => {
const [metaItem] = React.useState(() => ({
id,
type,
name: 'Underworld',
logo: 'https://images.metahub.space/logo/medium/tt0320691/img',
background: 'https://images.metahub.space/background/medium/tt0320691/img',
duration: '121 min',
releaseInfo: '2003',
released: new Date('2003-09-19T00:00:00.000Z'),
description: 'Selene, a vampire warrior, is entrenched in a conflict between vampires and werewolves, while falling in love with Michael, a human who is sought by werewolves for unknown reasons.',
genres: ['Action', 'Fantasy', 'Thriller'],
writers: ['Kevin Grevioux', 'Len Wiseman', 'Danny McBride', 'Danny McBride'],
directors: ['Len Wiseman'],
cast: ['Kate Beckinsale', 'Scott Speedman', 'Michael Sheen', 'Shane Brolly'],
imdbRating: '7.0',
trailer: 'mn4O3iQ8B_s',
imdbId: 'tt0320691',
share: 'movie/underworld-0320691',
videos: [
{
id: '1',
name: 'How to create a Stremio add-on with Node.js',
description: 'This is a step-by-step tutorial on how to create your own add-on using Node.js.',
released: new Date('Mon Jul 01 2019 00:00:00 GMT+0300 (Eastern European Summer Time)'),
poster: 'https://theme.zdassets.com/theme_assets/2160011/77a6ad5aee11a07eb9b87281070f1aadf946f2b3.png',
season: 1,
episode: 1
},
{
id: '2',
name: 'How to create a Stremio add-on with Node.js',
description: 'This is a step-by-step tutorial on how to create your own add-on using Node.js.',
released: new Date('Mon Jul 02 2019 00:00:00 GMT+0300 (Eastern European Summer Time)'),
poster: 'https://theme.zdassets.com/theme_assets/2160011/77a6ad5aee11a07eb9b87281070f1aadf946f2b3.png',
season: 2,
episode: 1
}
]
}));
return metaItem;
};
module.exports = useMetaItem;

View file

@ -1,58 +1,148 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { Dropdown, MainNavBar, MetaItem, MetaPreview } = require('stremio/common');
const useCatalog = require('./useCatalog');
const Icon = require('stremio-icons/dom');
const { Button, MainNavBar, MetaItem, MetaPreview, Multiselect, ModalDialog, PaginationInput, useBinaryState } = require('stremio/common');
const useDiscover = require('./useDiscover');
const useSelectableInputs = require('./useSelectableInputs');
const styles = require('./styles');
const getMetaItemAtIndex = (catalog_resource, index) => {
return index !== null &&
isFinite(index) &&
catalog_resource !== null &&
catalog_resource.content.type === 'Ready' &&
catalog_resource.content.content[index] ?
catalog_resource.content.content[index]
:
null;
};
const Discover = ({ urlParams, queryParams }) => {
const [dropdowns, metaItems] = useCatalog(urlParams, queryParams);
const [selectedItem, setSelectedItem] = React.useState(null);
const metaItemsOnMouseDown = React.useCallback((event) => {
event.nativeEvent.blurPrevented = true;
}, []);
const metaItemsOnFocus = React.useCallback((event) => {
const metaItem = metaItems.find(({ id }) => {
return id === event.target.dataset.id;
});
if (metaItem) {
setSelectedItem(metaItem);
const discover = useDiscover(urlParams, queryParams);
const [selectInputs, paginationInput] = useSelectableInputs(discover);
const [inputsModalOpen, openInputsModal, closeInputsModal] = useBinaryState(false);
const [selectedMetaItem, setSelectedMetaItem] = React.useState(() => {
return getMetaItemAtIndex(discover.catalog_resource, 0);
});
const metaItemsOnFocusCapture = React.useCallback((event) => {
const metaItem = getMetaItemAtIndex(discover.catalog_resource, event.target.dataset.index);
setSelectedMetaItem(metaItem);
}, [discover.catalog_resource]);
const metaItemOnClick = React.useCallback((event) => {
const metaItem = getMetaItemAtIndex(discover.catalog_resource, event.currentTarget.dataset.index);
if (metaItem !== selectedMetaItem) {
event.preventDefault();
event.currentTarget.focus();
}
}, []);
React.useEffect(() => {
const metaItem = metaItems.length > 0 ? metaItems[0] : null;
setSelectedItem(metaItem);
}, [metaItems]);
}, [discover.catalog_resource, selectedMetaItem]);
React.useLayoutEffect(() => {
const metaItem = getMetaItemAtIndex(discover.catalog_resource, 0);
setSelectedMetaItem(metaItem);
}, [discover.catalog_resource]);
React.useLayoutEffect(() => {
closeInputsModal();
}, [urlParams, queryParams]);
return (
<div className={styles['discover-container']}>
<MainNavBar className={styles['nav-bar']} />
<div className={styles['discover-content']}>
<div className={styles['dropdowns-container']}>
{dropdowns.map((dropdown) => (
<Dropdown {...dropdown} key={dropdown.name} className={styles['dropdown']} />
))}
</div>
<div className={styles['meta-items-container']} onFocusCapture={metaItemsOnFocus} onMouseDownCapture={metaItemsOnMouseDown}>
{metaItems.map((metaItem) => (
<MetaItem
{...metaItem}
key={metaItem.id}
className={classnames(styles['meta-item'], { 'selected': selectedItem !== null && metaItem.id === selectedItem.id })}
<div className={styles['selectable-inputs-container']}>
{selectInputs.map((selectInput, index) => (
<Multiselect
{...selectInput}
key={index}
className={styles['select-input-container']}
/>
))}
<Button className={styles['filter-container']} title={'More filters'} onClick={openInputsModal}>
<Icon className={styles['filter-icon']} icon={'ic_filter'} />
</Button>
<div className={styles['spacing']} />
{
paginationInput !== null ?
<PaginationInput
{...paginationInput}
className={styles['pagination-input-container']}
/>
:
null
}
</div>
<div className={styles['catalog-content-container']}>
{
discover.selectable.types.length === 0 && discover.catalog_resource === null ?
<div className={styles['message-container']}>
No catalogs
</div>
:
discover.catalog_resource === null ?
<div className={styles['message-container']}>
No select
</div>
:
discover.catalog_resource.content.type === 'Err' ?
<div className={styles['message-container']}>
Catalog Error
</div>
:
discover.catalog_resource.content.type === 'Loading' ?
<div className={styles['message-container']}>
Loading
</div>
:
<div className={styles['meta-items-container']} onFocusCapture={metaItemsOnFocusCapture}>
{discover.catalog_resource.content.content.map((metaItem, index) => (
<MetaItem
key={index}
className={classnames(styles['meta-item'], { 'selected': selectedMetaItem === metaItem })}
type={metaItem.type}
name={metaItem.name}
poster={metaItem.poster}
posterShape={metaItem.posterShape}
href={metaItem.href}
data-index={index}
onClick={metaItemOnClick}
/>
))}
</div>
}
</div>
{
selectedItem !== null ?
selectedMetaItem !== null ?
<MetaPreview
{...selectedItem}
className={styles['meta-preview-container']}
compact={true}
name={selectedMetaItem.name}
logo={selectedMetaItem.logo}
background={selectedMetaItem.poster}
runtime={selectedMetaItem.runtime}
releaseInfo={selectedMetaItem.releaseInfo}
released={selectedMetaItem.released}
description={selectedMetaItem.description}
trailer={selectedMetaItem.trailer}
/>
:
null
<div className={styles['meta-preview-container']} />
}
</div>
{
inputsModalOpen ?
<ModalDialog onCloseRequest={closeInputsModal} />
:
null
}
</div>
);
};
Discover.propTypes = {
urlParams: PropTypes.exact({
addonTransportUrl: PropTypes.string,
type: PropTypes.string,
catalogId: PropTypes.string
}),
queryParams: PropTypes.instanceOf(URLSearchParams)
};
module.exports = Discover;

View file

@ -1,5 +1,15 @@
@import (reference) '~stremio/common/screen-sizes.less';
:import('~stremio/common/Multiselect/styles.less') {
multiselect-menu-container: menu-container;
}
:import('~stremio/common/PaginationInput/styles.less') {
pagination-prev-button-container: prev-button-container;
pagination-next-button-container: next-button-container;
pagination-button-icon: icon;
}
.discover-container {
display: flex;
flex-direction: column;
@ -16,41 +26,110 @@
flex: 1;
align-self: stretch;
display: grid;
grid-template-columns: 1fr 26rem;
grid-template-rows: fit-content(15rem) 1fr;
grid-template-columns: 1fr 28rem;
grid-template-rows: auto 1fr;
grid-template-areas:
"dropdowns-area meta-preview-area"
"meta-items-area meta-preview-area";
"selectable-inputs-area meta-preview-area"
"catalog-content-area meta-preview-area";
.dropdowns-container {
grid-area: dropdowns-area;
display: grid;
grid-template-columns: repeat(auto-fill, 15rem);
grid-gap: 1rem;
margin: 2rem 0;
padding: 0 2rem;
overflow-y: auto;
.selectable-inputs-container {
grid-area: selectable-inputs-area;
display: flex;
flex-direction: row;
padding: 1.5rem;
overflow: visible;
.dropdown {
.select-input-container {
flex-grow: 0;
flex-shrink: 1;
flex-basis: 15rem;
height: 3rem;
margin-right: 1.5rem;
&:nth-child(n+4) {
display: none;
&~.filter-container {
display: flex;
}
}
.multiselect-menu-container {
max-height: calc(3.2rem * 7);
overflow: auto;
}
}
.filter-container {
flex: none;
display: none;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background-color: var(--color-backgroundlighter);
&:not(:nth-last-child(2)) {
margin-right: 1.5rem;
}
.filter-icon {
flex: none;
width: 1.2rem;
height: 1.2rem;
fill: var(--color-surfacelighter);
}
}
.spacing {
flex: 1;
}
.pagination-input-container {
flex: none;
height: 3rem;
background-color: var(--color-backgroundlighter);
.pagination-prev-button-container, .pagination-next-button-container {
width: 3rem;
height: 3rem;
.pagination-button-icon {
width: 1rem;
height: 1rem;
}
}
}
}
.meta-items-container {
grid-area: meta-items-area;
display: grid;
grid-auto-rows: max-content;
grid-gap: 1.5rem;
align-items: center;
padding: 0 2rem;
overflow-y: auto;
.catalog-content-container {
grid-area: catalog-content-area;
.meta-item {
&.selected {
outline: calc(1.5 * var(--focus-outline-size)) solid var(--color-surfacelighter);
outline-offset: calc(-1.5 * var(--focus-outline-size));
.meta-items-container {
display: grid;
max-height: 100%;
grid-auto-rows: max-content;
align-items: center;
grid-gap: 1.5rem;
margin-right: 1.5rem;
padding: 0 1.5rem;
overflow-y: auto;
.meta-item {
&:global(.selected) {
&::after {
outline-width: calc(1.5 * var(--focus-outline-size));
outline-offset: calc(-1.5 * var(--focus-outline-size));
}
}
}
}
.message-container {
padding: 0 1.5rem;
font-size: 2rem;
color: var(--color-surfacelighter);
}
}
.meta-preview-container {
@ -63,8 +142,10 @@
@media only screen and (min-width: @xxlarge) {
.discover-container {
.discover-content {
.meta-items-container {
grid-template-columns: repeat(8, auto);
.catalog-content-container {
.meta-items-container {
grid-template-columns: repeat(8, 1fr);
}
}
}
}
@ -73,8 +154,10 @@
@media only screen and (max-width: @xxlarge) {
.discover-container {
.discover-content {
.meta-items-container {
grid-template-columns: repeat(7, auto);
.catalog-content-container {
.meta-items-container {
grid-template-columns: repeat(7, 1fr);
}
}
}
}
@ -83,8 +166,10 @@
@media only screen and (max-width: @normal) {
.discover-container {
.discover-content {
.meta-items-container {
grid-template-columns: repeat(6, auto);
.catalog-content-container {
.meta-items-container {
grid-template-columns: repeat(6, 1fr);
}
}
}
}
@ -93,8 +178,10 @@
@media only screen and (max-width: @medium) {
.discover-container {
.discover-content {
.meta-items-container {
grid-template-columns: repeat(5, auto);
.catalog-content-container {
.meta-items-container {
grid-template-columns: repeat(5, 1fr);
}
}
}
}
@ -103,8 +190,10 @@
@media only screen and (max-width: @small) {
.discover-container {
.discover-content {
.meta-items-container {
grid-template-columns: repeat(4, auto);
.catalog-content-container {
.meta-items-container {
grid-template-columns: repeat(4, 1fr);
}
}
}
}
@ -114,17 +203,17 @@
.discover-container {
.discover-content {
grid-template-columns: 1fr;
grid-template-rows: fit-content(19rem) 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"dropdowns-area"
"meta-items-area";
"selectable-inputs-area"
"catalog-content-area";
.dropdowns-container {
grid-template-columns: repeat(4, 1fr);
}
.catalog-content-container {
margin-right: 0;
.meta-items-container {
grid-template-columns: repeat(5, auto);
.meta-items-container {
grid-template-columns: repeat(5, 1fr);
}
}
.meta-preview-container {

View file

@ -1,104 +0,0 @@
const React = require('react');
const useCatalog = (urlParams, queryParams) => {
const queryString = new URLSearchParams(queryParams).toString();
const [addon, catalog] = React.useMemo(() => {
// TODO impl this logic to stremio-core for code-reuse:
// TODO use type if it is part of user's addons
// TODO fallback to first type from user's addons
// TODO find catalog for addonId, catalogId and type
// TODO fallback to first catalog for the type from user's catalogs
const addon = {
id: 'com.linvo.cinemeta',
version: '2.11.0',
name: 'Cinemeta'
};
const catalog = {
id: 'top',
type: 'movie',
name: 'Top',
extra: [
{
name: 'genre',
isRequired: false,
options: ['Action', 'Drama', 'Boring']
},
{
name: 'year',
isRequired: false,
options: ['2017', '2016', '2015']
}
]
};
return [addon, catalog];
}, [urlParams.type, urlParams.catalog]);
const dropdowns = React.useMemo(() => {
const onTypeChange = (event) => {
const { value } = event.currentTarget.dataset;
const query = new URLSearchParams(queryParams);
window.location = `#/discover/${value}/${addon.id}:${catalog.id}?${query}`;
};
const onCatalogChange = (event) => {
const { value } = event.currentTarget.dataset;
const query = new URLSearchParams(queryParams);
window.location = `#/discover/${catalog.type}/${value}?${query}`;
};
const onQueryParamChange = (event) => {
const { name, value } = event.currentTarget.dataset;
const query = new URLSearchParams({ ...queryParams, [name]: value });
window.location = `#/discover/${catalog.type}/${addon.id}:${catalog.id}?${query}`;
};
const requiredDropdowns = [
{
name: 'type',
selected: [catalog.type],
options: [
{ value: 'movie', label: 'movie' },
{ value: 'series', label: 'series' },
{ value: 'channels', label: 'channels' },
{ value: 'games', label: 'games' }
],
onSelect: onTypeChange
},
{
name: 'catalog',
selected: [`${addon.id}:${catalog.id}`],
options: [
{ value: 'com.linvo.cinemeta:top', label: 'Top' },
{ value: 'com.linvo.cinemeta:year', label: 'By year' }
],
onSelect: onCatalogChange
}
];
const extraDropdowns = catalog.extra
.filter((extra) => {
return extra.name !== 'skip' && extra.name !== 'search';
})
.map((extra) => ({
...extra,
onSelect: onQueryParamChange,
options: extra.options.map((option) => ({ value: option, label: option })),
selected: extra.options.includes(queryParams[extra.name]) ?
[queryParams[extra.name]]
:
extra.isRequired ?
[extra.options[0]]
:
[]
}));
return requiredDropdowns.concat(extraDropdowns);
}, [addon, catalog, queryString]);
const items = React.useMemo(() => {
return Array(100).fill(null).map((_, index) => ({
id: `tt${index}`,
type: 'movie',
name: `Stremio demo item ${index}`,
poster: `https://www.stremio.com/website/technology-hero.jpg`,
logo: `https://www.stremio.com/website/stremio-logo-small.png`,
posterShape: 'poster'
}));
}, []);
return [dropdowns, items];
};
module.exports = useCatalog;

View file

@ -0,0 +1,102 @@
const React = require('react');
const { useServices } = require('stremio/services');
const { useModelState } = require('stremio/common');
const initDiscoverState = () => ({
selectable: {
types: [],
catalogs: [],
extra: [],
has_next_page: false,
has_prev_page: false
},
catalog_resource: null
});
const mapDiscoverState = (discover) => {
const selectable = discover.selectable;
const catalog_resource = discover.catalog_resource !== null && discover.catalog_resource.content.type === 'Ready' ?
{
...discover.catalog_resource,
content: {
...discover.catalog_resource.content,
content: discover.catalog_resource.content.content.map((metaItem) => ({
type: metaItem.type,
name: metaItem.name,
logo: metaItem.logo,
background: metaItem.background,
poster: metaItem.poster,
posterShape: metaItem.posterShape,
runtime: metaItem.runtime,
releaseInfo: metaItem.releaseInfo,
released: new Date(metaItem.released),
description: metaItem.description,
links: metaItem.links,
trailer: metaItem.trailer,
href: `#/metadetails/${encodeURIComponent(metaItem.type)}/${encodeURIComponent(metaItem.id)}` // TODO this should redirect with videoId at some cases
}))
}
}
:
discover.catalog_resource;
return { selectable, catalog_resource };
};
const onNewDiscoverState = (discover) => {
if (discover.catalog_resource === null && discover.selectable.types.length > 0) {
return {
action: 'Load',
args: {
load: 'CatalogFiltered',
args: discover.selectable.types[0].load_request
}
};
}
};
const useDiscover = (urlParams, queryParams) => {
const { core } = useServices();
const loadDiscoverAction = React.useMemo(() => {
if (typeof urlParams.addonTransportUrl === 'string' && typeof urlParams.type === 'string' && typeof urlParams.catalogId === 'string') {
return {
action: 'Load',
args: {
load: 'CatalogFiltered',
args: {
base: urlParams.addonTransportUrl,
path: {
resource: 'catalog',
type_name: urlParams.type,
id: urlParams.catalogId,
extra: Array.from(queryParams.entries())
}
}
}
};
} else {
const discover = core.getState('discover');
if (discover.selectable.types.length > 0) {
return {
action: 'Load',
args: {
load: 'CatalogFiltered',
args: discover.selectable.types[0].load_request
}
};
} else {
return {
action: 'Unload'
};
}
}
}, [urlParams, queryParams]);
return useModelState({
model: 'discover',
action: loadDiscoverAction,
map: mapDiscoverState,
init: initDiscoverState,
onNewState: onNewDiscoverState
});
};
module.exports = useDiscover;

View file

@ -0,0 +1,188 @@
const React = require('react');
const NONE_EXTRA_VALUE = 'None';
const CATALOG_PAGE_SIZE = 100;
const SKIP_EXTRA = {
name: 'skip',
optionsLimit: 1,
isRequired: false
};
const navigateWithLoadRequest = (load_request) => {
const addonTransportUrl = encodeURIComponent(load_request.base);
const type = encodeURIComponent(load_request.path.type_name);
const catalogId = encodeURIComponent(load_request.path.id);
const extra = new URLSearchParams(load_request.path.extra).toString();
window.location.replace(`#/discover/${addonTransportUrl}/${type}/${catalogId}?${extra}`);
};
const getNextExtra = (prevExtra, extraProp, extraValue) => {
return prevExtra
.concat([[extraProp.name, extraValue]])
.reduceRight((result, [name, value]) => {
if (extraProp.name === name) {
if (extraValue !== NONE_EXTRA_VALUE) {
const optionsCount = result.reduce((optionsCount, [name]) => {
if (extraProp.name === name) {
optionsCount++;
}
return optionsCount;
}, 0);
if (extraProp.optionsLimit === 1) {
if (optionsCount === 0) {
result.unshift([name, value]);
}
} else if (extraProp.optionsLimit > 1) {
const valueIndex = result.findIndex(([_name, _value]) => {
return _name === name && _value === value;
});
if (valueIndex !== -1) {
result.splice(valueIndex, 1);
} else if (extraProp.optionsLimit > optionsCount) {
result.unshift([name, value]);
}
}
}
} else {
result.unshift([name, value]);
}
return result;
}, []);
};
const equalWithouExtra = (request1, request2) => {
return request1.base === request2.base &&
request1.path.resource === request2.path.resource &&
request1.path.type_name === request2.path.type_name &&
request1.path.id === request2.path.id;
};
const mapSelectableInputs = (discover) => {
const selectedCatalogRequest = discover.catalog_resource !== null ?
discover.catalog_resource.request
:
{
base: null,
path: {
resource: 'catalog',
id: null,
type_name: null,
extra: []
}
};
const requestedPage = selectedCatalogRequest.path.extra.reduce((requestedPage, [name, value]) => {
if (name === SKIP_EXTRA.name) {
const skip = parseInt(value);
if (isFinite(skip)) {
return Math.floor(skip / CATALOG_PAGE_SIZE) + 1;
}
}
return requestedPage;
}, 1);
const typeSelect = {
title: 'Select type',
options: discover.selectable.types
.map(({ name, load_request }) => ({
value: JSON.stringify(load_request),
label: name
})),
selected: discover.selectable.types
.filter(({ load_request: { path: { type_name } } }) => {
return type_name === selectedCatalogRequest.path.type_name;
})
.map(({ load_request }) => JSON.stringify(load_request)),
onSelect: (event) => {
navigateWithLoadRequest(JSON.parse(event.value));
}
};
const catalogSelect = {
title: 'Select catalog',
options: discover.selectable.catalogs
.map(({ name, load_request }) => ({
value: JSON.stringify(load_request),
label: name
})),
selected: discover.selectable.catalogs
.filter(({ load_request }) => {
return equalWithouExtra(load_request, selectedCatalogRequest);
})
.map(({ load_request }) => JSON.stringify(load_request)),
onSelect: (event) => {
navigateWithLoadRequest(JSON.parse(event.value));
}
};
const extraSelects = discover.selectable.extra.map((extra) => {
const title = `Select ${extra.name}`;
const options = (extra.isRequired ? [] : [NONE_EXTRA_VALUE])
.concat(extra.options)
.map((option) => ({
value: option,
label: option
}));
const selected = selectedCatalogRequest.path.extra
.reduce((selected, [name, value]) => {
if (name === extra.name) {
selected = selected
.filter((value) => value !== NONE_EXTRA_VALUE)
.concat([value]);
}
return selected;
}, extra.isRequired ? [] : [NONE_EXTRA_VALUE]);
const renderLabelText = selected.includes(NONE_EXTRA_VALUE) ?
() => title
:
null;
const onSelect = (event) => {
navigateWithLoadRequest({
base: selectedCatalogRequest.base,
path: {
resource: 'catalog',
type_name: selectedCatalogRequest.path.type_name,
id: selectedCatalogRequest.path.id,
extra: getNextExtra(selectedCatalogRequest.path.extra, extra, event.value)
}
});
};
return { title, options, selected, renderLabelText, onSelect };
});
const paginationInput = discover.selectable.has_prev_page || discover.selectable.has_next_page ?
{
label: String(requestedPage),
onSelect: (event) => {
if (event.value === 'prev' && !discover.selectable.has_prev_page ||
event.value === 'next' && !discover.selectable.has_next_page) {
return;
}
const nextValue = event.value === 'next' ?
String(requestedPage * CATALOG_PAGE_SIZE)
:
String((requestedPage - 2) * CATALOG_PAGE_SIZE);
navigateWithLoadRequest({
base: selectedCatalogRequest.base,
path: {
resource: 'catalog',
type_name: selectedCatalogRequest.path.type_name,
id: selectedCatalogRequest.path.id,
extra: getNextExtra(selectedCatalogRequest.path.extra, SKIP_EXTRA, nextValue)
}
});
}
}
:
null;
return [[typeSelect, catalogSelect, ...extraSelects], paginationInput];
};
const useSelectableInputs = (discover) => {
const selectableInputs = React.useMemo(() => {
return mapSelectableInputs(discover);
}, [discover]);
return selectableInputs;
};
module.exports = useSelectableInputs;

View file

@ -4,12 +4,20 @@ const classnames = require('classnames');
const { Button, Checkbox } = require('stremio/common');
const styles = require('./styles');
const ConsentCheckbox = React.forwardRef(({ className, checked, label, link, href, toggle, ...props }, ref) => {
const ConsentCheckbox = React.forwardRef(({ className, checked, label, link, href, onToggle, ...props }, ref) => {
const checkboxOnClick = React.useCallback((event) => {
if (!event.nativeEvent.togglePrevented && typeof toggle === 'function') {
toggle(event);
if (typeof props.onClick === 'function') {
props.onClick(event);
}
}, [toggle]);
if (!event.nativeEvent.togglePrevented && typeof onToggle === 'function') {
onToggle({
type: 'toggle',
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}, [onToggle]);
const linkOnClick = React.useCallback((event) => {
event.nativeEvent.togglePrevented = true;
}, []);
@ -38,7 +46,8 @@ ConsentCheckbox.propTypes = {
label: PropTypes.string,
link: PropTypes.string,
href: PropTypes.string,
toggle: PropTypes.func
onToggle: PropTypes.func,
onClick: PropTypes.func
};
module.exports = ConsentCheckbox;

View file

@ -8,7 +8,7 @@ const CredentialsTextInput = React.forwardRef((props, ref) => {
props.onKeyDown(event);
}
if (!event.navigationPrevented) {
if (!event.nativeEvent.navigationPrevented) {
event.stopPropagation();
if (event.key === 'ArrowDown') {
window.navigate('down');
@ -24,7 +24,7 @@ const CredentialsTextInput = React.forwardRef((props, ref) => {
CredentialsTextInput.displayName = 'CredentialsTextInput';
CredentialsTextInput.propTYpes = {
CredentialsTextInput.propTypes = {
onKeyDown: PropTypes.func
};

View file

@ -1,16 +1,19 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const { useRouteFocused } = require('stremio-router');
const { Button } = require('stremio/common');
const { useServices } = require('stremio/services');
const CredentialsTextInput = require('./CredentialsTextInput');
const ConsentCheckbox = require('./ConsentCheckbox');
const styles = require('./styles');
const LOGIN_FORM = 'LOGIN_FORM';
const SIGNUP_FORM = 'SIGNUP_FORM';
const SIGNUP_FORM = 'signup';
const LOGIN_FORM = 'login';
const Intro = () => {
const Intro = ({ queryParams }) => {
const { core } = useServices();
const routeFocused = useRouteFocused();
const emailRef = React.useRef();
const passwordRef = React.useRef();
@ -22,17 +25,20 @@ const Intro = () => {
const [state, dispatch] = React.useReducer(
(state, action) => {
switch (action.type) {
case 'switch-form':
return {
form: state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM,
email: '',
password: '',
confirmPassword: '',
termsAccepted: false,
privacyPolicyAccepted: false,
marketingAccepted: false,
error: ''
};
case 'set-form':
if (state.form !== action.form) {
return {
form: action.form,
email: '',
password: '',
confirmPassword: '',
termsAccepted: false,
privacyPolicyAccepted: false,
marketingAccepted: false,
error: ''
};
}
return state;
case 'change-credentials':
return {
...state,
@ -55,7 +61,7 @@ const Intro = () => {
}
},
{
form: SIGNUP_FORM,
form: [LOGIN_FORM, SIGNUP_FORM].includes(queryParams.get('form')) ? queryParams.get('form') : SIGNUP_FORM,
email: '',
password: '',
confirmPassword: '',
@ -65,27 +71,127 @@ const Intro = () => {
error: ''
}
);
React.useEffect(() => {
const onEvent = ({ event, args }) => {
switch (event) {
case 'CtxActionErr': {
const [, error] = args;
dispatch({ type: 'error', error: error.args.message });
break;
}
case 'CtxChanged': {
const state = core.getState();
if (state.ctx.content.auth !== null) {
window.location.replace('#/');
}
}
}
};
if (routeFocused) {
core.on('Event', onEvent);
}
return () => {
core.off('Event', onEvent);
};
}, [routeFocused]);
const loginWithFacebook = React.useCallback(() => {
alert('TODO: Facebook login');
}, []);
FB.login((response) => {
if (response.status === 'connected') {
fetch('https://www.strem.io/fb-login-with-token/' + encodeURIComponent(response.authResponse.accessToken), { timeout: 10 * 1000 })
.then((resp) => {
if (resp.status < 200 || resp.status >= 300) {
throw new Error('Login failed at getting token from Stremio with status ' + resp.status);
} else {
return resp.json();
}
})
.then(() => {
core.dispatch({
action: 'UserOp',
args: {
userOp: 'Login',
args: {
email: state.email,
password: response.authResponse.accessToken
}
}
});
})
.catch(() => { });
}
});
}, [state.email, state.password]);
const loginWithEmail = React.useCallback(() => {
if (typeof state.email !== 'string' || state.email.length === 0) {
dispatch({ type: 'error', error: 'Invalid email' });
return;
}
alert('TODO: Login');
if (typeof state.password !== 'string' || state.password.length === 0) {
dispatch({ type: 'error', error: 'Invalid password' });
return;
}
core.dispatch({
action: 'UserOp',
args: {
userOp: 'Login',
args: {
email: state.email,
password: state.password
}
}
});
}, [state.email, state.password]);
const loginAsGuest = React.useCallback(() => {
if (!state.termsAccepted) {
dispatch({ type: 'error', error: 'You must accept the Terms of Service' });
return;
}
alert('TODO: Guest login');
}, [state.termsAccepted, state.privacyPolicyAccepted, state.marketingAccepted]);
core.dispatch({
action: 'UserOp',
args: {
userOp: 'Logout'
}
});
window.location.replace('#/');
}, [state.termsAccepted]);
const signup = React.useCallback(() => {
alert('TODO: Signup');
if (typeof state.email !== 'string' || state.email.length === 0) {
dispatch({ type: 'error', error: 'Invalid email' });
return;
}
if (typeof state.password !== 'string' || state.password.length === 0) {
dispatch({ type: 'error', error: 'Invalid password' });
return;
}
if (state.password !== state.confirmPassword) {
dispatch({ type: 'error', error: 'Passwords do not match' });
return;
}
if (!state.termsAccepted) {
dispatch({ type: 'error', error: 'You must accept the Terms of Service' });
return;
}
if (!state.privacyPolicyAccepted) {
dispatch({ type: 'error', error: 'You must accept the Privacy Policy' });
return;
}
core.dispatch({
action: 'UserOp',
args: {
userOp: 'Register',
args: {
email: state.email,
password: state.password,
gdpr_consent: {
tos: state.termsAccepted,
privacy: state.privacyPolicyAccepted,
marketing: state.marketingAccepted,
time: new Date(),
from: 'web'
}
}
}
});
}, [state.email, state.password, state.confirmPassword, state.termsAccepted, state.privacyPolicyAccepted, state.marketingAccepted]);
const emailOnChange = React.useCallback((event) => {
dispatch({
@ -130,9 +236,11 @@ const Intro = () => {
const toggleMarketingAccepted = React.useCallback(() => {
dispatch({ type: 'toggle-checkbox', name: 'marketingAccepted' });
}, []);
const switchForm = React.useCallback(() => {
dispatch({ type: 'switch-form' });
}, []);
React.useEffect(() => {
if ([LOGIN_FORM, SIGNUP_FORM].includes(queryParams.get('form'))) {
dispatch({ type: 'set-form', form: queryParams.get('form') });
}
}, [queryParams]);
React.useEffect(() => {
if (typeof state.error === 'string' && state.error.length > 0) {
errorRef.current.scrollIntoView();
@ -150,7 +258,7 @@ const Intro = () => {
<Icon className={styles['icon']} icon={'ic_facebook'} />
<div className={styles['label']}>Continue with Facebook</div>
</Button>
<div className={styles['facebook-statement']}>We won't post anything on your behalf</div>
<div className={styles['facebook-statement']}>We won&#39;t post anything on your behalf</div>
<CredentialsTextInput
ref={emailRef}
className={styles['credentials-text-input']}
@ -188,7 +296,7 @@ const Intro = () => {
link={'Terms and conditions'}
href={'https://www.stremio.com/tos'}
checked={state.termsAccepted}
toggle={toggleTermsAccepted}
onToggle={toggleTermsAccepted}
/>
<ConsentCheckbox
ref={privacyPolicyRef}
@ -197,14 +305,14 @@ const Intro = () => {
link={'Privacy Policy'}
href={'https://www.stremio.com/privacy'}
checked={state.privacyPolicyAccepted}
toggle={togglePrivacyPolicyAccepted}
onToggle={togglePrivacyPolicyAccepted}
/>
<ConsentCheckbox
ref={marketingRef}
className={styles['consent-checkbox']}
label={'I agree to receive marketing communications from Stremio'}
checked={state.marketingAccepted}
toggle={toggleMarketingAccepted}
onToggle={toggleMarketingAccepted}
/>
</React.Fragment>
:
@ -229,7 +337,7 @@ const Intro = () => {
:
null
}
<Button className={classnames(styles['form-button'], styles['switch-form-button'])} onClick={switchForm}>
<Button className={classnames(styles['form-button'], styles['switch-form-button'])} href={state.form === SIGNUP_FORM ? '#/intro?form=login' : '#/intro?form=signup'}>
<div className={styles['label']}>{state.form === SIGNUP_FORM ? 'LOG IN' : 'SING UP WITH EMAIL'}</div>
</Button>
</div>
@ -237,4 +345,8 @@ const Intro = () => {
);
};
Intro.propTypes = {
queryParams: PropTypes.instanceOf(URLSearchParams)
};
module.exports = Intro;

View file

@ -1,30 +1,80 @@
const React = require('react');
const { Dropdown, MainNavBar, MetaItem } = require('stremio/common');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { Button, Multiselect, MainNavBar, MetaItem } = require('stremio/common');
const useLibrary = require('./useLibrary');
const useSelectableInputs = require('./useSelectableInputs');
const useItemOptions = require('./useItemOptions');
const styles = require('./styles');
const Library = ({ urlParams, queryParams }) => {
const [metaItems, dropdowns] = useLibrary(urlParams.type, queryParams.get('sort'));
const library = useLibrary(urlParams, queryParams);
const [typeSelect, sortPropSelect] = useSelectableInputs(library);
const [options, optionOnSelect] = useItemOptions();
return (
<div className={styles['library-container']}>
<MainNavBar className={styles['nav-bar']} />
<div className={styles['library-content']}>
<div className={styles['dropdowns-container']}>
{dropdowns.map((dropdown) => (
<Dropdown {...dropdown} key={dropdown.name} className={styles['dropdown']} />
))}
</div>
<div className={styles['meta-items-container']}>
{metaItems.map((metaItem) => (
<MetaItem
{...metaItem}
key={metaItem.id}
/>
))}
</div>
{
library.library_state.type === 'Ready' && library.library_state.content.uid !== null && library.type_names.length > 0 ?
<div className={styles['selectable-inputs-container']}>
<Multiselect {...typeSelect} className={styles['select-input-container']} />
<Multiselect {...sortPropSelect} className={styles['select-input-container']} />
</div>
:
null
}
{
library.library_state.type === 'Ready' && library.library_state.content.uid === null ?
<div className={classnames(styles['message-container'], styles['no-user-message-container'])}>
<div className={styles['message-label']}>Library is only availavle for logged in users</div>
<Button className={styles['login-button-container']} href={'#/intro'}>
<div className={styles['label']}>LOG IN</div>
</Button>
</div>
:
library.library_state.type !== 'Ready' ?
<div className={styles['message-container']}>
<div className={styles['message-label']}>Loading</div>
</div>
:
library.type_names.length === 0 ?
<div className={styles['message-container']}>
<div className={styles['message-label']}>Empty library</div>
</div>
:
library.selected === null ?
<div className={styles['message-container']}>
<div className={styles['message-label']}>Please select a type</div>
</div>
:
library.lib_items.length === 0 ?
<div className={styles['message-container']}>
<div className={styles['message-label']}>There are no items for the selected type</div>
</div>
:
<div className={styles['meta-items-container']}>
{library.lib_items.map(({ id, videoId, ...libItem }, index) => (
<MetaItem
{...libItem}
key={index}
dataset={{ id, videoId, type: libItem.type }}
options={options}
optionOnSelect={optionOnSelect}
/>
))}
</div>
}
</div>
</div>
);
}
};
Library.propTypes = {
urlParams: PropTypes.exact({
type: PropTypes.string,
}),
queryParams: PropTypes.instanceOf(URLSearchParams)
};
module.exports = Library;

View file

@ -1,5 +1,9 @@
@import (reference) '~stremio/common/screen-sizes.less';
:import('~stremio/common/Multiselect/styles.less') {
multiselect-menu-container: menu-container;
}
.library-container {
display: flex;
flex-direction: column;
@ -15,38 +19,98 @@
.library-content {
flex: 1;
align-self: stretch;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto 1fr;
grid-template-areas:
"dropdowns-area switch-view-area"
"meta-items-area meta-items-area";
display: flex;
flex-direction: column;
.dropdowns-container {
grid-area: dropdowns-area;
.selectable-inputs-container {
flex: none;
align-self: stretch;
display: flex;
flex-direction: row;
margin: 2rem;
margin: 1.5rem;
overflow: visible;
.dropdown {
.select-input-container {
flex-grow: 0;
flex-shrink: 1;
flex-basis: 15rem;
height: 3rem;
&:not(:last-child) {
margin-right: 1rem;
margin-right: 1.5rem;
}
.multiselect-menu-container {
max-height: calc(3.2rem * 7);
overflow: auto;
}
}
}
.message-container {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding: 0 1.5rem;
&:first-child {
.message-label {
margin: 1.5rem 0;
}
}
&.no-user-message-container {
flex: 1;
justify-content: center;
.login-button-container {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 20rem;
min-height: 4rem;
padding: 0.5rem 1rem;
background-color: var(--color-primarydark);
&:hover {
background-color: var(--color-primary);
}
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
max-height: 4.8em;
font-size: 1.2rem;
font-weight: 700;
color: var(--color-surfacelighter);
text-align: center;
}
}
}
.message-label {
flex: none;
max-height: 4.8em;
font-size: 2rem;
color: var(--color-surfacelighter);
text-align: center;
}
}
.meta-items-container {
grid-area: meta-items-area;
flex: 1;
display: grid;
grid-auto-rows: max-content;
grid-gap: 1.5rem;
align-items: center;
padding: 0 2rem;
grid-gap: 1.5rem;
padding: 0 1.5rem;
overflow-y: auto;
}
}
@ -56,7 +120,7 @@
.library-container {
.library-content {
.meta-items-container {
grid-template-columns: repeat(10, auto);
grid-template-columns: repeat(10, 1fr);
}
}
}
@ -66,7 +130,7 @@
.library-container {
.library-content {
.meta-items-container {
grid-template-columns: repeat(9, auto);
grid-template-columns: repeat(9, 1fr);
}
}
}
@ -76,7 +140,7 @@
.library-container {
.library-content {
.meta-items-container {
grid-template-columns: repeat(8, auto);
grid-template-columns: repeat(8, 1fr);
}
}
}
@ -86,7 +150,7 @@
.library-container {
.library-content {
.meta-items-container {
grid-template-columns: repeat(7, auto);
grid-template-columns: repeat(7, 1fr);
}
}
}
@ -96,7 +160,7 @@
.library-container {
.library-content {
.meta-items-container {
grid-template-columns: repeat(6, auto);
grid-template-columns: repeat(6, 1fr);
}
}
}
@ -106,7 +170,7 @@
.library-container {
.library-content {
.meta-items-container {
grid-template-columns: repeat(5, auto);
grid-template-columns: repeat(5, 1fr);
}
}
}

View file

@ -0,0 +1,25 @@
const React = require('react');
const PLAY_OPTION = {
label: 'Play',
value: 'play'
};
const DISMISS_OPTION = {
label: 'Dismiss',
value: 'dismiss'
};
const onSelect = () => {
// TODO {{event.value}} {{event.dataset}}
};
const useItemOptions = () => {
const options = React.useMemo(() => ([
PLAY_OPTION,
DISMISS_OPTION
]), []);
return [options, onSelect];
};
module.exports = useItemOptions;

View file

@ -1,99 +1,95 @@
const React = require('react');
const { useServices } = require('stremio/services');
const { useModelState } = require('stremio/common');
const DEFAULT_TYPE = 'movie';
const libraryItems = [
{
id: '1',
type: 'movie',
name: 'Stremio demo item movie 1',
poster: '/images/intro_background.jpg',
logo: '/images/default_avatar.png',
posterShape: 'poster'
const initLibraryState = () => ({
library_state: {
type: 'NotLoaded'
},
{
id: '2',
type: 'movie',
name: 'Stremio demo item movie 2',
poster: '/images/intro_background.jpg',
logo: '/images/default_avatar.png',
posterShape: 'poster'
},
{
id: '3',
type: 'series',
name: 'Stremio demo item series 1',
poster: '/images/default_avatar.png',
logo: '/images/default_avatar.png',
posterShape: 'poster'
},
{
id: '4',
type: 'series',
name: 'Stremio demo item series 2',
poster: '/images/default_avatar.png',
logo: '/images/default_avatar.png',
posterShape: 'poster'
},
{
id: '5',
type: 'channel',
name: 'Stremio demo item channel 1',
poster: '/images/anonymous.png',
logo: '/images/default_avatar.png',
posterShape: 'square'
},
{
id: '6',
type: 'channel',
name: 'Stremio demo item channel 2',
poster: '/images/anonymous.png',
logo: '/images/default_avatar.png',
posterShape: 'square'
selected: null,
type_names: [],
lib_items: []
});
const mapLibraryState = (library) => {
const library_state = library.library_state;
const selected = library.selected;
const type_names = library.type_names;
const lib_items = library.lib_items.map((lib_item) => ({
id: lib_item._id,
type: lib_item.type,
name: lib_item.name,
poster: lib_item.poster,
posterShape: lib_item.posterShape,
progress: lib_item.state.timeOffset > 0 && lib_item.state.duration > 0 ?
lib_item.state.timeOffset / lib_item.state.duration
:
null,
videoId: lib_item.state.video_id,
href: `#/metadetails/${encodeURIComponent(lib_item.type)}/${encodeURIComponent(lib_item._id)}${lib_item.state.video_id !== null ? `/${encodeURIComponent(lib_item.state.video_id)}` : ''}`
}));
return { library_state, selected, type_names, lib_items };
};
const onNewLibraryState = (library) => {
if (library.selected === null && library.type_names.length > 0) {
return {
action: 'Load',
args: {
load: 'LibraryFiltered',
args: {
type_name: library.type_names[0],
sort_prop: null
}
}
};
}
];
};
const useLibrary = (type, sort) => {
type = typeof type === 'string' && type.length > 0 ? type : DEFAULT_TYPE;
const items = React.useMemo(() => {
return libraryItems.filter(item => item.type === type);
}, [type, sort]);
const onSelect = React.useCallback((event) => {
const { name, value } = event.currentTarget.dataset;
if (name === 'type') {
const nextQuery = new URLSearchParams({ sort: typeof sort === 'string' ? sort : '' });
const nextType = typeof value === 'string' ? value : '';
window.location.replace(`#/library/${nextType}?${nextQuery}`);
} else if (name === 'sort') {
const nextQuery = new URLSearchParams({ sort: typeof value === 'string' ? value : '' });
const nextType = typeof type === 'string' ? type : '';
window.location.replace(`#/library/${nextType}?${nextQuery}`);
const useLibrary = (urlParams, queryParams) => {
const { core } = useServices();
const loadLibraryAction = React.useMemo(() => {
if (typeof urlParams.type === 'string') {
return {
action: 'Load',
args: {
load: 'LibraryFiltered',
args: {
type_name: urlParams.type,
sort_prop: queryParams.has('sort_prop') ?
queryParams.get('sort_prop')
:
null
}
}
};
} else {
const library = core.getState('library');
if (library.type_names.length > 0) {
return {
action: 'Load',
args: {
load: 'LibraryFiltered',
args: {
type_name: library.type_names[0],
sort_prop: null
}
}
};
} else {
return {
action: 'Unload'
};
}
}
}, [type, sort]);
const typeDropdown = React.useMemo(() => {
const selected = typeof type === 'string' && type.length > 0 ? [type] : [];
const options = libraryItems
.map(({ type }) => type)
.concat(selected)
.filter((type, index, types) => types.indexOf(type) === index)
.map((type) => ({ label: type, value: type }));
return {
name: 'type',
selected,
options,
onSelect
};
}, [type, onSelect]);
const sortDropdown = React.useMemo(() => {
const selected = typeof sort === 'string' && sort.length > 0 ? [sort] : [];
const options = [{ label: 'A-Z', value: 'a-z' }, { label: 'Recent', value: 'recent' }];
return {
name: 'sort',
selected,
options,
onSelect
};
}, [sort, onSelect]);
return [items, [typeDropdown, sortDropdown]];
}, [urlParams, queryParams]);
return useModelState({
model: 'library',
action: loadLibraryAction,
map: mapLibraryState,
init: initLibraryState,
onNewState: onNewLibraryState
});
};
module.exports = useLibrary;

View file

@ -0,0 +1,52 @@
const React = require('react');
const SORT_PROP_OPTIONS = [
{ label: 'Recent', value: '_ctime' },
{ label: 'A-Z', value: 'name' },
{ label: 'Year', value: 'year' },
];
const mapSelectableInputs = (library) => {
const typeSelect = {
title: 'Select type',
selected: library.selected !== null ?
[library.selected.type_name]
:
[],
options: library.type_names
.map((type) => ({ label: type, value: type })),
onSelect: (event) => {
const queryParams = new URLSearchParams(
library.selected !== null ?
[['sort_prop', library.selected.sort_prop]]
:
[]
);
window.location.replace(`#/library/${encodeURIComponent(event.value)}?${queryParams.toString()}`);
}
};
const sortPropSelect = {
title: 'Select sort',
selected: library.selected !== null ?
[library.selected.sort_prop]
:
[],
options: SORT_PROP_OPTIONS,
onSelect: (event) => {
const queryParams = new URLSearchParams([['sort_prop', event.value]]);
if (library.selected !== null) {
window.location.replace(`#/library/${encodeURIComponent(library.selected.type_name)}?${queryParams.toString()}`);
}
}
};
return [typeSelect, sortPropSelect];
};
const useSelectableInputs = (library) => {
const selectableInputs = React.useMemo(() => {
return mapSelectableInputs(library);
}, [library]);
return selectableInputs;
};
module.exports = useSelectableInputs;

View file

@ -0,0 +1,123 @@
const React = require('react');
const PropTypes = require('prop-types');
const { NavBar, MetaPreview, useInLibrary } = require('stremio/common');
const VideosList = require('./VideosList');
const StreamsList = require('./StreamsList');
const useMetaDetails = require('./useMetaDetails');
const useSelectableResource = require('./useSelectableResource');
const styles = require('./styles');
const MetaDetails = ({ urlParams }) => {
const metaDetails = useMetaDetails(urlParams);
const [metaResourceRef, metaResources, selectedMetaResource] = useSelectableResource(metaDetails.selected.meta_resource_ref, metaDetails.meta_resources);
const streamsResourceRef = metaDetails.selected.streams_resource_ref;
const streamsResources = metaDetails.streams_resources;
const metaItem = React.useMemo(() => {
return selectedMetaResource !== null ?
selectedMetaResource.content.content
:
metaResourceRef !== null ?
{
id: metaResourceRef.id,
type: metaResourceRef.type_name,
name: ''
}
:
null;
}, [metaResourceRef, selectedMetaResource]);
const [inLibrary, toggleInLibrary] = useInLibrary(metaItem);
return (
<div className={styles['metadetails-container']}>
<NavBar
className={styles['nav-bar']}
backButton={true}
title={selectedMetaResource !== null ? selectedMetaResource.content.content.name : null}
/>
<div className={styles['metadetails-content']}>
{
metaResourceRef === null ?
<MetaPreview
className={styles['meta-preview']}
name={'No meta was selected'}
/>
:
metaResources.length === 0 ?
<MetaPreview
className={styles['meta-preview']}
name={'No addons ware requested for this meta'}
inLibrary={inLibrary}
toggleInLibrary={toggleInLibrary}
/>
:
metaResources.every((metaResource) => metaResource.content.type === 'Err') ?
<MetaPreview
className={styles['meta-preview']}
name={'No metadata was found'}
inLibrary={inLibrary}
toggleInLibrary={toggleInLibrary}
/>
:
selectedMetaResource !== null ?
<React.Fragment>
{
typeof selectedMetaResource.content.content.background === 'string' &&
selectedMetaResource.content.content.background.length > 0 ?
<div className={styles['background-image-layer']}>
<img
className={styles['background-image']}
src={selectedMetaResource.content.content.background}
alt={' '}
/>
</div>
:
null
}
<MetaPreview
className={styles['meta-preview']}
name={selectedMetaResource.content.content.name}
logo={selectedMetaResource.content.content.logo}
background={null}
runtime={selectedMetaResource.content.content.runtime}
releaseInfo={selectedMetaResource.content.content.releaseInfo}
released={selectedMetaResource.content.content.released}
description={selectedMetaResource.content.content.description}
links={selectedMetaResource.content.content.links}
trailer={selectedMetaResource.content.content.trailer}
inLibrary={inLibrary}
toggleInLibrary={toggleInLibrary}
/>
</React.Fragment>
:
<MetaPreview.Placeholder
className={styles['meta-preview']}
/>
}
{
streamsResourceRef !== null ?
<StreamsList
className={styles['streams-list']}
streamsResources={streamsResources}
/>
:
metaResourceRef !== null ?
<VideosList
className={styles['videos-list']}
metaResource={selectedMetaResource}
/>
:
null
}
</div>
</div>
);
};
MetaDetails.propTypes = {
urlParams: PropTypes.exact({
type: PropTypes.string,
id: PropTypes.string,
videoId: PropTypes.string
})
};
module.exports = MetaDetails;

View file

@ -0,0 +1,66 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const { Button, Image } = require('stremio/common');
const StreamPlaceholder = require('./StreamPlaceholder');
const styles = require('./styles');
const Stream = ({ className, addonName, title, thumbnail, progress, ...props }) => {
return (
<Button {...props} className={classnames(className, styles['stream-container'])} title={title}>
{
typeof thumbnail === 'string' && thumbnail.length > 0 ?
<div className={styles['thumbnail-container']}>
<Image
className={styles['thumbnail']}
src={thumbnail}
alt={' '}
renderFallback={() => (
<Icon
className={styles['placeholder-icon']}
icon={'ic_broken_link'}
/>
)}
/>
</div>
:
<div className={styles['addon-name-container']}>
<div className={styles['addon-name']}>{addonName}</div>
</div>
}
<div className={styles['info-container']}>
{
typeof thumbnail === 'string' && thumbnail.length > 0 ?
<div className={styles['addon-name-label']}>{addonName}</div>
:
null
}
<div className={styles['title-label']}>{title}</div>
</div>
<div className={styles['play-icon-container']}>
<Icon className={styles['play-icon']} icon={'ic_play'} />
</div>
{
progress !== null && !isNaN(progress) && progress > 0 ?
<div className={styles['progress-bar-container']}>
<div className={styles['progress-bar']} style={{ width: `${Math.min(progress, 1) * 100}%` }} />
</div>
:
null
}
</Button>
);
};
Stream.Placeholder = StreamPlaceholder;
Stream.propTypes = {
className: PropTypes.string,
addonName: PropTypes.string,
title: PropTypes.string,
thumbnail: PropTypes.string,
progress: PropTypes.number,
};
module.exports = Stream;

View file

@ -36,12 +36,14 @@
.play-icon-container {
flex: none;
width: 5rem;
height: 5rem;
padding: 1.5rem;
.play-icon {
display: block;
width: 2rem;
height: 2rem;
width: 100%;
height: 100%;
fill: var(--color-placeholder);
}
}

Some files were not shown because too many files have changed in this diff Show more