diff --git a/src/components/ContextMenu/ContextMenu.less b/src/components/ContextMenu/ContextMenu.less new file mode 100644 index 000000000..e38b7bad2 --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.less @@ -0,0 +1,17 @@ +@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; + +.context-menu-container { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + + .context-menu { + position: fixed; + border-radius: var(--border-radius); + background-color: var(--modal-background-color); + box-shadow: 0 1.35rem 2.7rem @color-background-dark5-40, + 0 1.1rem 0.85rem @color-background-dark5-20; + } +} \ No newline at end of file diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 000000000..0f78454e8 --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,101 @@ +import React, { memo, RefObject, useCallback, useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './ContextMenu.less'; + +const PADDING = 8; + +type Coordinates = [number, number]; +type Size = [number, number]; + +type Props = { + children: React.ReactNode, + on: RefObject[], + autoClose: boolean, +}; + +const ContextMenu = ({ children, on, autoClose }: Props) => { + const [active, setActive] = useState(false); + const [position, setPosition] = useState([0, 0]); + const [containerSize, setContainerSize] = useState([0, 0]); + + const ref = useCallback((element: HTMLDivElement) => { + element && setContainerSize([element.offsetWidth, element.offsetHeight]); + }, []); + + const style = useMemo(() => { + const [viewportWidth, viewportHeight] = [window.innerWidth, window.innerHeight]; + const [containerWidth, containerHeight] = containerSize; + const [x, y] = position; + + const left = Math.max( + PADDING, + Math.min( + x + containerWidth > viewportWidth - PADDING ? x - containerWidth : x, + viewportWidth - containerWidth - PADDING + ) + ); + + const top = Math.max( + PADDING, + Math.min( + y + containerHeight > viewportHeight - PADDING ? y - containerHeight : y, + viewportHeight - containerHeight - PADDING + ) + ); + + return { top, left }; + }, [position, containerSize]); + + const close = () => { + setPosition([0, 0]); + setActive(false); + }; + + const stopPropagation = (event: React.MouseEvent | React.TouchEvent) => { + event.stopPropagation(); + }; + + const onContextMenu = (event: MouseEvent) => { + event.preventDefault(); + + setPosition([event.clientX, event.clientY]); + setActive(true); + }; + + const handleKeyDown = useCallback((event: KeyboardEvent) => event.key === 'Escape' && close(), []); + + const onClick = useCallback(() => { + autoClose && close(); + }, [autoClose]); + + useEffect(() => { + on.forEach((ref) => ref.current && ref.current.addEventListener('contextmenu', onContextMenu)); + document.addEventListener('keydown', handleKeyDown); + + return () => { + on.forEach((ref) => ref.current && ref.current.removeEventListener('contextmenu', onContextMenu)); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [on]); + + return active && createPortal(( +
+
+ {children} +
+
+ ), document.body); +}; + +export default memo(ContextMenu); diff --git a/src/components/ContextMenu/index.ts b/src/components/ContextMenu/index.ts new file mode 100644 index 000000000..10ae5e5d7 --- /dev/null +++ b/src/components/ContextMenu/index.ts @@ -0,0 +1,2 @@ +import ContextMenu from './ContextMenu'; +export default ContextMenu; diff --git a/src/components/index.ts b/src/components/index.ts index 7ef75f888..bd819f658 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,6 +4,7 @@ import Button from './Button'; import Checkbox from './Checkbox'; import Chips from './Chips'; import ColorInput from './ColorInput'; +import ContextMenu from './ContextMenu'; import ContinueWatchingItem from './ContinueWatchingItem'; import DelayedRenderer from './DelayedRenderer'; import EventModal from './EventModal'; @@ -35,6 +36,7 @@ export { Checkbox, Chips, ColorInput, + ContextMenu, ContinueWatchingItem, DelayedRenderer, EventModal, diff --git a/src/routes/Player/BufferingLoader/BufferingLoader.js b/src/routes/Player/BufferingLoader/BufferingLoader.js index 2fea2f4a0..3d5664ae4 100644 --- a/src/routes/Player/BufferingLoader/BufferingLoader.js +++ b/src/routes/Player/BufferingLoader/BufferingLoader.js @@ -6,9 +6,9 @@ const classnames = require('classnames'); const { Image } = require('stremio/components'); const styles = require('./styles'); -const BufferingLoader = ({ className, logo }) => { +const BufferingLoader = React.forwardRef(({ className, logo }, ref) => { return ( -
+
{ />
); -}; +}); BufferingLoader.propTypes = { className: PropTypes.string, - logo: PropTypes.string + logo: PropTypes.string, }; module.exports = BufferingLoader; diff --git a/src/routes/Player/Error/Error.js b/src/routes/Player/Error/Error.js index c9a7c3bef..61db0e45f 100644 --- a/src/routes/Player/Error/Error.js +++ b/src/routes/Player/Error/Error.js @@ -8,7 +8,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react'); const { Button } = require('stremio/components'); const styles = require('./styles'); -const Error = ({ className, code, message, stream }) => { +const Error = React.forwardRef(({ className, code, message, stream }, ref) => { const { t } = useTranslation(); const [playlist, fileName] = React.useMemo(() => { @@ -19,7 +19,7 @@ const Error = ({ className, code, message, stream }) => { }, [stream]); return ( -
+
{message}
{ code === 2 ? @@ -44,7 +44,7 @@ const Error = ({ className, code, message, stream }) => { }
); -}; +}); Error.propTypes = { className: PropTypes.string, diff --git a/src/routes/Player/OptionsMenu/OptionsMenu.js b/src/routes/Player/OptionsMenu/OptionsMenu.js index 11738cb3e..56241e009 100644 --- a/src/routes/Player/OptionsMenu/OptionsMenu.js +++ b/src/routes/Player/OptionsMenu/OptionsMenu.js @@ -69,6 +69,7 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => { const onMouseDown = React.useCallback((event) => { event.nativeEvent.optionsMenuClosePrevented = true; }, []); + return (
{ @@ -112,7 +113,7 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => { OptionsMenu.propTypes = { className: PropTypes.string, stream: PropTypes.object, - playbackDevices: PropTypes.array + playbackDevices: PropTypes.array, }; module.exports = OptionsMenu; diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index cd8a65a9a..1369f814a 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -9,7 +9,7 @@ const { useTranslation } = require('react-i18next'); const { useRouteFocused } = require('stremio-router'); const { useServices } = require('stremio/services'); const { onFileDrop, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS } = require('stremio/common'); -const { HorizontalNavBar, Transition } = require('stremio/components'); +const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components'); const BufferingLoader = require('./BufferingLoader'); const VolumeChangeIndicator = require('./VolumeChangeIndicator'); const Error = require('./Error'); @@ -49,6 +49,10 @@ const Player = ({ urlParams, queryParams }) => { const [casting, setCasting] = React.useState(() => { return chromecast.active && chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED; }); + const playbackDevices = React.useMemo(() => streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : [], [streamingServer]); + + const bufferingRef = React.useRef(); + const errorRef = React.useRef(); const [immersed, setImmersed] = React.useState(true); const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []); @@ -626,7 +630,7 @@ const Player = ({ urlParams, queryParams }) => { onMouseOver={onContainerMouseMove} onMouseLeave={onContainerMouseLeave}>