diff --git a/src/App/App.js b/src/App/App.js index 1ad2a0980..132f32911 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -3,9 +3,9 @@ require('spatial-navigation-polyfill'); const React = require('react'); const { useTranslation } = require('react-i18next'); -const { useNavigate } = require('react-router'); +const { useLocation, useNavigate } = require('react-router'); const { useCore } = require('stremio/core'); -const { Routes } = require('stremio-router'); +const { Routes, navigateToRoute } = require('stremio-router'); const { Chromecast, ServicesProvider, GamepadProvider } = require('stremio/services'); const { FullscreenProvider, ToastProvider, TooltipProvider, ShortcutsProvider, DiscordProvider, CONSTANTS, useBinaryState, useProfile, withCoreSuspender, onFileDrop, usePlatform } = require('stremio/common'); const ServicesToaster = require('./ServicesToaster'); @@ -24,7 +24,10 @@ const App = () => { const profile = useProfile(); const { i18n } = useTranslation(); const { shell } = usePlatform(); + const location = useLocation(); const navigate = useNavigate(); + const locationRef = React.useRef(location); + locationRef.current = location; const [gamepadSupportEnabled, setGamepadSupportEnabled] = React.useState(false); const services = React.useMemo(() => { return { @@ -113,7 +116,9 @@ const App = () => { const transportUrl = `https://${hostname}${pathname}`; navigate(`/addons?addon=${encodeURIComponent(transportUrl)}`); } else { - navigate(`${pathname}?${searchParams.toString()}`); + const search = searchParams.toString(); + const path = search ? `${pathname}?${search}` : pathname; + navigateToRoute(navigate, locationRef.current, path); } } } catch (e) { @@ -127,7 +132,7 @@ const App = () => { } return () => shell.off('open-media', onOpenMedia); - }, [shell.state.initialized]); + }, [shell.state.initialized, navigate]); React.useEffect(() => { if (typeof profile.settings?.interfaceLanguage === 'string') { diff --git a/src/App/DeepLinkHandler.js b/src/App/DeepLinkHandler.js index 0726fbbe7..ad8800bac 100644 --- a/src/App/DeepLinkHandler.js +++ b/src/App/DeepLinkHandler.js @@ -1,11 +1,12 @@ // Copyright (C) 2017-2023 Smart code 203358507 const React = require('react'); -const { useNavigate } = require('react-router'); +const { useLocation, useNavigate } = require('react-router'); const { withCoreSuspender, useStreamingServer } = require('stremio/common'); -const { default: toPath } = require('stremio-router/toPath'); +const { navigateToRoute, toPath } = require('stremio-router'); const DeepLinkHandler = () => { + const location = useLocation(); const navigate = useNavigate(); const streamingServer = useStreamingServer(); React.useEffect(() => { @@ -14,11 +15,12 @@ const DeepLinkHandler = () => { if (type === 'Ready') { const [, deepLinks] = content; if (typeof deepLinks.metaDetailsVideos === 'string') { - navigate(toPath(deepLinks.metaDetailsVideos)); + const path = toPath(deepLinks.metaDetailsVideos); + navigateToRoute(navigate, location, path); } } } - }, [streamingServer.torrent]); + }, [streamingServer.torrent, location.pathname, location.search, navigate]); return null; }; diff --git a/src/router/index.ts b/src/router/index.ts index 8b413e559..0259c957a 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -6,6 +6,7 @@ import Modal from './Modal'; import Routes from './Routes'; import Route from './Route'; import toPath from './toPath'; +import navigateToRoute from './routePath'; export { useModalsContainer, @@ -14,4 +15,5 @@ export { Routes, Route, toPath, + navigateToRoute, }; diff --git a/src/router/routePath.ts b/src/router/routePath.ts new file mode 100644 index 000000000..0bef85b46 --- /dev/null +++ b/src/router/routePath.ts @@ -0,0 +1,41 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import type { Location, NavigateFunction } from 'react-router-dom'; + +const decodePathname = (pathname: string): string => { + try { + return decodeURIComponent(pathname); + } catch { + return pathname; + } +}; + +const normalizeRoutePathname = (pathname: string): string => { + return decodePathname(pathname) + .replace(/^\/metadetails(?=\/|$)/, '/detail'); +}; + +const getPathname = (path: string): string => path.split(/[?#]/)[0]; + +const getSearch = (path: string): string => { + const searchIndex = path.indexOf('?'); + return searchIndex !== -1 ? path.slice(searchIndex) : ''; +}; + +const sameRoutePathname = (location: Location, path: string): boolean => { + return normalizeRoutePathname(location.pathname) === normalizeRoutePathname(getPathname(path)); +}; + +const sameRouteLocation = (location: Location, path: string): boolean => { + return sameRoutePathname(location, path) && location.search === getSearch(path); +}; + +const navigateToRoute = (navigate: NavigateFunction, location: Location, path: string): void => { + if (!sameRouteLocation(location, path)) { + navigate(path, { + replace: sameRoutePathname(location, path) + }); + } +}; + +export default navigateToRoute; diff --git a/src/routes/MetaDetails/MetaDetails.js b/src/routes/MetaDetails/MetaDetails.js index 41e9eee15..07baf0a3b 100644 --- a/src/routes/MetaDetails/MetaDetails.js +++ b/src/routes/MetaDetails/MetaDetails.js @@ -2,6 +2,7 @@ const React = require('react'); const { useParams, useLocation, useNavigate } = require('react-router'); +const { useSearchParams } = require('react-router-dom'); const { useTranslation } = require('react-i18next'); const classnames = require('classnames'); const { useCore } = require('stremio/core'); @@ -14,6 +15,7 @@ const VideosList = require('./VideosList'); const useMetaDetails = require('./useMetaDetails'); const useSeason = require('./useSeason'); const useMetaExtensionTabs = require('./useMetaExtensionTabs'); +const { default: useExternalPlayerCallback } = require('./useExternalPlayerCallback'); const styles = require('./styles'); const GAMEPAD_HANDLER_ID = 'metadetails'; @@ -22,6 +24,7 @@ const MetaDetails = () => { const { type, id, videoId } = useParams(); const location = useLocation(); const navigate = useNavigate(); + const [queryParams] = useSearchParams(); const { getStoredOrigin } = useNavigateWithOrigin(); const contentRef = React.useRef(null); const { t } = useTranslation(); @@ -32,6 +35,7 @@ const MetaDetails = () => { videoId }), [type, id, videoId]); const metaDetails = useMetaDetails(urlParams); + useExternalPlayerCallback(urlParams, queryParams, metaDetails); const [season, setSeason] = useSeason(urlParams); const [tabs, metaExtension, clearMetaExtension] = useMetaExtensionTabs(metaDetails.metaExtensions); const [metaPath, streamPath] = React.useMemo(() => { @@ -52,6 +56,11 @@ const MetaDetails = () => { : null; }, [metaDetails.metaItem, streamPath]); + const externalPlayerCallbackCanMarkWatched = React.useMemo(() => { + return typeof video?.id === 'string' && + metaDetails.libraryItem?.state?.video_id === video.id && + metaDetails.libraryItem?.state?.duration > 0; + }, [metaDetails.libraryItem, video]); const addToLibrary = React.useCallback(() => { if (metaDetails.metaItem === null || metaDetails.metaItem.content.type !== 'Ready') { return; @@ -215,6 +224,7 @@ const MetaDetails = () => { streams={metaDetails.streams} video={video} type={streamPath.type} + externalPlayerCallbackCanMarkWatched={externalPlayerCallbackCanMarkWatched} onEpisodeSearch={handleEpisodeSearch} /> : diff --git a/src/routes/MetaDetails/StreamsList/Stream/Stream.js b/src/routes/MetaDetails/StreamsList/Stream/Stream.js index 0dbe42f7e..e5e14e9be 100644 --- a/src/routes/MetaDetails/StreamsList/Stream/Stream.js +++ b/src/routes/MetaDetails/StreamsList/Stream/Stream.js @@ -12,7 +12,7 @@ const { default: useRouteFocused } = require('stremio/common/useRouteFocused'); const StreamPlaceholder = require('./StreamPlaceholder'); const styles = require('./styles'); -const Stream = ({ className, videoId, videoReleased, addonName, name, description, thumbnail, progress, deepLinks, ...props }) => { +const Stream = ({ className, videoId, videoReleased, addonName, name, description, thumbnail, progress, deepLinks, externalPlayerCallbackCanMarkWatched, ...props }) => { const profile = useProfile(); const toast = useToast(); const platform = usePlatform(); @@ -118,7 +118,9 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio } if (profile.settings.playerType !== null) { - markVideoAsWatched(); + if (profile.settings.playerType !== 'infuse' || !externalPlayerCallbackCanMarkWatched) { + markVideoAsWatched(); + } toast.show({ type: 'success', title: 'Stream opened in external player', @@ -129,7 +131,7 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio if (typeof props.onClick === 'function') { props.onClick(event); } - }, [props.onClick, profile.settings, markVideoAsWatched]); + }, [props.onClick, profile.settings.playerType, externalPlayerCallbackCanMarkWatched, markVideoAsWatched]); const copyMagnetLink = React.useCallback((event) => { event.preventDefault(); @@ -321,6 +323,7 @@ Stream.propTypes = { }) }) }), + externalPlayerCallbackCanMarkWatched: PropTypes.bool, onClick: PropTypes.func }; diff --git a/src/routes/MetaDetails/StreamsList/StreamsList.js b/src/routes/MetaDetails/StreamsList/StreamsList.js index 49aafc300..e33b3cf19 100644 --- a/src/routes/MetaDetails/StreamsList/StreamsList.js +++ b/src/routes/MetaDetails/StreamsList/StreamsList.js @@ -16,7 +16,7 @@ const { default: SeasonEpisodePicker } = require('../EpisodePicker'); const ALL_ADDONS_KEY = 'ALL'; -const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => { +const StreamsList = ({ className, video, type, externalPlayerCallbackCanMarkWatched, onEpisodeSearch, ...props }) => { const { t } = useTranslation(); const core = useCore(); const platform = usePlatform(); @@ -184,6 +184,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => { thumbnail={stream.thumbnail} progress={stream.progress} deepLinks={stream.deepLinks} + externalPlayerCallbackCanMarkWatched={externalPlayerCallbackCanMarkWatched} onClick={stream.onClick} /> ))} @@ -219,6 +220,7 @@ StreamsList.propTypes = { streams: PropTypes.arrayOf(PropTypes.object).isRequired, video: PropTypes.object, type: PropTypes.string, + externalPlayerCallbackCanMarkWatched: PropTypes.bool, onEpisodeSearch: PropTypes.func }; diff --git a/src/routes/MetaDetails/useExternalPlayerCallback.ts b/src/routes/MetaDetails/useExternalPlayerCallback.ts new file mode 100644 index 000000000..f098e826f --- /dev/null +++ b/src/routes/MetaDetails/useExternalPlayerCallback.ts @@ -0,0 +1,59 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import React from 'react'; +import { useCore } from 'stremio/core'; + +type UrlParams = { + type?: string, + id?: string, + videoId?: string, +}; + +const useExternalPlayerCallback = ( + urlParams: UrlParams, + searchParams: URLSearchParams, + metaDetails: MetaDetails, +) => { + const core = useCore(); + const callbackRef = React.useRef(null); + + React.useEffect(() => { + const position = searchParams.get('position'); + if (position === null || position === '') { + return; + } + + const positionNumber = Number(position); + const time = Math.round(positionNumber * 1000); + const streamPath = metaDetails.selected?.streamPath ?? null; + const callbackKey = [ + urlParams.type, + urlParams.id, + urlParams.videoId, + time, + searchParams.get('lastPlayedUrl') + ].join(':'); + if ( + !Number.isFinite(positionNumber) || + positionNumber < 0 || + streamPath === null || + metaDetails.libraryItem === null || + callbackRef.current === callbackKey + ) { + return; + } + + callbackRef.current = callbackKey; + core.transport.dispatch({ + action: 'MetaDetails', + args: { + action: 'ExternalPlayerProgressChanged', + args: { + time + } + } + }); + }, [urlParams, searchParams, metaDetails]); +}; + +export default useExternalPlayerCallback;