mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-04-21 07:32:02 +00:00
Merge pull request #813 from Stremio/feat/right-click-context-menu
Some checks failed
Build / build (push) Has been cancelled
Some checks failed
Build / build (push) Has been cancelled
Player: add context menu
This commit is contained in:
commit
2da5a0c6d1
9 changed files with 155 additions and 16 deletions
17
src/components/ContextMenu/ContextMenu.less
Normal file
17
src/components/ContextMenu/ContextMenu.less
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/components/ContextMenu/ContextMenu.tsx
Normal file
101
src/components/ContextMenu/ContextMenu.tsx
Normal file
|
|
@ -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<HTMLElement>[],
|
||||||
|
autoClose: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ContextMenu = ({ children, on, autoClose }: Props) => {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const [position, setPosition] = useState<Coordinates>([0, 0]);
|
||||||
|
const [containerSize, setContainerSize] = useState<Size>([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((
|
||||||
|
<div
|
||||||
|
className={styles['context-menu-container']}
|
||||||
|
onMouseDown={close}
|
||||||
|
onTouchStart={close}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={styles['context-menu']}
|
||||||
|
style={style}
|
||||||
|
onMouseDown={stopPropagation}
|
||||||
|
onTouchStart={stopPropagation}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
), document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(ContextMenu);
|
||||||
2
src/components/ContextMenu/index.ts
Normal file
2
src/components/ContextMenu/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
import ContextMenu from './ContextMenu';
|
||||||
|
export default ContextMenu;
|
||||||
|
|
@ -4,6 +4,7 @@ import Button from './Button';
|
||||||
import Checkbox from './Checkbox';
|
import Checkbox from './Checkbox';
|
||||||
import Chips from './Chips';
|
import Chips from './Chips';
|
||||||
import ColorInput from './ColorInput';
|
import ColorInput from './ColorInput';
|
||||||
|
import ContextMenu from './ContextMenu';
|
||||||
import ContinueWatchingItem from './ContinueWatchingItem';
|
import ContinueWatchingItem from './ContinueWatchingItem';
|
||||||
import DelayedRenderer from './DelayedRenderer';
|
import DelayedRenderer from './DelayedRenderer';
|
||||||
import EventModal from './EventModal';
|
import EventModal from './EventModal';
|
||||||
|
|
@ -35,6 +36,7 @@ export {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Chips,
|
Chips,
|
||||||
ColorInput,
|
ColorInput,
|
||||||
|
ContextMenu,
|
||||||
ContinueWatchingItem,
|
ContinueWatchingItem,
|
||||||
DelayedRenderer,
|
DelayedRenderer,
|
||||||
EventModal,
|
EventModal,
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ const classnames = require('classnames');
|
||||||
const { Image } = require('stremio/components');
|
const { Image } = require('stremio/components');
|
||||||
const styles = require('./styles');
|
const styles = require('./styles');
|
||||||
|
|
||||||
const BufferingLoader = ({ className, logo }) => {
|
const BufferingLoader = React.forwardRef(({ className, logo }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div className={classnames(className, styles['buffering-loader-container'])}>
|
<div ref={ref} className={classnames(className, styles['buffering-loader-container'])}>
|
||||||
<Image
|
<Image
|
||||||
className={styles['buffering-loader']}
|
className={styles['buffering-loader']}
|
||||||
src={logo}
|
src={logo}
|
||||||
|
|
@ -17,11 +17,11 @@ const BufferingLoader = ({ className, logo }) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
BufferingLoader.propTypes = {
|
BufferingLoader.propTypes = {
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
logo: PropTypes.string
|
logo: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = BufferingLoader;
|
module.exports = BufferingLoader;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||||
const { Button } = require('stremio/components');
|
const { Button } = require('stremio/components');
|
||||||
const styles = require('./styles');
|
const styles = require('./styles');
|
||||||
|
|
||||||
const Error = ({ className, code, message, stream }) => {
|
const Error = React.forwardRef(({ className, code, message, stream }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [playlist, fileName] = React.useMemo(() => {
|
const [playlist, fileName] = React.useMemo(() => {
|
||||||
|
|
@ -19,7 +19,7 @@ const Error = ({ className, code, message, stream }) => {
|
||||||
}, [stream]);
|
}, [stream]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(className, styles['error'])}>
|
<div ref={ref} className={classNames(className, styles['error'])}>
|
||||||
<div className={styles['error-label']} title={message}>{message}</div>
|
<div className={styles['error-label']} title={message}>{message}</div>
|
||||||
{
|
{
|
||||||
code === 2 ?
|
code === 2 ?
|
||||||
|
|
@ -44,7 +44,7 @@ const Error = ({ className, code, message, stream }) => {
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
Error.propTypes = {
|
Error.propTypes = {
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => {
|
||||||
const onMouseDown = React.useCallback((event) => {
|
const onMouseDown = React.useCallback((event) => {
|
||||||
event.nativeEvent.optionsMenuClosePrevented = true;
|
event.nativeEvent.optionsMenuClosePrevented = true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames(className, styles['options-menu-container'])} onMouseDown={onMouseDown}>
|
<div className={classnames(className, styles['options-menu-container'])} onMouseDown={onMouseDown}>
|
||||||
{
|
{
|
||||||
|
|
@ -112,7 +113,7 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => {
|
||||||
OptionsMenu.propTypes = {
|
OptionsMenu.propTypes = {
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
stream: PropTypes.object,
|
stream: PropTypes.object,
|
||||||
playbackDevices: PropTypes.array
|
playbackDevices: PropTypes.array,
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = OptionsMenu;
|
module.exports = OptionsMenu;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const { useTranslation } = require('react-i18next');
|
||||||
const { useRouteFocused } = require('stremio-router');
|
const { useRouteFocused } = require('stremio-router');
|
||||||
const { useServices } = require('stremio/services');
|
const { useServices } = require('stremio/services');
|
||||||
const { onFileDrop, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS } = require('stremio/common');
|
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 BufferingLoader = require('./BufferingLoader');
|
||||||
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
|
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
|
||||||
const Error = require('./Error');
|
const Error = require('./Error');
|
||||||
|
|
@ -49,6 +49,10 @@ const Player = ({ urlParams, queryParams }) => {
|
||||||
const [casting, setCasting] = React.useState(() => {
|
const [casting, setCasting] = React.useState(() => {
|
||||||
return chromecast.active && chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED;
|
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 [immersed, setImmersed] = React.useState(true);
|
||||||
const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []);
|
const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []);
|
||||||
|
|
@ -626,7 +630,7 @@ const Player = ({ urlParams, queryParams }) => {
|
||||||
onMouseOver={onContainerMouseMove}
|
onMouseOver={onContainerMouseMove}
|
||||||
onMouseLeave={onContainerMouseLeave}>
|
onMouseLeave={onContainerMouseLeave}>
|
||||||
<Video
|
<Video
|
||||||
ref={video.containerElement}
|
ref={video.containerRef}
|
||||||
className={styles['layer']}
|
className={styles['layer']}
|
||||||
onClick={onVideoClick}
|
onClick={onVideoClick}
|
||||||
onDoubleClick={onVideoDoubleClick}
|
onDoubleClick={onVideoDoubleClick}
|
||||||
|
|
@ -641,13 +645,18 @@ const Player = ({ urlParams, queryParams }) => {
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
(video.state.buffering || !video.state.loaded) && !error ?
|
(video.state.buffering || !video.state.loaded) && !error ?
|
||||||
<BufferingLoader className={classnames(styles['layer'], styles['buffering-layer'])} logo={player?.metaItem?.content?.logo} />
|
<BufferingLoader
|
||||||
|
ref={bufferingRef}
|
||||||
|
className={classnames(styles['layer'], styles['buffering-layer'])}
|
||||||
|
logo={player?.metaItem?.content?.logo}
|
||||||
|
/>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
error !== null ?
|
error !== null ?
|
||||||
<Error
|
<Error
|
||||||
|
ref={errorRef}
|
||||||
className={classnames(styles['layer'], styles['error-layer'])}
|
className={classnames(styles['layer'], styles['error-layer'])}
|
||||||
stream={video.state.stream}
|
stream={video.state.stream}
|
||||||
{...error}
|
{...error}
|
||||||
|
|
@ -670,6 +679,13 @@ const Player = ({ urlParams, queryParams }) => {
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
<ContextMenu on={[video.containerRef, bufferingRef, errorRef]} autoClose>
|
||||||
|
<OptionsMenu
|
||||||
|
className={classnames(styles['layer'], styles['menu-layer'])}
|
||||||
|
stream={player?.selected?.stream}
|
||||||
|
playbackDevices={playbackDevices}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
<HorizontalNavBar
|
<HorizontalNavBar
|
||||||
className={classnames(styles['layer'], styles['nav-bar-layer'])}
|
className={classnames(styles['layer'], styles['nav-bar-layer'])}
|
||||||
title={player.title !== null ? player.title : ''}
|
title={player.title !== null ? player.title : ''}
|
||||||
|
|
@ -798,7 +814,7 @@ const Player = ({ urlParams, queryParams }) => {
|
||||||
<OptionsMenu
|
<OptionsMenu
|
||||||
className={classnames(styles['layer'], styles['menu-layer'])}
|
className={classnames(styles['layer'], styles['menu-layer'])}
|
||||||
stream={player.selected.stream}
|
stream={player.selected.stream}
|
||||||
playbackDevices={streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : []}
|
playbackDevices={playbackDevices}
|
||||||
/>
|
/>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const events = new EventEmitter();
|
||||||
|
|
||||||
const useVideo = () => {
|
const useVideo = () => {
|
||||||
const video = React.useRef(null);
|
const video = React.useRef(null);
|
||||||
const containerElement = React.useRef(null);
|
const containerRef = React.useRef(null);
|
||||||
|
|
||||||
const [state, setState] = React.useState({
|
const [state, setState] = React.useState({
|
||||||
manifest: null,
|
manifest: null,
|
||||||
|
|
@ -42,11 +42,11 @@ const useVideo = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const dispatch = (action, options) => {
|
const dispatch = (action, options) => {
|
||||||
if (video.current && containerElement.current) {
|
if (video.current && containerRef.current) {
|
||||||
try {
|
try {
|
||||||
video.current.dispatch(action, {
|
video.current.dispatch(action, {
|
||||||
...options,
|
...options,
|
||||||
containerElement: containerElement.current,
|
containerElement: containerRef.current,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Video:', error);
|
console.error('Video:', error);
|
||||||
|
|
@ -157,7 +157,7 @@ const useVideo = () => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events,
|
events,
|
||||||
containerElement,
|
containerRef,
|
||||||
state,
|
state,
|
||||||
load,
|
load,
|
||||||
unload,
|
unload,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue