Merge branch 'board' of github.com:Stremio/stremio-web

This commit is contained in:
NikolaBorislavovHristov 2019-01-28 13:07:29 +02:00
commit 413ca7d8ca
47 changed files with 1341 additions and 997 deletions

View file

@ -17,45 +17,37 @@
"hat": "0.0.3",
"lodash.debounce": "4.0.8",
"prop-types": "15.6.2",
"react": "16.6.3",
"react-dom": "16.6.3",
"react-router": "4.3.1",
"react-router-dom": "4.3.1",
"stremio-addon-client": "git+ssh://git@github.com/Stremio/stremio-addon-client.git#v1.5.1",
"stremio-addons": "git+ssh://git@github.com/Stremio/stremio-addons.git#v2.8.14",
"stremio-aggregators": "git+ssh://git@github.com/Stremio/stremio-aggregators.git#v1.4.1",
"stremio-api-client": "git+ssh://git@github.com/Stremio/stremio-api-client.git#e8459d01fdd3507113b13b02aab628d24e20515e",
"react": "16.8.0-alpha.1",
"react-dom": "16.8.0-alpha.1",
"stremio-colors": "git+ssh://git@github.com/Stremio/stremio-colors.git#v2.0.3",
"stremio-icons": "git+ssh://git@github.com/Stremio/stremio-icons.git#v1.0.2",
"stremio-icons": "git+ssh://git@github.com/Stremio/stremio-icons.git#v1.0.4",
"stremio-json-data": "git+ssh://git@github.com/stremio/stremio-json-data.git#v1.2.3",
"stremio-models": "git+ssh://git@github.com/stremio/stremio-models.git#v1.51.5",
"stremio-official-addons": "git+ssh://git@github.com/Stremio/stremio-official-addons.git#v1.1.1",
"stremio-translations": "git+ssh://git@github.com/Stremio/stremio-translations.git#v1.41.0",
"vtt.js": "0.13.0"
},
"devDependencies": {
"@babel/core": "7.2.2",
"@babel/plugin-proposal-class-properties": "7.2.1",
"@babel/plugin-proposal-class-properties": "7.2.3",
"@babel/plugin-proposal-object-rest-spread": "7.2.0",
"@babel/preset-env": "7.2.0",
"@babel/preset-env": "7.2.3",
"@babel/preset-react": "7.0.0",
"@babel/runtime": "7.2.0",
"@storybook/addon-actions": "4.1.2",
"@storybook/addon-links": "4.1.2",
"@storybook/addons": "4.1.2",
"@storybook/addon-actions": "4.1.6",
"@storybook/addon-links": "4.1.6",
"@storybook/addons": "4.1.6",
"@storybook/react": "4.1.2",
"autoprefixer": "9.4.3",
"babel-loader": "8.0.4",
"autoprefixer": "9.4.5",
"babel-loader": "8.0.5",
"copy-webpack-plugin": "4.6.0",
"css-loader": "2.0.1",
"css-loader": "2.1.0",
"html-webpack-plugin": "3.2.0",
"less": "3.9.0",
"less-loader": "4.1.0",
"postcss-loader": "3.0.0",
"style-loader": "0.23.1",
"terser-webpack-plugin": "1.1.0",
"webpack": "4.27.1",
"webpack-cli": "3.1.2",
"webpack-dev-server": "3.1.10"
"terser-webpack-plugin": "1.2.1",
"webpack": "4.28.4",
"webpack-cli": "3.2.1",
"webpack-dev-server": "3.1.14"
}
}
}

12
src/App/App.js Normal file
View file

@ -0,0 +1,12 @@
import React, { StrictMode } from 'react';
import { Router } from 'stremio-common';
import routerConfig from './routerConfig';
import styles from './styles';
const App = () => (
<StrictMode>
<Router className={styles['router']} config={routerConfig} />
</StrictMode>
);
export default App;

3
src/App/index.js Normal file
View file

@ -0,0 +1,3 @@
import App from './App';
export default App;

View file

