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 (