From 965feed67bfc9c45686e18e1395b3a5f7164aacd Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Mon, 20 Apr 2026 12:17:52 +0300 Subject: [PATCH 01/17] 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 e36d15eccf5308df5132b9323f6f73dc83989935 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Tue, 21 Apr 2026 00:26:52 +0300 Subject: [PATCH 02/17] feat: add hdr badge --- src/routes/Player/HdrBadge/HdrBadge.less | 19 ++++++++++++++ src/routes/Player/HdrBadge/HdrBadge.tsx | 33 ++++++++++++++++++++++++ src/routes/Player/Player.js | 5 ++++ src/routes/Player/styles.less | 7 +++++ src/routes/Player/useVideo.js | 1 + 5 files changed, 65 insertions(+) create mode 100644 src/routes/Player/HdrBadge/HdrBadge.less create mode 100644 src/routes/Player/HdrBadge/HdrBadge.tsx diff --git a/src/routes/Player/HdrBadge/HdrBadge.less b/src/routes/Player/HdrBadge/HdrBadge.less new file mode 100644 index 000000000..a373df267 --- /dev/null +++ b/src/routes/Player/HdrBadge/HdrBadge.less @@ -0,0 +1,19 @@ +.hdr-badge-container { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + user-select: none; + + .hdr-badge { + flex: none; + padding: 0.4rem 0.8rem; + border-radius: 0.4rem; + font-size: 1.2rem; + font-weight: bold; + letter-spacing: 0.05rem; + color: var(--primary-foreground-color); + background-color: var(--modal-background-color); + border: 0.1rem solid var(--primary-foreground-color); + } +} diff --git a/src/routes/Player/HdrBadge/HdrBadge.tsx b/src/routes/Player/HdrBadge/HdrBadge.tsx new file mode 100644 index 000000000..e7ca2f2b5 --- /dev/null +++ b/src/routes/Player/HdrBadge/HdrBadge.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './HdrBadge.less'; + +type HdrInfo = { + gamma?: string | null, + primaries?: string | null, +}; + +type Props = { + className?: string, + hdrInfo: HdrInfo | null, +}; + +const labelFor = (hdrInfo: HdrInfo | null): string | null => { + const gamma = hdrInfo?.gamma?.toLowerCase(); + if (gamma === 'pq') return 'HDR10'; + if (gamma === 'hlg') return 'HLG'; + return null; +}; + +const HdrBadge = ({ className, hdrInfo }: Props) => { + const label = labelFor(hdrInfo); + if (!label) return null; + + return ( +
+
{label}
+
+ ); +}; + +export default HdrBadge; diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 662717b70..4d56d3366 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -28,6 +28,7 @@ const useVideo = require('./useVideo'); const styles = require('./styles'); const Video = require('./Video'); const { default: Indicator } = require('./Indicator/Indicator'); +const { default: HdrBadge } = require('./HdrBadge/HdrBadge'); const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang); const findTrackById = (tracks, id) => tracks.find((track) => track.id === id); @@ -1010,6 +1011,10 @@ const Player = ({ urlParams, queryParams }) => { videoState={video.state} disabled={subtitlesMenuOpen} /> + { nextVideoPopupOpen ? { muted: null, playbackSpeed: null, videoParams: null, + hdrInfo: null, audioTracks: [], selectedAudioTrackId: null, subtitlesTracks: [], From c8592d56106b8165aa5d6fb072e8c36b85e8000f Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Tue, 21 Apr 2026 13:22:35 +0300 Subject: [PATCH 03/17] feat: use an icon for hdr badge --- .../HorizontalNavBar/HorizontalNavBar.js | 15 +++++++-- .../NavBar/HorizontalNavBar/styles.less | 20 +++++++++++ src/routes/Player/HdrBadge/HdrBadge.less | 19 ----------- src/routes/Player/HdrBadge/HdrBadge.tsx | 33 ------------------- src/routes/Player/Player.js | 6 +--- src/routes/Player/styles.less | 7 ---- 6 files changed, 34 insertions(+), 66 deletions(-) delete mode 100644 src/routes/Player/HdrBadge/HdrBadge.less delete mode 100644 src/routes/Player/HdrBadge/HdrBadge.tsx diff --git a/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js b/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js index 65bf30c94..6fed91c8a 100644 --- a/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js +++ b/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js @@ -12,7 +12,7 @@ const NavMenu = require('./NavMenu'); const styles = require('./styles'); const { t } = require('i18next'); -const HorizontalNavBar = React.memo(({ className, route, query, title, backButton, searchBar, fullscreenButton, navMenu, ...props }) => { +const HorizontalNavBar = React.memo(({ className, route, query, title, backButton, searchBar, fullscreenButton, navMenu, hdrInfo, ...props }) => { const backButtonOnClick = React.useCallback(() => { window.history.back(); }, []); @@ -53,6 +53,14 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto null }
+ { + hdrInfo && (hdrInfo.gamma === 'pq' || hdrInfo.gamma === 'hlg') ? +
+ +
+ : + null + } { !isIOSPWA && fullscreenButton ? @@ -194,6 +200,9 @@ ControlBar.propTypes = { volume: PropTypes.number, muted: PropTypes.bool, playbackSpeed: PropTypes.number, + videoScale: PropTypes.string, + videoScaleLabel: PropTypes.string, + onVideoScaleChanged: PropTypes.func, subtitlesTracks: PropTypes.array, audioTracks: PropTypes.array, metaItem: PropTypes.object, diff --git a/src/routes/Player/Indicator/Indicator.tsx b/src/routes/Player/Indicator/Indicator.tsx index 7525fe1cd..b9290ad64 100644 --- a/src/routes/Player/Indicator/Indicator.tsx +++ b/src/routes/Player/Indicator/Indicator.tsx @@ -7,7 +7,13 @@ import styles from './Indicator.less'; type Property = { label: string, - format: (value: number) => string, + format: (value: number | string) => string, +}; + +const VIDEO_SCALE_LABELS: Record = { + 'contain': 'Fit', + 'cover': 'Crop', + 'fill': 'Stretch', }; const PROPERTIES: Record = { @@ -15,9 +21,13 @@ const PROPERTIES: Record = { label: 'SUBTITLES_DELAY', format: (value) => `${(value / 1000).toFixed(2)}s`, }, + 'videoScale': { + label: 'VIDEO_SCALE', + format: (value) => VIDEO_SCALE_LABELS[String(value)] || String(value), + }, }; -type VideoState = Record; +type VideoState = Record; type Props = { className: string, diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 662717b70..add5c0c7c 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -97,6 +97,9 @@ const Player = ({ urlParams, queryParams }) => { const isNavigating = React.useRef(false); + const VIDEO_SCALES = ['contain', 'cover', 'fill']; + const VIDEO_SCALE_LABELS = { contain: 'Fit', cover: 'Crop', fill: 'Stretch' }; + const playbackSpeed = React.useRef(video.state.playbackSpeed || 1); const pressTimer = React.useRef(null); const longPress = React.useRef(false); @@ -235,6 +238,13 @@ const Player = ({ urlParams, queryParams }) => { }, []); + const onVideoScaleChanged = React.useCallback(() => { + const currentScale = video.state.videoScale || 'contain'; + const currentIndex = VIDEO_SCALES.indexOf(currentScale); + const nextScale = VIDEO_SCALES[(currentIndex + 1) % VIDEO_SCALES.length]; + video.setVideoScale(nextScale); + }, [video.state.videoScale]); + const onSubtitlesTrackSelected = React.useCallback((track) => { video.setSubtitlesTrack(track?.id ?? null); streamStateChanged({ @@ -999,6 +1009,9 @@ const Player = ({ urlParams, queryParams }) => { onToggleSubtitlesMenu={toggleSubtitlesMenu} onToggleAudioMenu={toggleAudioMenu} onToggleSpeedMenu={toggleSpeedMenu} + videoScale={video.state.videoScale} + videoScaleLabel={VIDEO_SCALE_LABELS[video.state.videoScale || 'contain']} + onVideoScaleChanged={onVideoScaleChanged} onToggleStatisticsMenu={toggleStatisticsMenu} onToggleSideDrawer={toggleSideDrawer} onMouseMove={onBarMouseMove} diff --git a/src/routes/Player/useVideo.js b/src/routes/Player/useVideo.js index b3a5d2e39..bfc0ec810 100644 --- a/src/routes/Player/useVideo.js +++ b/src/routes/Player/useVideo.js @@ -142,6 +142,10 @@ const useVideo = () => { setProp('extraSubtitlesOffset', offset); }; + const setVideoScale = (scale) => { + setProp('videoScale', scale); + }; + const setSubtitlesTextColor = (color) => { setProp('subtitlesTextColor', color); setProp('extraSubtitlesTextColor', color); @@ -238,6 +242,7 @@ const useVideo = () => { setSubtitlesBackgroundColor, setSubtitlesOutlineColor, setExtraSubtitlesTrack, + setVideoScale, }; }; From 45f8e31f1ab296b00db81ef8eede9aec08a497f6 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Wed, 22 Apr 2026 17:42:56 +0300 Subject: [PATCH 08/17] feat: player add media key support --- src/routes/Player/Player.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 662717b70..a762ebdf3 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -643,6 +643,26 @@ const Player = ({ urlParams, queryParams }) => { navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback); }, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]); + React.useEffect(() => { + const onMediaKey = (action) => { + switch (action) { + case 'play-pause': + video.state.paused ? onPlayRequested() : onPauseRequested(); + break; + case 'next-track': + onNextVideoRequested(); + break; + case 'previous-track': + if (video.state.time !== null && video.state.time > 5000) { + onSeekRequested(0); + } + break; + } + }; + shell.on('media-key', onMediaKey); + return () => shell.off('media-key', onMediaKey); + }, [video.state.paused, video.state.time, onPlayRequested, onPauseRequested, onNextVideoRequested, onSeekRequested]); + onShortcut('seekForward', (combo) => { if (video.state.time !== null) { const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration; From 17d823565a31bbdb84239be700a7e883625a3220 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Wed, 22 Apr 2026 17:55:26 +0300 Subject: [PATCH 09/17] fix: correctly start next video from 0 --- src/routes/Player/Player.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index a762ebdf3..497520b81 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -650,6 +650,7 @@ const Player = ({ urlParams, queryParams }) => { video.state.paused ? onPlayRequested() : onPauseRequested(); break; case 'next-track': + video.setTime(0); onNextVideoRequested(); break; case 'previous-track': From b18df103fa47e9741786e592684b6da203b419e8 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Wed, 22 Apr 2026 22:21:31 +0300 Subject: [PATCH 10/17] 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 11/17] 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 (
{selected ?
: null} - - ); - - if (track.embedded) { - return button; - } - - return ( - <> - {button} - - { - downloadUrl ? + {!track.embedded && + + {downloadUrl ? : null - } - { - canCopyUrl ? + } + {canCopyUrl ? : null - } - { - track.addonSubtitleId ? + } + {track.addonSubtitleId ? : null - } - - + } + + } + ); }; From ca37d77c2dead0b40ba84004f6222d717051dbf6 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Thu, 23 Apr 2026 13:58:02 +0300 Subject: [PATCH 12/17] bump: stremio-video to v0.0.77 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 4fe8c9adf..4634525f8 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@stremio/stremio-colors": "5.2.0", "@stremio/stremio-core-web": "0.56.4", "@stremio/stremio-icons": "5.8.0", - "@stremio/stremio-video": "0.0.75", + "@stremio/stremio-video": "0.0.77", "a-color-picker": "1.2.1", "bowser": "2.11.0", "buffer": "6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c9853d5f..7de85f520 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: 5.8.0 version: 5.8.0 '@stremio/stremio-video': - specifier: 0.0.75 - version: 0.0.75 + specifier: 0.0.77 + version: 0.0.77 a-color-picker: specifier: 1.2.1 version: 1.2.1 @@ -1126,8 +1126,8 @@ packages: '@stremio/stremio-icons@5.8.0': resolution: {integrity: sha512-IVUvQbIWfA4YEHCTed7v/sdQJCJ+OOCf84LTWpkE2W6GLQ+15WHcMEJrVkE1X3ekYJnGg3GjT0KLO6tKSU0P4w==} - '@stremio/stremio-video@0.0.75': - resolution: {integrity: sha512-oKXMq156BVagzziWoTsmgNYABCSfwV9hR/TM6+JR4lne5pW4qmUN17ba/Fxsr+USKHeCKUaz1u0asKBj06HfyA==} + '@stremio/stremio-video@0.0.77': + resolution: {integrity: sha512-bnKBS5a9R3+M0zx95YpDUiPs1gXcKCsybgdxfZmpWuQaN0RE9bTBAUlIfBSrcEjVhufMOvg+cfXScT+0fBzTTw==} '@stylistic/eslint-plugin-jsx@4.4.1': resolution: {integrity: sha512-83SInq4u7z71vWwGG+6ViOtlOmZ6tSrDkMPhrvdBBTGMLA0gs22WSdhQ4vZP3oJ5Xg4ythvqeUiFSedvVxzhyA==} @@ -5876,7 +5876,7 @@ snapshots: '@stremio/stremio-icons@5.8.0': {} - '@stremio/stremio-video@0.0.75': + '@stremio/stremio-video@0.0.77': dependencies: buffer: 6.0.3 color: 4.2.3 From f579873e1bc6e94622f0ec96d58fdfdc63edf51b Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Thu, 23 Apr 2026 16:17:38 +0200 Subject: [PATCH 13/17] fix: don't restart stream on next-track media key when no next video MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'next-track' media-key handler called video.setTime(0) before checking whether a next video existed. onNextVideoRequested() no-ops when player.nextVideo is null, but the unconditional setTime(0) had already rewound the current stream — causing movies (which have no next video) to restart from the beginning when the Windows next-track media key was pressed. Guard both calls with the same player.nextVideo check that the navigator.mediaSession 'nexttrack' handler already uses. --- src/routes/Player/Player.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 497520b81..d1d981f65 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -650,8 +650,10 @@ const Player = ({ urlParams, queryParams }) => { video.state.paused ? onPlayRequested() : onPauseRequested(); break; case 'next-track': - video.setTime(0); - onNextVideoRequested(); + if (player.nextVideo !== null) { + video.setTime(0); + onNextVideoRequested(); + } break; case 'previous-track': if (video.state.time !== null && video.state.time > 5000) { @@ -662,7 +664,7 @@ const Player = ({ urlParams, queryParams }) => { }; shell.on('media-key', onMediaKey); return () => shell.off('media-key', onMediaKey); - }, [video.state.paused, video.state.time, onPlayRequested, onPauseRequested, onNextVideoRequested, onSeekRequested]); + }, [video.state.paused, video.state.time, player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested, onSeekRequested]); onShortcut('seekForward', (combo) => { if (video.state.time !== null) { From 3c417a33064277af1568493988c24a860d98e066 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Thu, 23 Apr 2026 19:40:37 +0200 Subject: [PATCH 14/17] 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 (