diff --git a/.github/workflows/auto_assign.yml b/.github/workflows/auto_assign.yml index 24a7cd5fe..58d5ac412 100644 --- a/.github/workflows/auto_assign.yml +++ b/.github/workflows/auto_assign.yml @@ -14,7 +14,7 @@ jobs: # Auto assign PR to author - name: Auto Assign PR to Author if: github.event.pull_request.head.repo.fork == false && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -34,7 +34,7 @@ jobs: if: github.event.pull_request.head.repo.fork == false && github.actor != 'dependabot[bot]' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a3f9b7485..83a3aab34 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 with: version: 10 run_install: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dc8542d0..40b474af0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: - name: Checkout uses: actions/checkout@v6 - name: Install pnpm - uses: pnpm/action-setup@v5 + uses: pnpm/action-setup@v6 with: version: 10 run_install: false diff --git a/package.json b/package.json index 4f1f16806..4fe8c9adf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stremio", "displayName": "Stremio", - "version": "5.0.0-beta.32", + "version": "5.0.0-beta.34", "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.55.0", + "@stremio/stremio-core-web": "0.56.4", "@stremio/stremio-icons": "5.8.0", - "@stremio/stremio-video": "0.0.70", + "@stremio/stremio-video": "0.0.75", "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 321a4110b..4c9853d5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,14 +18,14 @@ importers: specifier: 5.2.0 version: 5.2.0 '@stremio/stremio-core-web': - specifier: 0.55.0 - version: 0.55.0 + specifier: 0.56.4 + version: 0.56.4 '@stremio/stremio-icons': specifier: 5.8.0 version: 5.8.0 '@stremio/stremio-video': - specifier: 0.0.70 - version: 0.0.70 + specifier: 0.0.75 + version: 0.0.75 a-color-picker: specifier: 1.2.1 version: 1.2.1 @@ -1120,14 +1120,14 @@ packages: '@stremio/stremio-colors@5.2.0': resolution: {integrity: sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg==} - '@stremio/stremio-core-web@0.55.0': - resolution: {integrity: sha512-MdalnThEwnA8osQh+3/5OMzVIYZOoYmd94dN3nmCeT4rfV7IZXRFUg/uyCY+5bqigStlE3SfKEaGiSc6UnNtlQ==} + '@stremio/stremio-core-web@0.56.4': + resolution: {integrity: sha512-tFAMYgKrJ1bkvHRMpxDykM/844sDjgRPFk6FLhjQiwh01OHIyEgDqGo/NgwFM+CuMR4mW676SDvwNHkK0Xqg3w==} '@stremio/stremio-icons@5.8.0': resolution: {integrity: sha512-IVUvQbIWfA4YEHCTed7v/sdQJCJ+OOCf84LTWpkE2W6GLQ+15WHcMEJrVkE1X3ekYJnGg3GjT0KLO6tKSU0P4w==} - '@stremio/stremio-video@0.0.70': - resolution: {integrity: sha512-a0flQYAUdrZNMm7mmts2vpZOqN1nus7Hs9Mjl4mrN5rtduD0ojUyhD5J4lPcCpZ7WB0YdEUOGLXR19qHpgoKmg==} + '@stremio/stremio-video@0.0.75': + resolution: {integrity: sha512-oKXMq156BVagzziWoTsmgNYABCSfwV9hR/TM6+JR4lne5pW4qmUN17ba/Fxsr+USKHeCKUaz1u0asKBj06HfyA==} '@stylistic/eslint-plugin-jsx@4.4.1': resolution: {integrity: sha512-83SInq4u7z71vWwGG+6ViOtlOmZ6tSrDkMPhrvdBBTGMLA0gs22WSdhQ4vZP3oJ5Xg4ythvqeUiFSedvVxzhyA==} @@ -5870,13 +5870,13 @@ snapshots: '@stremio/stremio-colors@5.2.0': {} - '@stremio/stremio-core-web@0.55.0': + '@stremio/stremio-core-web@0.56.4': dependencies: '@babel/runtime': 7.24.1 '@stremio/stremio-icons@5.8.0': {} - '@stremio/stremio-video@0.0.70': + '@stremio/stremio-video@0.0.75': dependencies: buffer: 6.0.3 color: 4.2.3 diff --git a/src/App/ServicesToaster.js b/src/App/ServicesToaster.js index a12757169..7ca7e2914 100644 --- a/src/App/ServicesToaster.js +++ b/src/App/ServicesToaster.js @@ -44,7 +44,7 @@ const ServicesToaster = () => { } case 'MagnetParsed': { toast.show({ - type: 'success', + type: 'info', title: 'Magnet link parsed', timeout: 4000 }); diff --git a/src/common/Toast/ToastContext.js b/src/common/Toast/ToastContext.js index 6a5ede356..cefe9071e 100644 --- a/src/common/Toast/ToastContext.js +++ b/src/common/Toast/ToastContext.js @@ -6,6 +6,7 @@ const React = require('react'); const ToastContext = React.createContext({ show: () => { }, + remove: () => { }, clear: () => { } }); diff --git a/src/common/Toast/ToastProvider.js b/src/common/Toast/ToastProvider.js index a9cab9bb4..e375267af 100644 --- a/src/common/Toast/ToastProvider.js +++ b/src/common/Toast/ToastProvider.js @@ -42,7 +42,7 @@ const ToastProvider = ({ className, children }) => { }, show: (item) => { if (filters.some((filter) => filter(item))) { - return; + return null; } const timeout = typeof item.timeout === 'number' && !isNaN(item.timeout) ? @@ -64,6 +64,11 @@ const ToastProvider = ({ className, children }) => { onClose: itemOnClose } }); + return id; + }, + remove: (id) => { + clearTimeout(id); + dispatch({ type: 'remove', id }); }, clear: () => { dispatch({ type: 'clear' }); diff --git a/src/common/index.js b/src/common/index.js index 7b27517f6..e608e7e23 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -25,6 +25,7 @@ const { default: useSettings } = require('./useSettings'); const { default: useShell } = require('./useShell'); const useStreamingServer = require('./useStreamingServer'); const { default: useTimeout } = require('./useTimeout'); +const { default: usePlayUrl } = require('./usePlayUrl'); const useTorrent = require('./useTorrent'); const useTranslate = require('./useTranslate'); const { default: useOrientation } = require('./useOrientation'); @@ -63,6 +64,7 @@ module.exports = { useShell, useStreamingServer, useTimeout, + usePlayUrl, useTorrent, useTranslate, useOrientation, diff --git a/src/common/usePlayUrl.ts b/src/common/usePlayUrl.ts new file mode 100644 index 000000000..49fe386ed --- /dev/null +++ b/src/common/usePlayUrl.ts @@ -0,0 +1,65 @@ +import { useCallback } from 'react'; +import magnet from 'magnet-uri'; +import { useServices } from 'stremio/services'; +import useToast from 'stremio/common/Toast/useToast'; +import useTorrent from 'stremio/common/useTorrent'; +import useStreamingServer from 'stremio/common/useStreamingServer'; + +const HTTP_REGEX = /^https?:\/\/.+/i; + +const usePlayUrl = () => { + const { core } = useServices(); + const toast = useToast(); + const { createTorrentFromMagnet } = useTorrent(); + const streamingServer = useStreamingServer(); + + const handlePlayUrl = useCallback(async (text: string): Promise => { + if (!text || !text.trim()) return false; + const trimmed = text.trim(); + + if (HTTP_REGEX.test(trimmed)) { + toast.show({ + type: 'success', + title: 'Loading HTTP stream…', + timeout: 3000 + }); + try { + const encoded = await core.transport.encodeStream({ url: trimmed }); + if (typeof encoded === 'string') { + window.location.hash = `#/player/${encodeURIComponent(encoded)}`; + return true; + } + } catch (e) { + console.error('Failed to encode stream:', e); + } + toast.show({ + type: 'error', + title: 'Failed to load HTTP stream.', + timeout: 5000 + }); + return false; + } + + const parsed = magnet.decode(trimmed); + if (parsed && typeof parsed.infoHash === 'string') { + const serverReady = streamingServer.settings !== null + && streamingServer.settings.type === 'Ready'; + if (!serverReady) { + toast.show({ + type: 'error', + title: 'Streaming server is not available. Cannot play magnet links.', + timeout: 5000 + }); + return false; + } + createTorrentFromMagnet(trimmed); + return true; + } + + return false; + }, [streamingServer.settings, createTorrentFromMagnet]); + + return { handlePlayUrl }; +}; + +export default usePlayUrl; diff --git a/src/common/useTorrent.js b/src/common/useTorrent.js index 0ae117d3a..2527154a2 100644 --- a/src/common/useTorrent.js +++ b/src/common/useTorrent.js @@ -6,14 +6,22 @@ const { useServices } = require('stremio/services'); const useToast = require('stremio/common/Toast/useToast'); const useStreamingServer = require('stremio/common/useStreamingServer'); +const CREATE_TORRENT_TIMEOUT = 20000; + const useTorrent = () => { const { core } = useServices(); const streamingServer = useStreamingServer(); const toast = useToast(); const createTorrentTimeout = React.useRef(null); + const parsingToastId = React.useRef(null); const createTorrentFromMagnet = React.useCallback((text) => { const parsed = magnet.decode(text); if (parsed && typeof parsed.infoHash === 'string') { + parsingToastId.current = toast.show({ + type: 'success', + title: 'Loading magnet link…', + timeout: CREATE_TORRENT_TIMEOUT + }); core.transport.dispatch({ action: 'StreamingServer', args: { @@ -23,12 +31,13 @@ const useTorrent = () => { }); clearTimeout(createTorrentTimeout.current); createTorrentTimeout.current = setTimeout(() => { + toast.remove(parsingToastId.current); toast.show({ type: 'error', - title: 'It\'s taking a long time to get metadata from the torrent.', - timeout: 10000 + title: 'Failed to parse magnet link.', + timeout: 8000 }); - }, 10000); + }, CREATE_TORRENT_TIMEOUT); } }, []); React.useEffect(() => { @@ -36,6 +45,7 @@ const useTorrent = () => { const [, { type }] = streamingServer.torrent; if (type === 'Ready') { clearTimeout(createTorrentTimeout.current); + toast.remove(parsingToastId.current); } } }, [streamingServer.torrent]); diff --git a/src/components/MetaPreview/Ratings/Ratings.less b/src/components/ActionsGroup/ActionsGroup.less similarity index 96% rename from src/components/MetaPreview/Ratings/Ratings.less rename to src/components/ActionsGroup/ActionsGroup.less index ffba0415b..09e903435 100644 --- a/src/components/MetaPreview/Ratings/Ratings.less +++ b/src/components/ActionsGroup/ActionsGroup.less @@ -8,7 +8,7 @@ @width-mobile: 3rem; -.ratings-container { +.group-container { display: flex; flex-direction: row; align-items: center; @@ -46,7 +46,7 @@ } @media @phone-landscape { - .ratings-container { + .group-container { height: @height-mobile; .icon-container { diff --git a/src/components/ActionsGroup/ActionsGroup.tsx b/src/components/ActionsGroup/ActionsGroup.tsx new file mode 100644 index 000000000..052f25016 --- /dev/null +++ b/src/components/ActionsGroup/ActionsGroup.tsx @@ -0,0 +1,45 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import classNames from 'classnames'; +import React from 'react'; +import Icon from '@stremio/stremio-icons/react'; +import { Tooltip } from 'stremio/common/Tooltips'; +import styles from './ActionsGroup.less'; + +type Item = { + icon: string; + label?: string; + filled?: string; + disabled?: boolean; + className?: string; + onClick?: () => void; +}; + +type Props = { + items: Item[]; + className?: string; +}; + +const ActionsGroup = ({ items, className }: Props) => { + return ( +
+ { + items.map((item, index) => ( +
+ { + item.label && + + } + +
+ )) + } +
+ ); +}; + +export default ActionsGroup; diff --git a/src/components/ActionsGroup/index.ts b/src/components/ActionsGroup/index.ts new file mode 100644 index 000000000..4dea1b83a --- /dev/null +++ b/src/components/ActionsGroup/index.ts @@ -0,0 +1,6 @@ +// Copyright (C) 2017-2025 Smart code 203358507 + +import ActionsGroup from './ActionsGroup'; + +export default ActionsGroup; + diff --git a/src/components/MetaPreview/MetaPreview.js b/src/components/MetaPreview/MetaPreview.js index 13717919a..5fa7d8ff0 100644 --- a/src/components/MetaPreview/MetaPreview.js +++ b/src/components/MetaPreview/MetaPreview.js @@ -8,6 +8,7 @@ const { useTranslation } = require('react-i18next'); const { default: Icon } = require('@stremio/stremio-icons/react'); const { default: Button } = require('stremio/components/Button'); const { default: Image } = require('stremio/components/Image'); +const { default: ActionsGroup } = require('stremio/components/ActionsGroup'); const ModalDialog = require('stremio/components/ModalDialog'); const SharePrompt = require('stremio/components/SharePrompt'); const CONSTANTS = require('stremio/common/CONSTANTS'); @@ -25,7 +26,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, ratingInfo }, ref) => { +const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, watched, toggleWatched, ratingInfo }, ref) => { const { t } = useTranslation(); const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false); const linksGroups = React.useMemo(() => { @@ -98,6 +99,18 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou const renderLogoFallback = React.useCallback(() => (
{name}
), [name]); + const metaItemActions = React.useMemo(() => [ + { + icon: inLibrary ? 'remove-from-library' : 'add-to-library', + label: inLibrary ? t('REMOVE_FROM_LIB') : t('ADD_TO_LIB'), + onClick: typeof toggleInLibrary === 'function' ? toggleInLibrary : null, + }, + { + icon: watched ? 'eye-off' : 'eye', + label: watched ? t('CTX_MARK_UNWATCHED') : t('CTX_MARK_WATCHED'), + onClick: typeof toggleWatched === 'function' ? toggleWatched : undefined, + }, + ], [inLibrary, watched, toggleInLibrary, toggleWatched]); return (
{ @@ -195,19 +208,6 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou }
- { - typeof toggleInLibrary === 'function' ? - - : - null - } { typeof trailerHref === 'string' ? + : null + } { typeof showHref === 'string' && compact ? : null @@ -298,6 +303,8 @@ MetaPreview.propTypes = { trailerStreams: PropTypes.array, inLibrary: PropTypes.bool, toggleInLibrary: PropTypes.func, + watched: PropTypes.bool, + toggleWatched: PropTypes.func, ratingInfo: PropTypes.object, }; diff --git a/src/components/MetaPreview/Ratings/Ratings.tsx b/src/components/MetaPreview/Ratings/Ratings.tsx index 6bef0cc6d..329ee4945 100644 --- a/src/components/MetaPreview/Ratings/Ratings.tsx +++ b/src/components/MetaPreview/Ratings/Ratings.tsx @@ -2,9 +2,7 @@ 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'; +import { ActionsGroup } from 'stremio/components'; type Props = { metaId?: string; @@ -16,15 +14,21 @@ const Ratings = ({ ratingInfo, className }: Props) => { const { onLiked, onLoved, liked, loved } = useRating(ratingInfo); const disabled = useMemo(() => ratingInfo?.type !== 'Ready', [ratingInfo]); + const items = useMemo(() => [ + { + icon: liked ? 'thumbs-up' : 'thumbs-up-outline', + disabled, + onClick: onLiked, + }, + { + icon: loved ? 'heart' : 'heart-outline', + disabled, + onClick: onLoved, + }, + ], [liked, loved, disabled]); + return ( -
-
- -
-
- -
-
+ ); }; diff --git a/src/components/MetaPreview/styles.less b/src/components/MetaPreview/styles.less index 3fea95a5f..3b21c0ed6 100644 --- a/src/components/MetaPreview/styles.less +++ b/src/components/MetaPreview/styles.less @@ -32,7 +32,7 @@ .action-buttons-container { justify-content: space-between; - .action-button:not(:last-child) { + .action-button:not(:last-child), .group-container:not(:last-child) { margin-right: 0; } } @@ -207,11 +207,20 @@ } } } - } + + .group-container { + margin-bottom: 1rem; - .ratings { - margin-bottom: 1rem; - margin-right: 1rem; + &:global(.wide) { + width: auto; + padding: 0 2rem; + border-radius: 4rem; + } + + &:not(:last-child) { + margin-right: 1rem; + } + } } } @@ -233,17 +242,13 @@ padding-top: 1.5rem; gap: 0.5rem; - .action-button { + .action-button, .group-container { padding: 0 1.5rem !important; margin-right: 0rem !important; height: 3rem; border-radius: 2rem; } } - - .ratings { - margin-right: 0; - } } } @@ -272,6 +277,10 @@ &::-webkit-scrollbar { display: none; } + + .action-button { + padding: 0 1rem !important; + } } } diff --git a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js index 4b2fdc87e..b6309a44c 100644 --- a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js +++ b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js @@ -10,7 +10,8 @@ const { Button } = require('stremio/components'); const { default: useFullscreen } = require('stremio/common/useFullscreen'); const useProfile = require('stremio/common/useProfile'); const usePWA = require('stremio/common/usePWA'); -const useTorrent = require('stremio/common/useTorrent'); +const { default: usePlayUrl } = require('stremio/common/usePlayUrl'); +const useToast = require('stremio/common/Toast/useToast'); const { withCoreSuspender } = require('stremio/common/CoreSuspender'); const useStreamingServer = require('stremio/common/useStreamingServer'); const styles = require('./styles'); @@ -20,7 +21,8 @@ const NavMenuContent = ({ onClick }) => { const { core } = useServices(); const profile = useProfile(); const streamingServer = useStreamingServer(); - const { createTorrentFromMagnet } = useTorrent(); + const { handlePlayUrl } = usePlayUrl(); + const toast = useToast(); const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen(); const [isIOSPWA, isAndroidPWA] = usePWA(); const streamingServerWarningDismissed = React.useMemo(() => { @@ -40,11 +42,18 @@ const NavMenuContent = ({ onClick }) => { const onPlayMagnetLinkClick = React.useCallback(async () => { try { const clipboardText = await navigator.clipboard.readText(); - createTorrentFromMagnet(clipboardText); + const handled = await handlePlayUrl(clipboardText); + if (!handled) { + toast.show({ + type: 'error', + title: 'Clipboard does not contain a valid URL or magnet link.', + timeout: 5000 + }); + } } catch(e) { console.error(e); } - }, []); + }, [handlePlayUrl]); return (
diff --git a/src/components/NavBar/HorizontalNavBar/SearchBar/SearchBar.js b/src/components/NavBar/HorizontalNavBar/SearchBar/SearchBar.js index 0bc95cd73..e8f58bdff 100644 --- a/src/components/NavBar/HorizontalNavBar/SearchBar/SearchBar.js +++ b/src/components/NavBar/HorizontalNavBar/SearchBar/SearchBar.js @@ -9,7 +9,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react'); const { useRouteFocused } = require('stremio-router'); const Button = require('stremio/components/Button').default; const TextInput = require('stremio/components/TextInput').default; -const useTorrent = require('stremio/common/useTorrent'); +const { default: usePlayUrl } = require('stremio/common/usePlayUrl'); const { withCoreSuspender } = require('stremio/common/CoreSuspender'); const useSearchHistory = require('./useSearchHistory'); const useLocalSearch = require('./useLocalSearch'); @@ -21,7 +21,7 @@ const SearchBar = React.memo(({ className, query, active }) => { const routeFocused = useRouteFocused(); const searchHistory = useSearchHistory(); const localSearch = useLocalSearch(); - const { createTorrentFromMagnet } = useTorrent(); + const { handlePlayUrl } = usePlayUrl(); const [historyOpen, openHistory, closeHistory, ] = useBinaryState(query === null ? true : false); const [currentQuery, setCurrentQuery] = React.useState(query || ''); @@ -52,12 +52,14 @@ const SearchBar = React.memo(({ className, query, active }) => { const value = searchInputRef.current.value; setCurrentQuery(value); openHistory(); - try { - createTorrentFromMagnet(value); - } catch (error) { - console.error('Failed to create torrent from magnet:', error); + }, []); + + const queryInputOnPaste = React.useCallback((event) => { + const pasted = event.clipboardData.getData('text'); + if (pasted) { + handlePlayUrl(pasted); } - }, [createTorrentFromMagnet]); + }, [handlePlayUrl]); const queryInputOnSubmit = React.useCallback((event) => { event.preventDefault(); @@ -108,6 +110,7 @@ const SearchBar = React.memo(({ className, query, active }) => { defaultValue={query} tabIndex={-1} onChange={queryInputOnChange} + onPaste={queryInputOnPaste} onSubmit={queryInputOnSubmit} onClick={openHistory} /> diff --git a/src/components/index.ts b/src/components/index.ts index a47c2c709..75400b0dd 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -30,6 +30,7 @@ import TextInput from './TextInput'; import Toggle from './Toggle'; import Transition from './Transition'; import Video from './Video'; +import ActionsGroup from './ActionsGroup'; export { AddonDetailsModal, @@ -65,4 +66,5 @@ export { Toggle, Transition, Video, + ActionsGroup }; diff --git a/src/routes/Discover/Discover.js b/src/routes/Discover/Discover.js index a28a86405..e181dff5f 100644 --- a/src/routes/Discover/Discover.js +++ b/src/routes/Discover/Discover.js @@ -23,6 +23,11 @@ const Discover = ({ urlParams, queryParams }) => { const [addonModalOpen, openAddonModal, closeAddonModal] = useBinaryState(false); const [selectedMetaItemIndex, setSelectedMetaItemIndex] = React.useState(0); + const selectedMetaItem = React.useMemo(() => { + return discover.catalog?.content.type === 'Ready' && + discover.catalog.content.content[selectedMetaItemIndex] || null; + }, [discover.catalog, selectedMetaItemIndex]); + const metasContainerRef = React.useRef(); const metaPreviewRef = React.useRef(); @@ -40,14 +45,6 @@ const Discover = ({ urlParams, queryParams }) => { } } }, [hasNextPage, loadNextPage]); - const selectedMetaItem = React.useMemo(() => { - return discover.catalog !== null && - discover.catalog.content.type === 'Ready' && - discover.catalog.content.content[selectedMetaItemIndex] ? - discover.catalog.content.content[selectedMetaItemIndex] - : - null; - }, [discover.catalog, selectedMetaItemIndex]); const addToLibrary = React.useCallback(() => { if (selectedMetaItem === null) { return; @@ -74,6 +71,22 @@ const Discover = ({ urlParams, queryParams }) => { } }); }, [selectedMetaItem]); + const toggleWatched = React.useCallback(() => { + if (selectedMetaItem === null) { + return; + } + + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'MetaItemMarkAsWatched', + args: { + meta_item: selectedMetaItem, + is_watched: !selectedMetaItem.watched, + } + } + }); + }, [selectedMetaItem]); const metaItemsOnFocusCapture = React.useCallback((event) => { if (event.target.dataset.index !== null && !isNaN(event.target.dataset.index)) { setSelectedMetaItemIndex(parseInt(event.target.dataset.index, 10)); @@ -193,6 +206,8 @@ const Discover = ({ urlParams, queryParams }) => { trailerStreams={selectedMetaItem.trailerStreams} inLibrary={selectedMetaItem.inLibrary} toggleInLibrary={selectedMetaItem.inLibrary ? removeFromLibrary : addToLibrary} + watched={selectedMetaItem.watched} + toggleWatched={toggleWatched} metaId={selectedMetaItem.id} like={selectedMetaItem.like} /> diff --git a/src/routes/MetaDetails/MetaDetails.js b/src/routes/MetaDetails/MetaDetails.js index 9f2279bd9..8376cdf2c 100644 --- a/src/routes/MetaDetails/MetaDetails.js +++ b/src/routes/MetaDetails/MetaDetails.js @@ -64,6 +64,19 @@ const MetaDetails = ({ urlParams, queryParams }) => { } }); }, [metaDetails]); + const toggleWatched = React.useCallback(() => { + if (metaDetails.metaItem === null || metaDetails.metaItem.content.type !== 'Ready') { + return; + } + + core.transport.dispatch({ + action: 'MetaDetails', + args: { + action: 'MarkAsWatched', + args: !metaDetails.metaItem.content.content.watched + } + }); + }, [metaDetails]); const toggleNotifications = React.useCallback(() => { if (metaDetails.libraryItem) { core.transport.dispatch({ @@ -172,6 +185,8 @@ const MetaDetails = ({ urlParams, queryParams }) => { trailerStreams={metaDetails.metaItem.content.content.trailerStreams} inLibrary={metaDetails.metaItem.content.content.inLibrary} toggleInLibrary={metaDetails.metaItem.content.content.inLibrary ? removeFromLibrary : addToLibrary} + watched={metaDetails.metaItem.content.content.watched} + toggleWatched={toggleWatched} metaId={metaDetails.metaItem.content.content.id} ratingInfo={metaDetails.ratingInfo} /> diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 0f3addee2..77e8a952c 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: useMediaSession } = require('./useMediaSession'); 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); @@ -235,23 +236,17 @@ const Player = ({ urlParams, queryParams }) => { }, []); - const onSubtitlesTrackSelected = React.useCallback((id) => { - video.setSubtitlesTrack(id); + const onSubtitlesTrackSelected = React.useCallback((track) => { + video.setSubtitlesTrack(track?.id ?? null); streamStateChanged({ - subtitleTrack: { - id, - embedded: true, - }, + subtitleTrack: track ? { id: track.id, embedded: true, lang: track.lang } : null, }); }, [streamStateChanged]); - const onExtraSubtitlesTrackSelected = React.useCallback((id) => { - video.setExtraSubtitlesTrack(id); + const onExtraSubtitlesTrackSelected = React.useCallback((track) => { + video.setExtraSubtitlesTrack(track?.id ?? null); streamStateChanged({ - subtitleTrack: { - id, - embedded: false, - }, + subtitleTrack: track ? { id: track.id, embedded: false, lang: track.lang } : null, }); }, [streamStateChanged]); @@ -466,23 +461,34 @@ const Player = ({ urlParams, queryParams }) => { } const savedTrackId = player.streamState?.subtitleTrack?.id; - const subtitlesTrack = savedTrackId ? - findTrackById(video.state.subtitlesTracks, savedTrackId) : - findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage); + const savedLang = player.streamState?.subtitleTrack?.lang; + const savedIsExternal = savedTrackId && player.streamState?.subtitleTrack?.embedded === false; - const extraSubtitlesTrack = savedTrackId ? - findTrackById(video.state.extraSubtitlesTracks, savedTrackId) : - findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage); + const subtitlesTrack = + savedTrackId ? findTrackById(video.state.subtitlesTracks, savedTrackId) : + savedLang ? findTrackByLang(video.state.subtitlesTracks, savedLang) : + findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage); + + const extraSubtitlesTrack = + savedTrackId ? findTrackById(video.state.extraSubtitlesTracks, savedTrackId) : + savedLang ? findTrackByLang(video.state.extraSubtitlesTracks, savedLang) : + findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage); if (subtitlesTrack && subtitlesTrack.id) { - video.setSubtitlesTrack(subtitlesTrack.id); + if (video.state.selectedSubtitlesTrackId !== subtitlesTrack.id) { + video.setSubtitlesTrack(subtitlesTrack.id); + } defaultSubtitlesSelected.current = true; } else if (extraSubtitlesTrack && extraSubtitlesTrack.id) { - video.setExtraSubtitlesTrack(extraSubtitlesTrack.id); - defaultSubtitlesSelected.current = true; + if (video.state.selectedExtraSubtitlesTrackId !== extraSubtitlesTrack.id) { + video.setExtraSubtitlesTrack(extraSubtitlesTrack.id); + } + if (savedIsExternal) { + defaultSubtitlesSelected.current = true; + } } } - }, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, player.streamState]); + }, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, video.state.selectedSubtitlesTrackId, video.state.selectedExtraSubtitlesTrackId, player.streamState]); // Auto audio track selection React.useEffect(() => { @@ -591,63 +597,7 @@ const Player = ({ urlParams, queryParams }) => { } }, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]); - // Media Session PlaybackState - React.useEffect(() => { - if (!navigator.mediaSession) return; - - const playbackState = !video.state.paused ? 'playing' : 'paused'; - navigator.mediaSession.playbackState = playbackState; - - return () => navigator.mediaSession.playbackState = 'none'; - }, [video.state.paused]); - - // Media Session Metadata - React.useEffect(() => { - if (!navigator.mediaSession) return; - - const metaItem = player.metaItem && player.metaItem?.type === 'Ready' ? player.metaItem.content : null; - const videoId = player.selected ? player.selected?.streamRequest?.path?.id : null; - const video = metaItem ? metaItem.videos.find(({ id }) => id === videoId) : null; - - const videoInfo = video && video.season && video.episode ? ` (${video.season}x${video.episode})` : null; - const videoTitle = video ? `${video.title}${videoInfo}` : null; - const metaTitle = metaItem ? metaItem.name : null; - const imageUrl = metaItem ? metaItem.logo : null; - - const title = videoTitle ?? metaTitle; - const artist = videoTitle ? metaTitle : undefined; - const artwork = imageUrl ? [{ src: imageUrl }] : undefined; - - if (title) { - navigator.mediaSession.metadata = new MediaMetadata({ - title, - artist, - artwork, - }); - } - }, [player.metaItem, player.selected]); - - // Media Session Actions - React.useEffect(() => { - if (!navigator.mediaSession) return; - - navigator.mediaSession.setActionHandler('play', onPlayRequested); - navigator.mediaSession.setActionHandler('pause', onPauseRequested); - - const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null; - navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback); - }, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]); - - onShortcut('playPause', () => { - if (video.state.paused !== null) { - if (video.state.paused) { - onPlayRequested(); - setSeeking(false); - } else if (!pressTimer.current) { - onPauseRequested(); - } - } - }, [video.state.paused, pressTimer.current, onPlayRequested, onPauseRequested], !menusOpen); + useMediaSession(video.state, player, onPlayRequested, onPauseRequested, onNextVideoRequested); onShortcut('seekForward', (combo) => { if (video.state.time !== null) { @@ -745,7 +695,7 @@ const Player = ({ urlParams, queryParams }) => { onShortcut('statisticsMenu', () => { closeMenus(); const stream = player.selected?.stream; - if (streamingServer?.statistics?.type !== 'Err' && typeof stream === 'string' && typeof stream === 'number') { + if (streamingServer?.statistics?.type !== 'Err' && typeof stream?.infoHash === 'string' && typeof stream?.fileIdx === 'number') { toggleStatisticsMenu(); } }, [player.selected, streamingServer.statistics, toggleStatisticsMenu]); @@ -793,7 +743,17 @@ const Player = ({ urlParams, queryParams }) => { if (e.code === 'Space') { clearTimeout(pressTimer.current); pressTimer.current = null; - onPlaybackSpeedChanged(playbackSpeed.current); + if (longPress.current) { + onPlaybackSpeedChanged(playbackSpeed.current); + } else if (!menusOpen && video.state.paused !== null) { + if (video.state.paused) { + onPlayRequested(); + setSeeking(false); + } else { + onPauseRequested(); + } + } + longPress.current = false; } }; @@ -832,12 +792,23 @@ const Player = ({ urlParams, queryParams }) => { } }; + const onBlur = () => { + clearTimeout(pressTimer.current); + pressTimer.current = null; + if (longPress.current) { + onPlaybackSpeedChanged(playbackSpeed.current); + longPress.current = false; + } + setSeeking(false); + }; + if (routeFocused) { window.addEventListener('keyup', onKeyUp); window.addEventListener('keydown', onKeyDown); window.addEventListener('wheel', onWheel); window.addEventListener('mousedown', onMouseDownHold); window.addEventListener('mouseup', onMouseUp); + window.addEventListener('blur', onBlur); } return () => { window.removeEventListener('keyup', onKeyUp); @@ -845,8 +816,9 @@ const Player = ({ urlParams, queryParams }) => { window.removeEventListener('wheel', onWheel); window.removeEventListener('mousedown', onMouseDownHold); window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('blur', onBlur); }; - }, [routeFocused, menusOpen, video.state.volume]); + }, [routeFocused, menusOpen, video.state.volume, video.state.paused]); React.useEffect(() => { video.events.on('error', onError); diff --git a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js index ef70410f6..751362ba9 100644 --- a/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js +++ b/src/routes/Player/SubtitlesMenu/SubtitlesMenu.js @@ -48,7 +48,7 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => { const userLanguage = languages.toCode(props.subtitlesLanguage) ?? DEFAULT_SUBTITLES_LANGUAGE; const interfaceLanguage = languages.toCode(props.interfaceLanguage) ?? DEFAULT_SUBTITLES_LANGUAGE; const priorities = [LOCAL_SUBTITLES_LANGUAGE, userLanguage, interfaceLanguage]; - const langs = Object.keys(Object.groupBy(allSubtitles, ({ lang }) => lang)).sort((a, b) => a.localeCompare(b)); + const langs = [...new Set(allSubtitles.map(({ lang }) => lang))].sort((a, b) => a.localeCompare(b)); return sortByValues(langs, priorities); }, [allSubtitles, props.subtitlesLanguage, props.interfaceLanguage]); @@ -95,11 +95,11 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => { } } else if (track.embedded) { if (typeof props.onSubtitlesTrackSelected === 'function') { - props.onSubtitlesTrackSelected(track.id); + props.onSubtitlesTrackSelected(track); } } else { if (typeof props.onExtraSubtitlesTrackSelected === 'function') { - props.onExtraSubtitlesTrackSelected(track.id); + props.onExtraSubtitlesTrackSelected(track); } } }, [allSubtitles, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]); @@ -113,7 +113,7 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => { props.onExtraSubtitlesTrackSelected(track.id); } } - }, [props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]); + }, [subtitlesTracksForLanguage, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]); const onSubtitlesDelayChanged = React.useCallback((value) => { if (typeof props.selectedExtraSubtitlesTrackId === 'string') { if (props.extraSubtitlesDelay !== null && !isNaN(props.extraSubtitlesDelay)) { diff --git a/src/routes/Player/useMediaSession.ts b/src/routes/Player/useMediaSession.ts new file mode 100644 index 000000000..7a63423bd --- /dev/null +++ b/src/routes/Player/useMediaSession.ts @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; + +const useMediaSession = ( + videoState: VideoState, + player: Player, + onPlayRequested: () => void, + onPauseRequested: () => void, + onNextVideoRequested: () => void, +) => { + useEffect(() => { + if (!navigator.mediaSession) return; + + const playbackState = !videoState.paused ? 'playing' : 'paused'; + navigator.mediaSession.playbackState = playbackState; + + return () => { + navigator.mediaSession.playbackState = 'none'; + }; + }, [videoState.paused]); + + useEffect(() => { + if (!navigator.mediaSession) return; + + const metaItem = player.metaItem && player.metaItem?.type === 'Ready' ? player.metaItem.content as MetaItemPlayer : null; + const videoId = player.selected ? player.selected?.streamRequest?.path?.id : null; + const video = metaItem?.videos.find(({ id }) => id === videoId); + + const videoInfo = video?.season && video?.episode ? ` (${video.season}x${video.episode})` : null; + const videoTitle = video ? `${video.title}${videoInfo}` : null; + const metaTitle = metaItem ? metaItem.name : null; + const imageUrl = metaItem ? metaItem.logo : null; + + const title = videoTitle ?? metaTitle; + const artist = (videoTitle && metaTitle) ?? undefined; + const artwork = imageUrl ? [{ src: imageUrl }] : undefined; + + if (title) { + navigator.mediaSession.metadata = new MediaMetadata({ + title, + artist, + artwork, + }); + } + }, [player.metaItem, player.selected]); + + useEffect(() => { + if (!navigator.mediaSession) return; + + navigator.mediaSession.setActionHandler('play', onPlayRequested); + navigator.mediaSession.setActionHandler('pause', onPauseRequested); + + const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null; + navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback); + }, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]); +}; + +export default useMediaSession; diff --git a/src/routes/Player/videoState.d.ts b/src/routes/Player/videoState.d.ts new file mode 100644 index 000000000..0f8a78c10 --- /dev/null +++ b/src/routes/Player/videoState.d.ts @@ -0,0 +1,3 @@ +type VideoState = { + paused?: boolean; +}; diff --git a/src/services/Core/CoreTransport.js b/src/services/Core/CoreTransport.js index b140551f6..c497d75c6 100644 --- a/src/services/Core/CoreTransport.js +++ b/src/services/Core/CoreTransport.js @@ -52,6 +52,9 @@ function CoreTransport(args) { this.decodeStream = async function(stream) { return bridge.call(['decodeStream'], [stream]); }; + this.encodeStream = async function(stream) { + return bridge.call(['encodeStream'], [stream]); + }; } module.exports = CoreTransport; diff --git a/src/services/Core/types.d.ts b/src/services/Core/types.d.ts index 7e4249ac8..668479e12 100644 --- a/src/services/Core/types.d.ts +++ b/src/services/Core/types.d.ts @@ -17,6 +17,7 @@ interface CoreTransport { getState: (model: string) => Promise, dispatch: (action: Action, model?: string) => Promise, decodeStream: (stream: string) => Promise, + encodeStream: (stream: object) => Promise, analytics: (event: AnalyticsEvent) => Promise, on: (name: string, listener: () => void) => void, off: (name: string, listener: () => void) => void, diff --git a/src/types/models/Player.d.ts b/src/types/models/Player.d.ts index 321127316..14e78a768 100644 --- a/src/types/models/Player.d.ts +++ b/src/types/models/Player.d.ts @@ -5,7 +5,6 @@ type LibraryItemPlayer = Pick & { type VideoPlayer = Video & { upcoming: boolean, watched: boolean, - progress: boolean | null, scheduled: boolean, deepLinks: VideoDeepLinks, };