mirror of
https://github.com/Stremio/stremio-web
synced 2026-04-21 13:37:26 +00:00
feat: impl context menu on subtitle choice
This commit is contained in:
parent
880304f46f
commit
965feed67b
7 changed files with 303 additions and 55 deletions
|
|
@ -10,6 +10,7 @@ type Props = {
|
|||
style?: object,
|
||||
href?: string,
|
||||
target?: string
|
||||
download?: string,
|
||||
title?: string,
|
||||
disabled?: boolean,
|
||||
tabIndex?: number,
|
||||
|
|
|
|||
|
|
@ -7,17 +7,20 @@ const PADDING = 8;
|
|||
|
||||
type Coordinates = [number, number];
|
||||
type Size = [number, number];
|
||||
type Lock = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode,
|
||||
on: RefObject<HTMLElement>[],
|
||||
autoClose: boolean,
|
||||
lock?: Lock,
|
||||
};
|
||||
|
||||
const ContextMenu = ({ children, on, autoClose }: Props) => {
|
||||
const ContextMenu = ({ children, on, autoClose, lock }: Props) => {
|
||||
const [active, setActive] = useState(false);
|
||||
const [position, setPosition] = useState<Coordinates>([0, 0]);
|
||||
const [containerSize, setContainerSize] = useState<Size>([0, 0]);
|
||||
const [triggerRect, setTriggerRect] = useState<DOMRect | null>(null);
|
||||
|
||||
const ref = useCallback((element: HTMLDivElement) => {
|
||||
element && setContainerSize([element.offsetWidth, element.offsetHeight]);
|
||||
|
|
@ -26,7 +29,32 @@ const ContextMenu = ({ children, on, autoClose }: Props) => {
|
|||
const style = useMemo(() => {
|
||||
const [viewportWidth, viewportHeight] = [window.innerWidth, window.innerHeight];
|
||||
const [containerWidth, containerHeight] = containerSize;
|
||||
const [x, y] = position;
|
||||
|
||||
let x: number;
|
||||
let y: number;
|
||||
|
||||
if (lock && triggerRect) {
|
||||
switch (lock) {
|
||||
case 'top':
|
||||
x = triggerRect.left;
|
||||
y = triggerRect.top - containerHeight;
|
||||
break;
|
||||
case 'bottom':
|
||||
x = triggerRect.left;
|
||||
y = triggerRect.bottom;
|
||||
break;
|
||||
case 'left':
|
||||
x = triggerRect.left - containerWidth;
|
||||
y = triggerRect.top;
|
||||
break;
|
||||
case 'right':
|
||||
x = triggerRect.right;
|
||||
y = triggerRect.top;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
[x, y] = position;
|
||||
}
|
||||
|
||||
const left = Math.max(
|
||||
PADDING,
|
||||
|
|
@ -45,7 +73,7 @@ const ContextMenu = ({ children, on, autoClose }: Props) => {
|
|||
);
|
||||
|
||||
return { top, left };
|
||||
}, [position, containerSize]);
|
||||
}, [position, containerSize, lock, triggerRect]);
|
||||
|
||||
const close = () => {
|
||||
setActive(false);
|
||||
|
|
@ -55,12 +83,17 @@ const ContextMenu = ({ children, on, autoClose }: Props) => {
|
|||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
const onContextMenu = useCallback((event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setPosition([event.clientX, event.clientY]);
|
||||
if (lock) {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
setTriggerRect(target.getBoundingClientRect());
|
||||
} else {
|
||||
setPosition([event.clientX, event.clientY]);
|
||||
}
|
||||
setActive(true);
|
||||
};
|
||||
}, [lock]);
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => event.key === 'Escape' && close(), []);
|
||||
|
||||
|
|
@ -76,7 +109,7 @@ const ContextMenu = ({ children, on, autoClose }: Props) => {
|
|||
on.forEach((ref) => ref.current && ref.current.removeEventListener('contextmenu', onContextMenu));
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [on]);
|
||||
}, [on, onContextMenu, handleKeyDown]);
|
||||
|
||||
return createPortal((
|
||||
<Transition when={active} name={'fade'}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
|
||||
|
||||
.variant-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 4rem;
|
||||
padding: 0 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
&:global(.selected), &:hover {
|
||||
background-color: var(--overlay-color);
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
.variant-label {
|
||||
flex: 1;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5rem;
|
||||
color: var(--primary-foreground-color);
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.variant-origin {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-placeholder-text);
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 100%;
|
||||
margin-left: 1rem;
|
||||
background-color: var(--secondary-accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-width: 16rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: var(--overlay-color);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
flex: none;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
color: var(--color-placeholder);
|
||||
}
|
||||
|
||||
.context-menu-option-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--primary-foreground-color);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import { Button, ContextMenu } from 'stremio/components';
|
||||
import { languages, useToast } from 'stremio/common';
|
||||
import styles from './SubtitleVariant.less';
|
||||
|
||||
type SubtitlesTrack = {
|
||||
id: string,
|
||||
addonSubtitleId?: string,
|
||||
lang: string,
|
||||
origin: string,
|
||||
label?: string,
|
||||
url?: string,
|
||||
fallbackUrl?: string,
|
||||
embedded?: boolean,
|
||||
local?: boolean,
|
||||
exclusive?: boolean,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
track: SubtitlesTrack,
|
||||
selected: boolean,
|
||||
onSelect: (track: SubtitlesTrack) => void,
|
||||
};
|
||||
|
||||
const SubtitleVariant = ({ track, selected, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const buttonRef = useRef<HTMLElement>(null);
|
||||
const triggers = useMemo(() => [buttonRef], []);
|
||||
|
||||
const downloadUrl = useMemo(() => {
|
||||
return track.fallbackUrl || track.url;
|
||||
}, [track.fallbackUrl, track.url]);
|
||||
|
||||
const variantLabel = useMemo(() => {
|
||||
return (track.label && track.label.length > 0 && !track.label.startsWith('http'))
|
||||
? track.label
|
||||
: languages.label(track.lang);
|
||||
}, [track.label, track.lang]);
|
||||
|
||||
const downloadFileName = useMemo(() => {
|
||||
return (track.label && track.label.length > 0 && !track.label.startsWith('http'))
|
||||
? track.label
|
||||
: `subtitle-${track.lang || 'unknown'}`;
|
||||
}, [track.label, track.lang]);
|
||||
|
||||
const canCopyUrl = useMemo(() => {
|
||||
return typeof downloadUrl === 'string' && !downloadUrl.startsWith('blob:');
|
||||
}, [downloadUrl]);
|
||||
|
||||
const onSelectClick = useCallback(() => {
|
||||
onSelect(track);
|
||||
}, [onSelect, track]);
|
||||
|
||||
const copyToClipboard = useCallback((value: string, successKey: string, errorKey: string) => {
|
||||
navigator.clipboard.writeText(value)
|
||||
.then(() => toast.show({ type: 'success', title: t(successKey), timeout: 4000 }))
|
||||
.catch(() => toast.show({ type: 'error', title: t(errorKey), timeout: 4000 }));
|
||||
}, [toast, t]);
|
||||
|
||||
const onCopyUrlClick = useCallback(() => {
|
||||
if (downloadUrl) {
|
||||
copyToClipboard(downloadUrl, 'PLAYER_COPY_SUBTITLE_URL_SUCCESS', 'PLAYER_COPY_SUBTITLE_URL_ERROR');
|
||||
}
|
||||
}, [downloadUrl, copyToClipboard]);
|
||||
|
||||
const onCopyIdClick = useCallback(() => {
|
||||
if (track.addonSubtitleId) {
|
||||
copyToClipboard(track.addonSubtitleId, 'PLAYER_COPY_SUBTITLE_ID_SUCCESS', 'PLAYER_COPY_SUBTITLE_ID_ERROR');
|
||||
}
|
||||
}, [track.addonSubtitleId, copyToClipboard]);
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
title={track.label}
|
||||
onClick={onSelectClick}
|
||||
className={classNames(styles['variant-option'], { 'selected': selected })}
|
||||
>
|
||||
<div className={styles['info']}>
|
||||
<div className={styles['variant-label']}>
|
||||
{variantLabel}
|
||||
</div>
|
||||
<div className={styles['variant-origin']}>
|
||||
{t(track.origin)}
|
||||
</div>
|
||||
</div>
|
||||
{selected ? <div className={styles['icon']} /> : null}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (track.embedded) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{button}
|
||||
<ContextMenu on={triggers} autoClose={true} lock={'bottom'}>
|
||||
{
|
||||
downloadUrl ?
|
||||
<Button
|
||||
className={styles['context-menu-option']}
|
||||
title={t('CTX_DOWNLOAD_SUBTITLE')}
|
||||
href={downloadUrl}
|
||||
target={'_blank'}
|
||||
download={downloadFileName}
|
||||
>
|
||||
<Icon className={styles['menu-icon']} name={'download'} />
|
||||
<div className={styles['context-menu-option-label']}>
|
||||
{t('CTX_DOWNLOAD_SUBTITLE')}
|
||||
</div>
|
||||
</Button>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
canCopyUrl ?
|
||||
<Button
|
||||
className={styles['context-menu-option']}
|
||||
title={t('CTX_COPY_SUBTITLE_URL')}
|
||||
onClick={onCopyUrlClick}
|
||||
>
|
||||
<Icon className={styles['menu-icon']} name={'link'} />
|
||||
<div className={styles['context-menu-option-label']}>
|
||||
{t('CTX_COPY_SUBTITLE_URL')}
|
||||
</div>
|
||||
</Button>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
track.addonSubtitleId ?
|
||||
<Button
|
||||
className={styles['context-menu-option']}
|
||||
title={t('CTX_COPY_SUBTITLE_ID')}
|
||||
onClick={onCopyIdClick}
|
||||
>
|
||||
<Icon className={styles['menu-icon']} name={'share'} />
|
||||
<div className={styles['context-menu-option-label']}>
|
||||
{t('CTX_COPY_SUBTITLE_ID')}
|
||||
</div>
|
||||
</Button>
|
||||
:
|
||||
null
|
||||
}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubtitleVariant;
|
||||
5
src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts
Normal file
5
src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import SubtitleVariant from './SubtitleVariant';
|
||||
|
||||
export default SubtitleVariant;
|
||||
|
|
@ -9,6 +9,7 @@ const { Button } = require('stremio/components');
|
|||
const styles = require('./styles');
|
||||
const { t } = require('i18next');
|
||||
const { default: Stepper } = require('./Stepper');
|
||||
const { default: SubtitleVariant } = require('./SubtitleVariant');
|
||||
|
||||
const ORIGIN_PRIORITIES = [
|
||||
'LOCAL',
|
||||
|
|
@ -102,14 +103,14 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
|
|||
}
|
||||
}
|
||||
}, [allSubtitles, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
|
||||
const subtitlesTrackOnClick = React.useCallback((event) => {
|
||||
if (event.currentTarget.dataset.embedded === 'true') {
|
||||
const subtitlesTrackOnSelect = React.useCallback((track) => {
|
||||
if (track.embedded) {
|
||||
if (typeof props.onSubtitlesTrackSelected === 'function') {
|
||||
props.onSubtitlesTrackSelected(event.currentTarget.dataset.id);
|
||||
props.onSubtitlesTrackSelected(track.id);
|
||||
}
|
||||
} else {
|
||||
if (typeof props.onExtraSubtitlesTrackSelected === 'function') {
|
||||
props.onExtraSubtitlesTrackSelected(event.currentTarget.dataset.id);
|
||||
props.onExtraSubtitlesTrackSelected(track.id);
|
||||
}
|
||||
}
|
||||
}, [props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
|
||||
|
|
@ -189,24 +190,12 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
|
|||
subtitlesTracksForLanguage.length > 0 ?
|
||||
<div className={styles['variants-list']}>
|
||||
{subtitlesTracksForLanguage.map((track, index) => (
|
||||
<Button key={index} title={track.label} className={classnames(styles['variant-option'], { 'selected': props.selectedSubtitlesTrackId === track.id || props.selectedExtraSubtitlesTrackId === track.id })} data-id={track.id} data-origin={track.origin} data-embedded={track.embedded} onClick={subtitlesTrackOnClick}>
|
||||
<div className={styles['info']}>
|
||||
<div className={styles['variant-label']}>
|
||||
{
|
||||
(track.label && track.label.length > 0 && !track.label.startsWith('http')) ? track.label : languages.label(track.lang)
|
||||
}
|
||||
</div>
|
||||
<div className={styles['variant-origin']}>
|
||||
{ t(track.origin) }
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
props.selectedSubtitlesTrackId === track.id || props.selectedExtraSubtitlesTrackId === track.id ?
|
||||
<div className={styles['icon']} />
|
||||
:
|
||||
null
|
||||
}
|
||||
</Button>
|
||||
<SubtitleVariant
|
||||
key={index}
|
||||
track={track}
|
||||
selected={props.selectedSubtitlesTrackId === track.id || props.selectedExtraSubtitlesTrackId === track.id}
|
||||
onSelect={subtitlesTrackOnSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
:
|
||||
|
|
@ -275,7 +264,11 @@ SubtitlesMenu.propTypes = {
|
|||
id: PropTypes.string.isRequired,
|
||||
lang: PropTypes.string.isRequired,
|
||||
origin: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired
|
||||
label: PropTypes.string,
|
||||
url: PropTypes.string,
|
||||
embedded: PropTypes.bool,
|
||||
local: PropTypes.bool,
|
||||
exclusive: PropTypes.bool
|
||||
})),
|
||||
selectedExtraSubtitlesTrackId: PropTypes.string,
|
||||
extraSubtitlesOffset: PropTypes.number,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
overflow-y: auto;
|
||||
padding: 0 1rem;
|
||||
|
||||
.language-option, .variant-option {
|
||||
.language-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
|
@ -40,13 +40,10 @@
|
|||
background-color: var(--overlay-color);
|
||||
}
|
||||
|
||||
.language-label, .variant-label {
|
||||
.language-label {
|
||||
flex: 1;
|
||||
font-size: 1.1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.language-label, .variant-label, .variant-origin {
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
|
@ -60,26 +57,6 @@
|
|||
background-color: var(--secondary-accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.variant-option {
|
||||
height: 4rem;
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
.variant-label {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.variant-origin {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-placeholder-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue