diff --git a/package.json b/package.json
index a0be620d9..fb124a437 100755
--- a/package.json
+++ b/package.json
@@ -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"
}
-}
+}
\ No newline at end of file
diff --git a/src/App/App.js b/src/App/App.js
new file mode 100644
index 000000000..a284c9e27
--- /dev/null
+++ b/src/App/App.js
@@ -0,0 +1,12 @@
+import React, { StrictMode } from 'react';
+import { Router } from 'stremio-common';
+import routerConfig from './routerConfig';
+import styles from './styles';
+
+const App = () => (
+
+
+
+);
+
+export default App;
diff --git a/src/App/index.js b/src/App/index.js
new file mode 100644
index 000000000..f1f2a246e
--- /dev/null
+++ b/src/App/index.js
@@ -0,0 +1,3 @@
+import App from './App';
+
+export default App;
diff --git a/src/app/routerConfig.js b/src/App/routerConfig.js
similarity index 100%
rename from src/app/routerConfig.js
rename to src/App/routerConfig.js
diff --git a/src/app/styles.less b/src/App/styles.less
similarity index 50%
rename from src/app/styles.less
rename to src/App/styles.less
index 054ae2fce..da0ea8c21 100644
--- a/src/app/styles.less
+++ b/src/App/styles.less
@@ -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);
+ }
}
\ No newline at end of file
diff --git a/src/app/app.js b/src/app/app.js
deleted file mode 100644
index e69e0b6ea..000000000
--- a/src/app/app.js
+++ /dev/null
@@ -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 (
-
-
-
- );
- }
-}
-
-export default App;
diff --git a/src/app/index.js b/src/app/index.js
deleted file mode 100644
index ee338bcf0..000000000
--- a/src/app/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import App from './app';
-
-export default App;
diff --git a/src/common/Button/Button.js b/src/common/Button/Button.js
new file mode 100644
index 000000000..e33803e07
--- /dev/null
+++ b/src/common/Button/Button.js
@@ -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 (
+
+ );
+ }
+}
+
+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) => (
+
+));
+
+ButtonWithForwardedRef.displayName = 'ButtonWithForwardedRef';
+
+export default ButtonWithForwardedRef;
diff --git a/src/common/Button/index.js b/src/common/Button/index.js
new file mode 100644
index 000000000..803f51fbb
--- /dev/null
+++ b/src/common/Button/index.js
@@ -0,0 +1,3 @@
+import Button from './Button';
+
+export default Button;
diff --git a/src/common/Focusable/FocusableContext.js b/src/common/Focusable/FocusableContext.js
new file mode 100644
index 000000000..2bb527728
--- /dev/null
+++ b/src/common/Focusable/FocusableContext.js
@@ -0,0 +1,7 @@
+import React from 'react';
+
+const FocusableContext = React.createContext(false);
+
+FocusableContext.displayName = 'FocusableContext';
+
+export default FocusableContext;
diff --git a/src/common/Focusable/index.js b/src/common/Focusable/index.js
new file mode 100644
index 000000000..b6def29fb
--- /dev/null
+++ b/src/common/Focusable/index.js
@@ -0,0 +1,7 @@
+import FocusableContext from './FocusableContext';
+import withFocusable from './withFocusable';
+
+export {
+ FocusableContext,
+ withFocusable
+};
diff --git a/src/common/Focusable/withFocusable.js b/src/common/Focusable/withFocusable.js
new file mode 100644
index 000000000..b0a3bb1ef
--- /dev/null
+++ b/src/common/Focusable/withFocusable.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import FocusableContext from './FocusableContext';
+
+const withFocusable = (Component) => {
+ return function withFocusable(props) {
+ return (
+
+ {focusable => }
+
+ );
+ };
+};
+
+export default withFocusable;
diff --git a/src/common/MetaItem/MetaItem.js b/src/common/MetaItem/MetaItem.js
index 01ded0860..611855980 100644
--- a/src/common/MetaItem/MetaItem.js
+++ b/src/common/MetaItem/MetaItem.js
@@ -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 (
-
- );
-}
-
-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 (
- {episode}
- );
-}
-
-const renderTitle = (title) => {
- if (title.length === 0) {
- return null;
+ onMenuPopupOpen = () => {
+ this.setState({ menuPopupOpen: true });
}
- return (
- {title}
- );
-}
-
-const renderReleaseInfo = (releaseInfo) => {
- if (releaseInfo.length === 0) {
- return null;
+ onMenuPopupClose = () => {
+ this.setState({ menuPopupOpen: false });
}
- return (
- {releaseInfo}
- );
-}
+ onClick = (event) => {
+ if (typeof this.props.onClick === 'function') {
+ this.props.onClick(event);
+ }
+ }
-const renderPopupIcon = (onItemClicked, popup) => {
- if (!popup) {
- return null;
- }
-
- return (
-
-
-
- );
-}
+ 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 (
+
+ );
}
- 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 (
+
+
+
+ {this.renderProgress()}
+
+ );
+ }
- return (
-
-
-
-
+ renderInfoBar() {
+ if (this.props.title.length === 0 && this.props.subtitle.length === 0 && this.props.menu.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
{this.props.title}
+ {
+ this.props.menu.length > 0 ?
+
+
+
+
+
+
+ {this.props.menu.map(({ label, onSelect }) => (
+
+ ))}
+
+
+
+ :
+ null
+ }
-
- {renderProgress(props.progress)}
-
- {renderEpisode(props.episode)}
- {renderTitle(props.title)}
- {renderReleaseInfo(props.releaseInfo)}
-
- {renderPopupIcon(props.onItemClicked, props.popup)}
-
- );
+ {
+ this.props.subtitle.length > 0 ?
+
+
{this.props.subtitle}
+
+ :
+ null
+ }
+
+ );
+ }
+
+ render() {
+ return (
+
+ );
+ }
}
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;
\ No newline at end of file
+export default MetaItem;
diff --git a/src/common/MetaItem/constants.json b/src/common/MetaItem/constants.json
deleted file mode 100644
index b477ccb3d..000000000
--- a/src/common/MetaItem/constants.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "RELATIVE_POSTER_SIZE": 138
-}
\ No newline at end of file
diff --git a/src/common/MetaItem/styles.less b/src/common/MetaItem/styles.less
index fa53551c5..a18020e29 100644
--- a/src/common/MetaItem/styles.less
+++ b/src/common/MetaItem/styles.less
@@ -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";
}
\ No newline at end of file
diff --git a/src/common/Modal/Modal.js b/src/common/Modal/Modal.js
index df4a94fd7..233a86544 100644
--- a/src/common/Modal/Modal.js
+++ b/src/common/Modal/Modal.js
@@ -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(
, 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(
+
+
+ {children}
+
+ ,
+ modalsContainer
+ );
+};
+
+const ModalWithModalsContainer = withModalsContainer(Modal);
+
+ModalWithModalsContainer.displayName = 'ModalWithModalsContainer';
+
+export default ModalWithModalsContainer;
diff --git a/src/common/Modal/ModalsContainerContext.js b/src/common/Modal/ModalsContainerContext.js
new file mode 100644
index 000000000..f6f2bca02
--- /dev/null
+++ b/src/common/Modal/ModalsContainerContext.js
@@ -0,0 +1,8 @@
+import React from 'react';
+
+const ModalsContainerContext = React.createContext(null);
+
+ModalsContainerContext.displayName = 'ModalsContainerContext';
+
+export default ModalsContainerContext;
+
diff --git a/src/common/Modal/index.js b/src/common/Modal/index.js
index 8144af51b..5e49d08e3 100644
--- a/src/common/Modal/index.js
+++ b/src/common/Modal/index.js
@@ -1,3 +1,9 @@
+import ModalsContainerContext from './ModalsContainerContext';
import Modal from './Modal';
+import withModalsContainer from './withModalsContainer';
-export default Modal;
+export {
+ ModalsContainerContext,
+ Modal,
+ withModalsContainer
+};
diff --git a/src/common/Modal/withModalsContainer.js b/src/common/Modal/withModalsContainer.js
new file mode 100644
index 000000000..5d25bdd10
--- /dev/null
+++ b/src/common/Modal/withModalsContainer.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import ModalsContainerContext from './ModalsContainerContext';
+
+const withModalsContainer = (Component) => {
+ return function withModalsContainer(props) {
+ return (
+
+ {modalsContainer => }
+
+ );
+ };
+};
+
+export default withModalsContainer;
diff --git a/src/common/NavBar/NavTab/NavTab.js b/src/common/NavBar/NavTab/NavTab.js
index 1e30b97ef..4e11a5030 100644
--- a/src/common/NavBar/NavTab/NavTab.js
+++ b/src/common/NavBar/NavTab/NavTab.js
@@ -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';
diff --git a/src/common/NavBar/SearchInput/SearchInput.js b/src/common/NavBar/SearchInput/SearchInput.js
index 1bdf4e9f6..1ece5e91a 100644
--- a/src/common/NavBar/SearchInput/SearchInput.js
+++ b/src/common/NavBar/SearchInput/SearchInput.js
@@ -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;
diff --git a/src/common/Popup/Label.js b/src/common/Popup/Label.js
index 6971481bd..b4a7a1849 100644
--- a/src/common/Popup/Label.js
+++ b/src/common/Popup/Label.js
@@ -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;
diff --git a/src/common/Popup/Menu.js b/src/common/Popup/Menu.js
index 3de5df137..26d3ce9e4 100644
--- a/src/common/Popup/Menu.js
+++ b/src/common/Popup/Menu.js
@@ -1,7 +1,11 @@
import React from 'react';
-const Menu = ({ children }) => {
- return React.Children.only(children);
-};
+const Menu = React.forwardRef(({ children }, ref) => (
+
+ {children}
+
+));
+
+Menu.displayName = 'Popup.Menu';
export default Menu;
diff --git a/src/common/Popup/Popup.js b/src/common/Popup/Popup.js
index 6ad80d7bb..e02029595 100644
--- a/src/common/Popup/Popup.js
+++ b/src/common/Popup/Popup.js
@@ -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 (
-
-
-
-
- {children}
+
+
+
+
+ {React.cloneElement(children, { ref: this.menuChildrenRef })}
+
+
+
+
-
-
-
-
+
+
+
+
+
-
-
-
-
-
);
}
diff --git a/src/common/Popup/styles.less b/src/common/Popup/styles.less
index daa8bacf6..e70dbcb99 100644
--- a/src/common/Popup/styles.less
+++ b/src/common/Popup/styles.less
@@ -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;
+ }
}
}
\ No newline at end of file
diff --git a/src/common/Router/Route/ModalsContainerProvider.js b/src/common/Router/Route/ModalsContainerProvider.js
new file mode 100644
index 000000000..44f2ee914
--- /dev/null
+++ b/src/common/Router/Route/ModalsContainerProvider.js
@@ -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 (
+
+ {this.state.modalsContainer ? this.props.children : null}
+
+
+ );
+ }
+}
+
+ModalsContainerProvider.propTypes = {
+ modalsContainerClassName: PropTypes.string
+};
+
+export default ModalsContainerProvider;
diff --git a/src/common/Router/Route/Route.js b/src/common/Router/Route/Route.js
new file mode 100644
index 000000000..8b5c233a5
--- /dev/null
+++ b/src/common/Router/Route/Route.js
@@ -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 (
+
+
+
+
+ {this.props.children}
+
+
+
+
+ );
+ }
+}
+
+export default Route;
diff --git a/src/common/Router/Route/RouteFocusableProvider.js b/src/common/Router/Route/RouteFocusableProvider.js
new file mode 100644
index 000000000..5b2f91f54
--- /dev/null
+++ b/src/common/Router/Route/RouteFocusableProvider.js
@@ -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 (
+
+ {React.cloneElement(React.Children.only(this.props.children), { ref: this.routeContentRef })}
+
+ );
+ }
+}
+
+const RouteFocusableProviderWithModalsContainer = withModalsContainer(RouteFocusableProvider);
+
+RouteFocusableProviderWithModalsContainer.displayName = 'RouteFocusableProviderWithModalsContainer';
+
+export default RouteFocusableProviderWithModalsContainer;
diff --git a/src/common/Router/Route/index.js b/src/common/Router/Route/index.js
new file mode 100644
index 000000000..5036f4e5d
--- /dev/null
+++ b/src/common/Router/Route/index.js
@@ -0,0 +1,3 @@
+import Route from './Route';
+
+export default Route;
diff --git a/src/common/Router/Route/styles.less b/src/common/Router/Route/styles.less
new file mode 100644
index 000000000..3ea772dee
--- /dev/null
+++ b/src/common/Router/Route/styles.less
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/common/Router/Router.js b/src/common/Router/Router.js
index ec07216d1..9d32f3def 100644
--- a/src/common/Router/Router.js
+++ b/src/common/Router/Router.js
@@ -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 (
-
+
{
this.state.views
.filter(({ element }) => React.isValidElement(element))
- .map(({ path, element }) =>
{element}
)
+ .map(({ path, element }) => (
+
{element}
+ ))
}
-
+
);
}
}
diff --git a/src/common/index.js b/src/common/index.js
index 9487eaa0c..c2f325279 100644
--- a/src/common/index.js
+++ b/src/common/index.js
@@ -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
};
diff --git a/src/index.js b/src/index.js
index f7a459979..a22f17594 100755
--- a/src/index.js
+++ b/src/index.js
@@ -1,5 +1,5 @@
import React from 'react';
import ReactDOM from 'react-dom';
-import App from './app';
+import App from './App';
ReactDOM.render(, document.getElementById('app'));
diff --git a/src/routes/Board/Board.js b/src/routes/Board/Board.js
index feb30a9be..aa771f47a 100644
--- a/src/routes/Board/Board.js
+++ b/src/routes/Board/Board.js
@@ -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 (
-
- Board
+
+
+ {this.items.cw.map((props) => (
+
+ ))}
+
);
}
diff --git a/src/routes/Board/styles.less b/src/routes/Board/styles.less
new file mode 100644
index 000000000..3c23aa645
--- /dev/null
+++ b/src/routes/Board/styles.less
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/routes/Main/Main.js b/src/routes/Main/Main.js
index 944dcb75f..d6c60556d 100644
--- a/src/routes/Main/Main.js
+++ b/src/routes/Main/Main.js
@@ -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';
diff --git a/src/routes/Player/ControlBar/ControlBar.js b/src/routes/Player/ControlBar/ControlBar.js
index 8c0f83f81..3405bb85b 100644
--- a/src/routes/Player/ControlBar/ControlBar.js
+++ b/src/routes/Player/ControlBar/ControlBar.js
@@ -15,6 +15,8 @@ const ControlBarButton = React.forwardRef(({ icon, active, disabled, onClick },
));
+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 (
-
+
+
{
@@ -98,6 +98,7 @@ class Player extends Component {
return (