diff --git a/images/stremio_symbol.png b/images/stremio_symbol.png new file mode 100644 index 000000000..11093da31 Binary files /dev/null and b/images/stremio_symbol.png differ diff --git a/package.json b/package.json index d2f4b81e8..03b12da14 100755 --- a/package.json +++ b/package.json @@ -20,10 +20,11 @@ "prop-types": "15.7.2", "react": "16.9.0", "react-dom": "16.9.0", + "react-focus-lock": "2.1.1", "spatial-navigation-polyfill": "git+ssh://git@github.com/NikolaBorislavovHristov/spatial-navigation.git#964d09bf2b0853e27af6c25924b595d6621a019d", "stremio-colors": "git+ssh://git@github.com/Stremio/stremio-colors.git#v2.0.4", - "stremio-icons": "git+ssh://git@github.com/Stremio/stremio-icons.git#v1.0.11", "stremio-core-web": "git+ssh://git@github.com/stremio/stremio-core-web.git#v0.6.0", + "stremio-icons": "git+ssh://git@github.com/Stremio/stremio-icons.git#v1.0.11", "vtt.js": "0.13.0" }, "devDependencies": { @@ -54,4 +55,4 @@ "webpack-cli": "3.3.9", "webpack-dev-server": "3.8.1" } -} \ No newline at end of file +} diff --git a/src/common/Button/Button.js b/src/common/Button/Button.js index d163a3413..fa52ca1d3 100644 --- a/src/common/Button/Button.js +++ b/src/common/Button/Button.js @@ -1,16 +1,14 @@ const React = require('react'); const classnames = require('classnames'); -const useTabIndex = require('stremio/common/useTabIndex'); const styles = require('./styles'); const Button = React.forwardRef(({ children, ...props }, ref) => { - const tabIndex = useTabIndex(props.tabIndex, props.disabled); const onKeyUp = React.useCallback((event) => { if (typeof props.onKeyUp === 'function') { props.onKeyUp(event); } - if (event.key === 'Enter' && !event.nativeEvent.clickPrevented) { + if (event.key === 'Enter' && !event.nativeEvent.buttonClickPrevented) { event.currentTarget.click(); } }, [props.onKeyUp]); @@ -19,7 +17,7 @@ const Button = React.forwardRef(({ children, ...props }, ref) => { props.onMouseDown(event); } - if (!event.nativeEvent.blurPrevented) { + if (!event.nativeEvent.buttonBlurPrevented) { event.preventDefault(); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); @@ -29,10 +27,10 @@ const Button = React.forwardRef(({ children, ...props }, ref) => { return React.createElement( typeof props.href === 'string' && props.href.length > 0 ? 'a' : 'div', { + tabIndex: 0, ...props, ref, className: classnames(props.className, styles['button-container'], { 'disabled': props.disabled }), - tabIndex, onKeyUp, onMouseDown }, diff --git a/src/common/Checkbox/styles.less b/src/common/Checkbox/styles.less index 7efb48c73..38c9a4209 100644 --- a/src/common/Checkbox/styles.less +++ b/src/common/Checkbox/styles.less @@ -7,6 +7,8 @@ .icon { display: block; + width: 1rem; + height: 1rem; fill: var(--color-surfacelighter); } } \ No newline at end of file diff --git a/src/common/ColorInput/ColorInput.js b/src/common/ColorInput/ColorInput.js index ee5a37a10..87bf72e6b 100644 --- a/src/common/ColorInput/ColorInput.js +++ b/src/common/ColorInput/ColorInput.js @@ -1,60 +1,82 @@ const React = require('react'); const PropTypes = require('prop-types'); +const AColorPicker = require('a-color-picker'); +const Icon = require('stremio-icons/dom'); const { Modal } = require('stremio-router'); const Button = require('stremio/common/Button'); -const ColorPicker = require('stremio/common/ColorPicker'); const useBinaryState = require('stremio/common/useBinaryState'); -const Icon = require('stremio-icons/dom'); +const useDataset = require('stremio/common/useDataset'); +const ColorPicker = require('./ColorPicker'); const styles = require('./styles'); -const ColorInput = ({ className, id, value, onChange }) => { - const [colorInputVisible, showColorInput, closeColorInput] = useBinaryState(false); - const [selectedColor, setSelectedColor] = React.useState(value); +const COLOR_FORMAT = 'hexcss4'; - const confirmColorInput = React.useCallback((event) => { - if(typeof onChange === "function") { - event.nativeEvent.value = selectedColor; - onChange(event); - } - closeColorInput(); - }, [selectedColor, onChange]); - - React.useEffect(() => { - setSelectedColor(value); - }, [value, colorInputVisible]); - - const modalBackgroundOnClick = React.useCallback((event) => { - if(event.target === event.currentTarget) { - closeColorInput(); +const ColorInput = ({ className, value, onChange, ...props }) => { + value = AColorPicker.parseColor(value, COLOR_FORMAT); + const dataset = useDataset(props); + const [modalOpen, openModal, closeModal] = useBinaryState(false); + const [tempValue, setTempValue] = React.useState(value); + const pickerLabelOnClick = React.useCallback((event) => { + if (!event.nativeEvent.openModalPrevented) { + openModal(); } }, []); + const modalContainerOnClick = React.useCallback((event) => { + event.nativeEvent.openModalPrevented = true; + }, []); + const modalContainerOnMouseDown = React.useCallback((event) => { + if (!event.nativeEvent.closeModalPrevented) { + closeModal(); + } + }, []); + const modalContentOnMouseDown = React.useCallback((event) => { + event.nativeEvent.closeModalPrevented = true; + }, []); + const colorPickerOnInput = React.useCallback((event) => { + setTempValue(event.value); + }, []); + const submitButtonOnClick = React.useCallback((event) => { + if (typeof onChange === 'function') { + onChange({ + type: 'change', + value: tempValue, + dataset: dataset, + reactEvent: event, + nativeEvent: event.nativeEvent + }); + } + closeModal(); + }, [onChange, tempValue, dataset]); + React.useEffect(() => { + setTempValue(value); + }, [value, modalOpen]); return ( - - + + + + -

Choose a color:

- - : null } -
+ ); }; ColorInput.propTypes = { - className: PropTypes.string, - id: PropTypes.string.isRequired, value: PropTypes.string, onChange: PropTypes.func }; diff --git a/src/common/ColorPicker/ColorPicker.js b/src/common/ColorInput/ColorPicker/ColorPicker.js similarity index 56% rename from src/common/ColorPicker/ColorPicker.js rename to src/common/ColorInput/ColorPicker/ColorPicker.js index 62b65a36b..d27a1142f 100644 --- a/src/common/ColorPicker/ColorPicker.js +++ b/src/common/ColorInput/ColorPicker/ColorPicker.js @@ -1,11 +1,13 @@ const React = require('react'); const PropTypes = require('prop-types'); +const classnames = require('classnames'); const AColorPicker = require('a-color-picker'); +const styles = require('./styles'); -const COLOR_FORMAT = 'rgbacss'; +const COLOR_FORMAT = 'hexcss4'; // TODO implement custom picker which is keyboard accessible -const ColorPicker = ({ className, value, onChange }) => { +const ColorPicker = ({ className, value, onInput }) => { value = AColorPicker.parseColor(value, COLOR_FORMAT); const pickerRef = React.useRef(null); const pickerElementRef = React.useRef(null); @@ -17,29 +19,38 @@ const ColorPicker = ({ className, value, onChange }) => { showRGB: false, showAlpha: true }); + const clipboardPicker = pickerElementRef.current.querySelector('.a-color-picker-clipbaord'); + if (clipboardPicker instanceof HTMLElement) { + clipboardPicker.tabIndex = -1; + } }, []); React.useEffect(() => { - pickerRef.current.off('change'); pickerRef.current.on('change', (picker, color) => { - if (typeof onChange === 'function') { - onChange(AColorPicker.parseColor(color, COLOR_FORMAT)); + if (typeof onInput === 'function') { + onInput({ + type: 'input', + value: AColorPicker.parseColor(color, COLOR_FORMAT) + }); } }); - }, [onChange]); + return () => { + pickerRef.current.off('change'); + }; + }, [onInput]); React.useEffect(() => { if (AColorPicker.parseColor(pickerRef.current.color, COLOR_FORMAT) !== value) { pickerRef.current.color = value; } }, [value]); return ( -
+
); }; ColorPicker.propTypes = { className: PropTypes.string, value: PropTypes.string, - onChange: PropTypes.func + onInput: PropTypes.func }; module.exports = ColorPicker; diff --git a/src/common/ColorPicker/index.js b/src/common/ColorInput/ColorPicker/index.js similarity index 100% rename from src/common/ColorPicker/index.js rename to src/common/ColorInput/ColorPicker/index.js diff --git a/src/common/ColorInput/ColorPicker/styles.less b/src/common/ColorInput/ColorPicker/styles.less new file mode 100644 index 000000000..174895a99 --- /dev/null +++ b/src/common/ColorInput/ColorPicker/styles.less @@ -0,0 +1,19 @@ +.color-picker-container { + overflow: visible; + + * { + overflow: visible; + } + + :global(.a-color-picker-stack):not(:global(.a-color-picker-row-top)) canvas, :global(.a-color-picker-circle) { + border: solid thin var(--color-surfacedark); + } + + :global(.a-color-picker-circle) { + box-shadow: 0 0 .2rem var(--color-surfacedark); + } + + :global(.a-color-picker-clipbaord) { + pointer-events: none; + } +} \ No newline at end of file diff --git a/src/common/ColorInput/styles.less b/src/common/ColorInput/styles.less index c52eaaba4..8242c6e67 100644 --- a/src/common/ColorInput/styles.less +++ b/src/common/ColorInput/styles.less @@ -1,49 +1,81 @@ -.color-input-modal { - background-color: var(--color-backgrounddarker40); +.color-input-modal-container { display: flex; - flex-direction: column; - + flex-direction: row; + align-items: center; + justify-content: center; + pointer-events: auto; + background-color: var(--color-backgrounddarker40); .color-input-container { - position: relative; + flex: none; + display: flex; + flex-direction: column; + align-items: center; + max-width: 25rem; padding: 1rem; background-color: var(--color-surfacelighter); - margin: auto; + .header-container { + flex: none; + align-self: stretch; + display: flex; + flex-direction: row; + align-items: flex-start; - * { - overflow: visible; - } - - .x-icon { - position: absolute; - top: 1rem; - right: 1rem; - width: 1rem; - height: 1rem; - fill: var(--color-surfacedark); - } - - h1 { - font-size: 1.2rem; - } - - .color-input { - margin: 1rem auto 0 auto; - :global(.a-color-picker-stack):not(:global(.a-color-picker-row-top)) canvas, :global(.a-color-picker-circle) { - border: solid 1px var(--color-surfacedark); + .title { + flex: 1; + margin-right: 1rem; + font-size: 1.2rem; + max-height: 2.4em; } - :global(.a-color-picker-circle) { - box-shadow: 0 0 .2rem var(--color-surfacedark); + + .close-button-container { + flex: none; + width: 1.5rem; + height: 1.5rem; + padding: 0.25rem; + + &:hover, &:focus { + background-color: var(--color-surfacedark20); + } + + &:focus { + outline-color: var(--color-surfacedarker); + } + + .icon { + display: block; + width: 100%; + height: 100%; + fill: var(--color-surfacedarker); + } } } - .button { - text-align: center; - color: var(--color-surfacelighter); - background-color: var(--color-signal5); + .color-picker { + flex: none; + margin: 1rem; + } + + .submit-button-container { + flex: none; + align-self: stretch; padding: 1rem; - margin-top: 1rem; + background-color: var(--color-signal5); + + &:hover, &:focus { + filter: brightness(1.2); + } + + &:focus { + outline-color: var(--color-surfacedarker); + } + + .label { + max-height: 2.4em; + text-align: center; + color: var(--color-surfacelighter); + } } } } \ No newline at end of file diff --git a/src/common/Dropdown/Dropdown.js b/src/common/Dropdown/Dropdown.js deleted file mode 100644 index d9698785d..000000000 --- a/src/common/Dropdown/Dropdown.js +++ /dev/null @@ -1,100 +0,0 @@ -const React = require('react'); -const PropTypes = require('prop-types'); -const classnames = require('classnames'); -const Icon = require('stremio-icons/dom'); -const Button = require('stremio/common/Button'); -const Popup = require('stremio/common/Popup'); -const useBinaryState = require('stremio/common/useBinaryState'); -const styles = require('./styles'); - -// TODO rename to multiselect -const Dropdown = ({ className, menuClassName, menuMatchLabelWidth, renderLabel, name, selected, options, tabIndex, onOpen, onClose, onSelect }) => { - const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false); - const optionOnClick = React.useCallback((event) => { - if (typeof onSelect === 'function') { - onSelect(event); - } - - if (!event.nativeEvent.closeMenuPrevented) { - closeMenu(); - } - }, [onSelect]); - React.useEffect(() => { - if (menuOpen) { - if (typeof onOpen === 'function') { - onOpen(); - } - } else { - if (typeof onClose === 'function') { - onClose(); - } - } - }, [menuOpen, onOpen, onClose]); - return ( - ( - - )} - renderMenu={() => ( -
- { - Array.isArray(options) && options.length > 0 ? - options.map(({ label, value }) => ( - - )) - : - null - } -
- )} - /> - ); -}; - -Dropdown.propTypes = { - className: PropTypes.string, - menuClassName: PropTypes.string, - menuMatchLabelWidth: PropTypes.bool, - renderLabel: PropTypes.func, - name: PropTypes.string, - selected: PropTypes.arrayOf(PropTypes.string), - options: PropTypes.arrayOf(PropTypes.shape({ - label: PropTypes.string.isRequired, - value: PropTypes.string.isRequired - })), - tabIndex: PropTypes.number, - onOpen: PropTypes.func, - onClose: PropTypes.func, - onSelect: PropTypes.func -}; - -module.exports = Dropdown; diff --git a/src/common/Dropdown/index.js b/src/common/Dropdown/index.js deleted file mode 100644 index 3ac11be07..000000000 --- a/src/common/Dropdown/index.js +++ /dev/null @@ -1,3 +0,0 @@ -const Dropdown = require('./Dropdown'); - -module.exports = Dropdown; diff --git a/src/common/Dropdown/styles.less b/src/common/Dropdown/styles.less deleted file mode 100644 index af7f98ae0..000000000 --- a/src/common/Dropdown/styles.less +++ /dev/null @@ -1,74 +0,0 @@ -.dropdown-label-container { - display: flex; - flex-direction: row; - align-items: center; - padding: 0 1rem; - background-color: var(--color-backgroundlighter); - - &:hover, &:focus { - filter: brightness(1.2); - } - - &:global(.active) { - background-color: var(--color-surfacelight); - - .label { - color: var(--color-backgrounddarker); - } - - .icon { - fill: var(--color-backgrounddarker); - } - } - - .label { - flex: 1; - max-height: 2.4em; - color: var(--color-surfacelighter); - } - - .icon { - flex: none; - width: 1rem; - height: 1rem; - margin-left: 1rem; - fill: var(--color-surfacelighter); - } -} - -.dropdown-menu-container { - .dropdown-option-container { - display: flex; - flex-direction: row; - align-items: center; - padding: 1rem; - background-color: var(--color-backgroundlighter); - - &:global(.selected) { - background-color: var(--color-surfacedarker); - - .icon { - display: block; - } - } - - &:hover, &:focus { - background-color: var(--color-surfacedark); - } - - .label { - flex: 1; - max-height: 4.8em; - color: var(--color-surfacelighter); - } - - .icon { - flex: none; - display: none; - width: 1rem; - height: 1rem; - margin-left: 1rem; - fill: var(--color-surfacelighter); - } - } -} \ No newline at end of file diff --git a/src/common/Image/Image.js b/src/common/Image/Image.js new file mode 100644 index 000000000..98f4955e8 --- /dev/null +++ b/src/common/Image/Image.js @@ -0,0 +1,29 @@ +const React = require('react'); +const PropTypes = require('prop-types'); + +const Image = ({ className, src, alt, fallbackSrc, renderFallback }) => { + const [broken, setBroken] = React.useState(false); + const onError = React.useCallback(() => { + setBroken(true); + }, []); + React.useLayoutEffect(() => { + setBroken(false); + }, [src]); + return (broken || typeof src !== 'string' || src.length === 0) && (typeof renderFallback === 'function' || typeof fallbackSrc === 'string') ? + typeof renderFallback === 'function' ? + renderFallback() + : + {alt} + : + {alt}; +}; + +Image.propTypes = { + className: PropTypes.string, + src: PropTypes.string, + alt: PropTypes.string, + fallbackSrc: PropTypes.string, + renderFallback: PropTypes.func +}; + +module.exports = Image; diff --git a/src/common/Image/index.js b/src/common/Image/index.js new file mode 100644 index 000000000..abe561e57 --- /dev/null +++ b/src/common/Image/index.js @@ -0,0 +1,3 @@ +const Image = require('./Image'); + +module.exports = Image; diff --git a/src/common/MetaItem/MetaItem.js b/src/common/MetaItem/MetaItem.js index a25c905ea..d33b9efec 100644 --- a/src/common/MetaItem/MetaItem.js +++ b/src/common/MetaItem/MetaItem.js @@ -3,8 +3,11 @@ const PropTypes = require('prop-types'); const classnames = require('classnames'); const Icon = require('stremio-icons/dom'); const Button = require('stremio/common/Button'); -const PlayIconCircleCentered = require('stremio/common/PlayIconCircleCentered'); -const Dropdown = require('stremio/common/Dropdown'); +const Image = require('stremio/common/Image'); +const Multiselect = require('stremio/common/Multiselect'); +const useBinaryState = require('stremio/common/useBinaryState'); +const useDataset = require('stremio/common/useDataset'); +const PlayIconCircleCentered = require('./PlayIconCircleCentered'); const styles = require('./styles'); const ICON_FOR_TYPE = Object.assign(Object.create(null), { @@ -15,39 +18,48 @@ const ICON_FOR_TYPE = Object.assign(Object.create(null), { 'other': 'ic_movies' }); -const MetaItem = React.memo(({ className, id, type, name, posterShape, poster, title, subtitle, progress, playIcon, menuOptions, onSelect, menuOptionOnSelect }) => { - const [menuOpen, setMenuOpen] = React.useState(false); - const onOpen = React.useCallback(() => { - setMenuOpen(true); - }, []); - const onClose = React.useCallback(() => { - setMenuOpen(false); - }, []); +const MetaItem = React.memo(({ className, type, name, poster, posterShape, playIcon, progress, menuOptions, onSelect, menuOptionOnSelect, ...props }) => { + const dataset = useDataset(props); + const [menuOpen, onMenuOpen, onMenuClose] = useBinaryState(false); const metaItemOnClick = React.useCallback((event) => { if (!event.nativeEvent.selectMetaItemPrevented && typeof onSelect === 'function') { - onSelect(event); + onSelect({ + type: 'select', + dataset: dataset, + reactEvent: event, + nativeEvent: event.nativeEvent + }); } - }, [onSelect]); - const menuOnClick = React.useCallback((event) => { + }, [onSelect, dataset]); + const multiselectOnClick = React.useCallback((event) => { event.nativeEvent.selectMetaItemPrevented = true; }, []); + const multiselectOnSelect = React.useCallback((event) => { + if (typeof menuOptionOnSelect === 'function') { + menuOptionOnSelect({ + type: 'select-option', + dataset: dataset, + reactEvent: event.reactEvent, + nativeEvent: event.nativeEvent + }); + } + }, [menuOptionOnSelect, dataset]); return ( - - : - null }
); }; +MetaRow.Placeholder = MetaRowPlaceholder; + MetaRow.propTypes = { className: PropTypes.string, title: PropTypes.string, message: PropTypes.string, items: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, posterShape: PropTypes.string })), - itemMenuOptions: PropTypes.array + maximumItemsCount: PropTypes.number, + itemMenuOptions: PropTypes.any }; module.exports = MetaRow; diff --git a/src/common/MetaRow/MetaRowPlaceholder/MetaRowPlaceholder.js b/src/common/MetaRow/MetaRowPlaceholder/MetaRowPlaceholder.js new file mode 100644 index 000000000..a6d3bda30 --- /dev/null +++ b/src/common/MetaRow/MetaRowPlaceholder/MetaRowPlaceholder.js @@ -0,0 +1,30 @@ +const React = require('react'); +const PropTypes = require('prop-types'); +const classnames = require('classnames'); +const styles = require('./styles'); + +const MetaRowPlaceholder = ({ className, title, maximumItemsCount }) => { + maximumItemsCount = maximumItemsCount !== null && isFinite(maximumItemsCount) ? maximumItemsCount : 20; + return ( +
+
{title}
+
+ {Array(maximumItemsCount).fill(null).map((_, index) => ( +
+
+
+
+ ))} +
+
+
+ ); +}; + +MetaRowPlaceholder.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + maximumItemsCount: PropTypes.number +}; + +module.exports = MetaRowPlaceholder; diff --git a/src/common/MetaRowPlaceholder/index.js b/src/common/MetaRow/MetaRowPlaceholder/index.js similarity index 100% rename from src/common/MetaRowPlaceholder/index.js rename to src/common/MetaRow/MetaRowPlaceholder/index.js diff --git a/src/common/MetaRowPlaceholder/styles.less b/src/common/MetaRow/MetaRowPlaceholder/styles.less similarity index 57% rename from src/common/MetaRowPlaceholder/styles.less rename to src/common/MetaRow/MetaRowPlaceholder/styles.less index f060d73f6..8e563492c 100644 --- a/src/common/MetaRowPlaceholder/styles.less +++ b/src/common/MetaRow/MetaRowPlaceholder/styles.less @@ -7,13 +7,14 @@ .title-container { grid-area: title-area; - height: 2.4rem; + max-height: 2.4em; margin-bottom: 2rem; + font-size: 1.5rem; + color: var(--color-surface); - .title-label-container { - width: 60%; - height: 100%; - background-color: var(--color-placeholder); + &:empty { + height: 1.2em; + background: linear-gradient(to right, var(--color-placeholder) 0 40%, transparent 40% 100%); } } @@ -30,6 +31,16 @@ .poster-container { padding-bottom: calc(100% * var(--poster-shape-ratio)); } + + .title-bar-container { + height: 2.8rem; + background-color: var(--color-placeholder); + } } } + + .see-all-container { + grid-area: see-all-area; + background-color: var(--color-placeholder); + } } \ No newline at end of file diff --git a/src/common/MetaRow/styles.less b/src/common/MetaRow/styles.less index d5f2f954e..7cf253db9 100644 --- a/src/common/MetaRow/styles.less +++ b/src/common/MetaRow/styles.less @@ -5,6 +5,7 @@ "title-area title-area" "message-area message-area" "meta-items-area see-all-area"; + overflow: visible; .title-container { grid-area: title-area; @@ -19,10 +20,6 @@ max-height: 3.6em; font-size: 1.3rem; color: var(--color-surfacelighter); - - &~.meta-items-container, &~.see-all-container { - display: none; - } } .meta-items-container { @@ -30,6 +27,7 @@ display: flex; flex-direction: row; align-items: stretch; + overflow: visible; .meta-item { margin-right: 2rem; @@ -58,22 +56,18 @@ background-color: var(--color-backgroundlight); &:hover, &:focus { + outline-offset: 0; background-color: var(--color-backgroundlighter); - - .see-all-label { - color: var(--color-surfacelighter); - } - - .icon { - fill: var(--color-surfacelighter); - } } - .see-all-label { + .label { + flex-grow: 0; + flex-shrink: 1; + flex-basis: auto; max-height: 2.4em; font-size: 1.2rem; text-align: center; - color: var(--color-surfacelight); + color: var(--color-surfacelighter); } .icon { @@ -81,7 +75,7 @@ width: 1.3rem; height: 1.3rem; margin-left: 0.3rem; - fill: var(--color-surfacelight); + fill: var(--color-surfacelighter); } } } \ No newline at end of file diff --git a/src/common/MetaRowPlaceholder/MetaRowPlaceholder.js b/src/common/MetaRowPlaceholder/MetaRowPlaceholder.js deleted file mode 100644 index ca36a5298..000000000 --- a/src/common/MetaRowPlaceholder/MetaRowPlaceholder.js +++ /dev/null @@ -1,52 +0,0 @@ -const React = require('react'); -const PropTypes = require('prop-types'); -const classnames = require('classnames'); -const styles = require('./styles'); - -const MetaRowPlaceholder = ({ className }) => { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); -}; - -MetaRowPlaceholder.propTypes = { - className: PropTypes.string -}; - -module.exports = MetaRowPlaceholder; diff --git a/src/common/Multiselect/Multiselect.js b/src/common/Multiselect/Multiselect.js new file mode 100644 index 000000000..9f03f33e5 --- /dev/null +++ b/src/common/Multiselect/Multiselect.js @@ -0,0 +1,122 @@ +const React = require('react'); +const PropTypes = require('prop-types'); +const classnames = require('classnames'); +const Icon = require('stremio-icons/dom'); +const Button = require('stremio/common/Button'); +const Popup = require('stremio/common/Popup'); +const useBinaryState = require('stremio/common/useBinaryState'); +const useDataset = require('stremio/common/useDataset'); +const styles = require('./styles'); + +const Multiselect = ({ className, direction, title, renderLabelContent, options, selected, onOpen, onClose, onSelect, ...props }) => { + options = Array.isArray(options) ? + options.filter(option => option && typeof option.value === 'string') + : + []; + selected = Array.isArray(selected) ? + selected.filter(value => typeof value === 'string') + : + []; + const dataset = useDataset(props); + const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false); + const popupLabelOnClick = React.useCallback((event) => { + if (!event.nativeEvent.togglePopupPrevented) { + toggleMenu(); + } + }, [toggleMenu]); + const popupMenuOnClick = React.useCallback((event) => { + event.nativeEvent.togglePopupPrevented = true; + }, []); + const optionOnClick = React.useCallback((event) => { + if (typeof onSelect === 'function') { + onSelect({ + type: 'select', + reactEvent: event, + nativeEvent: event.nativeEvent, + dataset: dataset + }); + } + + if (!event.nativeEvent.closeMenuPrevented) { + closeMenu(); + } + }, [onSelect, dataset]); + React.useLayoutEffect(() => { + if (menuOpen) { + if (typeof onOpen === 'function') { + onOpen({ + type: 'open', + dataset: dataset + }); + } + } else { + if (typeof onClose === 'function') { + onClose({ + type: 'close', + dataset: dataset + }); + } + } + }, [menuOpen]); + return ( + ( + + )} + renderMenu={() => ( +
+ {options.map(({ label, value }) => ( + + ))} +
+ )} + /> + ); +}; + +Multiselect.propTypes = { + className: PropTypes.string, + direction: PropTypes.any, + title: PropTypes.string, + renderLabelContent: PropTypes.func, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.string.isRequired, + label: PropTypes.string + })), + selected: PropTypes.arrayOf(PropTypes.string), + onOpen: PropTypes.func, + onClose: PropTypes.func, + onSelect: PropTypes.func +}; + +module.exports = Multiselect; diff --git a/src/common/Multiselect/index.js b/src/common/Multiselect/index.js new file mode 100644 index 000000000..0df1069f6 --- /dev/null +++ b/src/common/Multiselect/index.js @@ -0,0 +1,3 @@ +const Multiselect = require('./Multiselect'); + +module.exports = Multiselect; diff --git a/src/common/Multiselect/styles.less b/src/common/Multiselect/styles.less new file mode 100644 index 000000000..c88dae55f --- /dev/null +++ b/src/common/Multiselect/styles.less @@ -0,0 +1,78 @@ +:import('~stremio/common/Popup/styles.less') { + popup-menu-container: menu-container; +} + +.label-container { + display: flex; + flex-direction: row; + align-items: center; + padding: 0 1rem; + background-color: var(--color-backgroundlighter); + + &:global(.active) { + background-color: var(--color-surfacelight); + + .label { + color: var(--color-backgrounddarker); + } + + .icon { + fill: var(--color-backgrounddarker); + } + } + + .label { + flex: 1; + max-height: 2.4em; + color: var(--color-surfacelighter); + } + + .icon { + flex: none; + width: 1rem; + height: 1rem; + margin-left: 1rem; + fill: var(--color-surfacelighter); + } + + .popup-menu-container { + width: 100%; + + .menu-container { + .option-container { + display: flex; + flex-direction: row; + align-items: center; + padding: 1rem; + background-color: var(--color-backgroundlighter); + + &:global(.selected) { + background-color: var(--color-surfacedarker); + + .icon { + display: block; + } + } + + &:hover, &:focus { + background-color: var(--color-surfacedark); + } + + .label { + flex: 1; + max-height: 4.8em; + color: var(--color-surfacelighter); + } + + .icon { + flex: none; + display: none; + width: 1rem; + height: 1rem; + margin-left: 1rem; + fill: var(--color-surfacelighter); + } + } + } + } +} \ No newline at end of file diff --git a/src/common/NavBar/NavMenu/NavMenu.js b/src/common/NavBar/NavMenu/NavMenu.js index f49ddf9ec..b8126116a 100644 --- a/src/common/NavBar/NavMenu/NavMenu.js +++ b/src/common/NavBar/NavMenu/NavMenu.js @@ -13,17 +13,27 @@ const NavMenu = ({ className }) => { const [menuOpen, openMenu, closeMenu, toggleMenu] = useBinaryState(false); const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen(); const user = useUser(); + const popupLabelOnClick = React.useCallback((event) => { + if (!event.nativeEvent.togglePopupPrevented) { + toggleMenu(); + } + }, [toggleMenu]); + const popupMenuOnClick = React.useCallback((event) => { + event.nativeEvent.togglePopupPrevented = true; + }, []); return ( ( - )} renderMenu={() => ( -
+
{ return null; }, [href]); - const active = useRouteActive(routeRegexp); + const routeActive = useRouteActive(routeRegexp); return ( - + ); } MuteButton.propTypes = { className: PropTypes.string, muted: PropTypes.bool, volume: PropTypes.number, - dispatch: PropTypes.func.isRequired + dispatch: PropTypes.func }; module.exports = MuteButton; diff --git a/src/routes/Player/ControlBar/PlayPauseButton/PlayPauseButton.js b/src/routes/Player/ControlBar/PlayPauseButton/PlayPauseButton.js index 891fff6ca..7c357047b 100644 --- a/src/routes/Player/ControlBar/PlayPauseButton/PlayPauseButton.js +++ b/src/routes/Player/ControlBar/PlayPauseButton/PlayPauseButton.js @@ -2,31 +2,28 @@ const React = require('react'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const Icon = require('stremio-icons/dom'); +const { Button } = require('stremio/common'); -class PlayPauseButton extends React.Component { - shouldComponentUpdate(nextProps, nextState) { - return nextProps.className !== this.props.className || - nextProps.paused !== this.props.paused; - } - - togglePaused = () => { - this.props.dispatch({ propName: 'paused', propValue: !this.props.paused }); - } - - render() { - const icon = this.props.paused === null || this.props.paused ? 'ic_play' : 'ic_pause'; - return ( -
- -
- ); - } -} +const PlayPauseButton = ({ className, paused, dispatch }) => { + const togglePaused = React.useCallback(() => { + if (typeof dispatch === 'function') { + dispatch({ propName: 'paused', propValue: !paused }); + } + }, [paused, dispatch]); + return ( + + ); +}; PlayPauseButton.propTypes = { className: PropTypes.string, paused: PropTypes.bool, - dispatch: PropTypes.func.isRequired + dispatch: PropTypes.func }; module.exports = PlayPauseButton; diff --git a/src/routes/Player/ControlBar/SeekBar/SeekBar.js b/src/routes/Player/ControlBar/SeekBar/SeekBar.js index b2e34d63d..d7481ec19 100644 --- a/src/routes/Player/ControlBar/SeekBar/SeekBar.js +++ b/src/routes/Player/ControlBar/SeekBar/SeekBar.js @@ -3,85 +3,51 @@ const PropTypes = require('prop-types'); const classnames = require('classnames'); const debounce = require('lodash.debounce'); const { Slider } = require('stremio/common'); +const formatTime = require('./formatTime'); const styles = require('./styles'); -class SeekBar extends React.Component { - constructor(props) { - super(props); - - this.state = { - time: null - }; - } - - shouldComponentUpdate(nextProps, nextState) { - return nextState.time !== this.state.time || - nextProps.className !== this.props.className || - nextProps.time !== this.props.time || - nextProps.duration !== this.props.duration; - } - - componentWillUnmount() { - this.resetTimeDebounced.cancel(); - } - - resetTimeDebounced = debounce(() => { - this.setState({ time: null }); - }, 1500) - - onSlide = (time) => { - this.resetTimeDebounced.cancel(); - this.setState({ time }); - } - - onComplete = (time) => { - this.resetTimeDebounced(); - this.setState({ time }); - this.props.dispatch({ propName: 'time', propValue: time }); - } - - onCancel = () => { - this.resetTimeDebounced.cancel(); - this.setState({ time: null }); - } - - formatTime = (time) => { - if (time === null || isNaN(time)) { - return '--:--:--'; +const SeekBar = ({ className, time, duration, dispatch }) => { + const [seekTime, setSeekTime] = React.useState(null); + const resetTimeDebounced = React.useCallback(debounce(() => { + setSeekTime(null); + }, 1500), []); + const onSlide = React.useCallback((time) => { + resetTimeDebounced.cancel(); + setSeekTime(time); + }, []); + const onComplete = React.useCallback((time) => { + resetTimeDebounced(); + setSeekTime(time); + if (typeof dispatch === 'function') { + dispatch({ propName: 'time', propValue: time }); } - - const hours = ('0' + Math.floor((time / (1000 * 60 * 60)) % 24)).slice(-2); - const minutes = ('0' + Math.floor((time / (1000 * 60)) % 60)).slice(-2); - const seconds = ('0' + Math.floor((time / 1000) % 60)).slice(-2); - return `${hours}:${minutes}:${seconds}`; - } - - render() { - const time = this.state.time !== null ? this.state.time : this.props.time; - return ( -
-
{this.formatTime(time)}
- -
{this.formatTime(this.props.duration)}
-
- ); - } -} + }, []); + React.useEffect(() => { + return () => { + resetTimeDebounced.cancel(); + }; + }, []); + return ( +
+
{formatTime(seekTime !== null ? seekTime : time)}
+ +
{formatTime(duration)}
+
+ ); +}; SeekBar.propTypes = { className: PropTypes.string, time: PropTypes.number, duration: PropTypes.number, - dispatch: PropTypes.func.isRequired + dispatch: PropTypes.func }; module.exports = SeekBar; diff --git a/src/routes/Player/ControlBar/SeekBar/formatTime.js b/src/routes/Player/ControlBar/SeekBar/formatTime.js new file mode 100644 index 000000000..004f88c26 --- /dev/null +++ b/src/routes/Player/ControlBar/SeekBar/formatTime.js @@ -0,0 +1,16 @@ +const formatUnit = (value) => { + return ('0' + value).slice(-1 * Math.max(value.toString().length, 2)); +}; + +const formatTime = (time) => { + if (time === null || isNaN(time)) { + return '--:--:--'; + } + + const hours = Math.floor(time / (1000 * 60 * 60)); + const minutes = Math.floor((time / (1000 * 60)) % 60); + const seconds = Math.floor((time / 1000) % 60); + return `${formatUnit(hours)}:${formatUnit(minutes)}:${formatUnit(seconds)}`; +}; + +module.exports = formatTime; diff --git a/src/routes/Player/ControlBar/SeekBar/styles.less b/src/routes/Player/ControlBar/SeekBar/styles.less index 0f15f4dd9..0c72177b0 100644 --- a/src/routes/Player/ControlBar/SeekBar/styles.less +++ b/src/routes/Player/ControlBar/SeekBar/styles.less @@ -1,35 +1,52 @@ +:import('~stremio/common/Slider/styles.less') { + slider-track: track; + slider-track-before: track-before; + slider-thumb: thumb; +} + .seek-bar-container { display: flex; flex-direction: row; align-items: center; + &:hover, &:global(.active) { + .slider:not(:global(.disabled)) { + .slider-track-before { + background-color: var(--color-primarylight); + } + + .slider-thumb { + fill: var(--color-surfacelighter); + } + } + } + + &:hover { + .slider:not(:global(.disabled)) { + .slider-thumb { + transition-delay: 100ms; + } + } + } + .label { - color: var(--color-surfacelight); + flex: none; + max-width: 5rem; + white-space: nowrap; + text-overflow: ellipsis; + direction: rtl; + text-align: left; + color: var(--color-surfacelighter); } .slider { - --thumb-size: var(--seek-bar-thumb-size); - --track-size: var(--seek-bar-track-size); - --track-before-color: var(--color-primary); - --track-color: var(--color-backgroundlighter); - --thumb-color: transparent; flex: 1; align-self: stretch; - margin: 0 var(--seek-bar-thumb-size); + margin: 0 var(--thumb-size); - &:global(.disabled) { - --track-color: var(--color-surfacedark); - } - } - - &:hover, &:global(.active) { - .label { - color: var(--color-surfacelighter); - } - - .slider { - --track-before-color: var(--color-primarylight); - --thumb-color: var(--color-surfacelighter); + .slider-thumb { + transition: 0s fill; + fill: transparent; } } } \ No newline at end of file diff --git a/src/routes/Player/ControlBar/ShareButton/ShareButton.js b/src/routes/Player/ControlBar/ShareButton/ShareButton.js index 8b9c00352..a17f4c4ca 100644 --- a/src/routes/Player/ControlBar/ShareButton/ShareButton.js +++ b/src/routes/Player/ControlBar/ShareButton/ShareButton.js @@ -2,47 +2,28 @@ const React = require('react'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const Icon = require('stremio-icons/dom'); -const { Popup } = require('stremio/common'); +const { Button, Popup, useBinaryState } = require('stremio/common'); const styles = require('./styles'); -class ShareButton extends React.Component { - constructor(props) { - super(props); - - this.state = { - popupOpen: false - }; - } - - shouldComponentUpdate(nextProps, nextState) { - return nextState.popupOpen !== this.state.popupOpen || - nextProps.className !== this.props.className || - nextProps.modalContainerClassName !== this.props.modalContainerClassName; - } - - onPopupOpen = () => { - this.setState({ popupOpen: true }); - } - - onPopupClose = () => { - this.setState({ popupOpen: false }); - } - - render() { - return ( - - -
- -
-
- -
- - - ); - } -} +const ShareButton = ({ className, modalContainerClassName }) => { + const [popupOpen, openPopup, closePopup, togglePopup] = useBinaryState(false); + return ( + ( + + )} + renderMenu={() => ( +
+ )} + onCloseRequest={closePopup} + /> + ); +}; ShareButton.propTypes = { className: PropTypes.string, diff --git a/src/routes/Player/ControlBar/ShareButton/styles.less b/src/routes/Player/ControlBar/ShareButton/styles.less index 2de50b10a..0c644a9ce 100644 --- a/src/routes/Player/ControlBar/ShareButton/styles.less +++ b/src/routes/Player/ControlBar/ShareButton/styles.less @@ -1,5 +1,6 @@ -.share-dialog-container { - width: calc(var(--control-bar-button-size) * 5); - height: calc(var(--control-bar-button-size) * 3); - background-color: var(--color-backgrounddark); +.share-modal-container { + .share-dialog-container { + width: 10rem; + height: 5rem; + } } \ No newline at end of file diff --git a/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesButton.js b/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesButton.js index a737f9c83..f0ad89a4b 100644 --- a/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesButton.js +++ b/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesButton.js @@ -2,65 +2,32 @@ const React = require('react'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const Icon = require('stremio-icons/dom'); -const { Popup } = require('stremio/common'); +const { Button, Popup, useBinaryState } = require('stremio/common'); const SubtitlesPicker = require('./SubtitlesPicker'); const styles = require('./styles'); -class SubtitlesButton extends React.Component { - constructor(props) { - super(props); - - this.state = { - popupOpen: false - }; - } - - shouldComponentUpdate(nextProps, nextState) { - return nextState.popupOpen !== this.state.popupOpen || - nextProps.className !== this.props.className || - nextProps.modalContainerClassName !== this.props.modalContainerClassName || - nextProps.subtitlesTracks !== this.props.subtitlesTracks || - nextProps.selectedSubtitlesTrackId !== this.props.selectedSubtitlesTrackId || - nextProps.subtitlesSize !== this.props.subtitlesSize || - nextProps.subtitlesDelay !== this.props.subtitlesDelay || - nextProps.subtitlesTextColor !== this.props.subtitlesTextColor || - nextProps.subtitlesBackgroundColor !== this.props.subtitlesBackgroundColor || - nextProps.subtitlesOutlineColor !== this.props.subtitlesOutlineColor; - } - - onPopupOpen = () => { - this.setState({ popupOpen: true }); - } - - onPopupClose = () => { - this.setState({ popupOpen: false }); - } - - render() { - return ( - - -
- -
-
- - - -
- ); - } -} +const SubtitlesButton = (props) => { + const [popupOpen, openPopup, closePopup, togglePopup] = useBinaryState(false); + return ( + ( + + )} + renderMenu={() => ( + + )} + onCloseRequest={closePopup} + /> + ); +}; SubtitlesButton.propTypes = { className: PropTypes.string, @@ -69,7 +36,7 @@ SubtitlesButton.propTypes = { id: PropTypes.string.isRequired, label: PropTypes.string.isRequired, origin: PropTypes.string.isRequired - })).isRequired, + })), selectedSubtitlesTrackId: PropTypes.string, subtitlesSize: PropTypes.number, subtitlesDelay: PropTypes.number, @@ -78,8 +45,5 @@ SubtitlesButton.propTypes = { subtitlesOutlineColor: PropTypes.string, dispatch: PropTypes.func.isRequired }; -SubtitlesButton.defaultProps = { - subtitlesTracks: Object.freeze([]) -}; module.exports = SubtitlesButton; diff --git a/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesPicker/SubtitlesPicker.js b/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesPicker/SubtitlesPicker.js index 785a54cfd..7e24eb44c 100644 --- a/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesPicker/SubtitlesPicker.js +++ b/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesPicker/SubtitlesPicker.js @@ -3,13 +3,16 @@ const PropTypes = require('prop-types'); const classnames = require('classnames'); const Icon = require('stremio-icons/dom'); const { Modal } = require('stremio-router'); -const { ColorPicker } = require('stremio/common'); +const { ColorInput } = require('stremio/common'); const styles = require('./styles'); const ORIGIN_PRIORITIES = Object.freeze({ 'LOCAL': 1, 'EMBEDDED': 2 }); +const LANGUAGE_PRIORITIES = Object.freeze({ + 'English': 1 +}); const SUBTITLES_SIZE_LABELS = Object.freeze({ 1: '75%', 2: '100%', @@ -27,13 +30,9 @@ const comparatorWithPriorities = (priorities) => { if (!isNaN(valueB)) return 1; return a - b; }; -} +}; const NumberInput = ({ value, label, delta, onChange }) => { - if (value === null) { - return null; - } - return (
@@ -47,100 +46,55 @@ const NumberInput = ({ value, label, delta, onChange }) => { ); }; -const SubtitlesColorPicker = ({ label, value, onChange }) => { - const [open, setOpen] = React.useState(false); - const onOpen = () => setOpen(true); - const onClose = () => setOpen(false); - if (value === null) { - return null; - } - - return ( - -
-
-
{label}
-
- { - open ? - -
- -
-
- : - null - } - - ); -}; - -class SubtitlesPicker extends React.Component { - shouldComponentUpdate(nextProps, nextState) { - return nextProps.className !== this.props.className || - nextProps.subtitlesTracks !== this.props.subtitlesTracks || - nextProps.selectedSubtitlesTrackId !== this.props.selectedSubtitlesTrackId || - nextProps.subtitlesSize !== this.props.subtitlesSize || - nextProps.subtitlesDelay !== this.props.subtitlesDelay || - nextProps.subtitlesTextColor !== this.props.subtitlesTextColor || - nextProps.subtitlesBackgroundColor !== this.props.subtitlesBackgroundColor || - nextProps.subtitlesOutlineColor !== this.props.subtitlesOutlineColor; - } - - toggleSubtitleEnabled = () => { - const selectedSubtitlesTrackId = this.props.selectedSubtitlesTrackId === null && this.props.subtitlesTracks.length > 0 ? - this.props.subtitlesTracks[0].id +const SubtitlesPicker = (props) => { + const toggleSubtitleEnabled = React.useCallback(() => { + const selectedSubtitlesTrackId = props.selectedSubtitlesTrackId === null && props.subtitlesTracks.length > 0 ? + props.subtitlesTracks[0].id : null; - this.props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: selectedSubtitlesTrackId }); - } - - labelOnClick = (event) => { - const subtitleTrack = this.props.subtitlesTracks.find(({ label, origin }) => { + props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: selectedSubtitlesTrackId }); + }, [props.selectedSubtitlesTrackId, props.subtitlesTracks, props.dispatch]); + const labelOnClick = React.useCallback((event) => { + const subtitleTrack = props.subtitlesTracks.find(({ label, origin }) => { return label === event.currentTarget.dataset.label && origin === event.currentTarget.dataset.origin; }); if (subtitleTrack) { - this.props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: subtitleTrack.id }); + props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: subtitleTrack.id }); } - } - - variantOnClick = (event) => { - this.props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: event.currentTarget.dataset.trackId }); - } - - setsubtitlesSize = (event) => { - this.props.dispatch({ propName: 'subtitlesSize', propValue: event.currentTarget.dataset.value }); - } - - setSubtitlesDelay = (event) => { - this.props.dispatch({ propName: 'subtitlesDelay', propValue: event.currentTarget.dataset.value }); - } - - setSubtitlesTextColor = (color) => { - this.props.dispatch({ propName: 'subtitlesTextColor', propValue: color }); - } - - setSubtitlesBackgroundColor = (color) => { - this.props.dispatch({ propName: 'subtitlesBackgroundColor', propValue: color }); - } - - setSubtitlesOutlineColor = (color) => { - this.props.dispatch({ propName: 'subtitlesOutlineColor', propValue: color }); - } - - renderToggleButton({ selectedTrack }) { - return ( -
+ }, [props.subtitlesTracks, props.dispatch]); + const variantOnClick = React.useCallback((event) => { + props.dispatch({ propName: 'selectedSubtitlesTrackId', propValue: event.currentTarget.dataset.trackId }); + }, [props.dispatch]); + const setsubtitlesSize = React.useCallback((event) => { + props.dispatch({ propName: 'subtitlesSize', propValue: event.currentTarget.dataset.value }); + }, [props.dispatch]); + const setSubtitlesDelay = React.useCallback((event) => { + props.dispatch({ propName: 'subtitlesDelay', propValue: event.currentTarget.dataset.value }); + }, [props.dispatch]); + const setSubtitlesTextColor = React.useCallback((event) => { + props.dispatch({ propName: 'subtitlesTextColor', propValue: event.nativeEvent.value }); + }, [props.dispatch]); + const setSubtitlesBackgroundColor = React.useCallback((color) => { + props.dispatch({ propName: 'subtitlesBackgroundColor', propValue: color }); + }, [props.dispatch]); + const setSubtitlesOutlineColor = React.useCallback((color) => { + props.dispatch({ propName: 'subtitlesOutlineColor', propValue: color }); + }, [props.dispatch]); + const selectedTrack = props.subtitlesTracks.find(({ id }) => id === props.selectedSubtitlesTrackId); + const groupedTracks = props.subtitlesTracks.reduce((result, track) => { + result[track.origin] = result[track.origin] || {}; + result[track.origin][track.label] = result[track.origin][track.label] || []; + result[track.origin][track.label].push(track); + return result; + }, {}); + return ( +
+
ON
OFF
- ); - } - - renderLabelsList({ groupedTracks, selectedTrack }) { - return (
{ Object.keys(groupedTracks) @@ -150,13 +104,13 @@ class SubtitlesPicker extends React.Component {
{origin}
{ Object.keys(groupedTracks[origin]) - .sort(comparatorWithPriorities(this.props.languagePriorities)) + .sort(comparatorWithPriorities(LANGUAGE_PRIORITIES)) .map((label) => { const selected = selectedTrack && selectedTrack.label === label && selectedTrack.origin === origin; return (
- ); - } - - renderVariantsList({ groupedTracks, selectedTrack }) { - if (groupedTracks[selectedTrack.origin][selectedTrack.label].length <= 1) { - return null; - } - - return ( -
- { - groupedTracks[selectedTrack.origin][selectedTrack.label].map((track, index) => ( -
+
Subtitles are disabled
+
+ : +
+
Preferences
+ { + groupedTracks[selectedTrack.origin][selectedTrack.label].length > 1 ? +
+ { + groupedTracks[selectedTrack.origin][selectedTrack.label].map((track, index) => ( +
+ )) + } +
+ : + null + } +
+ +
Text color
+
+ {/* - )) - } -
- ); - } - - renderPreferences({ groupedTracks, selectedTrack }) { - if (!selectedTrack) { - return ( -
-
Subtitles are disabled
-
- ); - } - - return ( -
-
Preferences
- {this.renderVariantsList({ groupedTracks, selectedTrack })} - - - - - -
- ); - } - - render() { - const selectedTrack = this.props.subtitlesTracks.find(({ id }) => id === this.props.selectedSubtitlesTrackId); - const groupedTracks = this.props.subtitlesTracks.reduce((result, track) => { - result[track.origin] = result[track.origin] || {}; - result[track.origin][track.label] = result[track.origin][track.label] || []; - result[track.origin][track.label].push(track); - return result; - }, {}); - - return ( -
- {this.renderToggleButton({ selectedTrack })} - {this.renderLabelsList({ groupedTracks, selectedTrack })} - {this.renderPreferences({ groupedTracks, selectedTrack })} -
- ); - } -} + */} + + +
+ } +
+ ); +}; SubtitlesPicker.propTypes = { className: PropTypes.string, - languagePriorities: PropTypes.objectOf(PropTypes.number).isRequired, + languagePriorities: PropTypes.objectOf(PropTypes.number), subtitlesTracks: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, label: PropTypes.string.isRequired, origin: PropTypes.string.isRequired - })).isRequired, + })), selectedSubtitlesTrackId: PropTypes.string, subtitlesSize: PropTypes.number, subtitlesDelay: PropTypes.number, subtitlesTextColor: PropTypes.string, subtitlesBackgroundColor: PropTypes.string, subtitlesOutlineColor: PropTypes.string, - dispatch: PropTypes.func.isRequired -}; -SubtitlesPicker.defaultProps = { - subtitlesTracks: Object.freeze([]), - languagePriorities: Object.freeze({ - English: 1 - }) + dispatch: PropTypes.func }; module.exports = SubtitlesPicker; diff --git a/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesPicker/styles.less b/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesPicker/styles.less index ac9bd7441..9b4537498 100644 --- a/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesPicker/styles.less +++ b/src/routes/Player/ControlBar/SubtitlesButton/SubtitlesPicker/styles.less @@ -1,6 +1,5 @@ .subtitles-picker-container { - width: calc(var(--subtitles-picker-button-size) * 14); - height: calc(var(--subtitles-picker-button-size) * 9); + --subtitles-picker-button-size: 2.5rem; font-size: calc(var(--subtitles-picker-button-size) * 0.45); padding: calc(var(--subtitles-picker-button-size) * 0.3); gap: calc(var(--subtitles-picker-button-size) * 0.3); diff --git a/src/routes/Player/ControlBar/SubtitlesButton/styles.less b/src/routes/Player/ControlBar/SubtitlesButton/styles.less index 89a0efb18..317825e56 100644 --- a/src/routes/Player/ControlBar/SubtitlesButton/styles.less +++ b/src/routes/Player/ControlBar/SubtitlesButton/styles.less @@ -1,4 +1,6 @@ -.subtitles-picker-container { - --subtitles-picker-button-size: calc(var(--control-bar-button-size) * 0.6); - background-color: var(--color-backgrounddark); +.subtitles-modal-container { + .subtitles-picker-container { + width: 35rem; + height: 25rem; + } } \ No newline at end of file diff --git a/src/routes/Player/ControlBar/VolumeSlider/VolumeSlider.js b/src/routes/Player/ControlBar/VolumeSlider/VolumeSlider.js index 96710f55c..595ebb96f 100644 --- a/src/routes/Player/ControlBar/VolumeSlider/VolumeSlider.js +++ b/src/routes/Player/ControlBar/VolumeSlider/VolumeSlider.js @@ -5,68 +5,43 @@ const debounce = require('lodash.debounce'); const { Slider } = require('stremio/common'); const styles = require('./styles'); -class VolumeSlider extends React.Component { - constructor(props) { - super(props); - - this.state = { - volume: null +const VolumeSlider = ({ className, volume, dispatch }) => { + const [slidingVolume, setSlidingVolume] = React.useState(null); + const resetVolumeDebounced = React.useCallback(debounce(() => { + setSlidingVolume(null); + }, 100), []); + const onSlide = React.useCallback((volume) => { + resetVolumeDebounced.cancel(); + setSlidingVolume(volume); + }, []); + const onComplete = React.useCallback((volume) => { + resetVolumeDebounced(); + setSlidingVolume(volume); + if (typeof dispatch === 'function') { + dispatch({ propName: 'volume', propValue: volume }); + } + }, []); + React.useEffect(() => { + return () => { + resetVolumeDebounced.cancel(); }; - } - - shouldComponentUpdate(nextProps, nextState) { - return nextState.volume !== this.state.volume || - nextProps.className !== this.props.className || - nextProps.volume !== this.props.volume; - } - - componentWillUnmount() { - this.resetVolumeDebounced.cancel(); - } - - resetVolumeDebounced = debounce(() => { - this.setState({ volume: null }); - }, 100) - - onSlide = (volume) => { - this.resetVolumeDebounced.cancel(); - this.setState({ volume }); - } - - onComplete = (volume) => { - this.resetVolumeDebounced(); - this.setState({ volume }); - this.props.dispatch({ propName: 'volume', propValue: volume }); - } - - onCancel = () => { - this.resetVolumeDebounced.cancel(); - this.setState({ volume: null }); - } - - render() { - const volume = this.state.volume !== null ? this.state.volume : this.props.volume; - return ( -
- -
- ); - } -} + }, []); + return ( + + ); +}; VolumeSlider.propTypes = { className: PropTypes.string, volume: PropTypes.number, - dispatch: PropTypes.func.isRequired + dispatch: PropTypes.func }; module.exports = VolumeSlider; diff --git a/src/routes/Player/ControlBar/VolumeSlider/styles.less b/src/routes/Player/ControlBar/VolumeSlider/styles.less index 1e2d3e563..156e86305 100644 --- a/src/routes/Player/ControlBar/VolumeSlider/styles.less +++ b/src/routes/Player/ControlBar/VolumeSlider/styles.less @@ -1,24 +1,11 @@ -.volume-slider-container { - padding: 0 calc(var(--volume-slider-thumb-size) / 2); - - .volume-slider { - --thumb-size: var(--volume-slider-thumb-size); - --track-size: var(--volume-slider-track-size); - --track-before-color: var(--color-primary); - --track-color: var(--color-backgroundlighter); - --thumb-color: transparent; - width: 100%; - height: 100%; - - &:global(.disabled) { - --track-color: var(--color-surfacedark); - } - } +:import('~stremio/common/Slider/styles.less') { + slider-track-before: track-before; +} +.volume-slider { &:hover, &:global(.active) { - .volume-slider { - --track-before-color: var(--color-primarylight); - --thumb-color: var(--color-surfacelighter); + .slider-track-before { + background-color: var(--color-primarylight); } } } \ No newline at end of file diff --git a/src/routes/Player/ControlBar/styles.less b/src/routes/Player/ControlBar/styles.less index 61afc2ec1..33b9fd7d6 100644 --- a/src/routes/Player/ControlBar/styles.less +++ b/src/routes/Player/ControlBar/styles.less @@ -1,72 +1,16 @@ +:import('./ShareButton/styles.less') { + share-dialog-container: share-dialog-container; +} + +:import('./SubtitlesButton/styles.less') { + subtitles-picker-container: subtitles-picker-container; +} + .control-bar-container { - padding: 0 calc(var(--control-bar-button-size) * 0.4); - display: flex; - flex-direction: column; - align-items: stretch; - - .seek-bar { - --seek-bar-thumb-size: calc(var(--control-bar-button-size) * 0.40); - --seek-bar-track-size: calc(var(--control-bar-button-size) * 0.12); - height: calc(var(--control-bar-button-size) * 0.6); - font-size: calc(var(--control-bar-button-size) * 0.35); - } - - .control-bar-buttons-container { - height: var(--control-bar-button-size); - display: flex; - flex-direction: row; - align-items: center; - - .control-bar-button { - width: var(--control-bar-button-size); - height: var(--control-bar-button-size); - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; - - :global(.icon) { - width: 60%; - height: 60%; - fill: var(--color-surfacelight); - overflow: visible; - } - - &:global(.active) { - background-color: var(--color-backgrounddarker); - - :global(.icon) { - fill: var(--color-surfacelighter); - } - } - - &:global(.disabled) { - cursor: default; - pointer-events: none; - - :global(.icon) { - fill: var(--color-surfacedark); - } - } - - &:hover:not(:global(.disabled)) { - :global(.icon) { - fill: var(--color-surfacelighter); - } - } - } - - .volume-slider { - --volume-slider-thumb-size: calc(var(--control-bar-button-size) * 0.36); - --volume-slider-track-size: calc(var(--control-bar-button-size) * 0.10); - width: calc(var(--control-bar-button-size) * 4); - height: var(--control-bar-button-size); - } - - .spacing { - flex: 1; - } - } + position: relative; + z-index: 0; + padding: 0 1.5rem; + overflow: visible; &::after { position: absolute; @@ -74,11 +18,69 @@ bottom: 0; left: 0; z-index: -1; - box-shadow: 0 0 calc(var(--control-bar-button-size) * 2) calc(var(--control-bar-button-size) * 2.3) var(--color-backgrounddarker); + box-shadow: 0 0 8rem 8rem var(--color-backgrounddarker); content: ""; } + + .seek-bar { + --thumb-size: 1.5rem; + height: 2.5rem; + } + + .control-bar-buttons-container { + display: flex; + flex-direction: row; + align-items: center; + + .control-bar-button { + flex: none; + width: 4rem; + height: 4rem; + display: flex; + justify-content: center; + align-items: center; + + &:global(.active) { + background-color: var(--color-backgrounddarker); + } + + &:global(.disabled) { + :global(.icon) { + fill: var(--color-surfacedark); + } + } + + :global(.icon) { + flex: none; + width: 2.5rem; + height: 2.5rem; + fill: var(--color-surfacelighter); + } + } + + .volume-slider { + --thumb-size: 1.25rem; + flex: none; + width: 16rem; + height: 4rem; + margin: 0 1rem; + } + + .spacing { + flex: 1; + } + } } .modal-container { - --border-color: var(--color-surfacelighter); + display: flex; + align-items: flex-end; + justify-content: flex-end; + padding: 8rem 1rem; + + .share-dialog-container, .subtitles-picker-container { + flex: none; + background-color: var(--color-backgroundlighter); + border: thin solid var(--color-surfacelighter20); + } } \ No newline at end of file diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 611cefeea..4f66fb1a4 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -1,63 +1,64 @@ const React = require('react'); -const PropTypes = require('prop-types'); const classnames = require('classnames'); +const { useSpreadState } = require('stremio/common'); const Video = require('./Video'); const BufferingLoader = require('./BufferingLoader'); const ControlBar = require('./ControlBar'); const styles = require('./styles'); -class Player extends React.Component { - constructor(props) { - super(props); - - this.videoRef = React.createRef(); - - this.state = { - paused: null, - time: null, - duration: null, - buffering: null, - volume: null, - muted: null, - subtitlesTracks: [], - selectedSubtitlesTrackId: null, - subtitlesSize: null, - subtitlesDelay: null, - subtitlesTextColor: null, - subtitlesBackgroundColor: null, - subtitlesOutlineColor: null +const Player = ({ urlParams }) => { + const videoRef = React.useRef(null); + const stream = React.useMemo(() => { + return { + // ytId: 'E4A0bcCQke0', + url: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4' }; - } - - shouldComponentUpdate(nextProps, nextState) { - return nextState.paused !== this.state.paused || - nextState.time !== this.state.time || - nextState.duration !== this.state.duration || - nextState.buffering !== this.state.buffering || - nextState.volume !== this.state.volume || - nextState.muted !== this.state.muted || - nextState.subtitlesTracks !== this.state.subtitlesTracks || - nextState.selectedSubtitlesTrackId !== this.state.selectedSubtitlesTrackId || - nextState.subtitlesSize !== this.state.subtitlesSize || - nextState.subtitlesDelay !== this.state.subtitlesDelay || - nextState.subtitlesTextColor !== this.state.subtitlesTextColor || - nextState.subtitlesBackgroundColor !== this.state.subtitlesBackgroundColor || - nextState.subtitlesOutlineColor !== this.state.subtitlesOutlineColor; - } - - componentDidMount() { - this.dispatch({ - commandName: 'load', - commandArgs: { - stream: this.props.stream, - ipc: window.shell - } + }, [urlParams.stream]); + const [state, setState] = useSpreadState({ + paused: null, + time: null, + duration: null, + buffering: null, + volume: null, + muted: null, + subtitlesTracks: [], + selectedSubtitlesTrackId: null, + subtitlesSize: null, + subtitlesDelay: null, + subtitlesTextColor: null, + subtitlesBackgroundColor: null, + subtitlesOutlineColor: null + }); + const dispatch = React.useCallback((args) => { + if (videoRef.current !== null) { + videoRef.current.dispatch(args); + } + }, []); + const onImplementationChanged = React.useCallback((manifest) => { + manifest.props.forEach((propName) => { + dispatch({ observedPropName: propName }); }); - this.dispatch({ + }, []); + const onEnded = React.useCallback(() => { + alert('ended'); + }, []); + const onError = React.useCallback((error) => { + alert(error.message); + console.error(error); + }, []); + const onPropChanged = React.useCallback((propName, propValue) => { + setState({ [propName]: propValue }); + }, []); + React.useEffect(() => { + dispatch({ + commandName: 'load', + commandArgs: { stream } + }); + dispatch({ propName: 'subtitlesOffset', propValue: 18 }); - this.dispatch({ + dispatch({ commandName: 'addSubtitlesTracks', commandArgs: { tracks: [ @@ -69,86 +70,47 @@ class Player extends React.Component { ] } }); - this.dispatch({ + dispatch({ propName: 'selectedSubtitlesTrackId', propValue: 'https://raw.githubusercontent.com/caitp/ng-media/master/example/assets/captions/bunny-en.vtt' }); - } - - onEnded = () => { - alert('ended'); - } - - onError = (error) => { - alert(error.message); - console.error(error); - } - - onPropValue = (propName, propValue) => { - this.setState({ [propName]: propValue }); - } - - onPropChanged = (propName, propValue) => { - this.setState({ [propName]: propValue }); - } - - onImplementationChanged = (manifest) => { - manifest.props.forEach((propName) => { - this.dispatch({ observedPropName: propName }); - }); - } - - dispatch = (args) => { - this.videoRef.current && this.videoRef.current.dispatch(args); - } - - render() { - return ( -
-