From 965feed67bfc9c45686e18e1395b3a5f7164aacd Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Mon, 20 Apr 2026 12:17:52 +0300 Subject: [PATCH 1/5] feat: impl context menu on subtitle choice --- src/components/Button/Button.tsx | 1 + src/components/ContextMenu/ContextMenu.tsx | 47 +++++- .../SubtitleVariant/SubtitleVariant.less | 82 +++++++++ .../SubtitleVariant/SubtitleVariant.tsx | 157 ++++++++++++++++++ .../SubtitlesMenu/SubtitleVariant/index.ts | 5 + .../Player/SubtitlesMenu/SubtitlesMenu.js | 39 ++--- src/routes/Player/SubtitlesMenu/styles.less | 27 +-- 7 files changed, 303 insertions(+), 55 deletions(-) create mode 100644 src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less create mode 100644 src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx create mode 100644 src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index a5756fc42..e1566c5d5 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -10,6 +10,7 @@ type Props = { style?: object, href?: string, target?: string + download?: string, title?: string, disabled?: boolean, tabIndex?: number, diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx index cb2d1f50c..1ac72949a 100644 --- a/src/components/ContextMenu/ContextMenu.tsx +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -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[], 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([0, 0]); const [containerSize, setContainerSize] = useState([0, 0]); + const [triggerRect, setTriggerRect] = useState(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(( diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less new file mode 100644 index 000000000..600b79658 --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.less @@ -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; + } +} diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx new file mode 100644 index 000000000..9ee6e1a9c --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx @@ -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(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 = ( + + ); + + if (track.embedded) { + return button; + } + + return ( + <> + {button} + + { + downloadUrl ? + + : + null + } + { + canCopyUrl ? + + : + null + } + { + track.addonSubtitleId ? + + : + null + } + + + ); +}; + +export default SubtitleVariant; diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts b/src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts new file mode 100644 index 000000000..16bda596b --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import SubtitleVariant from './SubtitleVariant'; + +export default SubtitleVariant; diff --git a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js index 8717aab07..ef70410f6 100644 --- a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js +++ b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js @@ -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 ?
{subtitlesTracksForLanguage.map((track, index) => ( - + ))}
: @@ -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, diff --git a/src/routes/Player/SubtitlesMenu/styles.less b/src/routes/Player/SubtitlesMenu/styles.less index bed7be75d..b0aa3b051 100644 --- a/src/routes/Player/SubtitlesMenu/styles.less +++ b/src/routes/Player/SubtitlesMenu/styles.less @@ -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); - } - } - } } } From b18df103fa47e9741786e592684b6da203b419e8 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Wed, 22 Apr 2026 22:21:31 +0300 Subject: [PATCH 2/5] refactor: simplify --- .../SubtitleVariant/SubtitleVariant.tsx | 25 +++++-------------- .../Player/SubtitlesMenu/SubtitlesMenu.js | 2 +- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx index 9ee6e1a9c..4033a4543 100644 --- a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx +++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx @@ -27,31 +27,18 @@ type Props = { onSelect: (track: SubtitlesTrack) => void, }; +const hasValidLabel = (label?: string) => label && label.length > 0 && !label.startsWith('http'); + const SubtitleVariant = ({ track, selected, onSelect }: Props) => { const { t } = useTranslation(); const toast = useToast(); const buttonRef = useRef(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 downloadUrl = track.fallbackUrl || track.url; + const variantLabel = hasValidLabel(track.label) ? track.label : languages.label(track.lang); + const downloadFileName = hasValidLabel(track.label) ? track.label : `subtitle-${track.lang || 'unknown'}`; + const canCopyUrl = typeof downloadUrl === 'string' && !downloadUrl.startsWith('blob:'); const onSelectClick = useCallback(() => { onSelect(track); diff --git a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js index 751362ba9..d72f3cd76 100644 --- a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js +++ b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js @@ -113,7 +113,7 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => { props.onExtraSubtitlesTrackSelected(track.id); } } - }, [subtitlesTracksForLanguage, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]); + }, [props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]); const onSubtitlesDelayChanged = React.useCallback((value) => { if (typeof props.selectedExtraSubtitlesTrackId === 'string') { if (props.extraSubtitlesDelay !== null && !isNaN(props.extraSubtitlesDelay)) { From d5b73f8dc253e91d46ad05f14a11e7ee867c8a0e Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Wed, 22 Apr 2026 22:24:22 +0300 Subject: [PATCH 3/5] refactor: simplify sub variant logic --- .../SubtitleVariant/SubtitleVariant.tsx | 39 +++++++------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx index 4033a4543..d5f4fecd9 100644 --- a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx +++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx @@ -2,10 +2,10 @@ 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 classNames from 'classnames'; +import Icon from '@stremio/stremio-icons/react'; import styles from './SubtitleVariant.less'; type SubtitlesTrack = { @@ -62,7 +62,7 @@ const SubtitleVariant = ({ track, selected, onSelect }: Props) => { } }, [track.addonSubtitleId, copyToClipboard]); - const button = ( + return ( - ); - - if (track.embedded) { - return button; - } - - return ( - <> - {button} - - { - downloadUrl ? + {!track.embedded && + + {downloadUrl ? : null - } - { - canCopyUrl ? + } + {canCopyUrl ? : null - } - { - track.addonSubtitleId ? + } + {track.addonSubtitleId ? : null - } - - + } + + } + ); }; From 3c417a33064277af1568493988c24a860d98e066 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Thu, 23 Apr 2026 19:40:37 +0200 Subject: [PATCH 4/5] fix: correctly show the variant label instead --- .../Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx index d5f4fecd9..4f7134c6e 100644 --- a/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx +++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx @@ -65,7 +65,7 @@ const SubtitleVariant = ({ track, selected, onSelect }: Props) => { return (