Merge branch 'master' of github.com:Stremio/stremio-web into modal-dialog

This commit is contained in:
svetlagasheva 2019-11-04 11:55:26 +02:00
commit 0f6d6b22f0
27 changed files with 1389 additions and 848 deletions

View file

@ -12,32 +12,32 @@
"storybook": "start-storybook --ci --config-dir ./storybook --static-dir ./ --port 6060"
},
"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",
"prop-types": "15.7.2",
"react": "16.9.0",
"react-dom": "16.9.0",
"react-focus-lock": "2.1.1",
"react": "16.11.0",
"react-dom": "16.11.0",
"react-focus-lock": "2.2.1",
"spatial-navigation-polyfill": "git+ssh://git@github.com/NikolaBorislavovHristov/spatial-navigation.git#964d09bf2b0853e27af6c25924b595d6621a019d",
"stremio-colors": "git+ssh://git@github.com/Stremio/stremio-colors.git#v2.0.4",
"stremio-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/core": "7.6.4",
"@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/preset-env": "7.6.3",
"@babel/preset-react": "7.6.3",
"@babel/runtime": "7.6.3",
"@storybook/addon-actions": "5.2.5",
"@storybook/addon-console": "1.2.1",
"@storybook/addons": "5.2.1",
"@storybook/react": "5.2.1",
"@storybook/addons": "5.2.5",
"@storybook/react": "5.2.5",
"babel-loader": "8.0.6",
"clean-webpack-plugin": "3.0.0",
"copy-webpack-plugin": "5.0.4",
@ -50,9 +50,9 @@
"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",
"terser-webpack-plugin": "2.2.1",
"webpack": "4.41.2",
"webpack-cli": "3.3.9",
"webpack-dev-server": "3.8.1"
"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

@ -17,10 +17,14 @@ const ColorInput = ({ className, value, onChange, ...props }) => {
const [modalOpen, openModal, closeModal] = useBinaryState(false);
const [tempValue, setTempValue] = React.useState(value);
const pickerLabelOnClick = React.useCallback((event) => {
if (typeof props.onClick === 'function') {
props.onClick(event);
}
if (!event.nativeEvent.openModalPrevented) {
openModal();
}
}, []);
}, [props.onClick]);
const modalContainerOnClick = React.useCallback((event) => {
event.nativeEvent.openModalPrevented = true;
}, []);
@ -52,7 +56,7 @@ const ColorInput = ({ className, value, onChange, ...props }) => {
setTempValue(value);
}, [value, modalOpen]);
return (
<Button style={{ backgroundColor: value }} className={className} title={value} onClick={pickerLabelOnClick}>
<Button title={value} {...props} style={{ ...props.style, backgroundColor: value }} className={className} onClick={pickerLabelOnClick}>
{
modalOpen ?
<Modal className={styles['color-input-modal-container']} onMouseDown={modalContainerOnMouseDown} onClick={modalContainerOnClick}>
@ -77,6 +81,7 @@ const ColorInput = ({ className, value, onChange, ...props }) => {
};
ColorInput.propTypes = {
className: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func
};

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,7 @@ const MainNavBar = ({ className }) => {
navMenu={true}
/>
);
};
});
MainNavBar.propTypes = {
className: PropTypes.string

View file

@ -10,18 +10,22 @@ 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 [menuOpen, onMenuOpen, onMenuClose] = useBinaryState(false);
const metaItemOnClick = React.useCallback((event) => {
if (typeof props.onClick === 'function') {
props.onClick(event);
}
if (!event.nativeEvent.selectMetaItemPrevented && typeof onSelect === 'function') {
onSelect({
type: 'select',
@ -30,7 +34,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
nativeEvent: event.nativeEvent
});
}
}, [onSelect, dataset]);
}, [props.onClick, onSelect, dataset]);
const multiselectOnClick = React.useCallback((event) => {
event.nativeEvent.selectMetaItemPrevented = true;
}, []);
@ -38,6 +42,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
if (typeof menuOptionOnSelect === 'function') {
menuOptionOnSelect({
type: 'select-option',
value: event.value,
dataset: dataset,
reactEvent: event.reactEvent,
nativeEvent: event.nativeEvent
@ -45,7 +50,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
}
}, [menuOptionOnSelect, dataset]);
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
@ -55,7 +60,7 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, playI
renderFallback={() => (
<Icon
className={styles['placeholder-icon']}
icon={typeof ICON_FOR_TYPE[type] === 'string' ? ICON_FOR_TYPE[type] : ICON_FOR_TYPE['other']}
icon={ICON_FOR_TYPE.has(type) ? ICON_FOR_TYPE.get(type) : ICON_FOR_TYPE.get('other')}
/>
)}
/>

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

View file

@ -2,13 +2,15 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { Modal } = require('stremio-router');
const Icon = require('stremio-icons/dom');
const Image = require('stremio/common/Image');
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 MetaPreview = ({ className, compact, id, type, name, logo, background, runtime, releaseInfo, released, description, genres, writers, directors, cast, imdbId, imdbRating, trailer, share, inLibrary, toggleInLibrary }) => {
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
const genresLinks = React.useMemo(() => {
return Array.isArray(genres) ?
@ -75,18 +77,26 @@ 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={
compact ?
() => (
<Icon className={styles['logo-placeholder-icon']} icon={'ic_broken_link'} />
)
:
null
}
/>
:
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 +107,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
}
@ -221,7 +231,7 @@ MetaPreview.propTypes = {
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,

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;

View file

@ -56,7 +56,6 @@
background-color: var(--color-backgroundlight);
&:hover, &:focus {
outline-offset: 0;
background-color: var(--color-backgroundlighter);
}

View file

@ -8,7 +8,7 @@ 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 }) => {
const Multiselect = ({ className, direction, title, renderLabelContent, renderLabelText, options, selected, disabled, onOpen, onClose, onSelect, ...props }) => {
options = Array.isArray(options) ?
options.filter(option => option && typeof option.value === 'string')
:
@ -31,6 +31,7 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
if (typeof onSelect === 'function') {
onSelect({
type: 'select',
value: event.currentTarget.dataset.value,
reactEvent: event,
nativeEvent: event.nativeEvent,
dataset: dataset
@ -64,7 +65,7 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
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}>
<Button ref={ref} className={classnames(className, popupLabelClassName, styles['label-container'], { 'active': menuOpen })} title={title} disabled={disabled} onClick={popupLabelOnClick}>
{
typeof renderLabelContent === 'function' ?
renderLabelContent()
@ -72,16 +73,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'} />
@ -92,12 +96,19 @@ const Multiselect = ({ className, direction, title, renderLabelContent, options,
)}
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>
))}
{
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>
)}
/>
@ -109,11 +120,13 @@ Multiselect.propTypes = {
direction: PropTypes.any,
title: PropTypes.string,
renderLabelContent: PropTypes.func,
renderLabelText: PropTypes.func,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string
})),
selected: PropTypes.arrayOf(PropTypes.string),
disabled: PropTypes.bool,
onOpen: PropTypes.func,
onClose: PropTypes.func,
onSelect: PropTypes.func

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

@ -13,7 +13,7 @@ const styles = require('./styles');
const NotificationsMenu = ({ className, onClearButtonClicked }) => {
const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false);
//TODO use useNotifications hook instead of useCatalogs
const metaItems = useCatalogs();
const metaItems = []; //useCatalogs();
return (
<Popup

View file

@ -0,0 +1,108 @@
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 Multiselect = require('stremio/common/Multiselect');
const useDataset = require('stremio/common/useDataset');
const styles = require('./styles');
const PaginateInput = ({ className, options, selected, onSelect, ...props }) => {
const dataset = useDataset(props);
const selectedLabelText = React.useMemo(() => {
if (Array.isArray(options)) {
const selectedOption = options.find(({ value }) => {
return selected === value;
});
if (selectedOption && typeof selectedOption.label === 'string') {
return selectedOption.label;
}
}
return selected;
}, [options, selected]);
const prevNextButtonOnClick = React.useCallback((event) => {
if (typeof onSelect === 'function') {
if (Array.isArray(options) && options.length > 0) {
const selectedValueIndex = options.findIndex(({ value }) => {
return selected === value;
});
const nextSelectedIndex = event.currentTarget.dataset.button === 'next' ?
Math.min(selectedValueIndex + 1, options.length - 1)
:
Math.max(selectedValueIndex - 1, 0);
const nextSelectedValue = options[nextSelectedIndex].value;
onSelect({
type: 'select',
value: nextSelectedValue,
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
} else {
const nextSelectedValue = event.currentTarget.dataset.button === 'next' ?
selected + 1
:
Math.max(selected - 1, 1);
onSelect({
type: 'select',
value: nextSelectedValue,
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}
}, [dataset, options, selected, onSelect]);
const optionOnSelect = React.useCallback((event) => {
const page = parseInt(event.value);
if (!isNaN(page) && typeof onSelect === 'function') {
onSelect({
type: 'select',
value: page,
dataset: dataset,
reactEvent: event.reactEvent,
nativeEvent: event.nativeEvent
});
}
}, [onSelect]);
return (
<div className={classnames(className, styles['paginate-input-container'])} title={selected}>
<Button className={styles['prev-button-container']} data-button={'prev'} onClick={prevNextButtonOnClick}>
<Icon className={styles['icon']} icon={'ic_arrow_left'} />
</Button>
<Multiselect
className={styles['multiselect-label-container']}
renderLabelContent={() => (
<div className={styles['multiselect-label']}>{selectedLabelText}</div>
)}
options={
Array.isArray(options) ?
options.map(({ value, label }) => ({
value: String(value),
label
}))
:
null
}
disabled={!Array.isArray(options) || options.length === 0}
onSelect={optionOnSelect}
/>
<Button className={styles['next-button-container']} data-button={'next'} onClick={prevNextButtonOnClick}>
<Icon className={styles['icon']} icon={'ic_arrow_right'} />
</Button>
</div>
);
};
PaginateInput.propTypes = {
className: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.number.isRequired,
label: PropTypes.string
})),
selected: PropTypes.number.isRequired,
onSelect: PropTypes.func
};
module.exports = PaginateInput;

View file

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

View file

@ -0,0 +1,32 @@
.paginate-input-container {
display: flex;
flex-direction: row;
overflow: visible;
.prev-button-container, .next-button-container {
flex: none;
display: flex;
align-items: center;
justify-content: center;
.icon {
display: block;
width: 40%;
height: 40%;
fill: var(--color-surfacelighter);
}
}
.multiselect-label-container {
flex: 1;
align-self: stretch;
display: flex;
align-items: center;
justify-content: center;
.multiselect-label {
flex: none;
color: var(--color-surfacelighter);
}
}
}

View file

@ -9,6 +9,7 @@ const MetaRow = require('./MetaRow');
const ModalDialog = require('./ModalDialog');
const Multiselect = require('./Multiselect');
const NavBar = require('./NavBar');
const PaginateInput = require('./PaginateInput');
const PlayIconCircleCentered = require('./PlayIconCircleCentered');
const Popup = require('./Popup');
const SharePrompt = require('./SharePrompt');
@ -23,6 +24,7 @@ const useLiveRef = require('./useLiveRef');
const useLocationHash = require('./useLocationHash');
const useRouteActive = require('./useRouteActive');
const useSpreadState = require('./useSpreadState');
const useUser = require('./useUser');
module.exports = {
Button,
@ -36,6 +38,7 @@ module.exports = {
ModalDialog,
Multiselect,
NavBar,
PaginateInput,
PlayIconCircleCentered,
Popup,
SharePrompt,
@ -49,5 +52,6 @@ module.exports = {
useLiveRef,
useLocationHash,
useRouteActive,
useSpreadState
useSpreadState,
useUser
};

View file

@ -8,8 +8,8 @@ const routesRegexp = {
urlParamsNames: []
},
discover: {
regexp: /^\/discover(?:\/([^\/]*?))?(?:\/([^\/]*?))?\/?$/i,
urlParamsNames: ['type', 'catalog']
regexp: /^\/discover(?:\/([^\/]+?)\/([^\/]+?)\/([^\/]+?))?\/?$/i,
urlParamsNames: ['addonTransportUrl', 'catalogId', 'type']
},
library: {
regexp: /^\/library(?:\/([^\/]*?))?\/?$/i,

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

@ -0,0 +1,19 @@
const React = require('react');
const { useServices } = require('stremio/services');
const useUser = () => {
const { core } = useServices();
const [user, setUser] = React.useState(state.ctx.auth ? state.ctx.auth.user : null);
React.useEffect(() => {
const onNewModel = () => {
setUser(state.ctx.auth ? state.ctx.auth.user : null);
};
core.on('NewModel', onNewModel);
return () => {
core.off('NewModel', onNewModel);
};
}, []);
return user;
};
module.exports = useUser;

View file

@ -64,7 +64,7 @@ const Router = ({ className, onPathNotMatch, ...props }) => {
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];
urlParams[name] = decodeURIComponent(matches[index + 1]);
} else {
urlParams[name] = null;
}

View file

@ -7,7 +7,7 @@ const useCatalogs = () => {
React.useEffect(() => {
const onNewState = () => {
const state = core.getState();
setCatalogs(state.catalogs.groups);
setCatalogs(state.board.groups);
};
core.on('NewModel', onNewState);
core.dispatch({

View file

@ -1,56 +1,106 @@
const React = require('react');
const classnames = require('classnames');
const { Dropdown, MainNavBar, MetaItem, MetaPreview } = require('stremio/common');
const useCatalog = require('./useCatalog');
const Icon = require('stremio-icons/dom');
const { Modal } = require('stremio-router');
const { Button, MainNavBar, MetaItem, MetaPreview, Multiselect, PaginateInput, useBinaryState } = require('stremio/common');
const useDiscover = require('./useDiscover');
const styles = require('./styles');
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 [selectInputs, paginateInput, metaItems, error] = useDiscover(urlParams, queryParams);
const [selectedMetaItem, setSelectedMetaItem] = React.useState(null);
const [filtersModalOpen, openFiltersModal, closeFiltersModal] = useBinaryState(false);
const metaItemsOnMouseDownCapture = React.useCallback((event) => {
event.nativeEvent.buttonBlurPrevented = true;
}, []);
const metaItemsOnFocus = React.useCallback((event) => {
const metaItemsOnFocusCapture = React.useCallback((event) => {
const metaItem = metaItems.find(({ id }) => {
return id === event.target.dataset.id;
});
if (metaItem) {
setSelectedItem(metaItem);
setSelectedMetaItem(metaItem);
}
}, []);
React.useEffect(() => {
const metaItem = metaItems.length > 0 ? metaItems[0] : null;
setSelectedItem(metaItem);
}, [metaItems]);
React.useEffect(() => {
const metaItem = Array.isArray(metaItems) && metaItems.length > 0 ? metaItems[0] : null;
setSelectedMetaItem(metaItem);
}, [metaItems]);
React.useEffect(() => {
closeFiltersModal();
}, [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['controls-container']}>
{selectInputs.map((selectInput, index) => (
<Multiselect
{...selectInput}
key={index}
className={styles['select-input-container']}
/>
))}
<Button className={styles['filter-container']} onClick={openFiltersModal}>
<Icon className={styles['filter-icon']} icon={'ic_filter'} />
</Button>
<div className={styles['spacing']} />
{
paginateInput !== null ?
<PaginateInput
{...paginateInput}
className={styles['paginate-input-container']}
/>
:
null
}
</div>
<div className={styles['catalog-content-container']}>
{
error !== null ?
<div className={styles['message-container']}>
{error.type}{error.type === 'Other' ? ` - ${error.content}` : null}
</div>
:
Array.isArray(metaItems) ?
<div className={styles['meta-items-container']} onMouseDownCapture={metaItemsOnMouseDownCapture} onFocusCapture={metaItemsOnFocusCapture}>
{metaItems.map(({ id, type, name, poster, posterShape }, index) => (
<MetaItem
key={index}
className={classnames(styles['meta-item'], { 'selected': selectedMetaItem !== null && selectedMetaItem.id === id })}
type={type}
name={name}
poster={poster}
posterShape={posterShape}
data-id={id}
/>
))}
</div>
:
<div className={styles['message-container']}>
Loading
</div>
}
</div>
{
selectedItem !== null ?
selectedMetaItem !== null ?
<MetaPreview
{...selectedItem}
{...selectedMetaItem}
className={styles['meta-preview-container']}
compact={true}
background={selectedMetaItem.poster}
/>
:
null
<div className={styles['meta-preview-container']} />
}
</div>
{
filtersModalOpen ?
<Modal>
{/* TODO */}
</Modal>
:
null
}
</div>
);
};

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/PaginateInput/styles.less') {
paginate-prev-button-container: prev-button-container;
paginate-next-button-container: next-button-container;
paginate-multiselect-label: multiselect-label;
}
.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: 7rem 1fr;
grid-template-areas:
"dropdowns-area meta-preview-area"
"meta-items-area meta-preview-area";
"controls-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;
.controls-container {
grid-area: controls-area;
display: flex;
flex-direction: row;
margin: 2rem 0;
padding: 0 2rem;
overflow-y: auto;
overflow: visible;
.dropdown {
.select-input-container {
flex-grow: 0;
flex-shrink: 1;
flex-basis: 15rem;
height: 3rem;
margin-right: 1rem;
&:nth-child(n+4) {
display: none;
&~.filter-container {
display: block;
}
}
.multiselect-menu-container {
max-height: calc(3.2rem * 7);
overflow: auto;
}
}
.filter-container {
display: none;
flex: none;
width: 3rem;
height: 3rem;
background-color: var(--color-backgroundlighter);
&:not(:nth-last-child(2)) {
margin-right: 1rem;
}
.filter-icon {
display: block;
width: 1.2rem;
height: 1.2rem;
margin: 0.9rem;
fill: var(--color-surfacelighter);
}
}
.spacing {
flex: 1;
}
.paginate-input-container {
flex: none;
background-color: var(--color-backgroundlighter);
.paginate-prev-button-container, .paginate-next-button-container {
width: 3rem;
height: 3rem;
}
.paginate-multiselect-label {
max-width: 3rem;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.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;
margin-right: 2rem;
.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;
grid-gap: 1.5rem;
align-items: center;
padding: 0 2rem;
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 2rem;
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);
}
}
}
}
@ -116,15 +205,15 @@
grid-template-columns: 1fr;
grid-template-rows: fit-content(19rem) 1fr;
grid-template-areas:
"dropdowns-area"
"meta-items-area";
"controls-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,211 @@
const React = require('react');
const { useServices } = require('stremio/services');
const DEFAULT_ADDON_TRANSPORT_URL = 'https://v3-cinemeta.strem.io/manifest.json';
const DEFAULT_CATALOG_ID = 'top';
const DEFAULT_TYPE = 'movie';
const NONE_EXTRA_VALUE = 'None';
const PAGE_SIZE = 100;
const useDiscover = (urlParams, queryParams) => {
const { core } = useServices();
const [discover, setDiscover] = React.useState([[], null, null, null]);
React.useEffect(() => {
const addonTransportUrl = typeof urlParams.addonTransportUrl === 'string' ? urlParams.addonTransportUrl : DEFAULT_ADDON_TRANSPORT_URL;
const catalogId = typeof urlParams.catalogId === 'string' ? urlParams.catalogId : DEFAULT_CATALOG_ID;
const type = typeof urlParams.type === 'string' ? urlParams.type : DEFAULT_TYPE;
queryParams = new URLSearchParams(queryParams);
if (queryParams.has('skip')) {
const skip = parseInt(queryParams.get('skip'));
if (!isNaN(skip)) {
queryParams.set('skip', Math.floor(skip / PAGE_SIZE) * PAGE_SIZE);
}
}
const onNewModel = () => {
const state = core.getState();
const navigateWithLoad = (load) => {
const addonTransportUrl = encodeURIComponent(load.base);
const catalogId = encodeURIComponent(load.path.id);
const type = encodeURIComponent(load.path.type_name);
const extra = new URLSearchParams(load.path.extra).toString();
window.location = `#/discover/${addonTransportUrl}/${catalogId}/${type}?${extra}`;
};
const selectInputs = [
{
title: 'Select type',
options: state.discover.types
.map(({ type_name, load }) => ({
value: JSON.stringify(load),
label: type_name
})),
selected: state.discover.types
.filter(({ is_selected }) => is_selected)
.map(({ load }) => JSON.stringify(load)),
onSelect: (event) => {
navigateWithLoad(JSON.parse(event.value));
}
},
{
title: 'Select catalog',
options: state.discover.catalogs
.filter(({ load: { path: { type_name } } }) => {
return type_name === type;
})
.map(({ name, load }) => ({
value: JSON.stringify(load),
label: name
})),
selected: state.discover.catalogs
.filter(({ is_selected }) => is_selected)
.map(({ load }) => JSON.stringify(load)),
onSelect: (event) => {
navigateWithLoad(JSON.parse(event.value));
}
},
...state.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 = state.discover.selected.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) => {
navigateWithLoad({
base: addonTransportUrl,
path: {
resource: 'catalog',
type_name: type,
id: catalogId,
extra: Array.from(queryParams.entries())
.concat([[extra.name, event.value]])
.reduceRight((result, [name, value]) => {
if (extra.name === name) {
if (event.value !== NONE_EXTRA_VALUE) {
const optionsCount = result.reduce((optionsCount, [name]) => {
if (extra.name === name) {
optionsCount++;
}
return optionsCount;
}, 0);
if (extra.optionsLimit === 1) {
if (optionsCount === 0) {
result.unshift([name, value]);
}
} else if (extra.optionsLimit > 1) {
const valueIndex = result.findIndex(([_name, _value]) => {
return _name === name && _value === value;
});
if (valueIndex !== -1) {
result.splice(valueIndex, 1);
} else if (extra.optionsLimit > optionsCount) {
result.unshift([name, value]);
}
}
}
} else {
result.unshift([name, value]);
}
return result;
}, [])
}
});
}
return { title, options, selected, renderLabelText, onSelect };
})
];
const paginateInput = state.discover.load_next !== null || state.discover.load_prev !== null || state.discover.selected.path.extra.some(([name]) => name === 'skip') ?
{
selected: state.discover.selected.path.extra.reduce((page, [name, value]) => {
if (name === 'skip') {
const parsedValue = parseInt(value);
if (!isNaN(parsedValue)) {
return Math.floor(parsedValue / 100) + 1;
}
}
return page;
}, 1),
onSelect: (event) => {
navigateWithLoad({
base: addonTransportUrl,
path: {
resource: 'catalog',
type_name: type,
id: catalogId,
extra: Array.from(queryParams.entries())
.concat([['skip', String((event.value - 1) * PAGE_SIZE)]])
.reduceRight((result, [name, value]) => {
if (name === 'skip') {
const optionsCount = result.reduce((optionsCount, [name]) => {
if (name === 'skip') {
optionsCount++;
}
return optionsCount;
}, 0);
if (optionsCount === 0) {
result.unshift([name, value]);
}
} else {
result.unshift([name, value]);
}
return result;
}, [])
}
});
}
}
:
null;
const items = state.discover.content.type === 'Ready' ?
state.discover.content.content.map((metaItem) => ({
...metaItem,
released: new Date(metaItem.released)
}))
:
null;
const error = state.discover.content.type === 'Err' ? state.discover.content.content : null;
setDiscover([selectInputs, paginateInput, items, error]);
};
core.on('NewModel', onNewModel);
core.dispatch({
action: 'Load',
args: {
load: 'CatalogFiltered',
args: {
base: addonTransportUrl,
path: {
resource: 'catalog',
type_name: type,
id: catalogId,
extra: Array.from(queryParams.entries())
}
}
}
}, 'Discover');
return () => {
core.off('NewModel', onNewModel);
};
}, [urlParams, queryParams]);
return discover;
};
module.exports = useDiscover;

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

View file

@ -23,7 +23,11 @@ function Core() {
if (starting) {
containerService = new ContainerService(({ name, args } = {}) => {
if (active) {
events.emit(name, args);
try {
events.emit(name, args);
} catch (e) {
console.error(e);
}
}
});
active = true;
@ -52,12 +56,15 @@ function Core() {
function off(name, listener) {
events.off(name, listener);
}
function dispatch({ action, args } = {}) {
function dispatch(action, model = 'All') {
if (!active) {
return;
}
containerService.dispatch({ action, args });
containerService.dispatch({
model,
args: action
});
}
function getState() {
if (!active) {

1248
yarn.lock

File diff suppressed because it is too large Load diff