Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Player: Show Options menu on right click #813

Open
wants to merge 11 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions src/common/useOutsideClick.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,25 @@
// Copyright (C) 2017-2024 Smart code 203358507

import { useEffect, useRef } from 'react';

const useOutsideClick = (callback: () => void) => {
const ref = useRef<HTMLDivElement>(null);
import { RefObject, useEffect } from 'react';

const useOutsideClick = (ref: RefObject<HTMLDivElement>, callback: () => void) => {
useEffect(() => {
if (!ref?.current) return;

const handleClickOutside = (event: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};

document.addEventListener('mouseup', handleClickOutside);
document.addEventListener('touchend', handleClickOutside);
document.addEventListener('mousedown', handleClickOutside, true);
document.addEventListener('touchstart', handleClickOutside, true);

return () => {
document.removeEventListener('mouseup', handleClickOutside);
document.removeEventListener('touchend', handleClickOutside);
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
};
}, [callback]);

return ref;
}, [ref, callback]);
};

export default useOutsideClick;
2 changes: 1 addition & 1 deletion src/components/MultiselectMenu/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen
.filter((option: MultiselectMenuOption) => !option.hidden)
.map((option: MultiselectMenuOption) => (
<Option
key={option.id}
key={option.value}
ref={handleSetOptionRef(option.value)}
option={option}
onSelect={onSelect}
Expand Down
6 changes: 4 additions & 2 deletions src/components/MultiselectMenu/MultiselectMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (C) 2017-2024 Smart code 203358507

import React from 'react';
import React, { useRef } from 'react';
import { Button } from 'stremio/components';
import useBinaryState from 'stremio/common/useBinaryState';
import Dropdown from './Dropdown';
Expand All @@ -19,13 +19,15 @@ type Props = {

const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }: Props) => {
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const multiselectMenuRef = useOutsideClick(() => closeMenu());
const multiselectMenuRef = useRef(null);
const [level, setLevel] = React.useState<number>(0);

const onOptionSelect = (value: number) => {
level ? setLevel(level + 1) : onSelect(value), closeMenu();
};

useOutsideClick(multiselectMenuRef, closeMenu);

return (
<div className={classNames(styles['multiselect-menu'], className)} ref={multiselectMenuRef}>
<Button
Expand Down
7 changes: 4 additions & 3 deletions src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const NavMenu = require('./NavMenu');
const styles = require('./styles');
const { t } = require('i18next');

const HorizontalNavBar = React.memo(({ className, route, query, title, backButton, searchBar, fullscreenButton, navMenu, ...props }) => {
const HorizontalNavBar = React.memo(({ className, route, query, title, backButton, searchBar, fullscreenButton, navMenu, onContextMenu, ...props }) => {
const backButtonOnClick = React.useCallback(() => {
window.history.back();
}, []);
Expand All @@ -25,7 +25,7 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
</Button>
), []);
return (
<nav {...props} className={classnames(className, styles['horizontal-nav-bar-container'])}>
<nav {...props} className={classnames(className, styles['horizontal-nav-bar-container'])} onContextMenu={onContextMenu}>
{
backButton ?
<Button className={classnames(styles['button-container'], styles['back-button-container'])} tabIndex={-1} onClick={backButtonOnClick}>
Expand Down Expand Up @@ -82,7 +82,8 @@ HorizontalNavBar.propTypes = {
backButton: PropTypes.bool,
searchBar: PropTypes.bool,
fullscreenButton: PropTypes.bool,
navMenu: PropTypes.bool
navMenu: PropTypes.bool,
onContextMenu: PropTypes.func,
};

module.exports = HorizontalNavBar;
7 changes: 4 additions & 3 deletions src/routes/Player/BufferingLoader/BufferingLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ const classnames = require('classnames');
const { Image } = require('stremio/components');
const styles = require('./styles');

const BufferingLoader = ({ className, logo }) => {
const BufferingLoader = ({ className, logo, onContextMenu }) => {
return (
<div className={classnames(className, styles['buffering-loader-container'])}>
<div className={classnames(className, styles['buffering-loader-container'])} onContextMenu={onContextMenu}>
<Image
className={styles['buffering-loader']}
src={logo}
Expand All @@ -21,7 +21,8 @@ const BufferingLoader = ({ className, logo }) => {

BufferingLoader.propTypes = {
className: PropTypes.string,
logo: PropTypes.string
logo: PropTypes.string,
onContextMenu: PropTypes.func
};

module.exports = BufferingLoader;
4 changes: 3 additions & 1 deletion src/routes/Player/ControlBar/ControlBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const ControlBar = ({
onToggleSideDrawer,
onToggleOptionsMenu,
onToggleStatisticsMenu,
onContextMenu,
...props
}) => {
const { chromecast } = useServices();
Expand Down Expand Up @@ -103,7 +104,7 @@ const ControlBar = ({
};
}, []);
return (
<div {...props} className={classnames(className, styles['control-bar-container'])}>
<div {...props} className={classnames(className, styles['control-bar-container'])} onContextMenu={onContextMenu}>
<SeekBar
className={styles['seek-bar']}
time={time}
Expand Down Expand Up @@ -205,6 +206,7 @@ ControlBar.propTypes = {
onToggleSideDrawer: PropTypes.func,
onToggleOptionsMenu: PropTypes.func,
onToggleStatisticsMenu: PropTypes.func,
onContextMenu: PropTypes.func,
};

module.exports = ControlBar;
18 changes: 15 additions & 3 deletions src/routes/Player/OptionsMenu/OptionsMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useTranslation } = require('react-i18next');
const { usePlatform, useToast } = require('stremio/common');
const { default: useOutsideClick } = require('stremio/common/useOutsideClick');
const { useServices } = require('stremio/services');
const Option = require('./Option');
const styles = require('./styles');

const OptionsMenu = ({ className, stream, playbackDevices }) => {
const OptionsMenu = ({ menuRef, className, stream, playbackDevices, style, onOutsideClick }) => {
const { t } = useTranslation();
const { core } = useServices();
const platform = usePlatform();
Expand Down Expand Up @@ -69,8 +70,13 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => {
const onMouseDown = React.useCallback((event) => {
event.nativeEvent.optionsMenuClosePrevented = true;
}, []);

useOutsideClick(menuRef, () => {
if (typeof onOutsideClick === 'function') onOutsideClick();
});

return (
<div className={classnames(className, styles['options-menu-container'])} onMouseDown={onMouseDown}>
<div ref={menuRef} style={style} className={classnames(className, styles['options-menu-container'])} onMouseDown={onMouseDown}>
{
streamingUrl || downloadUrl ?
<Option
Expand Down Expand Up @@ -110,9 +116,15 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => {
};

OptionsMenu.propTypes = {
menuRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.any })
]),
className: PropTypes.string,
stream: PropTypes.object,
playbackDevices: PropTypes.array
playbackDevices: PropTypes.array,
style: PropTypes.object,
onOutsideClick: PropTypes.func
};

module.exports = OptionsMenu;
100 changes: 90 additions & 10 deletions src/routes/Player/Player.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ 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 [immersed, setImmersed] = React.useState(true);
const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []);
Expand All @@ -61,9 +62,15 @@ const Player = ({ urlParams, queryParams }) => {
const [statisticsMenuOpen, , closeStatisticsMenu, toggleStatisticsMenu] = useBinaryState(false);
const [nextVideoPopupOpen, openNextVideoPopup, closeNextVideoPopup] = useBinaryState(false);
const [sideDrawerOpen, , closeSideDrawer, toggleSideDrawer] = useBinaryState(false);
const [contextMenuOpen, openContextMenu, closeContextMenu] = useBinaryState(false);
const [contextCoords, setContextCoords] = React.useState({
x: -document.documentElement.clientWidth,
y: -document.documentElement.clientHeight,
});
const contextMenuRef = React.useRef(null);

const menusOpen = React.useMemo(() => {
return optionsMenuOpen || subtitlesMenuOpen || audioMenuOpen || speedMenuOpen || statisticsMenuOpen || sideDrawerOpen;
return optionsMenuOpen || subtitlesMenuOpen || audioMenuOpen || speedMenuOpen || statisticsMenuOpen || sideDrawerOpen || contextMenuOpen;
}, [optionsMenuOpen, subtitlesMenuOpen, audioMenuOpen, speedMenuOpen, statisticsMenuOpen, sideDrawerOpen]);

const closeMenus = React.useCallback(() => {
Expand All @@ -73,6 +80,7 @@ const Player = ({ urlParams, queryParams }) => {
closeSpeedMenu();
closeStatisticsMenu();
closeSideDrawer();
closeContextMenu();
}, []);

const overlayHidden = React.useMemo(() => {
Expand Down Expand Up @@ -216,13 +224,17 @@ const Player = ({ urlParams, queryParams }) => {
}
}, [player.nextVideo]);

const onVideoClick = React.useCallback(() => {
if (video.state.paused !== null) {
if (video.state.paused) {
onPlayRequestedDebounced();
} else {
onPauseRequestedDebounced();
const onVideoClick = React.useCallback((e) => {
if (e.type === 'click') {
if (video.state.paused !== null) {
if (video.state.paused) {
onPlayRequestedDebounced();
} else {
onPauseRequestedDebounced();
}
}
} else if (e.type === 'contextmenu') {
onContextMenu(e);
}
}, [video.state.paused]);

Expand All @@ -232,6 +244,50 @@ const Player = ({ urlParams, queryParams }) => {
toggleFullscreen();
}, [toggleFullscreen]);

const onContextMenu = React.useCallback((e) => {
e.preventDefault();
const { clientX, clientY } = e;
const safeAreaTop = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')) || 0;
const safeAreaRight = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-right)')) || 0;
const safeAreaBottom = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)')) || 0;
const safeAreaLeft = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-left)')) || 0;

const maxX = document.documentElement.clientWidth - safeAreaRight;
const maxY = document.documentElement.clientHeight - safeAreaBottom;
const menuX = clientX < safeAreaLeft
? safeAreaLeft
: clientX > maxX
? maxX
: clientX;
const menuY = clientY < safeAreaTop
? safeAreaTop
: clientY > maxY
? maxY
: clientY;

const menuSize = contextMenuRef?.current?.getBoundingClientRect();
const adjustedX = menuX + menuSize.width > maxX ? menuX - menuSize.width : menuX;
const adjustedY = menuY + menuSize.height > maxY ? menuY - menuSize.height : menuY;

setContextCoords({
x: adjustedX,
y: adjustedY,
});
openContextMenu();
}, [contextMenuRef]);

React.useEffect(() => {
if (!contextMenuOpen) {
const menuSize = contextMenuRef?.current?.getBoundingClientRect();
if (menuSize?.width && menuSize?.height) {
setContextCoords({
x: -menuSize.width,
y: -menuSize.height,
});
}
}
}, [contextMenuOpen]);

const onContainerMouseDown = React.useCallback((event) => {
if (!event.nativeEvent.optionsMenuClosePrevented) {
closeOptionsMenu();
Expand Down Expand Up @@ -619,15 +675,15 @@ const Player = ({ urlParams, queryParams }) => {
/>
{
!video.state.loaded ?
<div className={classnames(styles['layer'], styles['background-layer'])}>
<div className={classnames(styles['layer'], styles['background-layer'])} onContextMenu={onContextMenu}>
<img className={styles['image']} src={player?.metaItem?.content?.background} />
</div>
:
null
}
{
(video.state.buffering || !video.state.loaded) && !error ?
<BufferingLoader className={classnames(styles['layer'], styles['buffering-layer'])} logo={player?.metaItem?.content?.logo} />
<BufferingLoader className={classnames(styles['layer'], styles['buffering-layer'])} logo={player?.metaItem?.content?.logo} onContextMenu={onContextMenu} />
:
null
}
Expand Down Expand Up @@ -656,19 +712,42 @@ const Player = ({ urlParams, queryParams }) => {
:
null
}
{
player.selected?.stream ?
<OptionsMenu
menuRef={contextMenuRef}
style={
{
zIndex: contextMenuOpen ? 1 : -1,
top: `${contextCoords.y}px`,
left: `${contextCoords.x}px`,
right: 'auto',
bottom: 'auto'
}
}
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player.selected.stream}
playbackDevices={playbackDevices}
onOutsideClick={closeContextMenu}
/>
:
null
}
<HorizontalNavBar
className={classnames(styles['layer'], styles['nav-bar-layer'])}
title={player.title !== null ? player.title : ''}
backButton={true}
fullscreenButton={true}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
onContextMenu={onContextMenu}
/>
{
player.metaItem?.type === 'Ready' ?
<SideDrawerButton
className={classnames(styles['layer'], styles['side-drawer-button-layer'])}
onClick={toggleSideDrawer}
onContextMenu={onContextMenu}
/>
:
null
Expand Down Expand Up @@ -703,6 +782,7 @@ const Player = ({ urlParams, queryParams }) => {
onToggleSideDrawer={toggleSideDrawer}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
onContextMenu={onContextMenu}
/>
{
nextVideoPopupOpen ?
Expand Down Expand Up @@ -783,7 +863,7 @@ const Player = ({ urlParams, queryParams }) => {
<OptionsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player.selected.stream}
playbackDevices={streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : []}
playbackDevices={playbackDevices}
/>
:
null
Expand Down
5 changes: 3 additions & 2 deletions src/routes/Player/SideDrawerButton/SideDrawerButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import styles from './SideDrawerButton.less';
type Props = {
className: string,
onClick: () => void,
onContextMenu: () => void,
};

const SideDrawerButton = ({ className, onClick }: Props) => {
const SideDrawerButton = ({ className, onClick, onContextMenu }: Props) => {
return (
<div className={classNames(className, styles['side-drawer-button'])} onClick={onClick}>
<div className={classNames(className, styles['side-drawer-button'])} onClick={onClick} onContextMenu={onContextMenu}>
<Icon name={'chevron-back'} className={styles['icon']} />
</div>
);
Expand Down
Loading