diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 973b6a625..a20479339 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup node uses: actions/setup-node@v4 with: diff --git a/.github/workflows/pages_cleanup.yml b/.github/workflows/pages_cleanup.yml new file mode 100644 index 000000000..58ba548a7 --- /dev/null +++ b/.github/workflows/pages_cleanup.yml @@ -0,0 +1,36 @@ +name: GitHub Pages Cleanup + +on: + schedule: + - cron: '0 0 * * 0' + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + ref: gh-pages + fetch-depth: 0 + + - name: Delete directories older than 1 year + run: | + for dir in $(find . -mindepth 1 -maxdepth 2 -type d -not -path '*/\.*'); do + if ! git log -1 --since="1 year ago" -- "$dir" | grep -q .; then + echo "Deleting $dir" + rm -rf "$dir" + fi + done + + - name: Commit and push + run: | + git config --global user.name 'GitHub Pages Cleanup' + git config --global user.email 'actions@stremio.com' + git add -A + git diff --cached --quiet || git commit -m "cleanup" + git push origin gh-pages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b9f26b6e..5c27de22d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install NPM dependencies run: npm install - name: Build @@ -19,16 +19,10 @@ jobs: - name: Zip build artifact run: zip -r stremio-web.zip ./build - name: Upload build artifact to GitHub release assets - uses: svenstaro/upload-release-action@2.10.0 + uses: svenstaro/upload-release-action@2.11.2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: stremio-web.zip asset_name: stremio-web.zip tag: ${{ github.ref }} - overwrite: true - - name: Upload build artifact to Netlify - run: | - curl -H "Content-Type: application/zip" \ - -H "Authorization: Bearer ${{ secrets.netlify_access_token }}" \ - --data-binary "@stremio-web.zip" \ - https://api.netlify.com/api/v1/sites/stremio-development.netlify.com/deploys + overwrite: true \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1fb9d968d..0ad583043 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,20 @@ { "name": "stremio", - "version": "5.0.0-beta.23", + "version": "5.0.0-beta.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stremio", - "version": "5.0.0-beta.23", + "version": "5.0.0-beta.26", "license": "gpl-2.0", "dependencies": { "@babel/runtime": "7.26.0", "@sentry/browser": "8.42.0", "@stremio/stremio-colors": "5.2.0", - "@stremio/stremio-core-web": "0.49.3", - "@stremio/stremio-icons": "5.4.1", - "@stremio/stremio-video": "0.0.60", + "@stremio/stremio-core-web": "0.49.4", + "@stremio/stremio-icons": "5.7.1", + "@stremio/stremio-video": "0.0.61", "a-color-picker": "1.2.1", "bowser": "2.11.0", "buffer": "6.0.3", @@ -38,7 +38,7 @@ "react-router": "6.30.0", "react-router-dom": "6.30.0", "spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6", - "stremio-translations": "github:Stremio/stremio-translations#8212fa77c4febd22ddb611590e9fb574dc845416", + "stremio-translations": "github:Stremio/stremio-translations#abe7684165a031755e9aee39da26daa806ba7824", "url": "0.11.4", "use-long-press": "^3.2.0" }, @@ -3385,10 +3385,9 @@ "integrity": "sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg==" }, "node_modules/@stremio/stremio-core-web": { - "version": "0.49.3", - "resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.3.tgz", - "integrity": "sha512-Ql/08LbwU99IUL6fOLy+v1Iv75boHXpunEPScKgXJALdq/OV5tZLG/IycN0O+5+50Nc/NHrI6HslnMNLTWA8JQ==", - "license": "MIT", + "version": "0.49.4", + "resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.49.4.tgz", + "integrity": "sha512-K9LJGKXs8juV3pZOHH6thWTwOShAhjFt9bLL6K1VlORAe6AiieZ2uRp9wdOwFmPX+UgzWLIOd0r2aFXJ4OsJCw==", "dependencies": { "@babel/runtime": "7.24.1" } @@ -3412,9 +3411,10 @@ "license": "MIT" }, "node_modules/@stremio/stremio-icons": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@stremio/stremio-icons/-/stremio-icons-5.4.1.tgz", - "integrity": "sha512-7g4JP7tPRT1UDZxbuH/Urq7fc6te3joy8qyx/NGWIW7wO169TTISO7ZWdejzESvUVgZ/7i6rzkRmXZ3wefWcBg==", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/@stremio/stremio-icons/-/stremio-icons-5.7.1.tgz", + "integrity": "sha512-Z96p36LLX3G+ewMnFKmNZVsO/AtcHA33WQ3wGOYFubxiYADPRAkcLVU5rHIfiGSC9IUaUVhxQWTPVB9ScY4Q5Q==", + "license": "MIT", "workspaces": [ "react", "react-native", @@ -3423,9 +3423,9 @@ ] }, "node_modules/@stremio/stremio-video": { - "version": "0.0.60", - "resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.60.tgz", - "integrity": "sha512-RbmSi+Lk+3pb6f2ZkGVCnoMoJoujvVvSLDHiLGkXnzQwjYf2B2022NKlAQmHRuHN1sjD+VEsKD8foQH4hXGG1A==", + "version": "0.0.61", + "resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.61.tgz", + "integrity": "sha512-+m3ScDmImTilcpCnY5WO091SdWuDMrW8KkUs7y+ZL6PioZXNtd8fvRsmQoHKkWkkKX3K3LNTIfA7w5unITv1jA==", "license": "MIT", "dependencies": { "buffer": "6.0.3", @@ -13477,8 +13477,8 @@ }, "node_modules/stremio-translations": { "version": "1.44.12", - "resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#8212fa77c4febd22ddb611590e9fb574dc845416", - "integrity": "sha512-5DladLUsghLlVRsZh2bBnb7UMqU8NEYMHc+YbzBvb1llgMk9elXFSHtAjInepZlC5zWx2pJYOQ8lQzzqogQdFw==", + "resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#abe7684165a031755e9aee39da26daa806ba7824", + "integrity": "sha512-bMpdJTFZqgemdoOQAARMPG7XaFgeu/zW/0vHmzavTM9DYUNIGuQaTC5RbVXIIII00RLOXoGLYf+dsxRVFiS9mA==", "license": "MIT" }, "node_modules/string_decoder": { diff --git a/package.json b/package.json index b7d1f108f..a31285d10 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stremio", "displayName": "Stremio", - "version": "5.0.0-beta.23", + "version": "5.0.0-beta.26", "author": "Smart Code OOD", "private": true, "license": "gpl-2.0", @@ -17,9 +17,9 @@ "@babel/runtime": "7.26.0", "@sentry/browser": "8.42.0", "@stremio/stremio-colors": "5.2.0", - "@stremio/stremio-core-web": "0.49.3", - "@stremio/stremio-icons": "5.4.1", - "@stremio/stremio-video": "0.0.60", + "@stremio/stremio-core-web": "0.49.4", + "@stremio/stremio-icons": "5.7.1", + "@stremio/stremio-video": "0.0.61", "a-color-picker": "1.2.1", "bowser": "2.11.0", "buffer": "6.0.3", @@ -43,7 +43,7 @@ "react-router": "6.30.0", "react-router-dom": "6.30.0", "spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6", - "stremio-translations": "github:Stremio/stremio-translations#8212fa77c4febd22ddb611590e9fb574dc845416", + "stremio-translations": "github:Stremio/stremio-translations#abe7684165a031755e9aee39da26daa806ba7824", "url": "0.11.4", "use-long-press": "^3.2.0" }, diff --git a/src/common/index.js b/src/common/index.js index a691d58c5..0200e1b17 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -15,6 +15,7 @@ const routesRegexp = require('./routesRegexp'); const useAnimationFrame = require('./useAnimationFrame'); const useBinaryState = require('./useBinaryState'); const { default: useFullscreen } = require('./useFullscreen'); +const { default: useInterval } = require('./useInterval'); const useLiveRef = require('./useLiveRef'); const useModelState = require('./useModelState'); const useNotifications = require('./useNotifications'); @@ -24,9 +25,11 @@ const { default: useRouteFocused } = require('./useRouteFocused'); const { default: useSettings } = require('./useSettings'); const { default: useShell } = require('./useShell'); const useStreamingServer = require('./useStreamingServer'); +const { default: useTimeout } = require('./useTimeout'); const useTorrent = require('./useTorrent'); const useTranslate = require('./useTranslate'); const { default: useOrientation } = require('./useOrientation'); +const { default: useLanguageSorting } = require('./useLanguageSorting'); module.exports = { FileDropProvider, @@ -49,6 +52,7 @@ module.exports = { useAnimationFrame, useBinaryState, useFullscreen, + useInterval, useLiveRef, useModelState, useNotifications, @@ -58,7 +62,9 @@ module.exports = { useSettings, useShell, useStreamingServer, + useTimeout, useTorrent, useTranslate, useOrientation, + useLanguageSorting, }; diff --git a/src/common/useFullscreen.ts b/src/common/useFullscreen.ts index 9bd5d0fc5..69cdcd494 100644 --- a/src/common/useFullscreen.ts +++ b/src/common/useFullscreen.ts @@ -42,11 +42,21 @@ const useFullscreen = () => { }; const onKeyDown = (event: KeyboardEvent) => { + + const activeElement = document.activeElement as HTMLElement; + + const inputFocused = + activeElement && + (activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' || + activeElement.tagName === 'SELECT' || + activeElement.isContentEditable); + if (event.code === 'Escape' && settings.escExitFullscreen) { exitFullscreen(); } - if (event.code === 'KeyF') { + if (event.code === 'KeyF' && !inputFocused) { toggleFullscreen(); } diff --git a/src/common/useInterval.ts b/src/common/useInterval.ts new file mode 100644 index 000000000..49fba607e --- /dev/null +++ b/src/common/useInterval.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react'; + +const useInterval = (duration: number) => { + const interval = useRef(null); + + const start = (callback: () => void) => { + cancel(); + interval.current = setInterval(callback, duration); + }; + + const cancel = () => { + interval.current && clearInterval(interval.current); + interval.current = null; + }; + + useEffect(() => { + return () => cancel(); + }, []); + + return { + start, + cancel, + }; +}; + +export default useInterval; diff --git a/src/common/useLanguageSorting.ts b/src/common/useLanguageSorting.ts new file mode 100644 index 000000000..d5969cf76 --- /dev/null +++ b/src/common/useLanguageSorting.ts @@ -0,0 +1,38 @@ +import { useMemo } from 'react'; +import interfaceLanguages from 'stremio/common/interfaceLanguages.json'; + +const useLanguageSorting = (options: MultiselectMenuOption[]) => { + const userLangCode = useMemo(() => { + const lang = interfaceLanguages.find((l) => l.codes.includes(navigator.language || 'en-US')); + if (lang) { + const threeLetter = lang.codes[1] || 'eng'; + const fullLocale = navigator.language || 'en-US'; + return [threeLetter, fullLocale]; + } + return ['eng']; + }, []); + + const isLanguageDropdown = useMemo(() => { + return options?.some((opt) => interfaceLanguages.some((l) => l.name === opt.label)); + }, [options]); + + const sortedOptions = useMemo(() => { + const matchingIndex = options.findIndex((opt) => { + const lang = interfaceLanguages.find((l) => l.name === opt.label); + return userLangCode.some((code) => lang?.codes.includes(code)); + }); + + if (matchingIndex === -1) { + return [...options].sort((a, b) => a.label.localeCompare(b.label)); + } + + const matchingOption = options[matchingIndex]; + const otherOptions = options.filter((_, idx) => idx !== matchingIndex).sort((a, b) => a.label.localeCompare(b.label)); + + return [matchingOption, ...otherOptions]; + }, [options, userLangCode, isLanguageDropdown]); + + return { userLangCode, isLanguageDropdown, sortedOptions }; +}; + +export default useLanguageSorting; diff --git a/src/common/useTimeout.ts b/src/common/useTimeout.ts new file mode 100644 index 000000000..e865aeefd --- /dev/null +++ b/src/common/useTimeout.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react'; + +const useTimeout = (duration: number) => { + const timeout = useRef(null); + + const start = (callback: () => void) => { + cancel(); + timeout.current = setTimeout(callback, duration); + }; + + const cancel = () => { + timeout.current && clearTimeout(timeout.current); + timeout.current = null; + }; + + useEffect(() => { + return () => cancel(); + }, []); + + return { + start, + cancel, + }; +}; + +export default useTimeout; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index dcce809d2..a5756fc42 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -16,6 +16,8 @@ type Props = { children: React.ReactNode, onKeyDown?: (event: React.KeyboardEvent) => void, onMouseDown?: (event: React.MouseEvent) => void, + onMouseUp?: (event: React.MouseEvent) => void, + onMouseLeave?: (event: React.MouseEvent) => void, onLongPress?: () => void, onClick?: (event: React.MouseEvent) => void, onDoubleClick?: () => void, diff --git a/src/components/MetaPreview/MetaPreview.js b/src/components/MetaPreview/MetaPreview.js index c0e9fb165..13717919a 100644 --- a/src/components/MetaPreview/MetaPreview.js +++ b/src/components/MetaPreview/MetaPreview.js @@ -17,6 +17,7 @@ const ActionButton = require('./ActionButton'); const MetaLinks = require('./MetaLinks'); const MetaPreviewPlaceholder = require('./MetaPreviewPlaceholder'); const styles = require('./styles'); +const { Ratings } = require('./Ratings'); const ALLOWED_LINK_REDIRECTS = [ routesRegexp.search.regexp, @@ -24,7 +25,7 @@ const ALLOWED_LINK_REDIRECTS = [ routesRegexp.metadetails.regexp ]; -const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary }, ref) => { +const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, ratingInfo }, ref) => { const { t } = useTranslation(); const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false); const linksGroups = React.useMemo(() => { @@ -232,6 +233,15 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou : null } + { + !compact && ratingInfo !== null ? + + : + null + } { linksGroups.has(CONSTANTS.SHARE_LINK_CATEGORY) && !compact ? @@ -287,7 +297,8 @@ MetaPreview.propTypes = { })), trailerStreams: PropTypes.array, inLibrary: PropTypes.bool, - toggleInLibrary: PropTypes.func + toggleInLibrary: PropTypes.func, + ratingInfo: PropTypes.object, }; module.exports = MetaPreview; diff --git a/src/components/MetaPreview/Ratings/Ratings.less b/src/components/MetaPreview/Ratings/Ratings.less new file mode 100644 index 000000000..afe7b3637 --- /dev/null +++ b/src/components/MetaPreview/Ratings/Ratings.less @@ -0,0 +1,61 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +@import (reference) '~stremio/common/screen-sizes.less'; + +@height: 4rem; +@width: 4rem; +@height-mobile: 3rem; +@width-mobile: 3rem; + + +.ratings-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + background-color: var(--overlay-color); + border-radius: 2rem; + height: @height; + width: fit-content; + + .icon-container { + display: flex; + justify-content: center; + align-items: center; + height: @height; + width: @width; + padding: 0 1rem; + cursor: pointer; + + .icon { + width: calc(@width / 2); + height: calc(@height / 2); + color: var(--primary-foreground-color); + opacity: 0.7; + + &:hover { + opacity: 1; + } + } + + &.disabled { + pointer-events: none; + } + } +} + +@media @phone-landscape { + .ratings-container { + height: @height-mobile; + + .icon-container { + height: @height-mobile; + width: @width-mobile; + + .icon { + width: 1.75rem; + height: 1.75rem; + } + } + } +} \ No newline at end of file diff --git a/src/components/MetaPreview/Ratings/Ratings.tsx b/src/components/MetaPreview/Ratings/Ratings.tsx new file mode 100644 index 000000000..6bef0cc6d --- /dev/null +++ b/src/components/MetaPreview/Ratings/Ratings.tsx @@ -0,0 +1,31 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import React, { useMemo } from 'react'; +import useRating from './useRating'; +import styles from './Ratings.less'; +import Icon from '@stremio/stremio-icons/react'; +import classNames from 'classnames'; + +type Props = { + metaId?: string; + ratingInfo?: Loadable; + className?: string; +}; + +const Ratings = ({ ratingInfo, className }: Props) => { + const { onLiked, onLoved, liked, loved } = useRating(ratingInfo); + const disabled = useMemo(() => ratingInfo?.type !== 'Ready', [ratingInfo]); + + return ( +
+
+ +
+
+ +
+
+ ); +}; + +export default Ratings; diff --git a/src/components/MetaPreview/Ratings/index.ts b/src/components/MetaPreview/Ratings/index.ts new file mode 100644 index 000000000..0a00e9c2f --- /dev/null +++ b/src/components/MetaPreview/Ratings/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import Ratings from './Ratings'; + +export { Ratings }; diff --git a/src/components/MetaPreview/Ratings/useRating.ts b/src/components/MetaPreview/Ratings/useRating.ts new file mode 100644 index 000000000..286ad7284 --- /dev/null +++ b/src/components/MetaPreview/Ratings/useRating.ts @@ -0,0 +1,48 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import { useMemo, useCallback } from 'react'; +import { useServices } from 'stremio/services'; + +const useRating = (ratingInfo?: Loadable) => { + const { core } = useServices(); + + const setRating = useCallback((status: Rating) => { + core.transport.dispatch({ + action: 'MetaDetails', + args: { + action: 'Rate', + args: status, + }, + }); + }, []); + + const status = useMemo(() => { + const content = ratingInfo?.type === 'Ready' ? ratingInfo.content as RatingInfo : null; + return content?.status; + }, [ratingInfo]); + + const liked = useMemo(() => { + return status === 'liked'; + }, [status]); + + const loved = useMemo(() => { + return status === 'loved'; + }, [status]); + + const onLiked = useCallback(() => { + setRating(status === 'liked' ? null : 'liked'); + }, [status]); + + const onLoved = useCallback(() => { + setRating(status === 'loved' ? null : 'loved'); + }, [status]); + + return { + onLiked, + onLoved, + liked, + loved, + }; +}; + +export default useRating; diff --git a/src/components/MetaPreview/styles.less b/src/components/MetaPreview/styles.less index 0725a384e..a614acf25 100644 --- a/src/components/MetaPreview/styles.less +++ b/src/components/MetaPreview/styles.less @@ -159,7 +159,6 @@ display: flex; flex-direction: row; align-items: flex-end; - max-height: 15rem; flex-wrap: wrap; padding-top: 3.5rem; overflow: visible; @@ -209,6 +208,11 @@ } } } + + .ratings { + margin-bottom: 1rem; + margin-right: 1rem; + } } .share-prompt { diff --git a/src/components/Multiselect/styles.less b/src/components/Multiselect/styles.less index f87c86d9b..4298b2697 100644 --- a/src/components/Multiselect/styles.less +++ b/src/components/Multiselect/styles.less @@ -50,7 +50,7 @@ .modal-container, .popup-menu-container { .menu-container { - max-height: calc(3.2rem * 7); + max-height: calc(3rem * 7); .option-container { display: flex; diff --git a/src/components/MultiselectMenu/Dropdown/Dropdown.less b/src/components/MultiselectMenu/Dropdown/Dropdown.less index 2373241f2..18681b4a3 100644 --- a/src/components/MultiselectMenu/Dropdown/Dropdown.less +++ b/src/components/MultiselectMenu/Dropdown/Dropdown.less @@ -3,6 +3,7 @@ @import (reference) '~stremio/common/screen-sizes.less'; @parent-height: 12rem; +@item-height: 3rem; .dropdown { background: var(--modal-background-color); @@ -18,7 +19,7 @@ &.open { display: block; - max-height: calc(3.3rem * 7); + max-height: calc(@item-height * 7); overflow: auto; } diff --git a/src/components/MultiselectMenu/Dropdown/Option/Option.less b/src/components/MultiselectMenu/Dropdown/Option/Option.less index a0ee1743f..03a5d03d1 100644 --- a/src/components/MultiselectMenu/Dropdown/Option/Option.less +++ b/src/components/MultiselectMenu/Dropdown/Option/Option.less @@ -1,6 +1,9 @@ // Copyright (C) 2017-2024 Smart code 203358507 +@height: 3rem; + .option { + height: @height; font-size: var(--font-size-normal); color: var(--primary-foreground-color); align-items: center; diff --git a/src/components/MultiselectMenu/MultiselectMenu.less b/src/components/MultiselectMenu/MultiselectMenu.less index 4aee1a4a8..1a886322e 100644 --- a/src/components/MultiselectMenu/MultiselectMenu.less +++ b/src/components/MultiselectMenu/MultiselectMenu.less @@ -1,6 +1,7 @@ // Copyright (C) 2017-2024 Smart code 203358507 @border-radius: 2.75rem; +@height: 3rem; .multiselect-menu { position: relative; @@ -14,6 +15,7 @@ } .multiselect-button { + height: @height; padding: 0.75rem 1.5rem; display: flex; flex: 1; diff --git a/src/components/Transition/Transition.tsx b/src/components/Transition/Transition.tsx index ae84d80e5..e12b2a42b 100644 --- a/src/components/Transition/Transition.tsx +++ b/src/components/Transition/Transition.tsx @@ -13,7 +13,6 @@ const Transition = ({ children, when, name }: Props) => { const [state, setState] = useState('enter'); const [active, setActive] = useState(false); - const [transitionEnded, setTransitionEnded] = useState(false); const callbackRef = useCallback((element: HTMLElement | null) => { setElement(element); @@ -31,14 +30,12 @@ const Transition = ({ children, when, name }: Props) => { }, [name, state, active, children]); const onTransitionEnd = useCallback(() => { - setTransitionEnded(true); state === 'exit' && setMounted(false); }, [state]); useEffect(() => { setState(when ? 'enter' : 'exit'); when && setMounted(true); - setTransitionEnded(false); }, [when]); useEffect(() => { @@ -56,7 +53,6 @@ const Transition = ({ children, when, name }: Props) => { mounted && cloneElement(children, { ref: callbackRef, className, - transitionEnded }) ); }; diff --git a/src/components/Video/Video.js b/src/components/Video/Video.js index d4cc2fbac..5d2d4a3cb 100644 --- a/src/components/Video/Video.js +++ b/src/components/Video/Video.js @@ -74,10 +74,27 @@ const Video = React.forwardRef(({ className, id, title, thumbnail, season, episo } } }, [deepLinks]); - const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ...props }) { + const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ref: popupRef, ...props }) { const blurThumbnail = profile.settings.hideSpoilers && season && episode && !watched; + const handleRef = React.useCallback((node) => { + if (popupRef) { + if (typeof popupRef === 'function') { + popupRef(node); + } else { + popupRef.current = node; + } + } + if (ref) { + if (typeof ref === 'function') { + ref(node); + } else { + ref.current = node; + } + } + }, [popupRef]); + return ( - -
{value}
- - - - ); -}; - -DiscreteSelectInput.propTypes = { - className: PropTypes.string, - value: PropTypes.string, - label: PropTypes.string, - disabled: PropTypes.bool, - dataset: PropTypes.object, - onChange: PropTypes.func -}; - -module.exports = DiscreteSelectInput; diff --git a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/index.js b/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/index.js deleted file mode 100644 index aaf93afb3..000000000 --- a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (C) 2017-2023 Smart code 203358507 - -const DiscreteSelectInput = require('./DiscreteSelectInput'); - -module.exports = DiscreteSelectInput; diff --git a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/styles.less b/src/routes/Player/SubtitlesMenu/Stepper/Stepper.less similarity index 79% rename from src/routes/Player/SubtitlesMenu/DiscreteSelectInput/styles.less rename to src/routes/Player/SubtitlesMenu/Stepper/Stepper.less index 2ceb41f80..67b034abe 100644 --- a/src/routes/Player/SubtitlesMenu/DiscreteSelectInput/styles.less +++ b/src/routes/Player/SubtitlesMenu/Stepper/Stepper.less @@ -1,14 +1,10 @@ -// Copyright (C) 2017-2023 Smart code 203358507 - -@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; - -.discrete-input-container { +.stepper { &:global(.disabled) { .header { color: var(--primary-foreground-color); } - .input-container { + .content { opacity: 0.4; } } @@ -19,14 +15,14 @@ opacity: 0.6; } - .input-container { + .content { display: flex; flex-direction: row; align-items: center; border-radius: 3.5rem; background: var(--overlay-color); - .button-container { + .button { flex: none; width: 3.5rem; height: 3.5rem; @@ -42,7 +38,7 @@ } } - .option-label { + .value { flex: 1; font-weight: 500; text-align: center; diff --git a/src/routes/Player/SubtitlesMenu/Stepper/Stepper.tsx b/src/routes/Player/SubtitlesMenu/Stepper/Stepper.tsx new file mode 100644 index 000000000..0d402a455 --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/Stepper/Stepper.tsx @@ -0,0 +1,98 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import classNames from 'classnames'; +import Icon from '@stremio/stremio-icons/react'; +import { Button } from 'stremio/components'; +import { useInterval, useTimeout } from 'stremio/common'; +import styles from './Stepper.less'; + +const clamp = (value: number, min?: number, max?: number) => { + const minClamped = typeof min === 'number' ? Math.max(value, min) : value; + const maxClamped = typeof max === 'number' ? Math.min(minClamped, max) : minClamped; + return maxClamped; +}; + +type Props = { + className: string, + label: string, + value: number, + unit?: string, + step: number, + min?: number, + max?: number, + disabled?: boolean, + onChange: (value: number) => void, +}; + +const Stepper = ({ className, label, value, unit, step, min, max, disabled, onChange }: Props) => { + const { t } = useTranslation(); + + const localValue = useRef(value); + + const interval = useInterval(100); + const timeout = useTimeout(250); + + const cancel = () => { + interval.cancel(); + timeout.cancel(); + }; + + const updateValue = useCallback((delta: number) => { + onChange(clamp(localValue.current + delta, min, max)); + }, [onChange]); + + const onDecrementMouseDown = useCallback(() => { + cancel(); + timeout.start(() => interval.start(() => updateValue(-step))); + }, [updateValue]); + + const onDecrementMouseUp = useCallback(() => { + cancel(); + updateValue(-step); + }, [updateValue]); + + const onIncrementMouseDown = useCallback(() => { + cancel(); + timeout.start(() => interval.start(() => updateValue(step))); + }, [updateValue]); + + const onIncrementMouseUp = useCallback(() => { + cancel(); + updateValue(step); + }, [updateValue]); + + useEffect(() => { + localValue.current = value; + }, [value]); + + return ( +
+
+ { t(label) } +
+
+ +
+ { disabled ? '--' : `${value}${unit}` } +
+ +
+
+ ); +}; + +export default Stepper; diff --git a/src/routes/Player/SubtitlesMenu/Stepper/index.ts b/src/routes/Player/SubtitlesMenu/Stepper/index.ts new file mode 100644 index 000000000..9fd275c70 --- /dev/null +++ b/src/routes/Player/SubtitlesMenu/Stepper/index.ts @@ -0,0 +1,2 @@ +import Stepper from './Stepper'; +export default Stepper; diff --git a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js index 39bc771e6..d94c5f70b 100644 --- a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js +++ b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js @@ -3,11 +3,12 @@ const React = require('react'); const PropTypes = require('prop-types'); const classnames = require('classnames'); -const { CONSTANTS, comparatorWithPriorities, languages } = require('stremio/common'); +const { comparatorWithPriorities, languages } = require('stremio/common'); +const { SUBTITLES_SIZES } = require('stremio/common/CONSTANTS'); const { Button } = require('stremio/components'); -const DiscreteSelectInput = require('./DiscreteSelectInput'); const styles = require('./styles'); const { t } = require('i18next'); +const { default: Stepper } = require('./Stepper'); const ORIGIN_PRIORITIES = { 'LOCAL': 3, @@ -98,51 +99,41 @@ const SubtitlesMenu = React.memo((props) => { } } }, [props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]); - const onSubtitlesDelayChanged = React.useCallback((event) => { - const delta = event.value === 'increment' ? 250 : -250; + const onSubtitlesDelayChanged = React.useCallback((value) => { if (typeof props.selectedExtraSubtitlesTrackId === 'string') { if (props.extraSubtitlesDelay !== null && !isNaN(props.extraSubtitlesDelay)) { - const extraDelay = props.extraSubtitlesDelay + delta; if (typeof props.onExtraSubtitlesDelayChanged === 'function') { - props.onExtraSubtitlesDelayChanged(extraDelay); + props.onExtraSubtitlesDelayChanged(value * 1000); } } } }, [props.selectedExtraSubtitlesTrackId, props.extraSubtitlesDelay, props.onExtraSubtitlesDelayChanged]); - const onSubtitlesSizeChanged = React.useCallback((event) => { - const delta = event.value === 'increment' ? 1 : -1; + const onSubtitlesSizeChanged = React.useCallback((value) => { if (typeof props.selectedSubtitlesTrackId === 'string') { if (props.subtitlesSize !== null && !isNaN(props.subtitlesSize)) { - const sizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(props.subtitlesSize); - const size = CONSTANTS.SUBTITLES_SIZES[Math.max(0, Math.min(CONSTANTS.SUBTITLES_SIZES.length - 1, sizeIndex + delta))]; if (typeof props.onSubtitlesSizeChanged === 'function') { - props.onSubtitlesSizeChanged(size); + props.onSubtitlesSizeChanged(value); } } } else if (typeof props.selectedExtraSubtitlesTrackId === 'string') { if (props.extraSubtitlesSize !== null && !isNaN(props.extraSubtitlesSize)) { - const extraSizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(props.extraSubtitlesSize); - const extraSize = CONSTANTS.SUBTITLES_SIZES[Math.max(0, Math.min(CONSTANTS.SUBTITLES_SIZES.length - 1, extraSizeIndex + delta))]; if (typeof props.onExtraSubtitlesSizeChanged === 'function') { - props.onExtraSubtitlesSizeChanged(extraSize); + props.onExtraSubtitlesSizeChanged(value); } } } }, [props.selectedSubtitlesTrackId, props.selectedExtraSubtitlesTrackId, props.subtitlesSize, props.extraSubtitlesSize, props.onSubtitlesSizeChanged, props.onExtraSubtitlesSizeChanged]); - const onSubtitlesOffsetChanged = React.useCallback((event) => { - const delta = event.value === 'increment' ? 1 : -1; + const onSubtitlesOffsetChanged = React.useCallback((value) => { if (typeof props.selectedSubtitlesTrackId === 'string') { if (props.subtitlesOffset !== null && !isNaN(props.subtitlesOffset)) { - const offset = Math.max(0, Math.min(100, Math.floor(props.subtitlesOffset + delta))); if (typeof props.onSubtitlesOffsetChanged === 'function') { - props.onSubtitlesOffsetChanged(offset); + props.onSubtitlesOffsetChanged(value); } } } else if (typeof props.selectedExtraSubtitlesTrackId === 'string') { if (props.extraSubtitlesOffset !== null && !isNaN(props.extraSubtitlesOffset)) { - const offset = Math.max(0, Math.min(100, Math.floor(props.extraSubtitlesOffset + delta))); if (typeof props.onExtraSubtitlesOffsetChanged === 'function') { - props.onExtraSubtitlesOffsetChanged(offset); + props.onExtraSubtitlesOffsetChanged(value); } } } @@ -215,57 +206,35 @@ const SubtitlesMenu = React.memo((props) => {
{t('PLAYER_SUBTITLES_SETTINGS')}
- - -
diff --git a/src/routes/Player/SubtitlesMenu/styles.less b/src/routes/Player/SubtitlesMenu/styles.less index 71f1d5cb4..bed7be75d 100644 --- a/src/routes/Player/SubtitlesMenu/styles.less +++ b/src/routes/Player/SubtitlesMenu/styles.less @@ -114,7 +114,7 @@ flex: 1; } - .discrete-input { + .stepper { padding: 0 1.5rem 1rem; } } diff --git a/src/routes/Player/usePlayer.js b/src/routes/Player/usePlayer.js index 05036f220..4ca2574ba 100644 --- a/src/routes/Player/usePlayer.js +++ b/src/routes/Player/usePlayer.js @@ -102,8 +102,8 @@ const usePlayer = (urlParams) => { args: { action: 'TimeChanged', args: { - time: Math.round(time), - duration, + time: Math.max(0, Math.round(time)), + duration: Math.max(0, Math.round(duration)), device, } } @@ -118,8 +118,8 @@ const usePlayer = (urlParams) => { args: { action: 'Seek', args: { - time: Math.round(time), - duration, + time: Math.max(0, Math.round(time)), + duration: Math.max(0, Math.round(duration)), device, } } diff --git a/src/routes/Settings/General/useGeneralOptions.ts b/src/routes/Settings/General/useGeneralOptions.ts index 59e7b40c1..a4ab84c5e 100644 --- a/src/routes/Settings/General/useGeneralOptions.ts +++ b/src/routes/Settings/General/useGeneralOptions.ts @@ -1,16 +1,24 @@ import { useMemo } from 'react'; -import { interfaceLanguages } from 'stremio/common'; +import { interfaceLanguages, useLanguageSorting } from 'stremio/common'; import { useServices } from 'stremio/services'; const useGeneralOptions = (profile: Profile) => { const { core } = useServices(); - const interfaceLanguageSelect = useMemo(() => ({ - options: interfaceLanguages.map(({ name, codes }) => ({ + const interfaceLanguageOptions = useMemo(() => + interfaceLanguages.map(({ name, codes }) => ({ value: codes[0], label: name, })), - value: interfaceLanguages.find(({ codes }) => codes[1] === profile.settings.interfaceLanguage)?.codes?.[0] || profile.settings.interfaceLanguage, + []); + + const { sortedOptions } = useLanguageSorting(interfaceLanguageOptions); + + const interfaceLanguageSelect = useMemo(() => ({ + options: sortedOptions, + value: + interfaceLanguages.find(({ codes }) => codes[1] === profile.settings.interfaceLanguage)?.codes?.[0] || + profile.settings.interfaceLanguage, onSelect: (value: string) => { core.transport.dispatch({ action: 'Ctx', @@ -23,7 +31,7 @@ const useGeneralOptions = (profile: Profile) => { } }); } - }), [profile.settings]); + }), [profile.settings, sortedOptions]); const escExitFullscreenToggle = useMemo(() => ({ checked: profile.settings.escExitFullscreen, diff --git a/src/routes/Settings/Player/usePlayerOptions.ts b/src/routes/Settings/Player/usePlayerOptions.ts index 04c263d54..edbce3d24 100644 --- a/src/routes/Settings/Player/usePlayerOptions.ts +++ b/src/routes/Settings/Player/usePlayerOptions.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useServices } from 'stremio/services'; -import { CONSTANTS, languageNames, usePlatform } from 'stremio/common'; +import { CONSTANTS, languageNames, usePlatform, useLanguageSorting } from 'stremio/common'; const LANGUAGES_NAMES: Record = languageNames; @@ -10,13 +10,17 @@ const usePlayerOptions = (profile: Profile) => { const { core } = useServices(); const platform = usePlatform(); + const languageOptions = useMemo(() => Object.keys(LANGUAGES_NAMES).map((code) => ({ + value: code, + label: LANGUAGES_NAMES[code] + })), []); + + const { sortedOptions: sortedLanguageOptions } = useLanguageSorting(languageOptions); + const subtitlesLanguageSelect = useMemo(() => ({ options: [ { value: null, label: t('NONE') }, - ...Object.keys(LANGUAGES_NAMES).map((code) => ({ - value: code, - label: LANGUAGES_NAMES[code] - })) + ...sortedLanguageOptions ], value: profile.settings.subtitlesLanguage, onSelect: (value: string) => { @@ -31,7 +35,7 @@ const usePlayerOptions = (profile: Profile) => { } }); } - }), [profile.settings]); + }), [profile.settings, sortedLanguageOptions]); const subtitlesSizeSelect = useMemo(() => ({ options: CONSTANTS.SUBTITLES_SIZES.map((size) => ({ @@ -105,10 +109,7 @@ const usePlayerOptions = (profile: Profile) => { }), [profile.settings]); const audioLanguageSelect = useMemo(() => ({ - options: Object.keys(LANGUAGES_NAMES).map((code) => ({ - value: code, - label: LANGUAGES_NAMES [code] - })), + options: sortedLanguageOptions, value: profile.settings.audioLanguage, onSelect: (value: string) => { core.transport.dispatch({ @@ -122,7 +123,7 @@ const usePlayerOptions = (profile: Profile) => { } }); } - }), [profile.settings]); + }), [profile.settings, sortedLanguageOptions]); const surroundSoundToggle = useMemo(() => ({ checked: profile.settings.surroundSound, diff --git a/src/routes/Settings/Shortcuts/Shortcuts.tsx b/src/routes/Settings/Shortcuts/Shortcuts.tsx index 0301eafca..d852280a6 100644 --- a/src/routes/Settings/Shortcuts/Shortcuts.tsx +++ b/src/routes/Settings/Shortcuts/Shortcuts.tsx @@ -56,16 +56,18 @@ const Shortcuts = forwardRef((_, ref) => { I
- +