Merge branch 'development' of github.com:Stremio/stremio-web into nav-bars

This commit is contained in:
svetlagasheva 2020-02-05 16:42:17 +02:00
commit 092f8dc321
54 changed files with 1016 additions and 223 deletions

View file

@ -13,6 +13,7 @@
"FB": "readonly"
},
"env": {
"node": true,
"commonjs": true,
"browser": true,
"es6": true

23
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: Build
on:
push:
branches:
- '*'
tags-ignore:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Build
run: |
echo -e "[url \"https://github.com/\"]\n\tinsteadOf = ssh://git@github.com/" > ~/.gitconfig
yarn install && yarn build
- uses: actions/upload-artifact@v1
with:
name: stremio-web
path: build

34
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Release
on:
push:
branches-ignore:
- '*'
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Build
run: |
echo -e "[url \"https://github.com/\"]\n\tinsteadOf = ssh://git@github.com/" > ~/.gitconfig
yarn install && yarn build
- run: zip -r stremio-web.zip ./build
- name: Upload binaries to release
uses: svenstaro/upload-release-action@v1-release
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: stremio-web.zip
asset_name: stremio-web.zip
tag: ${{ github.ref }}
overwrite: true
- run: |
curl -H "Content-Type: application/zip" \
-H "Authorization: Bearer ${{ secrets.netlify_access_token }}" \
--data-binary "@stremio-web.zip" \
https://api.netlify.com/api/v1/sites/stremio-staging.netlify.com/deploys

20
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,20 @@
name: Test
on:
push:
branches:
- '*'
tags-ignore:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Test
run: |
echo -e "[url \"https://github.com/\"]\n\tinsteadOf = ssh://git@github.com/" > ~/.gitconfig
yarn install && yarn test

2
.gitignore vendored
View file

@ -1,5 +1,5 @@
/node_modules
/dist
/build
/package-lock.json
/npm-debug.log
.DS_Store

View file

@ -14,6 +14,7 @@
"lint": "eslint src"
},
"dependencies": {
"@sentry/browser": "5.11.1",
"a-color-picker": "1.2.1",
"classnames": "2.2.6",
"events": "1.1.1",
@ -50,6 +51,7 @@
"css-loader": "3.4.0",
"cssnano": "4.1.10",
"cssnano-preset-advanced": "4.0.7",
"dotenv": "8.2.0",
"eslint": "6.7.2",
"eslint-plugin-react": "7.17.0",
"html-webpack-plugin": "3.2.0",

View file

@ -2,6 +2,8 @@ require('spatial-navigation-polyfill');
const React = require('react');
const { Router } = require('stremio-router');
const { Core, KeyboardNavigation, ServicesProvider, Shell } = require('stremio/services');
const { ToastProvider } = require('stremio/common');
const CoreEventsToaster = require('./CoreEventsToaster');
const routerViewsConfig = require('./routerViewsConfig');
const styles = require('./styles');
@ -21,11 +23,16 @@ const App = () => {
setShellInitialized(services.shell.active || services.shell.error instanceof Error);
};
const onCoreStateChanged = () => {
setCoreInitialized(services.core.active || services.core.error instanceof Error);
if (services.core.active) {
services.core.dispatch({ action: 'LoadCtx' });
window.core = services.core;
services.core.dispatch({
action: 'Load',
args: {
model: 'Ctx'
}
});
}
setCoreInitialized(services.core.active || services.core.error instanceof Error);
};
services.shell.on('stateChanged', onShellStateChanged);
services.core.on('stateChanged', onCoreStateChanged);
@ -45,12 +52,15 @@ const App = () => {
<ServicesProvider services={services}>
{
shellInitialized && coreInitialized ?
<Router
className={styles['router']}
homePath={'/'}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
<ToastProvider className={styles['toasts-container']}>
<CoreEventsToaster />
<Router
className={styles['router']}
homePath={'/'}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
</ToastProvider>
:
<div className={styles['app-loader']} />
}

View file

@ -0,0 +1,29 @@
const React = require('react');
const { useServices } = require('stremio/services');
const { useToast } = require('stremio/common');
const CoreEventsToaster = () => {
const { core } = useServices();
const toast = useToast();
React.useEffect(() => {
const onEvent = ({ event, args }) => {
// UserAuthenticated are handled only in the /intro route
if (event === 'Error' && args.source.event !== 'UserAuthenticated') {
toast.show({
type: 'error',
title: args.source.event,
message: args.error.message,
icon: 'ic_warning',
timeout: 10000
});
}
};
core.on('Event', onEvent);
return () => {
core.off('Event', onEvent);
};
}, []);
return null;
};
module.exports = CoreEventsToaster;

View file

@ -10,6 +10,7 @@
--landscape-shape-ratio: 0.5625;
--poster-shape-ratio: 1.464;
--scroll-bar-width: 6px;
--nav-bar-size: 3.2rem;
--focus-outline-size: 2px;
--color-facebook: #4267b2;
--color-twitter: #1DA1F2;
@ -71,6 +72,23 @@ html {
width: 100%;
height: 100%;
.toasts-container {
position: absolute;
top: calc(1.2 * var(--nav-bar-size));
right: 0;
bottom: calc(1.2 * var(--nav-bar-size));
left: auto;
z-index: 1;
padding: 0 calc(1.2 * var(--nav-bar-size));
overflow-y: auto;
scrollbar-width: none;
pointer-events: none;
&::-webkit-scrollbar {
width: var(--scroll-bar-width);
}
}
.router {
width: 100%;
height: 100%;

View file

@ -77,7 +77,7 @@ const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
ColorInput.propTypes = {
className: PropTypes.string,
value: PropTypes.string,
dataset: PropTypes.objectOf(PropTypes.string),
dataset: PropTypes.object,
onChange: PropTypes.func,
onClick: PropTypes.func
};

View file

@ -121,7 +121,7 @@ MetaItem.propTypes = {
playIcon: PropTypes.bool,
progress: PropTypes.number,
options: PropTypes.array,
dataset: PropTypes.objectOf(PropTypes.string),
dataset: PropTypes.object,
optionOnSelect: PropTypes.func,
onClick: PropTypes.func
};

View file

@ -112,7 +112,7 @@ ModalDialog.propTypes = {
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]),
dataset: PropTypes.objectOf(PropTypes.string),
dataset: PropTypes.object,
onCloseRequest: PropTypes.func
};

View file

@ -143,7 +143,7 @@ Multiselect.propTypes = {
})),
selected: PropTypes.arrayOf(PropTypes.string),
disabled: PropTypes.bool,
dataset: PropTypes.objectOf(PropTypes.string),
dataset: PropTypes.object,
renderLabelContent: PropTypes.func,
renderLabelText: PropTypes.func,
onOpen: PropTypes.func,

View file

@ -2,17 +2,19 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const { useServices } = require('stremio/services');
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('stremio/common/useUser');
const useProfile = require('./useProfile');
const styles = require('./styles');
const NavMenu = ({ className }) => {
const { core } = useServices();
const profile = useProfile();
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen();
const [user, logout] = useUser();
const popupLabelOnClick = React.useCallback((event) => {
if (!event.nativeEvent.togglePopupPrevented) {
toggleMenu();
@ -22,7 +24,12 @@ const NavMenu = ({ className }) => {
event.nativeEvent.togglePopupPrevented = true;
}, []);
const logoutButtonOnClick = React.useCallback(() => {
logout();
core.dispatch({
action: 'Ctx',
args: {
action: 'Logout'
}
});
}, []);
return (
<Popup
@ -40,17 +47,17 @@ const NavMenu = ({ className }) => {
<div
className={styles['avatar-container']}
style={{
backgroundImage: user === null ?
backgroundImage: profile.auth === null ?
'url(\'/images/anonymous.png\')'
:
`url('${user.avatar}'), url('/images/default_avatar.png')`
`url('${profile.auth.user.avatar}'), url('/images/default_avatar.png')`
}}
/>
<div className={styles['email-container']}>
<div className={styles['email-label']}>{user === null ? 'Anonymous user' : user.email}</div>
<div className={styles['email-label']}>{profile.auth === null ? 'Anonymous user' : profile.auth.user.email}</div>
</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 className={styles['logout-button-container']} title={profile.auth === null ? 'Log in / Sign up' : 'Log out'} href={'#/intro'} onClick={logoutButtonOnClick}>
<div className={styles['logout-label']}>{profile.auth === null ? 'Log in / Sign up' : 'Log out'}</div>
</Button>
</div>
<div className={styles['nav-menu-section']}>

View file

@ -0,0 +1,23 @@
const React = require('react');
const { useServices } = require('stremio/services');
const useModelState = require('stremio/common/useModelState');
const mapProfileState = (ctx) => {
return ctx.profile;
};
const useProfile = () => {
const { core } = useServices();
const initProfileState = React.useCallback(() => {
const ctx = core.getState('ctx');
return mapProfileState(ctx);
}, []);
const profile = useModelState({
model: 'ctx',
init: initProfileState,
map: mapProfileState
});
return profile;
};
module.exports = useProfile;

View file

@ -1,5 +1,4 @@
.nav-bar-container {
--nav-bar-size: 3.2rem;
display: flex;
flex-direction: row;
align-items: center;

View file

@ -35,7 +35,7 @@ const PaginationInput = ({ className, label, dataset, onSelect, ...props }) => {
PaginationInput.propTypes = {
className: PropTypes.string,
label: PropTypes.string,
dataset: PropTypes.objectOf(PropTypes.string),
dataset: PropTypes.object,
onSelect: PropTypes.func
};

View file

@ -103,7 +103,7 @@ Popup.propTypes = {
direction: PropTypes.oneOf(['top-left', 'bottom-left', 'top-right', 'bottom-right']),
renderLabel: PropTypes.func.isRequired,
renderMenu: PropTypes.func.isRequired,
dataset: PropTypes.objectOf(PropTypes.string),
dataset: PropTypes.object,
onCloseRequest: PropTypes.func
};

View file

@ -1,7 +1,7 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useFocusedRoute } = require('stremio-router');
const { useRouteFocused } = require('stremio-router');
const useAnimationFrame = require('stremio/common/useAnimationFrame');
const useLiveRef = require('stremio/common/useLiveRef');
const styles = require('./styles');
@ -13,7 +13,7 @@ const Slider = ({ className, value, minimumValue, maximumValue, onSlide, onCompl
const onSlideRef = useLiveRef(onSlide, [onSlide]);
const onCompleteRef = useLiveRef(onComplete, [onComplete]);
const sliderContainerRef = React.useRef(null);
const routeFocused = useFocusedRoute();
const routeFocused = useRouteFocused();
const [requestThumbAnimation, cancelThumbAnimation] = useAnimationFrame();
const calculateValueForMouseX = React.useCallback((mouseX) => {
if (sliderContainerRef.current === null) {

View file

@ -0,0 +1,10 @@
const React = require('react');
const ToastContext = React.createContext({
show: () => { },
clear: () => { }
});
ToastContext.displayName = 'ToastContext';
module.exports = ToastContext;

View file

@ -0,0 +1,71 @@
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 ToastItem = ({ type, title, message, icon, dataset, onSelect, onClose }) => {
const toastOnClick = React.useCallback((event) => {
if (!event.nativeEvent.selectPrevented && typeof onSelect === 'function') {
onSelect({
type: 'select',
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}, [dataset, onSelect]);
const closeButtonOnClick = React.useCallback((event) => {
event.nativeEvent.selectPrevented = true;
if (typeof onClose === 'function') {
onClose({
type: 'close',
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
}
}, [dataset, onClose]);
return (
<Button className={classnames(styles['toast-item-container'], styles['success'], styles[type])} tabIndex={-1} onClick={toastOnClick}>
{
typeof icon === 'string' && icon.length > 0 ?
<div className={styles['icon-container']}>
<Icon className={styles['icon']} icon={icon} />
</div>
:
null
}
<div className={styles['info-container']}>
{
typeof title === 'string' && title.length > 0 ?
<div className={styles['title-container']}>{title}</div>
:
null
}
{
typeof message === 'string' && message.length > 0 ?
<div className={styles['message-container']}>{message}</div>
:
null
}
</div>
<Button className={styles['close-button-container']} title={'Close'} tabIndex={-1} onClick={closeButtonOnClick}>
<Icon className={styles['icon']} icon={'ic_x'} />
</Button>
</Button>
);
};
ToastItem.propTypes = {
type: PropTypes.oneOf(['success', 'alert', 'error']),
title: PropTypes.string,
message: PropTypes.string,
icon: PropTypes.string,
dataset: PropTypes.object,
onSelect: PropTypes.func,
onClose: PropTypes.func
};
module.exports = ToastItem;

View file

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

View file

@ -0,0 +1,73 @@
.toast-item-container {
display: flex;
flex-direction: row;
width: 25rem;
min-height: 6rem;
margin-bottom: 1rem;
border: thin solid;
background-color: var(--color-surfacelighter);
overflow: visible;
box-shadow: 0 0.3rem 0.5rem var(--color-backgrounddarker40),
0 0.6rem 1rem var(--color-backgrounddarker20);
pointer-events: auto;
&.success {
color: var(--color-signal5);
fill: var(--color-signal5);
}
&.alert {
color: var(--color-signal3);
fill: var(--color-signal3);
}
&.error {
color: var(--color-signal2);
fill: var(--color-signal2);
}
.icon-container {
flex: none;
align-self: stretch;
width: 4.5rem;
padding: 1.2rem 0 1.2rem 1.2rem;
.icon {
display: block;
width: 100%;
height: 100%;
}
}
.info-container {
flex: 1;
align-self: stretch;
padding: 1rem;
.title-container {
font-size: 1.2rem;
}
.message-container {
font-size: 1.1rem;
}
}
.close-button-container {
flex: none;
align-self: flex-start;
width: 3rem;
height: 3rem;
padding: 1rem;
&:hover {
background-color: var(--color-surfacelight);
}
.icon {
display: block;
width: 100%;
height: 100%;
}
}
}

View file

@ -0,0 +1,72 @@
const React = require('react');
const PropTypes = require('prop-types');
const ToastItem = require('./ToastItem');
const ToastContext = require('./ToastContext');
const DEFAULT_TIMEOUT = 3000;
const ToastProvider = ({ className, children }) => {
const [container, setContainer] = React.useState(null);
const [items, dispatch] = React.useReducer(
(items, action) => {
switch (action.type) {
case 'add':
return items.concat(action.item);
case 'remove':
return items.filter((item) => item.id !== action.id);
case 'clear':
return [];
default:
return items;
}
},
[]
);
const itemOnClose = React.useCallback((event) => {
clearTimeout(event.dataset.id);
dispatch({ type: 'remove', id: event.dataset.id });
}, []);
const toast = React.useMemo(() => ({
show: (item) => {
const timeout = typeof item.timeout === 'number' && !isNaN(item.timeout) ?
item.timeout
:
DEFAULT_TIMEOUT;
const id = setTimeout(() => {
dispatch({ type: 'remove', id });
}, timeout);
dispatch({
type: 'add',
item: {
...item,
id,
dataset: {
...item.dataset,
id
},
onClose: itemOnClose
}
});
},
clear: () => {
dispatch({ type: 'clear' });
}
}), []);
return (
<ToastContext.Provider value={toast}>
{container instanceof HTMLElement ? children : null}
<div ref={setContainer} className={className}>
{items.map((item, index) => (
<ToastItem key={index} {...item} />
))}
</div>
</ToastContext.Provider>
);
};
ToastProvider.propTypes = {
className: PropTypes.string,
children: PropTypes.node
};
module.exports = ToastProvider;

View file

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

View file

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

View file

@ -17,15 +17,17 @@ const Popup = require('./Popup');
const SharePrompt = require('./SharePrompt');
const Slider = require('./Slider');
const TextInput = require('./TextInput');
const { ToastProvider, useToast } = require('./Toast');
const routesRegexp = require('./routesRegexp');
const useAnimationFrame = require('./useAnimationFrame');
const useBinaryState = require('./useBinaryState');
const useCoreEvent = require('./useCoreEvent');
const useDeepEqualEffect = require('./useDeepEqualEffect');
const useDeepEqualState = require('./useDeepEqualState');
const useFullscreen = require('./useFullscreen');
const useInLibrary = require('./useInLibrary');
const useLiveRef = require('./useLiveRef');
const useModelState = require('./useModelState');
const useUser = require('./useUser');
module.exports = {
AddonDetailsModal,
@ -47,13 +49,16 @@ module.exports = {
SharePrompt,
Slider,
TextInput,
ToastProvider,
useToast,
routesRegexp,
useAnimationFrame,
useBinaryState,
useCoreEvent,
useDeepEqualEffect,
useDeepEqualState,
useFullscreen,
useInLibrary,
useLiveRef,
useModelState,
useUser
useModelState
};

View file

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

View file

@ -0,0 +1,18 @@
const React = require('react');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const useCoreEvent = (onEvent) => {
const { core } = useServices();
const routeFocused = useRouteFocused();
React.useLayoutEffect(() => {
if (routeFocused) {
core.on('Event', onEvent);
}
return () => {
core.off('Event', onEvent);
};
}, [routeFocused, onEvent]);
};
module.exports = useCoreEvent;

View file

@ -0,0 +1,17 @@
const React = require('react');
const isEqual = require('lodash.isequal');
const useDeepEqualEffect = (cb, deps) => {
const mountedRef = React.useRef(false);
const depsRef = React.useRef(null);
React.useEffect(() => {
if (!mountedRef.current || !isEqual(depsRef.current, deps)) {
cb();
}
mountedRef.current = true;
depsRef.current = deps;
}, [deps]);
};
module.exports = useDeepEqualEffect;

View file

@ -8,7 +8,7 @@ const UNLOAD_ACTION = {
action: 'Unload',
};
const useModelState = ({ model, action, timeout, onNewState, map, mapWithCtx, init }) => {
const useModelState = ({ model, init, action, timeout, onNewState, map, mapWithCtx }) => {
const modelRef = React.useRef(model);
const mountedRef = React.useRef(false);
const { core } = useServices();
@ -52,7 +52,7 @@ const useModelState = ({ model, action, timeout, onNewState, map, mapWithCtx, in
onNewStateThrottled.cancel();
core.off('NewState', onNewStateThrottled);
};
}, [routeFocused]);
}, [routeFocused, timeout, onNewState, map, mapWithCtx]);
React.useLayoutEffect(() => {
mountedRef.current = true;
}, []);

View file

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

@ -1,5 +1,10 @@
const React = require('react');
const ReactDOM = require('react-dom');
const Sentry = require('@sentry/browser');
const App = require('./App');
if (typeof process.env.SENTRY_DSN === 'string') {
Sentry.init({ dsn: process.env.SENTRY_DSN });
}
ReactDOM.render(<App />, document.getElementById('app'));

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, children, ...props }) => {
const Modal = ({ className, autoFocus, disabled, children, ...props }) => {
const modalsContainer = useModalsContainer();
return ReactDOM.createPortal(
<FocusLock className={classnames(className, 'modal-container')} autoFocus={autoFocus} lockProps={props}>
<FocusLock className={classnames(className, 'modal-container')} autoFocus={autoFocus} disabled={disabled} lockProps={props}>
{children}
</FocusLock>,
modalsContainer
@ -18,6 +18,7 @@ const Modal = ({ className, autoFocus, children, ...props }) => {
Modal.propTypes = {
className: PropTypes.string,
autoFocus: PropTypes.bool,
disabled: PropTypes.bool,
children: PropTypes.node
};

View file

@ -105,7 +105,7 @@ Addon.propTypes = {
installed: PropTypes.bool,
onToggle: PropTypes.func,
onShare: PropTypes.func,
dataset: PropTypes.objectOf(PropTypes.string)
dataset: PropTypes.object
};
module.exports = Addon;

View file

@ -21,16 +21,16 @@ const mapAddonsStateWithCtx = (addons, ctx) => {
...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),
content: addons.catalog_resource.content.content.map((addon) => ({
transportUrl: addon.transportUrl,
installed: ctx.profile.addons.some(({ transportUrl }) => transportUrl === addon.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
id: addon.manifest.id,
name: addon.manifest.name,
version: addon.manifest.version,
logo: addon.manifest.logo,
description: addon.manifest.description,
types: addon.manifest.types
}
}))
}
@ -45,8 +45,10 @@ const onNewAddonsState = (addons) => {
return {
action: 'Load',
args: {
load: 'CatalogFiltered',
args: addons.selectable.catalogs[0].load_request
model: 'CatalogFiltered',
args: {
request: addons.selectable.catalogs[0].request
}
}
};
}
@ -59,14 +61,16 @@ const useAddons = (urlParams) => {
return {
action: 'Load',
args: {
load: 'CatalogFiltered',
model: 'CatalogFiltered',
args: {
base: urlParams.transportUrl,
path: {
resource: 'addon_catalog',
type_name: urlParams.type,
id: urlParams.catalogId,
extra: []
request: {
base: urlParams.transportUrl,
path: {
resource: 'addon_catalog',
type_name: urlParams.type,
id: urlParams.catalogId,
extra: []
}
}
}
}
@ -77,8 +81,10 @@ const useAddons = (urlParams) => {
return {
action: 'Load',
args: {
load: 'CatalogFiltered',
args: addons.selectable.catalogs[0].load_request
model: 'CatalogFiltered',
args: {
request: addons.selectable.catalogs[0].request
}
}
};
} else {

View file

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

View file

@ -2,7 +2,8 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('stremio-icons/dom');
const { Button, MainNavBar, MetaItem, MetaPreview, Multiselect, ModalDialog, PaginationInput, useBinaryState } = require('stremio/common');
const { AddonDetailsModal, Button, MainNavBar, MetaItem, MetaPreview, Multiselect, ModalDialog, PaginationInput, useBinaryState } = require('stremio/common');
const { useServices } = require('stremio/services');
const useDiscover = require('./useDiscover');
const useSelectableInputs = require('./useSelectableInputs');
const styles = require('./styles');
@ -19,9 +20,12 @@ const getMetaItemAtIndex = (catalog_resource, index) => {
};
const Discover = ({ urlParams, queryParams }) => {
const { core } = useServices();
const state = core.getState();
const discover = useDiscover(urlParams, queryParams);
const [selectInputs, paginationInput] = useSelectableInputs(discover);
const [inputsModalOpen, openInputsModal, closeInputsModal] = useBinaryState(false);
const [addonModalOpen, openAddonModal, closeAddonModal] = useBinaryState(false);
const [selectedMetaItem, setSelectedMetaItem] = React.useState(() => {
return getMetaItemAtIndex(discover.catalog_resource, 0);
});
@ -69,6 +73,17 @@ const Discover = ({ urlParams, queryParams }) => {
null
}
</div>
{
discover.catalog_resource != null && !state.ctx.content.addons.some((addon) => addon.transportUrl === discover.catalog_resource.request.base) ?
<div className={styles['missing-addon-warning-container']}>
<div className={styles['warning-info']}>This addon is not installed. Install now?</div>
<Button className={styles['install-button']} title={'Install addon'} onClick={openAddonModal}>
<div className={styles['label']}>Install</div>
</Button>
</div>
:
null
}
<div className={styles['catalog-content-container']}>
{
discover.selectable.types.length === 0 && discover.catalog_resource === null ?
@ -132,6 +147,15 @@ const Discover = ({ urlParams, queryParams }) => {
:
null
}
{
addonModalOpen ?
<AddonDetailsModal
transportUrl={discover.catalog_resource.request.base}
onCloseRequest={closeAddonModal}
/>
:
null
}
</div>
);
};

View file

@ -27,9 +27,10 @@
align-self: stretch;
display: grid;
grid-template-columns: 1fr 28rem;
grid-template-rows: auto 1fr;
grid-template-rows: auto auto 1fr;
grid-template-areas:
"selectable-inputs-area meta-preview-area"
"missing-addon-warning-container meta-preview-area"
"catalog-content-area meta-preview-area";
.selectable-inputs-container {
@ -102,6 +103,38 @@
}
}
.missing-addon-warning-container {
grid-area: missing-addon-warning-container;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 1.5rem 1.5rem 1.5rem;
.warning-info {
margin-bottom: 1rem;
font-size: 1.2rem;
color: var(--color-surfacelighter);
}
.install-button {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 10rem;
padding: 1rem;
background-color: var(--color-signal5);
&:hover {
filter: brightness(1.2);
}
.label {
color: var(--color-surfacelighter);
}
}
}
.catalog-content-container {
grid-area: catalog-content-area;
@ -203,9 +236,10 @@
.discover-container {
.discover-content {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
grid-template-rows: auto auto 1fr;
grid-template-areas:
"selectable-inputs-area"
"missing-addon-warning-container"
"catalog-content-area";
.catalog-content-container {

View file

@ -3,7 +3,7 @@ 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 { Button, useCoreEvent } = require('stremio/common');
const { useServices } = require('stremio/services');
const CredentialsTextInput = require('./CredentialsTextInput');
const ConsentCheckbox = require('./ConsentCheckbox');
@ -71,29 +71,21 @@ const Intro = ({ queryParams }) => {
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('#/');
}
}
useCoreEvent(React.useCallback(({ event, args }) => {
switch (event) {
case 'UserAuthenticated': {
window.location.replace('#/');
break;
}
case 'Error': {
if (args.source.event === 'UserAuthenticated') {
// TODO use error.code to match translated message;
dispatch({ type: 'error', error: args.error.message });
}
break;
}
};
if (routeFocused) {
core.on('Event', onEvent);
}
return () => {
core.off('Event', onEvent);
};
}, [routeFocused]);
}, []));
const loginWithFacebook = React.useCallback(() => {
FB.login((response) => {
if (response.status === 'connected') {
@ -131,10 +123,11 @@ const Intro = ({ queryParams }) => {
return;
}
core.dispatch({
action: 'UserOp',
action: 'Ctx',
args: {
userOp: 'Login',
action: 'Authenticate',
args: {
type: 'Login',
email: state.email,
password: state.password
}
@ -147,9 +140,9 @@ const Intro = ({ queryParams }) => {
return;
}
core.dispatch({
action: 'UserOp',
action: 'Ctx',
args: {
userOp: 'Logout'
action: 'Logout'
}
});
window.location.replace('#/');
@ -176,10 +169,11 @@ const Intro = ({ queryParams }) => {
return;
}
core.dispatch({
action: 'UserOp',
action: 'Ctx',
args: {
userOp: 'Register',
action: 'Authenticate',
args: {
type: 'Register',
email: state.email,
password: state.password,
gdpr_consent: {
@ -236,6 +230,11 @@ const Intro = ({ queryParams }) => {
const toggleMarketingAccepted = React.useCallback(() => {
dispatch({ type: 'toggle-checkbox', name: 'marketingAccepted' });
}, []);
const switchFormOnClick = React.useCallback(() => {
const nextQueryParams = new URLSearchParams(queryParams);
nextQueryParams.set('form', state.form === SIGNUP_FORM ? LOGIN_FORM : SIGNUP_FORM);
window.location.replace(`#/intro?${nextQueryParams}`);
}, [queryParams, state.form]);
React.useEffect(() => {
if ([LOGIN_FORM, SIGNUP_FORM].includes(queryParams.get('form'))) {
dispatch({ type: 'set-form', form: queryParams.get('form') });
@ -258,7 +257,6 @@ const Intro = ({ queryParams }) => {
<Icon className={styles['icon']} icon={'ic_facebook'} />
<div className={styles['label']}>Continue with Facebook</div>
</Button>
<div className={styles['facebook-statement']}>We won&#39;t post anything on your behalf</div>
<CredentialsTextInput
ref={emailRef}
className={styles['credentials-text-input']}
@ -337,7 +335,7 @@ const Intro = ({ queryParams }) => {
:
null
}
<Button className={classnames(styles['form-button'], styles['switch-form-button'])} href={state.form === SIGNUP_FORM ? '#/intro?form=login' : '#/intro?form=signup'}>
<Button className={classnames(styles['form-button'], styles['switch-form-button'])} onClick={switchFormOnClick}>
<div className={styles['label']}>{state.form === SIGNUP_FORM ? 'LOG IN' : 'SING UP WITH EMAIL'}</div>
</Button>
</div>

View file

@ -47,6 +47,7 @@
.facebook-button {
min-height: 4.5rem;
margin-bottom: 2rem;
background: var(--color-facebook);
&:hover, &:focus {
@ -58,13 +59,6 @@
}
}
.facebook-statement {
margin-top: 0.5rem;
margin-bottom: 2rem;
text-align: center;
color: var(--color-surface);
}
.credentials-text-input {
display: block;
width: 100%;

View file

@ -1,7 +1,7 @@
const React = require('react');
const PropTypes = require('prop-types');
const hat = require('hat');
const useVideoImplementation = require('./useVideoImplementation');
const selectVideoImplementation = require('./selectVideoImplementation');
const Video = React.forwardRef(({ className, ...props }, ref) => {
const [onEnded, onError, onPropValue, onPropChanged, onImplementationChanged] = React.useMemo(() => [
@ -16,7 +16,7 @@ const Video = React.forwardRef(({ className, ...props }, ref) => {
const id = React.useMemo(() => `video-${hat()}`, []);
const dispatch = React.useCallback((args) => {
if (args && args.commandName === 'load' && args.commandArgs) {
const Video = useVideoImplementation(args.commandArgs.shell, args.commandArgs.stream);
const Video = selectVideoImplementation(args.commandArgs.shell, args.commandArgs.stream);
if (typeof Video !== 'function') {
videoRef.current = null;
} else if (videoRef.current === null || videoRef.current.constructor !== Video) {

View file

@ -1,6 +1,6 @@
const { HTMLVideo, YouTubeVideo, MPVVideo } = require('stremio-video');
const useVideoImplementation = (shell, stream) => {
const selectVideoImplementation = (shell, stream) => {
if (shell) {
return MPVVideo;
}
@ -16,4 +16,4 @@ const useVideoImplementation = (shell, stream) => {
return null;
};
module.exports = useVideoImplementation;
module.exports = selectVideoImplementation;

View file

@ -1,13 +1,51 @@
const React = require('react');
const { useModelState } = require('stremio/common');
const initPlayer = () => ({
selected: null,
const initPlayerState = () => ({
selected: {
transport_url: null,
type_name: null,
id: null,
video_id: null,
stream: null,
},
meta_resource: null,
subtitles_resources: [],
next_video: null
});
const mapPlayerStateWithCtx = (player, ctx) => {
const selected = player.selected;
const meta_resource = player.meta_resource;
const subtitles_resources = player.subtitles_resources.map((subtitles_resource) => {
if (subtitles_resource.content.type === 'Ready') {
const origin = ctx.content.addons.reduce((origin, addon) => {
if (addon.transportUrl === subtitles_resource.request.base) {
return typeof addon.manifest.name === 'string' && addon.manifest.name.length > 0 ?
addon.manifest.name
:
addon.manifest.id;
}
return origin;
}, subtitles_resource.request.base);
subtitles_resource.content.content = subtitles_resource.content.content.map((subtitles) => ({
...subtitles,
origin
}));
}
return subtitles_resource;
}, []);
const next_video = player.next_video;
return {
selected,
meta_resource,
subtitles_resources,
next_video
};
};
const usePlayer = (urlParams) => {
const loadPlayerAction = React.useMemo(() => {
try {
@ -34,7 +72,8 @@ const usePlayer = (urlParams) => {
return useModelState({
model: 'player',
action: loadPlayerAction,
init: initPlayer
init: initPlayerState,
mapWithCtx: mapPlayerStateWithCtx
});
};

View file

@ -0,0 +1,25 @@
const React = require('react');
const { useModelState } = require('stremio/common');
const { useServices } = require('stremio/services');
const mapSubtitlesSettings = (ctx) => ({
size: ctx.content.settings.subtitles_size,
text_color: ctx.content.settings.subtitles_text_color,
background_color: ctx.content.settings.subtitles_background_color,
outline_color: ctx.content.settings.subtitles_outline_color,
});
const useSubtitlesSettings = () => {
const { core } = useServices();
const initSubtitlesSettings = React.useCallback(() => {
const ctx = core.getState('ctx');
return mapSubtitlesSettings(ctx);
}, []);
return useModelState({
model: 'ctx',
map: mapSubtitlesSettings,
init: initSubtitlesSettings
});
};
module.exports = useSubtitlesSettings;

View file

@ -2,20 +2,23 @@ const React = require('react');
const classnames = require('classnames');
const Route = require('stremio-router/Route');
const { RouteFocusedProvider } = require('stremio-router/RouteFocusedContext');
const { ToastsContainerProvider } = require('stremio/common/Toasts/ToastsContainerContext');
const appStyles = require('stremio/App/styles');
const styles = require('./styles');
const RouterDecorator = ({ children }) => (
<div id={'app'}>
<div className={classnames('routes-container', appStyles['router'])}>
<RouteFocusedProvider value={true}>
<Route>
<div className={styles['route-content-container']}>
{children}
</div>
</Route>
</RouteFocusedProvider>
</div>
<ToastsContainerProvider>
<div className={classnames('routes-container', appStyles['router'])}>
<RouteFocusedProvider value={true}>
<Route>
<div className={styles['route-content-container']}>
{children}
</div>
</Route>
</RouteFocusedProvider>
</div>
</ToastsContainerProvider>
</div>
);

View file

@ -0,0 +1,42 @@
const React = require('react');
const { storiesOf } = require('@storybook/react');
const { Toasts } = require('stremio/common');
const styles = require('./styles');
storiesOf('Toast', module).add('SimpleToast', () => {
const toastRef = React.useRef(null);
const showToast = React.useCallback((message) => {
toastRef.current.show({
title: 'Something to take your attention',
timeout: 0,
type: 'info',
icon: 'ic_sub',
closeButton: true,
...message
});
}, [toastRef.current]);
const clickSuccess = () => showToast({
title: 'You clicked it',
text: 'Congratulations! Click event handled successfully.',
type: 'success',
icon: 'ic_check',
timeout: 2e3
});
return (
<div className={styles['root-container']}>
<button onClick={() => showToast({ text: 'Longer message that contains a lot of words but it does not state anything. The idea is to test the handling of long messages.' })}>Long message</button>
<button onClick={() => showToast({ text: 'This will close after 3 seconds', timeout: 3e3 })}>Timeout 3s</button>
<button onClick={() => showToast({ text: 'Click me and see what happens later', title: 'Click here!', onClick: clickSuccess })}>Clickable</button>
<button onClick={() => showToast({ text: 'Type success', type: 'success', icon: 'ic_check' })}>Success</button>
<button onClick={() => showToast({ text: 'Type alert', type: 'alert', icon: 'ic_warning' })}>Alert</button>
<button onClick={() => showToast({ text: 'Type error', type: 'error', icon: 'ic_x' })}>Error</button>
<button onClick={() => showToast({ text: 'No title', type: 'info', title: null, icon: null })}>No title</button>
<button onClick={() => toastRef.current.show({})}>Empty</button>
<button onClick={() => toastRef.current.hideAll()}>Close all</button>
<Toasts ref={toastRef} className={styles['toasts-container']} />
</div>
);
});

View file

@ -0,0 +1 @@
require('./SimpleToast');

View file

@ -0,0 +1,16 @@
.root-container {
width: 100%;
height: 100%;
color: var(--color-surfacelighter);
button {
color: var(--color-surfacelighter);
background-color: var(--color-primarydark);
padding: .5rem;
margin: .5rem;
}
}
.toasts-container {
width: 30rem;
}

View file

@ -0,0 +1 @@
require('./SimpleToast');

View file

@ -12,3 +12,4 @@ require('./Popup');
require('./SeasonsBar');
require('./SharePrompt');
require('./Stream');
require('./Toast');

View file

@ -353,84 +353,159 @@ describe('routesRegexp', () => {
});
describe('player route regexp', () => {
it('match /player////', async () => {
expect(Array.from('/player////'.match(routesRegexp.player.regexp)))
.toEqual(['/player////', '', '', '', '']);
it('match /player/////', async () => {
expect(Array.from('/player/////'.match(routesRegexp.player.regexp)))
.toEqual(['/player/////', '', '', '', '', '']);
});
it('match /player/1///', async () => {
expect(Array.from('/player/1///'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1///', '1', '', '', '']);
it('match /player/1////', async () => {
expect(Array.from('/player/1////'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1////', '1', '', '', '', '']);
});
it('match /player//2//', async () => {
expect(Array.from('/player//2//'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2//', '', '2', '', '']);
it('match /player//2///', async () => {
expect(Array.from('/player//2///'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2///', '', '2', '', '', '']);
});
it('match /player///3/', async () => {
expect(Array.from('/player///3/'.match(routesRegexp.player.regexp)))
.toEqual(['/player///3/', '', '', '3', '']);
it('match /player///3//', async () => {
expect(Array.from('/player///3//'.match(routesRegexp.player.regexp)))
.toEqual(['/player///3//', '', '', '3', '', '']);
});
it('match /player////4', async () => {
expect(Array.from('/player////4'.match(routesRegexp.player.regexp)))
.toEqual(['/player////4', '', '', '', '4']);
it('match /player////4/', async () => {
expect(Array.from('/player////4/'.match(routesRegexp.player.regexp)))
.toEqual(['/player////4/', '', '', '', '4', '']);
});
it('match /player/1/2//', async () => {
expect(Array.from('/player/1/2//'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2//', '1', '2', '', '']);
it('match /player/////5', async () => {
expect(Array.from('/player/////5'.match(routesRegexp.player.regexp)))
.toEqual(['/player/////5', '', '', '', '', '5']);
});
it('match /player/1//3/', async () => {
expect(Array.from('/player/1//3/'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1//3/', '1', '', '3', '']);
it('match /player/1/2///', async () => {
expect(Array.from('/player/1/2///'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2///', '1', '2', '', '', '']);
});
it('match /player/1///4', async () => {
expect(Array.from('/player/1///4'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1///4', '1', '', '', '4']);
it('match /player/1//3//', async () => {
expect(Array.from('/player/1//3//'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1//3//', '1', '', '3', '', '']);
});
it('match /player//2/3/', async () => {
expect(Array.from('/player//2/3/'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2/3/', '', '2', '3', '']);
it('match /player/1///4/', async () => {
expect(Array.from('/player/1///4/'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1///4/', '1', '', '', '4', '']);
});
it('match /player//2//4', async () => {
expect(Array.from('/player//2//4'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2//4', '', '2', '', '4']);
it('match /player/1////5', async () => {
expect(Array.from('/player/1////5'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1////5', '1', '', '', '', '5']);
});
it('match /player///3/4', async () => {
expect(Array.from('/player///3/4'.match(routesRegexp.player.regexp)))
.toEqual(['/player///3/4', '', '', '3', '4']);
it('match /player//2/3//', async () => {
expect(Array.from('/player//2/3//'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2/3//', '', '2', '3', '', '']);
});
it('match /player/1/2/3/', async () => {
expect(Array.from('/player/1/2/3/'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2/3/', '1', '2', '3', '']);
it('match /player//2//4/', async () => {
expect(Array.from('/player//2//4/'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2//4/', '', '2', '', '4', '']);
});
it('match /player/1/2//4', async () => {
expect(Array.from('/player/1/2//4'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2//4', '1', '2', '', '4']);
it('match /player//2///5', async () => {
expect(Array.from('/player//2///5'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2///5', '', '2', '', '', '5']);
});
it('match /player/1//3/4', async () => {
expect(Array.from('/player/1//3/4'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1//3/4', '1', '', '3', '4']);
it('match /player///3/4/', async () => {
expect(Array.from('/player///3/4/'.match(routesRegexp.player.regexp)))
.toEqual(['/player///3/4/', '', '', '3', '4', '']);
});
it('match /player//2/3/4', async () => {
expect(Array.from('/player//2/3/4'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2/3/4', '', '2', '3', '4']);
it('match /player///3//5', async () => {
expect(Array.from('/player///3//5'.match(routesRegexp.player.regexp)))
.toEqual(['/player///3//5', '', '', '3', '', '5']);
});
it('match /player/1/2/3/4', async () => {
expect(Array.from('/player/1/2/3/4'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2/3/4', '1', '2', '3', '4']);
it('match /player////4/5', async () => {
expect(Array.from('/player////4/5'.match(routesRegexp.player.regexp)))
.toEqual(['/player////4/5', '', '', '', '4', '5']);
});
it('match /player/1/2/3//', async () => {
expect(Array.from('/player/1/2/3//'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2/3//', '1', '2', '3', '', '']);
});
it('match /player/1/2//4/', async () => {
expect(Array.from('/player/1/2//4/'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2//4/', '1', '2', '', '4', '']);
});
it('match /player/1/2///5', async () => {
expect(Array.from('/player/1/2///5'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2///5', '1', '2', '', '', '5']);
});
it('match /player/1//3/4/', async () => {
expect(Array.from('/player/1//3/4/'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1//3/4/', '1', '', '3', '4', '']);
});
it('match /player/1//3//5', async () => {
expect(Array.from('/player/1//3//5'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1//3//5', '1', '', '3', '', '5']);
});
it('match /player/1///4/5', async () => {
expect(Array.from('/player/1///4/5'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1///4/5', '1', '', '', '4', '5']);
});
it('match /player//2/3/4/', async () => {
expect(Array.from('/player//2/3/4/'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2/3/4/', '', '2', '3', '4', '']);
});
it('match /player//2/3//5', async () => {
expect(Array.from('/player//2/3//5'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2/3//5', '', '2', '3', '', '5']);
});
it('match /player///3/4/5', async () => {
expect(Array.from('/player///3/4/5'.match(routesRegexp.player.regexp)))
.toEqual(['/player///3/4/5', '', '', '3', '4', '5']);
});
it('match /player/1/2/3/4/', async () => {
expect(Array.from('/player/1/2/3/4/'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2/3/4/', '1', '2', '3', '4', '']);
});
it('match /player/1/2/3//5', async () => {
expect(Array.from('/player/1/2/3//5'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2/3//5', '1', '2', '3', '', '5']);
});
it('match /player/1/2//4/5', async () => {
expect(Array.from('/player/1/2//4/5'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2//4/5', '1', '2', '', '4', '5']);
});
it('match /player/1//3/4/5', async () => {
expect(Array.from('/player/1//3/4/5'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1//3/4/5', '1', '', '3', '4', '5']);
});
it('match /player//2/3/4/5', async () => {
expect(Array.from('/player//2/3/4/5'.match(routesRegexp.player.regexp)))
.toEqual(['/player//2/3/4/5', '', '2', '3', '4', '5']);
});
it('match /player/1/2/3/4/5', async () => {
expect(Array.from('/player/1/2/3/4/5'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1/2/3/4/5', '1', '2', '3', '4', '5']);
});
it('not match /player', async () => {
@ -439,8 +514,8 @@ describe('routesRegexp', () => {
});
it('not match /player/', async () => {
expect('/player/'.match(routesRegexp.player.regexp))
.toBe(null);
expect(Array.from('/player/'.match(routesRegexp.player.regexp)))
.toEqual(['/player/', '', undefined, undefined, undefined, undefined]);
});
it('not match /player//', async () => {
@ -453,14 +528,14 @@ describe('routesRegexp', () => {
.toBe(null);
});
it('not match /player/////', async () => {
expect('/player/////'.match(routesRegexp.player.regexp))
it('not match /player//////', async () => {
expect('/player//////'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player/1', async () => {
expect('/player/1'.match(routesRegexp.player.regexp))
.toBe(null);
expect(Array.from('/player/1'.match(routesRegexp.player.regexp)))
.toEqual(['/player/1', '1', undefined, undefined, undefined, undefined]);
});
it('not match /player/1/', async () => {
@ -473,18 +548,38 @@ describe('routesRegexp', () => {
.toBe(null);
});
it('not match /player/1///', async () => {
expect('/player/1///'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player//2', async () => {
expect('/player//2'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player//2/', async () => {
expect('/player//2/'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player//2//', async () => {
expect('/player//2//'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player///3', async () => {
expect('/player///3'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player////4/', async () => {
expect('/player////4/'.match(routesRegexp.player.regexp))
it('not match /player///3/', async () => {
expect('/player///3/'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player////4', async () => {
expect('/player////4'.match(routesRegexp.player.regexp))
.toBe(null);
});
@ -498,18 +593,48 @@ describe('routesRegexp', () => {
.toBe(null);
});
it('not match /player/1/2//', async () => {
expect('/player/1/2//'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player/1//3', async () => {
expect('/player/1//3'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player/1//3/', async () => {
expect('/player/1//3/'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player/1///4', async () => {
expect('/player/1///4'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player/1/2/3', async () => {
expect('/player/1/2/3'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player/1/2/3/4/', async () => {
expect('/player/1/2/3/4/'.match(routesRegexp.player.regexp))
it('not match /player/1/2/3/', async () => {
expect('/player/1/2/3/'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player/1/2//4', async () => {
expect('/player/1/2//4'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player/1/2/3/4', async () => {
expect('/player/1/2/3/4'.match(routesRegexp.player.regexp))
.toBe(null);
});
it('not match /player/1/2/3/4/5/', async () => {
expect('/player/1/2/3/4/5/'.match(routesRegexp.player.regexp))
.toBe(null);
});
});

View file

@ -6,8 +6,11 @@ const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
module.exports = (env, argv) => ({
entry: './src/index.js',
output: {
path: path.join(__dirname, 'build')
},
module: {
rules: [
{
@ -125,6 +128,10 @@ module.exports = {
]
},
plugins: [
new webpack.EnvironmentPlugin({
DEBUG: argv.mode !== 'production',
...env
}),
new webpack.ProgressPlugin(),
new CopyWebpackPlugin([
{ from: 'node_modules/stremio-core-web/static', to: '' },
@ -142,4 +149,4 @@ module.exports = {
cleanAfterEveryBuildPatterns: ['./main.js', './main.css']
})
]
};
});

View file

@ -1240,6 +1240,58 @@
react-lifecycles-compat "^3.0.4"
warning "^3.0.0"
"@sentry/browser@5.11.1":
version "5.11.1"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-5.11.1.tgz#337ffcb52711b23064c847a07629e966f54a5ebb"
integrity sha512-oqOX/otmuP92DEGRyZeBuQokXdeT9HQRxH73oqIURXXNLMP3PWJALSb4HtT4AftEt/2ROGobZLuA4TaID6My/Q==
dependencies:
"@sentry/core" "5.11.1"
"@sentry/types" "5.11.0"
"@sentry/utils" "5.11.1"
tslib "^1.9.3"
"@sentry/core@5.11.1":
version "5.11.1"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.11.1.tgz#9e2da485e196ae32971545c1c49ee6fe719930e2"
integrity sha512-BpvPosVNT20Xso4gAV54Lu3KqDmD20vO63HYwbNdST5LUi8oYV4JhvOkoBraPEM2cbBwQvwVcFdeEYKk4tin9A==
dependencies:
"@sentry/hub" "5.11.1"
"@sentry/minimal" "5.11.1"
"@sentry/types" "5.11.0"
"@sentry/utils" "5.11.1"
tslib "^1.9.3"
"@sentry/hub@5.11.1":
version "5.11.1"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.11.1.tgz#ddcb865563fae53852d405885c46b4c6de68a91b"
integrity sha512-ucKprYCbGGLLjVz4hWUqHN9KH0WKUkGf5ZYfD8LUhksuobRkYVyig0ZGbshECZxW5jcDTzip4Q9Qimq/PkkXBg==
dependencies:
"@sentry/types" "5.11.0"
"@sentry/utils" "5.11.1"
tslib "^1.9.3"
"@sentry/minimal@5.11.1":
version "5.11.1"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.11.1.tgz#0e705d01a567282d8fbbda2aed848b4974cc3cec"
integrity sha512-HK8zs7Pgdq7DsbZQTThrhQPrJsVWzz7MaluAbQA0rTIAJ3TvHKQpsVRu17xDpjZXypqWcKCRsthDrC4LxDM1Bg==
dependencies:
"@sentry/hub" "5.11.1"
"@sentry/types" "5.11.0"
tslib "^1.9.3"
"@sentry/types@5.11.0":
version "5.11.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.11.0.tgz#40f0f3174362928e033ddd9725d55e7c5cb7c5b6"
integrity sha512-1Uhycpmeo1ZK2GLvrtwZhTwIodJHcyIS6bn+t4IMkN9MFoo6ktbAfhvexBDW/IDtdLlCGJbfm8nIZerxy0QUpg==
"@sentry/utils@5.11.1":
version "5.11.1"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.11.1.tgz#aa19fcc234cf632257b2281261651d2fac967607"
integrity sha512-O0Zl4R2JJh8cTkQ8ZL2cDqGCmQdpA5VeXpuBbEl1v78LQPkBDISi35wH4mKmLwMsLBtTVpx2UeUHBj0KO5aLlA==
dependencies:
"@sentry/types" "5.11.0"
tslib "^1.9.3"
"@sheerun/mutationobserver-shim@^0.3.2":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz#8013f2af54a2b7d735f71560ff360d3a8176a87b"
@ -4420,16 +4472,16 @@ dotenv-webpack@^1.7.0:
dependencies:
dotenv-defaults "^1.0.2"
dotenv@8.2.0, dotenv@^8.0.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a"
integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==
dotenv@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.2.0.tgz#941c0410535d942c8becf28d3f357dbd9d476064"
integrity sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==
dotenv@^8.0.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a"
integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==
duplexer@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1"