refactor: use dedicated component for context menu

This commit is contained in:
Tim 2025-03-19 18:30:08 +01:00
parent a4054c80b8
commit ef730ae61a
10 changed files with 149 additions and 110 deletions

View 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;
}
}

View file

@ -0,0 +1,89 @@
import React, { RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from './ContextMenu.less';
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([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 = (x + containerWidth) > viewportWidth ? x - containerWidth : x;
const top = (y + containerHeight) > viewportHeight ? y - containerHeight : y;
return { top, left };
}, [position, containerSize]);
const close = () => {
setPosition([0, 0]);
setActive(false);
};
const onContextMenu = (event: MouseEvent) => {
event.preventDefault();
const { clientX, clientY } = event;
setPosition([clientX, clientY]);
setActive(true);
};
const onClickOutside = () => {
close();
};
const onClick = useCallback(() => {
autoClose && close();
}, [autoClose]);
const onMouseDown = (event: React.MouseEvent) => {
event.stopPropagation();
};
const onTouchStart = (event: React.TouchEvent) => {
event.stopPropagation();
};
useEffect(() => {
const containers = on.map((ref) => ref.current).filter((element) => !!element);
containers.forEach((container) => container.addEventListener('contextmenu', onContextMenu));
return () => {
containers.forEach((container) => container.removeEventListener('contextmenu', onContextMenu));
};
}, [on]);
return active && createPortal((
<div
className={styles['context-menu-container']}
onMouseDown={onClickOutside}
onTouchStart={onClickOutside}
>
<div
ref={ref}
className={styles['context-menu']}
style={style}
onMouseDown={onMouseDown}
onTouchStart={onTouchStart}
onClick={onClick}
>
{children}
</div>
</div>
), document.body);
};
export default ContextMenu;

View file

@ -0,0 +1,2 @@
import ContextMenu from './ContextMenu';
export default ContextMenu;

View file

