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..9bd8f909c --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/SubtitleVariant/SubtitleVariant.tsx @@ -0,0 +1,136 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import React, { useCallback, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +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 = { + 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 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 = 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 hoverTitle = hasValidLabel(track.label) + ? track.label + : downloadUrl?.split('/').pop()?.split('?')[0] || variantLabel; + + 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]); + + return ( + + : + 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 742904f3a..a71969bd5 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,9 +103,8 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => { } } }, [allSubtitles, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]); - const subtitlesTrackOnClick = React.useCallback((event) => { - const track = subtitlesTracksForLanguage.find((t) => t.id === event.currentTarget.dataset.id) ?? null; - if (track?.embedded) { + const subtitlesTrackOnSelect = React.useCallback((track) => { + if (track.embedded) { if (typeof props.onSubtitlesTrackSelected === 'function') { props.onSubtitlesTrackSelected(track); } @@ -113,7 +113,7 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => { props.onExtraSubtitlesTrackSelected(track); } } - }, [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)) { @@ -190,24 +190,12 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => { subtitlesTracksForLanguage.length > 0 ?
{subtitlesTracksForLanguage.map((track, index) => ( - + ))}
: @@ -276,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); - } - } - } } }