@ -44,63 +44,48 @@
:root {
--scroll-bar-width: 8px;
--landscape-shape-ratio: 0.5625;
--poster-shape-ratio: 1.464;
--window-min-width: 1000px;
--window-min-height: 650px;
}
:global {
* {
margin: 0px;
padding: 0px;
border: none;
list-style: none;
user-select: none;
box-sizing: border-box;
text-decoration: none;
font-weight: normal;
}
html, body, #app, .modal-container {
position: relative;
width: 100vw;
height: 100vh;
min-width: 1000px;
min-height: 650px;
font-family: 'Roboto', 'sans-serif';
line-height: 1;
::-webkit-scrollbar {
width: var(--scroll-bar-width);
}
::-webkit-scrollbar-thumb {
background-color: var(--color-secondarylighter80);
}
::-webkit-scrollbar-track {
background-color: var(--color-backgroundlight);
}
}
#app {
z-index: 0;
overflow: hidden;
}
.modal-container {
position: absolute;
top: 0;
left: 0;
z-index: 1;
overflow: hidden;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
border: none;
list-style: none;
user-select: none;
text-decoration: none;
outline: none;
}
.route-container {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
html, body, :global(#app) {
position: relative;
z-index: 0;
overflow: hidden;
width: 100vw;
height: 100vh;
min-width: var(--window-min-width);
min-height: var(--window-min-height);
font-family: 'Roboto', 'sans-serif';
line-height: 1;
background-color: var(--color-background);
.router {
width: 100%;
height: 100%;
}
::-webkit-scrollbar {
width: var(--scroll-bar-width);
}
::-webkit-scrollbar-thumb {
background-color: var(--color-secondarylighter80);
}
::-webkit-scrollbar-track {
background-color: var(--color-backgroundlight);
}
}

View file

@ -1,19 +0,0 @@
import React, { PureComponent, StrictMode } from 'react';
import { Router } from 'stremio-common';
import routerConfig from './routerConfig';
import styles from './styles';
class App extends PureComponent {
render() {
return (
<StrictMode>
<Router
routeContainerClassName={styles['route-container']}
config={routerConfig}
/>
</StrictMode>
);
}
}
export default App;

View file

@ -1,3 +0,0 @@
import App from './app';
export default App;

View file

@ -0,0 +1,55 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { withFocusable } from 'stremio-common';
class Button extends PureComponent {
onClick = (event) => {
if (this.props.stopPropagation) {
event.stopPropagation();
}
if (typeof this.props.onClick === 'function') {
this.props.onClick(event);
}
}
onKeyUp = (event) => {
if (event.which === 13) { // Enter key code
this.onClick(event);
}
}
render() {
const { forwardedRef, focusable, stopPropagation, ...props } = this.props;
return (
<div
{...props}
ref={forwardedRef}
tabIndex={focusable ? 0 : -1}
onClick={this.onClick}
onKeyUp={this.onKeyUp}
/>
);
}
}
Button.propTypes = {
focusable: PropTypes.bool.isRequired,
stopPropagation: PropTypes.bool.isRequired
};
Button.defaultProps = {
focusable: false,
stopPropagation: true
};
const ButtonWithFocusable = withFocusable(Button);
ButtonWithFocusable.displayName = 'ButtonWithFocusable';
const ButtonWithForwardedRef = React.forwardRef((props, ref) => (
<ButtonWithFocusable {...props} forwardedRef={ref} />
));
ButtonWithForwardedRef.displayName = 'ButtonWithForwardedRef';
export default ButtonWithForwardedRef;

View file

@ -0,0 +1,3 @@
import Button from './Button';
export default Button;

View file

@ -0,0 +1,7 @@
import React from 'react';
const FocusableContext = React.createContext(false);
FocusableContext.displayName = 'FocusableContext';
export default FocusableContext;

View file

@ -0,0 +1,7 @@
import FocusableContext from './FocusableContext';
import withFocusable from './withFocusable';
export {
FocusableContext,
withFocusable
};

View file

@ -0,0 +1,14 @@
import React from 'react';
import FocusableContext from './FocusableContext';
const withFocusable = (Component) => {
return function withFocusable(props) {
return (
<FocusableContext.Consumer>
{focusable => <Component {...props} focusable={focusable} />}
</FocusableContext.Consumer>
);
};
};
export default withFocusable;

View file

@ -1,173 +1,154 @@
import React from 'react';
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import Icon, { dataUrl as iconDataUrl } from 'stremio-icons/dom';
import colors from 'stremio-colors';
import { RELATIVE_POSTER_SIZE } from './constants';
import classnames from 'classnames';
import { Popup, Button } from 'stremio-common';
import Icon from 'stremio-icons/dom';
import styles from './styles';
const getShapeSize = (posterShape, progress) => {
switch (posterShape) {
case 'poster':
return {
width: RELATIVE_POSTER_SIZE
};
case 'landscape':
return {
width: RELATIVE_POSTER_SIZE / 0.5625
};
default:
if (progress) {
return {
width: RELATIVE_POSTER_SIZE * 1.464
};
}
return {
width: RELATIVE_POSTER_SIZE
};
}
}
class MetaItem extends Component {
constructor(props) {
super(props);
const getPlaceholderIcon = (type) => {
switch (type) {
case 'tv':
return 'ic_tv';
case 'series':
return 'ic_series';
case 'channel':
return 'ic_channels';
default:
return 'ic_movies';
}
}
const renderProgress = (progress) => {
if (progress <= 0) {
return null;
this.state = {
menuPopupOpen: false
};
}
return (
<div className={styles['progress-container']}>
<div style={{ width: progress + '%' }} className={styles['progress']} />
</div>
);
}
const renderEpisode = (episode) => {
if (episode.length === 0) {
return null;
shouldComponentUpdate(nextProps, nextState) {
return nextState.menuPopupOpen !== this.state.menuPopupOpen ||
nextProps.className !== this.props.className ||
nextProps.popupClassName !== this.props.popupClassName ||
nextProps.id !== this.props.id ||
nextProps.type !== this.props.type ||
nextProps.relativeSize !== this.props.relativeSize ||
nextProps.posterShape !== this.props.posterShape ||
nextProps.poster !== this.props.poster ||
nextProps.title !== this.props.title ||
nextProps.subtitle !== this.props.subtitle ||
nextProps.progress !== this.props.progress ||
nextProps.released !== this.props.released ||
nextProps.menu !== this.props.menu;
}
return (
<div className={styles['episode']}>{episode}</div>
);
}
const renderTitle = (title) => {
if (title.length === 0) {
return null;
onMenuPopupOpen = () => {
this.setState({ menuPopupOpen: true });
}
return (
<div className={styles['title']}>{title}</div>
);
}
const renderReleaseInfo = (releaseInfo) => {
if (releaseInfo.length === 0) {
return null;
onMenuPopupClose = () => {
this.setState({ menuPopupOpen: false });
}
return (
<div className={styles['year']}>{releaseInfo}</div>
);
}
onClick = (event) => {
if (typeof this.props.onClick === 'function') {
this.props.onClick(event);
}
}
const renderPopupIcon = (onItemClicked, popup) => {
if (!popup) {
return null;
}
return (
<div onClick={onItemClicked} className={styles['popup-icon-container']}>
<Icon className={styles['popup-icon']} icon={'ic_more'} />
</div>
);
}
renderProgress() {
if (this.props.progress <= 0) {
return null;
}
const getClassName = (progress, posterShape, title, releaseInfo, episode) => {
if ((progress > 0) && (title.length > 0 || releaseInfo.length > 0 || episode.length > 0)) {
if (posterShape === 'landscape') return 'progress-info-landscape-shape';
if (posterShape === 'square') return 'progress-info-square-shape';
return 'progress-info-poster-shape';
return (
<div className={styles['progress-bar-container']}>
<div className={styles['progress']} style={{ width: `${this.props.progress * 100}%` }} />
</div>
);
}
if ((progress > 0) && (title.length === 0 && releaseInfo.length === 0 && episode.length === 0)) {
if (posterShape === 'landscape') return 'progress-landscape-shape';
if (posterShape === 'square') return 'progress-square-shape';
return 'progress-poster-shape';
}
if (!progress && (title.length > 0 || releaseInfo.length > 0 || episode.length > 0)) {
if (posterShape === 'landscape') return 'info-landscape-shape';
if (posterShape === 'square') return 'info-square-shape';
return 'info-poster-shape';
}
if (!progress && (title.length === 0 && releaseInfo.length === 0 && episode.length === 0)) {
if (posterShape === 'landscape') return 'landscape-shape';
if (posterShape === 'square') return 'square-shape';
return 'poster-shape';
}
return 'meta-item';
}
const MetaItem = (props) => {
const posterSize = getShapeSize(props.posterShape, props.progress);
const contentContainerStyle = {
width: posterSize.width
};
const placeholderIcon = getPlaceholderIcon(props.type);
const placeholderIconUrl = iconDataUrl({ icon: placeholderIcon, fill: colors.accent, width: Math.round(RELATIVE_POSTER_SIZE / 2.2), height: Math.round(RELATIVE_POSTER_SIZE / 2.2) });
const imageStyle = {
backgroundImage: `url(${props.poster}), url('${placeholderIconUrl}')`
};
renderPoster() {
const placeholderIcon = this.props.type === 'tv' ? 'ic_tv'
: this.props.type === 'series' ? 'ic_series'
: this.props.type === 'channel' ? 'ic_channels'
: 'ic_movies';
return (
<div className={styles['poster-image-container']}>
<Icon className={styles['placeholder-image']} icon={placeholderIcon} />
<div className={styles['poster-image']} style={{ backgroundImage: `url('${this.props.poster}')` }} />
{this.renderProgress()}
</div>
);
}
return (
<div style={contentContainerStyle} className={styles[getClassName(props.progress, props.posterShape, props.title, props.releaseInfo, props.episode)]}>
<div style={imageStyle} className={styles['poster']}>
<div onClick={props.play} style={props.progress ? { visibility: 'visible' } : null} className={styles['play-container']}>
<Icon className={styles['play']} icon={'ic_play'} />
renderInfoBar() {
if (this.props.title.length === 0 && this.props.subtitle.length === 0 && this.props.menu.length === 0) {
return null;
}
return (
<Fragment>
<div className={styles['title-bar-container']}>
<div className={styles['title']}>{this.props.title}</div>
{
this.props.menu.length > 0 ?
<Popup className={classnames(styles['menu-popup-container'], this.props.popupClassName)} onOpen={this.onMenuPopupOpen} onClose={this.onMenuPopupClose}>
<Popup.Label>
<Icon
className={classnames(styles['menu-icon'], { 'active': this.state.menuPopupOpen })}
icon={'ic_more'}
/>
</Popup.Label>
<Popup.Menu>
<div className={styles['menu-items-container']}>
{this.props.menu.map(({ label, onSelect }) => (
<Button key={label} className={styles['menu-item']} onClick={onSelect}>{label}</Button>
))}
</div>
</Popup.Menu>
</Popup>
:
null
}
</div>
</div>
{renderProgress(props.progress)}
<div className={styles['info']}>
{renderEpisode(props.episode)}
{renderTitle(props.title)}
{renderReleaseInfo(props.releaseInfo)}
</div>
{renderPopupIcon(props.onItemClicked, props.popup)}
</div>
);
{
this.props.subtitle.length > 0 ?
<div className={styles['title-bar-container']}>
<div className={styles['title']}>{this.props.subtitle}</div>
</div>
:
null
}
</Fragment>
);
}
render() {
return (
<Button className={classnames(styles['meta-item-container'], styles[`relative-size-${this.props.relativeSize}`], styles[`poster-shape-${this.props.posterShape}`], this.props.className)} data-meta-item-id={this.props.id} onClick={this.onClick}>
{this.renderPoster()}
{this.renderInfoBar()}
</Button>
);
}
}
MetaItem.propTypes = {
type: PropTypes.oneOf(['movie', 'series', 'channel', 'tv', 'other']).isRequired,
poster: PropTypes.string.isRequired,
className: PropTypes.string,
popupClassName: PropTypes.string,
onClick: PropTypes.func,
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
relativeSize: PropTypes.oneOf(['auto', 'height']).isRequired,
posterShape: PropTypes.oneOf(['poster', 'landscape', 'square']).isRequired,
progress: PropTypes.number.isRequired,
episode: PropTypes.string.isRequired,
poster: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
releaseInfo: PropTypes.string.isRequired,
popup: PropTypes.bool.isRequired,
play: PropTypes.func,
onItemClicked: PropTypes.func
subtitle: PropTypes.string.isRequired,
progress: PropTypes.number.isRequired,
released: PropTypes.instanceOf(Date).isRequired,
menu: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string.isRequired,
onSelect: PropTypes.func.isRequired
})).isRequired
};
MetaItem.defaultProps = {
type: 'other',
relativeSize: 'auto',
posterShape: 'square',
poster: '',
posterShape: 'poster',
progress: 0,
episode: '',
title: '',
releaseInfo: '',
popup: false
subtitle: '',
progress: 0,
released: new Date(NaN),
menu: Object.freeze([])
};
export default MetaItem;
export default MetaItem;

View file

@ -1,3 +0,0 @@
{
"RELATIVE_POSTER_SIZE": 138
}

View file

@ -1,153 +1,152 @@
.meta-item, .progress-poster-shape, .progress-landscape-shape, .progress-square-shape, .progress-info-poster-shape, .progress-info-landscape-shape, .progress-info-square-shape, .poster-shape, .landscape-shape, .square-shape, .info-poster-shape, .info-landscape-shape, .info-square-shape {
display: grid;
color: var(--color-surfacelighter);
.poster {
grid-area: poster;
.meta-item-container {
display: inline-flex;
flex-direction: column;
background-color: var(--color-backgroundlight);
border: calc(var(--progress-bar-size) * 0.5) solid transparent;
cursor: pointer;
.poster-image-container {
position: relative;
display: flex;
background-position: center;
background-size: cover, auto;
background-repeat: no-repeat;
background-color: var(--color-backgrounddark);
.play-container {
width: 70px;
height: 70px;
margin: auto;
display: flex;
visibility: hidden;
border-radius: 50%;
background-color: var(--color-surfacelighter);
.play {
width: 26px;
height: 26px;
margin: auto;
margin-left: 26px;
fill: var(--color-primary);
justify-content: center;
align-items: center;
overflow: hidden;
z-index: 0;
.placeholder-image {
width: calc(var(--poster-relative-size) * 0.5);
height: calc(var(--poster-relative-size) * 0.5);
fill: var(--color-surfacelighter);
}
.poster-image {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-origin: border-box;
}
.progress-bar-container {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
height: var(--progress-bar-size);
background-color: var(--color-backgroundlighter);
box-shadow: 0 0 calc(var(--progress-bar-size) * 4) calc(var(--progress-bar-size) * 1.5) var(--color-backgroundlight);
.progress {
height: 100%;
background-color: var(--color-primary);
}
}
}
.progress-container {
grid-area: progress;
background-color: var(--color-surface);
.progress {
height: 4px;
background-color: var(--color-primarylight);
}
}
.info {
grid-area: info;
.title, .year, .episode {
grid-area: text;
color: var(--color-surfacelighter60);
}
:first-child {
.title-bar-container {
height: 3em;
display: flex;
flex-direction: row;
align-items: center;
.title {
flex: 1;
font-size: 1.4em;
padding: 0 0.5em;
color: var(--color-surfacelighter);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.menu-icon {
width: 3em;
height: 3em;
padding: 0.8em;
fill: var(--color-surfacelighter);
&:hover, &:global(.active) {
background-color: var(--color-backgroundlighter);
}
}
}
.popup-icon-container {
grid-area: popupIcon;
cursor: pointer;
fill: var(--color-surfacelighter);
.popup-icon {
width: 10px;
height: 10px;
&.poster-shape-square {
.poster-image-container {
width: var(--poster-relative-size);
height: var(--poster-relative-size);
}
.title-bar-container {
width: var(--poster-relative-size);
}
}
&:hover {
color: var(--color-backgrounddarker);
background-color: var(--color-surfacelighter);
outline: 2px solid var(--color-surfacelighter);
.play-container {
&.poster-shape-landscape {
.poster-image-container {
width: calc(var(--poster-relative-size) / var(--landscape-shape-ratio));
height: var(--poster-relative-size);
}
.title-bar-container {
width: calc(var(--poster-relative-size) / var(--landscape-shape-ratio));
}
}
&.poster-shape-poster {
&.relative-size-auto {
.poster-image-container {
width: var(--poster-relative-size);
height: calc(var(--poster-relative-size) * var(--poster-shape-ratio));
}
.title-bar-container {
width: var(--poster-relative-size);
}
}
&.relative-size-height {
.poster-image-container {
width: calc(var(--poster-relative-size) / var(--poster-shape-ratio));
height: var(--poster-relative-size);
}
.title-bar-container {
width: calc(var(--poster-relative-size) / var(--poster-shape-ratio));
}
}
}
&:hover, &:focus {
border-color: var(--color-surfacelighter);
}
}
.menu-popup-container {
--box-shadow: 0 0 2em .15em var(--color-background);
.menu-items-container {
background-color: var(--color-surfacelighter);
min-width: calc(var(--poster-relative-size) * 0.6);
max-width: var(--poster-relative-size);
.menu-item {
display: inline-block;
width: 100%;
color: var(--color-backgrounddarker);
font-size: 1.2em;
padding: 0.5em;
cursor: pointer;
visibility: visible;
}
.info {
.title, .year {
color: var(--color-backgrounddarker60);
&:hover, &:focus {
background-color: var(--color-surfacelight);
}
:first-child {
color: var(--color-backgrounddarker);
}
}
.popup-icon {
fill: var(--color-backgrounddarker40);
}
}
}
.progress-poster-shape, .progress-landscape-shape, .progress-square-shape {
grid-template-areas:
"poster poster"
"progress progress"
". popupIcon";
}
.progress-poster-shape {
grid-template-columns: 128px 10px;
grid-template-rows: 202.03px 4px;
}
.progress-landscape-shape {
grid-template-columns: 235.33px 10px;
grid-template-rows: 202.03px 4px;
}
.progress-square-shape {
grid-template-columns: 192.33px 10px;
grid-template-rows: 202.03px 4px;
}
.progress-info-poster-shape, .progress-info-landscape-shape, .progress-info-square-shape {
grid-template-areas:
"poster poster"
"progress progress"
". ."
"info popupIcon";
}
.progress-info-poster-shape {
grid-template-columns: 128px 10px;
grid-template-rows: 202.03px 4px 6px 77.97px;
}
.progress-info-landscape-shape {
grid-template-columns: 235.33px 10px;
grid-template-rows: 202.03px 4px 6px 77.97px;
}
.progress-info-square-shape {
grid-template-columns: 192.33px 10px;
grid-template-rows: 202.03px 4px 6px 77.97px;
}
.poster-shape, .landscape-shape, .square-shape {
grid-template-areas:
"poster poster"
". popupIcon";
}
.poster-shape {
grid-template-columns: 128px 10px;
grid-template-rows: 202.03px;
}
.landscape-shape {
grid-template-columns: 235.33px 10px;
grid-template-rows: 138px;
}
.square-shape {
grid-template-columns: 128px 10px;
grid-template-rows: 138px;
}
.info-poster-shape, .info-landscape-shape, .info-square-shape {
grid-template-areas:
"poster poster"
". ."
"info popupIcon";
}
.info-poster-shape {
grid-template-columns: 128px 10px;
grid-template-rows: 202.03px 6px 81.97px;
}
.info-landscape-shape {
grid-template-columns: 235.33px 10px;
grid-template-rows: 138px 6px 64.97px;
}
.info-square-shape {
grid-template-columns: 128px 10px;
grid-template-rows: 138px 6px 64.97px;
}
.meta-item {
grid-template-columns: 138px;
grid-template-rows: 202.03px;
grid-template-areas:
"poster";
}

View file

@ -1,6 +1,30 @@
import React from 'react';
import React, { useRef, useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
import { FocusableContext } from 'stremio-common';
import withModalsContainer from './withModalsContainer';
const Modal = (props) => ReactDOM.createPortal(<div {...props} />, document.body);
const Modal = ({ modalsContainer, children }) => {
const modalContainerRef = useRef(null);
const [focusable, setFocusable] = useState(false);
useEffect(() => {
const nextFocusable = modalsContainer.lastElementChild === modalContainerRef.current;
if (nextFocusable !== focusable) {
setFocusable(nextFocusable);
}
});
export default Modal;
return ReactDOM.createPortal(
<FocusableContext.Provider value={focusable}>
<div ref={modalContainerRef} className={'modal-container'}>
{children}
</div>
</FocusableContext.Provider>,
modalsContainer
);
};
const ModalWithModalsContainer = withModalsContainer(Modal);
ModalWithModalsContainer.displayName = 'ModalWithModalsContainer';
export default ModalWithModalsContainer;

View file

@ -0,0 +1,8 @@
import React from 'react';
const ModalsContainerContext = React.createContext(null);
ModalsContainerContext.displayName = 'ModalsContainerContext';
export default ModalsContainerContext;

View file

@ -1,3 +1,9 @@
import ModalsContainerContext from './ModalsContainerContext';
import Modal from './Modal';
import withModalsContainer from './withModalsContainer';
export default Modal;
export {
ModalsContainerContext,
Modal,
withModalsContainer
};

View file

@ -0,0 +1,14 @@
import React from 'react';
import ModalsContainerContext from './ModalsContainerContext';
const withModalsContainer = (Component) => {
return function withModalsContainer(props) {
return (
<ModalsContainerContext.Consumer>
{modalsContainer => <Component {...props} modalsContainer={modalsContainer} />}
</ModalsContainerContext.Consumer>
);
};
};
export default withModalsContainer;

View file

@ -1,6 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { NavLink } from 'react-router-dom';
import Icon from 'stremio-icons/dom';
import styles from './styles';

View file

@ -1,5 +1,4 @@
import React, { Component } from 'react';
import { matchPath, withRouter } from 'react-router-dom';
import Icon from 'stremio-icons/dom';
import classnames from 'classnames';
import styles from './styles';
@ -86,4 +85,4 @@ class SearchInput extends Component {
}
}
export default withRouter(SearchInput);
export default SearchInput;

View file

@ -1,7 +1,9 @@
import React from 'react';
const Label = ({ children, ...props }, ref) => {
const Label = React.forwardRef(({ children, ...props }, ref) => {
return React.cloneElement(React.Children.only(children), { ...props, ref });
};
});
export default React.forwardRef(Label);
Label.displayName = 'Popup.Label';
export default Label;

View file

@ -1,7 +1,11 @@
import React from 'react';
const Menu = ({ children }) => {
return React.Children.only(children);
};
const Menu = React.forwardRef(({ children }, ref) => (
<div ref={ref}>
{children}
</div>
));
Menu.displayName = 'Popup.Menu';
export default Menu;

View file

@ -54,6 +54,7 @@ class Popup extends Component {
this.popupMutationObserver.observe(document.documentElement, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
if (typeof this.props.onOpen === 'function') {
@ -107,15 +108,15 @@ class Popup extends Component {
this.labelBorderLeftRef.current.removeAttribute('style');
const menuDirections = {};
const bodyRect = document.body.getBoundingClientRect();
const documentRect = document.documentElement.getBoundingClientRect();
const labelRect = this.labelRef.current.getBoundingClientRect();
const menuChildredRect = this.menuChildrenRef.current.getBoundingClientRect();
const borderSize = parseFloat(window.getComputedStyle(this.hiddenBorderRef.current).getPropertyValue('border-top-width'));
const labelPosition = {
left: labelRect.x - bodyRect.x,
top: labelRect.y - bodyRect.y,
right: (bodyRect.width + bodyRect.x) - (labelRect.x + labelRect.width),
bottom: (bodyRect.height + bodyRect.y) - (labelRect.y + labelRect.height)
left: labelRect.x - documentRect.x,
top: labelRect.y - documentRect.y,
right: (documentRect.width + documentRect.x) - (labelRect.x + labelRect.width),
bottom: (documentRect.height + documentRect.y) - (labelRect.y + labelRect.height)
};
if (menuChildredRect.height <= labelPosition.bottom) {
@ -211,12 +212,22 @@ class Popup extends Component {
this.setState({ open: false });
}
labelOnClick = (event) => {
event.stopPropagation();
this.open();
}
menuContainerOnClick = (event) => {
event.stopPropagation();
}
modalBackgroundOnClick = (event) => {
event.stopPropagation();
this.close();
}
renderLabel(children) {
return React.cloneElement(children, { ref: this.labelRef, onClick: this.open });
return React.cloneElement(children, { ref: this.labelRef, onClick: this.labelOnClick });
}
renderMenu(children) {
@ -225,23 +236,23 @@ class Popup extends Component {
}
return (
<Modal className={classnames('modal-container', this.props.className)} onClick={this.close}>
<div ref={this.menuContainerRef} className={styles['menu-container']} onClick={this.menuContainerOnClick}>
<div ref={this.menuScrollRef} className={styles['menu-scroll-container']}>
<div ref={this.menuChildrenRef}>
{children}
<Modal>
<div className={classnames(styles['modal-container'], this.props.className)} onClick={this.modalBackgroundOnClick}>
<div ref={this.menuContainerRef} className={styles['menu-container']} onClick={this.menuContainerOnClick}>
<div ref={this.menuScrollRef} className={styles['menu-scroll-container']}>
{React.cloneElement(children, { ref: this.menuChildrenRef })}
</div>
<div ref={this.menuBorderTopRef} className={classnames(styles['border'], styles['border-top'])} />
<div ref={this.menuBorderRightRef} className={classnames(styles['border'], styles['border-right'])} />
<div ref={this.menuBorderBottomRef} className={classnames(styles['border'], styles['border-bottom'])} />
<div ref={this.menuBorderLeftRef} className={classnames(styles['border'], styles['border-left'])} />
</div>
<div ref={this.menuBorderTopRef} className={classnames(styles['border'], styles['border-top'])} />
<div ref={this.menuBorderRightRef} className={classnames(styles['border'], styles['border-right'])} />
<div ref={this.menuBorderBottomRef} className={classnames(styles['border'], styles['border-bottom'])} />
<div ref={this.menuBorderLeftRef} className={classnames(styles['border'], styles['border-left'])} />
<div ref={this.labelBorderTopRef} className={classnames(styles['border'], styles['border-top'])} />
<div ref={this.labelBorderRightRef} className={classnames(styles['border'], styles['border-right'])} />
<div ref={this.labelBorderBottomRef} className={classnames(styles['border'], styles['border-bottom'])} />
<div ref={this.labelBorderLeftRef} className={classnames(styles['border'], styles['border-left'])} />
<div ref={this.hiddenBorderRef} className={classnames(styles['border'], styles['border-hidden'])} />
</div>
<div ref={this.labelBorderTopRef} className={classnames(styles['border'], styles['border-top'])} />
<div ref={this.labelBorderRightRef} className={classnames(styles['border'], styles['border-right'])} />
<div ref={this.labelBorderBottomRef} className={classnames(styles['border'], styles['border-bottom'])} />
<div ref={this.labelBorderLeftRef} className={classnames(styles['border'], styles['border-left'])} />
<div ref={this.hiddenBorderRef} className={classnames(styles['border'], styles['border-hidden'])} />
</Modal>
);
}

View file

@ -1,43 +1,49 @@
.menu-container {
position: absolute;
visibility: hidden;
.modal-container {
width: 100%;
height: 100%;
.menu-scroll-container {
overflow: auto;
}
}
.menu-container {
position: absolute;
visibility: hidden;
.border {
position: absolute;
pointer-events: none;
background-color: var(--border-color);
&-top {
top: 0;
right: 0;
left: 0;
.menu-scroll-container {
box-shadow: var(--box-shadow);
overflow: auto;
}
}
&-bottom {
right: 0;
bottom: 0;
left: 0;
}
.border {
position: absolute;
pointer-events: none;
background-color: var(--border-color);
&-left {
top: 0;
bottom: 0;
left: 0;
}
&-top {
top: 0;
right: 0;
left: 0;
}
&-right {
top: 0;
right: 0;
bottom: 0;
}
&-bottom {
right: 0;
bottom: 0;
left: 0;
}
&-hidden {
display: none;
border: 1px solid;
&-left {
top: 0;
bottom: 0;
left: 0;
}
&-right {
top: 0;
right: 0;
bottom: 0;
}
&-hidden {
display: none;
border: 1px solid;
}
}
}

View file

@ -0,0 +1,38 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { ModalsContainerContext } from 'stremio-common';
class ModalsContainerProvider extends Component {
constructor(props) {
super(props);
this.state = {
modalsContainer: null
};
}
shouldComponentUpdate(nextProps, nextState) {
return nextState.modalsContainer !== this.state.modalsContainer ||
nextProps.modalsContainerClassName !== this.props.modalsContainerClassName ||
nextProps.children !== this.props.children;
}
modalsContainerRef = (modalsContainer) => {
this.setState({ modalsContainer });
}
render() {
return (
<ModalsContainerContext.Provider value={this.state.modalsContainer}>
{this.state.modalsContainer ? this.props.children : null}
<div ref={this.modalsContainerRef} className={this.props.modalsContainerClassName} />
</ModalsContainerContext.Provider>
);
}
}
ModalsContainerProvider.propTypes = {
modalsContainerClassName: PropTypes.string
};
export default ModalsContainerProvider;

View file

@ -0,0 +1,26 @@
import React, { Component } from 'react';
import ModalsContainerProvider from './ModalsContainerProvider';
import RouteFocusableProvider from './RouteFocusableProvider';
import styles from './styles';
class Route extends Component {
shouldComponentUpdate(nextProps, nextState) {
return nextProps.children !== this.props.children;
}
render() {
return (
<div className={styles['route']}>
<ModalsContainerProvider modalsContainerClassName={styles['modals-container']}>
<RouteFocusableProvider>
<div className={styles['route-content']}>
{this.props.children}
</div>
</RouteFocusableProvider>
</ModalsContainerProvider>
</div>
);
}
}
export default Route;

View file

@ -0,0 +1,58 @@
import React, { Component } from 'react';
import { FocusableContext, withModalsContainer } from 'stremio-common';
class RouteFocusableProvider extends Component {
constructor(props) {
super(props);
this.routeContentRef = React.createRef();
this.modalsContainerDomTreeObserver = new MutationObserver(this.onModalsContainerDomTreeChange);
this.state = {
focusable: false
};
}
componentDidMount() {
this.onModalsContainerDomTreeChange();
this.modalsContainerDomTreeObserver.observe(this.props.modalsContainer, {
childList: true
});
}
componentWillUnmount() {
this.modalsContainerDomTreeObserver.disconnect();
}
shouldComponentUpdate(nextProps, nextState) {
return nextState.focusable !== this.state.focusable ||
nextProps.modalsContainer !== this.props.modalsContainer ||
nextProps.children !== this.props.children;
}
componentDidUpdate(prevProps, prevState) {
if (prevState.focusable && !this.state.focusable) {
const focusedElement = this.routeContentRef.current.querySelector(':focus');
if (focusedElement !== null) {
focusedElement.blur();
}
}
}
onModalsContainerDomTreeChange = () => {
this.setState({ focusable: this.props.modalsContainer.childElementCount === 0 });
}
render() {
return (
<FocusableContext.Provider value={this.state.focusable}>
{React.cloneElement(React.Children.only(this.props.children), { ref: this.routeContentRef })}
</FocusableContext.Provider>
);
}
}
const RouteFocusableProviderWithModalsContainer = withModalsContainer(RouteFocusableProvider);
RouteFocusableProviderWithModalsContainer.displayName = 'RouteFocusableProviderWithModalsContainer';
export default RouteFocusableProviderWithModalsContainer;

View file

@ -0,0 +1,3 @@
import Route from './Route';
export default Route;

View file

@ -0,0 +1,30 @@
.route {
position: relative;
z-index: 0;
width: 100%;
height: 100%;
overflow: hidden;
.route-content {
width: 100%;
height: 100%;
}
.modals-container {
width: 0;
height: 0;
>:global(.modal-container) {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
}
}
&:not(:last-child) {
display: none;
}
}

View file

@ -1,7 +1,8 @@
import React, { Component, Fragment } from 'react';
import React, { Component } from 'react';
import pathToRegexp from 'path-to-regexp';
import PathUtils from 'path';
import UrlUtils from 'url';
import Route from './Route';
class Router extends Component {
constructor(props) {
@ -44,7 +45,8 @@ class Router extends Component {
}
shouldComponentUpdate(nextProps, nextState) {
return nextState.views !== this.state.views;
return nextState.views !== this.state.views ||
nextProps.className !== this.props.className;
}
onLocationChanged = () => {
@ -95,13 +97,15 @@ class Router extends Component {
render() {
return (
<Fragment>
<div className={this.props.className}>
{
this.state.views
.filter(({ element }) => React.isValidElement(element))
.map(({ path, element }) => <div key={path} className={this.props.routeContainerClassName}>{element}</div>)
.map(({ path, element }) => (
<Route key={path}>{element}</Route>
))
}
</Fragment>
</div>
);
}
}

View file

@ -1,7 +1,7 @@
import Checkbox from './Checkbox';
import Popup from './Popup';
import NavBar from './NavBar';
import Modal from './Modal';
import { ModalsContainerContext, Modal, withModalsContainer } from './Modal';
import MetadataItem from './MetadataItem';
import Router from './Router';
import LibraryItemList from './LibraryItemList';
@ -9,17 +9,24 @@ import MetaItem from './MetaItem';
import ShareAddon from './ShareAddon';
import UserPanel from './UserPanel';
import Slider from './Slider';
import { FocusableContext, withFocusable } from './Focusable';
import Button from './Button';
export {
Checkbox,
Popup,
NavBar,
ModalsContainerContext,
Modal,
withModalsContainer,
MetadataItem,
Router,
LibraryItemList,
MetaItem,
ShareAddon,
UserPanel,
Slider
Slider,
FocusableContext,
withFocusable,
Button
};

View file

@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './app';
import App from './App';
ReactDOM.render(<App />, document.getElementById('app'));

View file

@ -1,10 +1,68 @@
import React, { PureComponent } from 'react';
import { MetaItem } from 'stremio-common';
import styles from './styles';
class Board extends PureComponent {
constructor(props) {
super(props);
this.items = {
cw: [
{
id: 'cw1',
posterShape: 'poster',
type: 'movie',
progress: 0.4,
title: 'Movie title'
}
]
};
this.cwMenu = [
{
label: 'Play',
onSelect: (event) => {
console.log('Play', {
defaultPrevented: event.isDefaultPrevented(),
propagationStopped: event.isPropagationStopped()
});
}
},
{
label: 'Dismiss',
onSelect: (event) => {
console.log('Dismiss', {
defaultPrevented: event.isDefaultPrevented(),
propagationStopped: event.isPropagationStopped()
});
}
}
];
}
onClick = (event) => {
console.log('onClick', {
id: event.currentTarget.dataset.metaItemId,
defaultPrevented: event.isDefaultPrevented(),
propagationStopped: event.isPropagationStopped()
});
}
render() {
return (
<div style={{ paddingTop: 40, color: 'yellow' }}>
Board
<div className={styles['board-container']}>
<div className={styles['continue-watching-row']}>
{this.items.cw.map((props) => (
<MetaItem
key={props.id}
className={styles['meta-item-container']}
popupClassName={styles['meta-item-popup-container']}
relativeSize={'height'}
menu={this.cwMenu}
onClick={this.onClick}
{...props}
/>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,30 @@
.board-container, .meta-item-popup-container {
--poster-relative-size: 180px;
--progress-bar-size: 6px;
font-size: 12px;
.continue-watching-row {
--poster-relative-size: calc(180px * var(--poster-shape-ratio));
}
.search-row {
--poster-relative-size: 220px;
}
}
.board-container {
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
.continue-watching-row, .search-row {
display: flex;
flex-direction: row;
align-items: stretch;
}
.meta-item-container {
margin: 10px;
}
}

View file

@ -1,5 +1,4 @@
import React, { Component } from 'react';
import { Switch, Route } from 'react-router-dom';
import { NavBar } from 'stremio-common';
import { Board, Discover, Library, Calendar, Search } from 'stremio-routes';
import styles from './styles';

View file

@ -15,6 +15,8 @@ const ControlBarButton = React.forwardRef(({ icon, active, disabled, onClick },
</div>
));
ControlBarButton.displayName = 'ControlBarButton';
class ControlBar extends Component {
constructor(props) {
super(props);
@ -27,6 +29,7 @@ class ControlBar extends Component {
shouldComponentUpdate(nextProps, nextState) {
return nextProps.className !== this.props.className ||
nextProps.popupClassName !== this.props.popupClassName ||
nextProps.paused !== this.props.paused ||
nextProps.time !== this.props.time ||
nextProps.duration !== this.props.duration ||
@ -94,7 +97,7 @@ class ControlBar extends Component {
renderShareButton() {
return (
<Popup className={'player-popup-container'} border={true} onOpen={this.onSharePopupOpen} onClose={this.onSharePopupClose}>
<Popup className={classnames(styles['popup-container'], this.props.popupClassName)} border={true} onOpen={this.onSharePopupOpen} onClose={this.onSharePopupClose}>
<Popup.Label>
<ControlBarButton
icon={'ic_share'}
@ -110,7 +113,7 @@ class ControlBar extends Component {
renderSubtitlesButton() {
return (
<Popup className={'player-popup-container'} border={true} onOpen={this.onSubtitlesPopupOpen} onClose={this.onSubtitlesPopupClose}>
<Popup className={classnames(styles['popup-container'], this.props.popupClassName)} border={true} onOpen={this.onSubtitlesPopupOpen} onClose={this.onSubtitlesPopupClose}>
<Popup.Label>
<ControlBarButton
icon={'ic_sub'}
@ -151,6 +154,7 @@ class ControlBar extends Component {
ControlBar.propTypes = {
className: PropTypes.string,
popupClassName: PropTypes.string,
paused: PropTypes.bool,
time: PropTypes.number,
duration: PropTypes.number,

View file

@ -87,7 +87,7 @@
}
}
:global(.player-popup-container) {
.popup-container {
--border-color: var(--color-surfacelighter);
.popup-content {

View file

@ -40,13 +40,13 @@ class Player extends Component {
}
componentDidMount() {
this.dispatch('command', 'load', this.props.stream, {});
this.dispatch('command', 'addSubtitleTracks', [{
url: 'https://raw.githubusercontent.com/caitp/ng-media/master/example/assets/captions/bunny-en.vtt',
origin: 'Github',
label: 'English'
}]);
this.dispatch('setProp', 'selectedSubtitleTrackId', 'https://raw.githubusercontent.com/caitp/ng-media/master/example/assets/captions/bunny-en.vtt');
this.dispatch('command', 'load', this.props.stream, {});
}
onEnded = () => {
@ -98,6 +98,7 @@ class Player extends Component {
return (
<ControlBar
className={classnames(styles['layer'], styles['control-bar-layer'])}
popupClassName={styles['control-bar-popup-container']}
paused={this.state.paused}
time={this.state.time}
duration={this.state.duration}

View file

@ -1,4 +1,4 @@
.player-container, :global(.player-popup-container) {
.player-container, .control-bar-popup-container {
--control-bar-button-size: 60px;
}

View file

@ -1,6 +0,0 @@
import StremioAPI from 'stremio-api-client';
import storage from './storage';
// const API = new StremioAPI({ storage });
export default {};

View file

@ -1,3 +0,0 @@
import API from './API';
export default API;

View file

@ -1,21 +0,0 @@
const storage = {
getUser: function() {
try {
return JSON.parse(localStorage.getItem('user'));
} catch (e) {
return null;
}
},
setUser: function(user) {
try {
if (user === null) {
localStorage.removeItem('user');
} else {
localStorage.setItem('user', JSON.stringify(user));
}
} catch (e) {
}
}
};
export default storage;

View file

@ -1,7 +0,0 @@
import { AddonCollection } from 'stremio-addon-client';
import officialAddons from 'stremio-official-addons';
// const addons = new AddonCollection();
// addons.load(officialAddons);
export default {};

View file

@ -1,3 +0,0 @@
import addons from './addons';
export default addons;

View file

@ -1,7 +0,0 @@
import addons from './addons';
import API from './API';
export {
addons,
API
};

991
yarn.lock

File diff suppressed because it is too large Load diff