@ -3,6 +3,7 @@ import BottomSheet from './BottomSheet';
import Button from './Button';
import Chips from './Chips';
import ColorInput from './ColorInput';
import ContextMenu from './ContextMenu';
import ContinueWatchingItem from './ContinueWatchingItem';
import DelayedRenderer from './DelayedRenderer';
import EventModal from './EventModal';
@ -33,6 +34,7 @@ export {
Button,
Chips,
ColorInput,
ContextMenu,
ContinueWatchingItem,
DelayedRenderer,
EventModal,

View file

@ -6,9 +6,9 @@ const classnames = require('classnames');
const { Image } = require('stremio/components');
const styles = require('./styles');
const BufferingLoader = ({ className, logo, onContextMenu }) => {
const BufferingLoader = React.forwardRef(({ className, logo }, ref) => {
return (
<div className={classnames(className, styles['buffering-loader-container'])} onContextMenu={onContextMenu}>
<div ref={ref} className={classnames(className, styles['buffering-loader-container'])}>
<Image
className={styles['buffering-loader']}
src={logo}
@ -17,12 +17,11 @@ const BufferingLoader = ({ className, logo, onContextMenu }) => {
/>
</div>
);
};
});
BufferingLoader.propTypes = {
className: PropTypes.string,
logo: PropTypes.string,
onContextMenu: PropTypes.func
};
module.exports = BufferingLoader;

View file

@ -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 (
<div className={classNames(className, styles['error'])}>
<div ref={ref} className={classNames(className, styles['error'])}>
<div className={styles['error-label']} title={message}>{message}</div>
{
code === 2 ?
@ -44,7 +44,7 @@ const Error = ({ className, code, message, stream }) => {
}
</div>
);
};
});
Error.propTypes = {
className: PropTypes.string,

View file

@ -5,12 +5,11 @@ 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 = ({ menuRef, className, stream, playbackDevices, style, onOutsideClick }) => {
const OptionsMenu = ({ className, stream, playbackDevices, style }) => {
const { t } = useTranslation();
const { core } = useServices();
const platform = usePlatform();
@ -71,12 +70,8 @@ const OptionsMenu = ({ menuRef, className, stream, playbackDevices, style, onOut
event.nativeEvent.optionsMenuClosePrevented = true;
}, []);
useOutsideClick(menuRef, () => {
if (typeof onOutsideClick === 'function') onOutsideClick();
});
return (
<div ref={menuRef} style={style} className={classnames(className, styles['options-menu-container'])} onMouseDown={onMouseDown}>
<div style={style} className={classnames(className, styles['options-menu-container'])} onMouseDown={onMouseDown}>
{
streamingUrl || downloadUrl ?
<Option
@ -124,7 +119,6 @@ OptionsMenu.propTypes = {
stream: PropTypes.object,
playbackDevices: PropTypes.array,
style: PropTypes.object,
onOutsideClick: PropTypes.func
};
module.exports = OptionsMenu;

View file

@ -9,7 +9,7 @@ const { useTranslation } = require('react-i18next');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const { useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender } = 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');
@ -51,6 +51,9 @@ const Player = ({ urlParams, queryParams }) => {
});
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), []);
const [, , , toggleFullscreen] = useFullscreen();
@ -62,15 +65,9 @@ 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 || contextMenuOpen;
return optionsMenuOpen || subtitlesMenuOpen || audioMenuOpen || speedMenuOpen || statisticsMenuOpen || sideDrawerOpen;
}, [optionsMenuOpen, subtitlesMenuOpen, audioMenuOpen, speedMenuOpen, statisticsMenuOpen, sideDrawerOpen]);
const closeMenus = React.useCallback(() => {
@ -80,7 +77,6 @@ const Player = ({ urlParams, queryParams }) => {
closeSpeedMenu();
closeStatisticsMenu();
closeSideDrawer();
closeContextMenu();
}, []);
const overlayHidden = React.useMemo(() => {
@ -224,17 +220,13 @@ const Player = ({ urlParams, queryParams }) => {
}
}, [player.nextVideo]);
const onVideoClick = React.useCallback((e) => {
if (e.type === 'click') {
if (video.state.paused !== null) {
if (video.state.paused) {
onPlayRequestedDebounced();
} else {
onPauseRequestedDebounced();
}
const onVideoClick = React.useCallback(() => {
if (video.state.paused !== null) {
if (video.state.paused) {
onPlayRequestedDebounced();
} else {
onPauseRequestedDebounced();
}
} else if (e.type === 'contextmenu') {
onContextMenu(e);
}
}, [video.state.paused]);
@ -244,50 +236,6 @@ 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();
@ -668,14 +616,14 @@ const Player = ({ urlParams, queryParams }) => {
onMouseOver={onContainerMouseMove}
onMouseLeave={onContainerMouseLeave}>
<Video
ref={video.containerElement}
ref={video.containerRef}
className={styles['layer']}
onClick={onVideoClick}
onDoubleClick={onVideoDoubleClick}
/>
{
!video.state.loaded ?
<div className={classnames(styles['layer'], styles['background-layer'])} onContextMenu={onContextMenu}>
<div className={classnames(styles['layer'], styles['background-layer'])}>
<img className={styles['image']} src={player?.metaItem?.content?.background} />
</div>
:
@ -683,13 +631,18 @@ const Player = ({ urlParams, queryParams }) => {
}
{
(video.state.buffering || !video.state.loaded) && !error ?
<BufferingLoader className={classnames(styles['layer'], styles['buffering-layer'])} logo={player?.metaItem?.content?.logo} onContextMenu={onContextMenu} />
<BufferingLoader
ref={bufferingRef}
className={classnames(styles['layer'], styles['buffering-layer'])}
logo={player?.metaItem?.content?.logo}
/>
:
null
}
{
error !== null ?
<Error
ref={errorRef}
className={classnames(styles['layer'], styles['error-layer'])}
stream={video.state.stream}
{...error}
@ -712,27 +665,13 @@ 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
}
<ContextMenu on={[video.containerRef, bufferingRef, errorRef]} autoClose>
<OptionsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player?.selected?.stream}
playbackDevices={playbackDevices}
/>
</ContextMenu>
<HorizontalNavBar
className={classnames(styles['layer'], styles['nav-bar-layer'])}
title={player.title !== null ? player.title : ''}
@ -740,14 +679,12 @@ const Player = ({ urlParams, queryParams }) => {
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
@ -782,7 +719,6 @@ const Player = ({ urlParams, queryParams }) => {
onToggleSideDrawer={toggleSideDrawer}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
onContextMenu={onContextMenu}
/>
{
nextVideoPopupOpen ?

View file

@ -7,7 +7,7 @@ const styles = require('./styles');
const Video = React.forwardRef(({ className, onClick, onDoubleClick }, ref) => {
return (
<div className={classnames(className, styles['video-container'])} onClick={onClick} onContextMenu={onClick} onDoubleClick={onDoubleClick}>
<div className={classnames(className, styles['video-container'])} onClick={onClick} onDoubleClick={onDoubleClick}>
<div ref={ref} className={styles['video']} />
</div>
);

View file

@ -8,7 +8,7 @@ const events = new EventEmitter();
const useVideo = () => {
const video = React.useRef(null);
const containerElement = React.useRef(null);
const containerRef = React.useRef(null);
const [state, setState] = React.useState({
manifest: null,
@ -42,11 +42,11 @@ const useVideo = () => {
});
const dispatch = (action, options) => {
if (video.current && containerElement.current) {
if (video.current && containerRef.current) {
try {
video.current.dispatch(action, {
...options,
containerElement: containerElement.current,
containerElement: containerRef.current,
});
} catch (error) {
console.error('Video:', error);
@ -131,7 +131,7 @@ const useVideo = () => {
return {
events,
containerElement,
containerRef,
state,
load,
unload,