From 179afa67801f7720c1d73d1b78cba9c338c2cb26 Mon Sep 17 00:00:00 2001 From: Namyts <35004248+Namyts@users.noreply.github.com> Date: Tue, 23 Jul 2024 00:45:29 +0100 Subject: [PATCH] added context menu to steam list items with ability to copy stream infohash. switch steam to use Popup, just like Video --- .../MetaDetails/StreamsList/Stream/Stream.js | 270 +++++++++++++----- .../StreamsList/Stream/styles.less | 61 ++++ .../MetaDetails/StreamsList/StreamsList.js | 195 +++++++------ 3 files changed, 349 insertions(+), 177 deletions(-) diff --git a/src/routes/MetaDetails/StreamsList/Stream/Stream.js b/src/routes/MetaDetails/StreamsList/Stream/Stream.js index d386cbf95..5cb31b251 100644 --- a/src/routes/MetaDetails/StreamsList/Stream/Stream.js +++ b/src/routes/MetaDetails/StreamsList/Stream/Stream.js @@ -4,47 +4,93 @@ const React = require('react'); const PropTypes = require('prop-types'); const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); -const { Button, Image, useProfile, platform, useToast } = require('stremio/common'); +const { + Button, + Image, + useProfile, + platform, + useToast, + Popup, + useBinaryState, +} = require('stremio/common'); const { useServices } = require('stremio/services'); const StreamPlaceholder = require('./StreamPlaceholder'); +const { t } = require('i18next'); + const styles = require('./styles'); -const Stream = ({ className, videoId, videoReleased, addonName, name, description, thumbnail, progress, deepLinks, ...props }) => { +const Stream = ({ + className, + videoId, + videoReleased, + addonName, + name, + description, + thumbnail, + progress, + deepLinks, + infoHash, + ...props +}) => { const profile = useProfile(); const toast = useToast(); const { core } = useServices(); + const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false); + + const popupLabelOnMouseUp = React.useCallback((event) => { + if (!event.nativeEvent.togglePopupPrevented) { + if (event.nativeEvent.ctrlKey || event.nativeEvent.button === 2) { + event.preventDefault(); + toggleMenu(); + } + } + }, []); + const popupLabelOnContextMenu = React.useCallback((event) => { + if (!event.nativeEvent.togglePopupPrevented && !event.nativeEvent.ctrlKey) { + event.preventDefault(); + } + }, [toggleMenu]); + const popupLabelOnLongPress = React.useCallback((event) => { + if (event.nativeEvent.pointerType !== 'mouse' && !event.nativeEvent.togglePopupPrevented) { + toggleMenu(); + } + }, [toggleMenu]); + const popupMenuOnPointerDown = React.useCallback((event) => { + event.nativeEvent.togglePopupPrevented = true; + }, []); + const popupMenuOnContextMenu = React.useCallback((event) => { + event.nativeEvent.togglePopupPrevented = true; + }, []); + const popupMenuOnClick = React.useCallback((event) => { + event.nativeEvent.togglePopupPrevented = true; + }, []); + const popupMenuOnKeyDown = React.useCallback((event) => { + event.nativeEvent.buttonClickPrevented = true; + }, []); + const href = React.useMemo(() => { - return deepLinks ? - deepLinks.externalPlayer ? - deepLinks.externalPlayer.web ? - deepLinks.externalPlayer.web - : - deepLinks.externalPlayer.openPlayer ? - deepLinks.externalPlayer.openPlayer[platform.name] ? - deepLinks.externalPlayer.openPlayer[platform.name] - : - deepLinks.externalPlayer.playlist - : - deepLinks.player - : - deepLinks.player - : - null; + return deepLinks + ? deepLinks.externalPlayer + ? deepLinks.externalPlayer.web + ? deepLinks.externalPlayer.web + : deepLinks.externalPlayer.openPlayer + ? deepLinks.externalPlayer.openPlayer[platform.name] + ? deepLinks.externalPlayer.openPlayer[platform.name] + : deepLinks.externalPlayer.playlist + : deepLinks.player + : deepLinks.player + : null; }, [deepLinks]); const download = React.useMemo(() => { - return href === deepLinks?.externalPlayer?.playlist ? - deepLinks.externalPlayer.fileName - : - null; + return href === deepLinks?.externalPlayer?.playlist + ? deepLinks.externalPlayer.fileName + : null; }, [href, deepLinks]); const target = React.useMemo(() => { - return href === deepLinks?.externalPlayer?.web ? - '_blank' - : - null; + return href === deepLinks?.externalPlayer?.web ? '_blank' : null; }, [href, deepLinks]); const markVideoAsWatched = React.useCallback(() => { @@ -53,68 +99,136 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio action: 'MetaDetails', args: { action: 'MarkVideoAsWatched', - args: [{ id: videoId, released: videoReleased }, true] - } + args: [{ id: videoId, released: videoReleased }, true], + }, }); } }, [videoId, videoReleased]); - const onClick = React.useCallback((event) => { - if (profile.settings.playerType !== null) { - markVideoAsWatched(); + const onClick = React.useCallback( + (event) => { + if (profile.settings.playerType !== null) { + markVideoAsWatched(); + toast.show({ + type: 'success', + title: 'Stream opened in external player', + timeout: 4000, + }); + } + + if (typeof props.onClick === 'function') { + props.onClick(event); + } + }, + [props.onClick, profile.settings, markVideoAsWatched] + ); + + const copyInfoHashToClipboard = React.useCallback((event) => { + event.preventDefault(); + if (infoHash && navigator?.clipboard) { + navigator?.clipboard?.writeText(infoHash); toast.show({ type: 'success', - title: 'Stream opened in external player', - timeout: 4000 + title: t('PLAYER_COPY_DOWNLOAD_LINK_SUCCESS'), + timeout: 4000, }); } + }, []); - if (typeof props.onClick === 'function') { - props.onClick(event); - } - }, [props.onClick, profile.settings, markVideoAsWatched]); + const renderThumbnailFallback = React.useCallback( + () => ( + + ), + [] + ); - const onContextMenu = React.useCallback((event) => { - if (typeof props.onContextMenu === 'function') { - props.onContextMenu(event); - } - }, [props.onContextMenu]); + const renderLabel = React.useMemo( + () => + function renderLabel({ className, thumbnail, progress, children, ...props }) { + return ( + + ); + }, + [] + ); - const renderThumbnailFallback = React.useCallback(() => ( - - ), []); + const renderMenu = function renderMenu() { + return ( +
+ + {infoHash && } +
+ ); + }; return ( - + ); }; @@ -143,11 +257,11 @@ Stream.propTypes = { windows: PropTypes.string, macos: PropTypes.string, linux: PropTypes.string, - }) - }) + }), + }), }), onClick: PropTypes.func, - onContextMenu: PropTypes.func + infoHash: PropTypes.string, }; module.exports = Stream; diff --git a/src/routes/MetaDetails/StreamsList/Stream/styles.less b/src/routes/MetaDetails/StreamsList/Stream/styles.less index 22996622c..fa7509e3a 100644 --- a/src/routes/MetaDetails/StreamsList/Stream/styles.less +++ b/src/routes/MetaDetails/StreamsList/Stream/styles.less @@ -3,6 +3,14 @@ @import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; @import (reference) '~stremio/common/screen-sizes.less'; +:import('~stremio/common/Popup/styles.less') { + context-menu-container: menu-container; + menu-direction-top-left: menu-direction-top-left; + menu-direction-bottom-left: menu-direction-bottom-left; + menu-direction-top-right: menu-direction-top-right; + menu-direction-bottom-right: menu-direction-bottom-right; +} + .stream-container { display: flex; flex-direction: row; @@ -105,6 +113,33 @@ } } +.context-menu-container { + max-width: calc(90% - 1.5rem); + z-index: 2; + + .context-menu-content { + --spatial-navigation-contain: contain; + + .context-menu-option-container { + display: flex; + flex-direction: row; + align-items: center; + padding: 1rem 1.5rem; + + &:hover, + &:focus { + background-color: var(--overlay-color); + } + + .context-menu-option-label { + font-size: 1rem; + font-weight: 500; + color:var(--primary-foreground-color); + } + } + } +} + @media only screen and (max-width: @small) { .stream-container { .description-container { @@ -128,3 +163,29 @@ } } } + +@media only screen and (max-width: @minimum) { + .video-container { + .context-menu-container { + &.menu-direction-top-left, + &.menu-direction-bottom-left { + right: 1.5rem; + } + + &.menu-direction-top-right, + &.menu-direction-bottom-right { + left: 1.5rem; + } + + &.menu-direction-top-left, + &.menu-direction-top-right { + bottom: 90%; + } + + &.menu-direction-bottom-left, + &.menu-direction-bottom-right { + top: 90%; + } + } + } +} diff --git a/src/routes/MetaDetails/StreamsList/StreamsList.js b/src/routes/MetaDetails/StreamsList/StreamsList.js index 728f773b3..da7b657ab 100644 --- a/src/routes/MetaDetails/StreamsList/StreamsList.js +++ b/src/routes/MetaDetails/StreamsList/StreamsList.js @@ -5,7 +5,7 @@ const PropTypes = require('prop-types'); const classnames = require('classnames'); const { useTranslation } = require('react-i18next'); const { default: Icon } = require('@stremio/stremio-icons/react'); -const { Button, Image, Multiselect, useToast } = require('stremio/common'); +const { Button, Image, Multiselect } = require('stremio/common'); const { useServices } = require('stremio/services'); const Stream = require('./Stream'); const styles = require('./styles'); @@ -15,7 +15,6 @@ const ALL_ADDONS_KEY = 'ALL'; const StreamsList = ({ className, video, ...props }) => { const { t } = useTranslation(); const { core } = useServices(); - const toast = useToast(); const [selectedAddon, setSelectedAddon] = React.useState(ALL_ADDONS_KEY); const onAddonSelected = React.useCallback((event) => { setSelectedAddon(event.value); @@ -38,36 +37,25 @@ const StreamsList = ({ className, video, ...props }) => { core.transport.analytics({ event: 'StreamClicked', args: { - stream - } + stream, + }, }); }, - onContextMenu: (e) => { - e.preventDefault(); - if(stream?.infoHash && navigator?.clipboard) { - stream?.infoHash && navigator?.clipboard?.writeText(stream.infoHash); - toast.show({ - type: 'success', - title: 'Copied infohash to clipboard', - timeout: 4000 - }); - } - }, - addonName: streams.addon.manifest.name - })) + addonName: streams.addon.manifest.name, + })), }; return streamsByAddon; }, {}); }, [props.streams]); const filteredStreams = React.useMemo(() => { - return selectedAddon === ALL_ADDONS_KEY ? - Object.values(streamsByAddon).map(({ streams }) => streams).flat(1) - : - streamsByAddon[selectedAddon] ? - streamsByAddon[selectedAddon].streams - : - []; + return selectedAddon === ALL_ADDONS_KEY + ? Object.values(streamsByAddon) + .map(({ streams }) => streams) + .flat(1) + : streamsByAddon[selectedAddon] + ? streamsByAddon[selectedAddon].streams + : []; }, [streamsByAddon, selectedAddon]); const selectableOptions = React.useMemo(() => { return { @@ -76,97 +64,106 @@ const StreamsList = ({ className, video, ...props }) => { { value: ALL_ADDONS_KEY, label: t('ALL_ADDONS'), - title: t('ALL_ADDONS') + title: t('ALL_ADDONS'), }, ...Object.keys(streamsByAddon).map((transportUrl) => ({ value: transportUrl, label: streamsByAddon[transportUrl].addon.manifest.name, title: streamsByAddon[transportUrl].addon.manifest.name, - })) + })), ], selected: [selectedAddon], - onSelect: onAddonSelected + onSelect: onAddonSelected, }; }, [streamsByAddon, selectedAddon]); return (
- { - props.streams.length === 0 ? -
- {' -
No addons were requested for streams!
+ {props.streams.length === 0 ? ( +
+ {' +
+ No addons were requested for streams!
- : - props.streams.every((streams) => streams.content.type === 'Err') ? -
- {' -
{t('NO_STREAM')}
-
- : - filteredStreams.length === 0 ? -
- - +
+ ) : props.streams.every((streams) => streams.content.type === 'Err') ? ( +
+ {' +
{t('NO_STREAM')}
+
+ ) : filteredStreams.length === 0 ? ( +
+ + +
+ ) : ( + + {countLoadingAddons > 0 ? ( +
+
+ {countLoadingAddons} {t('MOBILE_ADDONS_LOADING')}
- : + +
+ ) : null} +
+ {video ? ( - { - countLoadingAddons > 0 ? -
-
- {countLoadingAddons} {t('MOBILE_ADDONS_LOADING')} -
- -
- : - null - } -
- { - video ? - - -
- {`S${video?.season}E${video?.episode} ${(video?.title)}`} -
-
- : - null - } - { - Object.keys(streamsByAddon).length > 1 ? - - : - null - } -
-
- {filteredStreams.map((stream, index) => ( - - ))} + +
+ {`S${video?.season}E${video?.episode} ${video?.title}`}
- } -
+
+ {filteredStreams.map((stream, index) => ( + + ))} +
+
+ )} +
); @@ -175,7 +172,7 @@ const StreamsList = ({ className, video, ...props }) => { StreamsList.propTypes = { className: PropTypes.string, streams: PropTypes.arrayOf(PropTypes.object).isRequired, - video: PropTypes.object + video: PropTypes.object, }; module.exports = StreamsList;