diff --git a/package-lock.json b/package-lock.json
index 5775eb1a9..31a0fdda8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,7 +36,7 @@
"react-i18next": "^12.1.1",
"react-is": "18.2.0",
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
- "stremio-translations": "github:Stremio/stremio-translations#b13b3e2653bd0dcf644d2a20ffa32074fe6532dd",
+ "stremio-translations": "github:Stremio/stremio-translations#378218c9617f3e763ba5f6755e4d39c1c158747d",
"url": "0.11.0",
"use-long-press": "^3.1.5"
},
@@ -12470,10 +12470,9 @@
}
},
"node_modules/stremio-translations": {
- "version": "1.44.7",
- "resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#b13b3e2653bd0dcf644d2a20ffa32074fe6532dd",
- "integrity": "sha512-OtRAM3j9ie89llgI379p4utCbgnNMswE+LtL/lyLRVLfm5B+jpBLp4ozpU25iQg0O4tvN+OHBjXZ870CCHtZMA==",
- "license": "MIT"
+ "version": "1.44.9",
+ "resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#378218c9617f3e763ba5f6755e4d39c1c158747d",
+ "integrity": "sha512-3GboN8JS2LgrdIVK/gW+n6r1kLrGG+D/tWkRv8PJo2mZLzh49HTzS2u7XXUSkNmA4AGUyEf8QRjyBhlOg8JNTQ=="
},
"node_modules/string_decoder": {
"version": "1.1.1",
diff --git a/package.json b/package.json
index 6610395a6..1339ba710 100755
--- a/package.json
+++ b/package.json
@@ -40,7 +40,7 @@
"react-i18next": "^12.1.1",
"react-is": "18.2.0",
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
- "stremio-translations": "github:Stremio/stremio-translations#b13b3e2653bd0dcf644d2a20ffa32074fe6532dd",
+ "stremio-translations": "github:Stremio/stremio-translations#378218c9617f3e763ba5f6755e4d39c1c158747d",
"url": "0.11.0",
"use-long-press": "^3.1.5"
},
diff --git a/src/App/App.js b/src/App/App.js
index 21ba1132b..730e75f28 100644
--- a/src/App/App.js
+++ b/src/App/App.js
@@ -6,7 +6,7 @@ const { useTranslation } = require('react-i18next');
const { Router } = require('stremio-router');
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
const { NotFound } = require('stremio/routes');
-const { ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender } = require('stremio/common');
+const { PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender } = require('stremio/common');
const ServicesToaster = require('./ServicesToaster');
const DeepLinkHandler = require('./DeepLinkHandler');
const SearchParamsHandler = require('./SearchParamsHandler');
@@ -162,18 +162,20 @@ const App = () => {
services.core.error instanceof Error ?
:
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
:
}
diff --git a/src/common/AddonDetailsModal/AddonDetailsModal.js b/src/common/AddonDetailsModal/AddonDetailsModal.js
index ffe2671d0..b4ffdab71 100644
--- a/src/common/AddonDetailsModal/AddonDetailsModal.js
+++ b/src/common/AddonDetailsModal/AddonDetailsModal.js
@@ -3,7 +3,7 @@
const React = require('react');
const PropTypes = require('prop-types');
const ModalDialog = require('stremio/common/ModalDialog');
-const { withCoreSuspender } = require('stremio/common/CoreSuspender');
+const { usePlatform, withCoreSuspender } = require('stremio/common/CoreSuspender');
const { useServices } = require('stremio/services');
const AddonDetailsWithRemoteAndLocalAddon = withRemoteAndLocalAddon(require('./AddonDetails'));
const useAddonDetails = require('./useAddonDetails');
@@ -43,6 +43,7 @@ function withRemoteAndLocalAddon(AddonDetails) {
const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
const { core } = useServices();
+ const platform = usePlatform();
const addonDetails = useAddonDetails(transportUrl);
const modalButtons = React.useMemo(() => {
const cancelButton = {
@@ -68,7 +69,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
label: 'Configure',
props: {
onClick: (event) => {
- window.open(transportUrl.replace('manifest.json', 'configure'));
+ platform.openExternal(transportUrl.replace('manifest.json', 'configure'));
if (typeof onCloseRequest === 'function') {
onCloseRequest({
type: 'configure',
diff --git a/src/common/CONSTANTS.js b/src/common/CONSTANTS.js
index 742c138d6..c08cf471d 100644
--- a/src/common/CONSTANTS.js
+++ b/src/common/CONSTANTS.js
@@ -93,6 +93,8 @@ const EXTERNAL_PLAYERS = [
},
];
+const WHITELISTED_HOSTS = ['stremio.com', 'strem.io', 'stremio.zendesk.com', 'google.com', 'youtube.com', 'twitch.tv', 'twitter.com', 'netflix.com', 'adex.network', 'amazon.com', 'forms.gle'];
+
module.exports = {
CHROMECAST_RECEIVER_APP_ID,
SUBTITLES_SIZES,
@@ -110,4 +112,5 @@ module.exports = {
TYPE_PRIORITIES,
ICON_FOR_TYPE,
EXTERNAL_PLAYERS,
+ WHITELISTED_HOSTS,
};
diff --git a/src/common/Platform/Platform.tsx b/src/common/Platform/Platform.tsx
new file mode 100644
index 000000000..69ff3e8c3
--- /dev/null
+++ b/src/common/Platform/Platform.tsx
@@ -0,0 +1,51 @@
+import React, { createContext, useContext } from 'react';
+import { WHITELISTED_HOSTS } from 'stremio/common/CONSTANTS';
+import useShell from './useShell';
+import { name, isMobile } from './device';
+
+interface PlatformContext {
+ name: string;
+ isMobile: boolean;
+ openExternal: (url: string) => void;
+}
+
+const PlatformContext = createContext(null);
+
+type Props = {
+ children: JSX.Element;
+};
+
+const PlatformProvider = ({ children }: Props) => {
+ const shell = useShell();
+
+ const openExternal = (url: string) => {
+ try {
+ const { hostname } = new URL(url);
+ const isWhitelisted = WHITELISTED_HOSTS.some((host: string) => hostname.endsWith(host));
+ const finalUrl = !isWhitelisted ? `https://www.stremio.com/warning#${encodeURIComponent(url)}` : url;
+
+ if (shell.active) {
+ shell.send('open-external', finalUrl);
+ } else {
+ window.open(finalUrl, '_blank');
+ }
+ } catch (e) {
+ console.error('Failed to parse external url:', e);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+const usePlatform = () => {
+ return useContext(PlatformContext);
+};
+
+export {
+ PlatformProvider,
+ usePlatform
+};
diff --git a/src/common/Platform/device.ts b/src/common/Platform/device.ts
new file mode 100644
index 000000000..68ca72b32
--- /dev/null
+++ b/src/common/Platform/device.ts
@@ -0,0 +1,31 @@
+import Bowser from 'bowser';
+
+const APPLE_MOBILE_DEVICES = [
+ 'iPad Simulator',
+ 'iPhone Simulator',
+ 'iPod Simulator',
+ 'iPad',
+ 'iPhone',
+ 'iPod',
+];
+
+const { userAgent, platform, maxTouchPoints } = globalThis.navigator;
+
+// this detects ipad properly in safari
+// while bowser does not
+const isIOS = APPLE_MOBILE_DEVICES.includes(platform) || (userAgent.includes('Mac') && 'ontouchend' in document);
+
+// Edge case: iPad is included in this function
+// Keep in mind maxTouchPoints for Vision Pro might change in the future
+const isVisionOS = userAgent.includes('Macintosh') || maxTouchPoints === 5;
+
+const bowser = Bowser.getParser(userAgent);
+const os = bowser.getOSName().toLowerCase();
+
+const name = isVisionOS ? 'visionos' : isIOS ? 'ios' : os || 'unknown';
+const isMobile = ['ios', 'android'].includes(name);
+
+export {
+ name,
+ isMobile,
+};
\ No newline at end of file
diff --git a/src/common/Platform/index.ts b/src/common/Platform/index.ts
new file mode 100644
index 000000000..8d2b68f12
--- /dev/null
+++ b/src/common/Platform/index.ts
@@ -0,0 +1,5 @@
+import { PlatformProvider, usePlatform } from './Platform';
+export {
+ PlatformProvider,
+ usePlatform,
+};
\ No newline at end of file
diff --git a/src/common/Platform/useShell.ts b/src/common/Platform/useShell.ts
new file mode 100644
index 000000000..aaf5a028e
--- /dev/null
+++ b/src/common/Platform/useShell.ts
@@ -0,0 +1,22 @@
+const createId = () => Math.floor(Math.random() * 9999) + 1;
+
+const useShell = () => {
+ const transport = globalThis?.qt?.webChannelTransport;
+
+ const send = (method: string, ...args: (string | number)[]) => {
+ transport?.send(JSON.stringify({
+ id: createId(),
+ type: 6,
+ object: 'transport',
+ method: 'onEvent',
+ args: [method, ...args],
+ }));
+ };
+
+ return {
+ active: !!transport,
+ send,
+ };
+};
+
+export default useShell;
\ No newline at end of file
diff --git a/src/common/index.js b/src/common/index.js
index c582a13ca..5adef3e60 100644
--- a/src/common/index.js
+++ b/src/common/index.js
@@ -18,6 +18,7 @@ const Multiselect = require('./Multiselect');
const { default: MultiselectMenu } = require('./MultiselectMenu');
const { HorizontalNavBar, VerticalNavBar } = require('./NavBar');
const PaginationInput = require('./PaginationInput');
+const { PlatformProvider, usePlatform } = require('./Platform');
const PlayIconCircleCentered = require('./PlayIconCircleCentered');
const Popup = require('./Popup');
const SearchBar = require('./SearchBar');
@@ -45,7 +46,6 @@ const useProfile = require('./useProfile');
const useStreamingServer = require('./useStreamingServer');
const useTorrent = require('./useTorrent');
const useTranslate = require('./useTranslate');
-const platform = require('./platform');
const EventModal = require('./EventModal');
module.exports = {
@@ -68,6 +68,8 @@ module.exports = {
HorizontalNavBar,
VerticalNavBar,
PaginationInput,
+ PlatformProvider,
+ usePlatform,
PlayIconCircleCentered,
Popup,
SearchBar,
@@ -98,6 +100,5 @@ module.exports = {
useStreamingServer,
useTorrent,
useTranslate,
- platform,
EventModal,
};
diff --git a/src/common/platform.js b/src/common/platform.js
deleted file mode 100644
index 7e423489a..000000000
--- a/src/common/platform.js
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2017-2024 Smart code 203358507
-
-// this detects ipad properly in safari
-// while bowser does not
-const iOS = () => {
- return [
- 'iPad Simulator',
- 'iPhone Simulator',
- 'iPod Simulator',
- 'iPad',
- 'iPhone',
- 'iPod'
- ].includes(navigator.platform)
- || (navigator.userAgent.includes('Mac') && 'ontouchend' in document);
-};
-
-const Bowser = require('bowser');
-
-const browser = Bowser.parse(window.navigator?.userAgent || '');
-
-// Edge case: iPad is included in this function
-// Keep in mind maxTouchPoints for Vision Pro might change in the future
-const isVisionProUser = () => {
- const isMacintosh = navigator.userAgent.includes('Macintosh');
- const hasFiveTouchPoints = navigator.maxTouchPoints === 5;
- return isMacintosh && hasFiveTouchPoints;
-};
-
-const name = isVisionProUser() ? 'visionos' : (iOS() ? 'ios' : (browser?.os?.name || 'unknown').toLowerCase());
-
-module.exports = {
- name,
- isMobile: () => {
- return name === 'ios' || name === 'android';
- }
-};
diff --git a/src/routes/Addons/Addons.js b/src/routes/Addons/Addons.js
index c4c99173a..0de34ad4a 100644
--- a/src/routes/Addons/Addons.js
+++ b/src/routes/Addons/Addons.js
@@ -5,7 +5,7 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
-const { AddonDetailsModal, Button, Image, Multiselect, MainNavBars, TextInput, SearchBar, SharePrompt, ModalDialog, useBinaryState, withCoreSuspender } = require('stremio/common');
+const { AddonDetailsModal, Button, Image, Multiselect, MainNavBars, TextInput, SearchBar, SharePrompt, ModalDialog, usePlatform, useBinaryState, withCoreSuspender } = require('stremio/common');
const Addon = require('./Addon');
const useInstalledAddons = require('./useInstalledAddons');
const useRemoteAddons = require('./useRemoteAddons');
@@ -15,6 +15,7 @@ const styles = require('./styles');
const Addons = ({ urlParams, queryParams }) => {
const { t } = useTranslation();
+ const platform = usePlatform();
const installedAddons = useInstalledAddons(urlParams);
const remoteAddons = useRemoteAddons(urlParams);
const [addonDetailsTransportUrl, setAddonDetailsTransportUrl] = useAddonDetailsTransportUrl(urlParams, queryParams);
@@ -59,7 +60,7 @@ const Addons = ({ urlParams, queryParams }) => {
setAddonDetailsTransportUrl(event.dataset.addon.transportUrl);
}, [setAddonDetailsTransportUrl]);
const onAddonConfigure = React.useCallback((event) => {
- window.open(event.dataset.addon.transportUrl.replace('manifest.json', 'configure'));
+ platform.openExternal(event.dataset.addon.transportUrl.replace('manifest.json', 'configure'));
}, []);
const closeAddonDetails = React.useCallback(() => {
setAddonDetailsTransportUrl(null);
diff --git a/src/routes/Intro/PasswordResetModal/PasswordResetModal.js b/src/routes/Intro/PasswordResetModal/PasswordResetModal.js
index 0d7bcaae4..1061f6044 100644
--- a/src/routes/Intro/PasswordResetModal/PasswordResetModal.js
+++ b/src/routes/Intro/PasswordResetModal/PasswordResetModal.js
@@ -3,17 +3,18 @@
const React = require('react');
const PropTypes = require('prop-types');
const { useRouteFocused } = require('stremio-router');
-const { ModalDialog } = require('stremio/common');
+const { ModalDialog, usePlatform } = require('stremio/common');
const CredentialsTextInput = require('../CredentialsTextInput');
const styles = require('./styles');
const PasswordResetModal = ({ email, onCloseRequest }) => {
const routeFocused = useRouteFocused();
+ const platform = usePlatform();
const [error, setError] = React.useState('');
const emailRef = React.useRef(null);
const goToPasswordReset = React.useCallback(() => {
emailRef.current.value.length > 0 && emailRef.current.validity.valid ?
- window.open('https://www.strem.io/reset-password/' + emailRef.current.value, '_blank')
+ platform.openExternal('https://www.strem.io/reset-password/' + emailRef.current.value, '_blank')
:
setError('Invalid email');
}, []);
diff --git a/src/routes/MetaDetails/StreamsList/Stream/Stream.js b/src/routes/MetaDetails/StreamsList/Stream/Stream.js
index 40ccb2f07..e4829ece0 100644
--- a/src/routes/MetaDetails/StreamsList/Stream/Stream.js
+++ b/src/routes/MetaDetails/StreamsList/Stream/Stream.js
@@ -5,7 +5,7 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { t } = require('i18next');
-const { Button, Image, useProfile, platform, useToast, Popup, useBinaryState } = require('stremio/common');
+const { Button, Image, useProfile, usePlatform, useToast, Popup, useBinaryState } = require('stremio/common');
const { useServices } = require('stremio/services');
const { useRouteFocused } = require('stremio-router');
const StreamPlaceholder = require('./StreamPlaceholder');
@@ -14,6 +14,7 @@ const styles = require('./styles');
const Stream = ({ className, videoId, videoReleased, addonName, name, description, thumbnail, progress, deepLinks, ...props }) => {
const profile = useProfile();
const toast = useToast();
+ const platform = usePlatform();
const { core } = useServices();
const routeFocused = useRouteFocused();
diff --git a/src/routes/Player/OptionsMenu/OptionsMenu.js b/src/routes/Player/OptionsMenu/OptionsMenu.js
index 6c50e1551..11738cb3e 100644
--- a/src/routes/Player/OptionsMenu/OptionsMenu.js
+++ b/src/routes/Player/OptionsMenu/OptionsMenu.js
@@ -4,7 +4,7 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useTranslation } = require('react-i18next');
-const { useToast } = require('stremio/common');
+const { usePlatform, useToast } = require('stremio/common');
const { useServices } = require('stremio/services');
const Option = require('./Option');
const styles = require('./styles');
@@ -12,6 +12,7 @@ const styles = require('./styles');
const OptionsMenu = ({ className, stream, playbackDevices }) => {
const { t } = useTranslation();
const { core } = useServices();
+ const platform = usePlatform();
const toast = useToast();
const [streamingUrl, downloadUrl] = React.useMemo(() => {
return stream !== null ?
@@ -48,7 +49,7 @@ const OptionsMenu = ({ className, stream, playbackDevices }) => {
}, [streamingUrl, downloadUrl]);
const onDownloadVideoButtonClick = React.useCallback(() => {
if (streamingUrl || downloadUrl) {
- window.open(streamingUrl || downloadUrl);
+ platform.openExternal(streamingUrl || downloadUrl);
}
}, [streamingUrl, downloadUrl]);
const onExternalDeviceRequested = React.useCallback((deviceId) => {
diff --git a/src/routes/Settings/Settings.js b/src/routes/Settings/Settings.js
index ce37d83ba..2ed0a1ab7 100644
--- a/src/routes/Settings/Settings.js
+++ b/src/routes/Settings/Settings.js
@@ -7,7 +7,7 @@ const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
-const { Button, Checkbox, MainNavBars, Multiselect, ColorInput, TextInput, ModalDialog, useProfile, useStreamingServer, useBinaryState, withCoreSuspender, useToast } = require('stremio/common');
+const { Button, Checkbox, MainNavBars, Multiselect, ColorInput, TextInput, ModalDialog, useProfile, usePlatform, useStreamingServer, useBinaryState, withCoreSuspender, useToast } = require('stremio/common');
const useProfileSettingsInputs = require('./useProfileSettingsInputs');
const useStreamingServerSettingsInputs = require('./useStreamingServerSettingsInputs');
const useDataExport = require('./useDataExport');
@@ -25,6 +25,7 @@ const Settings = () => {
const profile = useProfile();
const [dataExport, loadDataExport] = useDataExport();
const streamingServer = useStreamingServer();
+ const platform = usePlatform();
const toast = useToast();
const {
interfaceLanguageSelect,
@@ -90,7 +91,7 @@ const Settings = () => {
}, []);
const toggleTraktOnClick = React.useCallback(() => {
if (!isTraktAuthenticated && profile.auth !== null && profile.auth.user !== null && typeof profile.auth.user._id === 'string') {
- window.open(`https://www.strem.io/trakt/auth/${profile.auth.user._id}`);
+ platform.openExternal(`https://www.strem.io/trakt/auth/${profile.auth.user._id}`);
setTraktAuthStarted(true);
} else {
core.transport.dispatch({
@@ -102,15 +103,18 @@ const Settings = () => {
}
}, [isTraktAuthenticated, profile.auth]);
const subscribeCalendarOnClick = React.useCallback(() => {
- const url = `webcal://www.strem.io/calendar/${profile.auth.user._id}.ics`;
- window.open(url);
+ if (!profile.auth) return;
+
+ const protocol = platform.name === 'ios' ? 'webcal' : 'https';
+ const url = `${protocol}://www.strem.io/calendar/${profile.auth.user._id}.ics`;
+ platform.openExternal(url);
toast.show({
type: 'success',
- title: 'Calendar has been added to your default caldendar app',
+ title: platform.name === 'ios' ? t('SETTINGS_SUBSCRIBE_CALENDAR_IOS_TOAST') : t('SETTINGS_SUBSCRIBE_CALENDAR_TOAST'),
timeout: 25000
});
- //Stremio 4 emits not documented event subscribeCalendar
- }, []);
+ // Stremio 4 emits not documented event subscribeCalendar
+ }, [profile.auth]);
const exportDataOnClick = React.useCallback(() => {
loadDataExport();
}, []);
@@ -181,7 +185,7 @@ const Settings = () => {
}, [isTraktAuthenticated, traktAuthStarted]);
React.useEffect(() => {
if (dataExport.exportUrl !== null && typeof dataExport.exportUrl === 'string') {
- window.open(dataExport.exportUrl);
+ platform.openExternal(dataExport.exportUrl);
}
}, [dataExport.exportUrl]);
React.useLayoutEffect(() => {
diff --git a/src/routes/Settings/useProfileSettingsInputs.js b/src/routes/Settings/useProfileSettingsInputs.js
index 939e5e7cf..a90d0d483 100644
--- a/src/routes/Settings/useProfileSettingsInputs.js
+++ b/src/routes/Settings/useProfileSettingsInputs.js
@@ -3,11 +3,12 @@
const React = require('react');
const { useTranslation } = require('react-i18next');
const { useServices } = require('stremio/services');
-const { CONSTANTS, interfaceLanguages, languageNames, platform } = require('stremio/common');
+const { CONSTANTS, usePlatform, interfaceLanguages, languageNames } = require('stremio/common');
const useProfileSettingsInputs = (profile) => {
const { t } = useTranslation();
const { core } = useServices();
+ const platform = usePlatform();
// TODO combine those useMemo in one
const interfaceLanguageSelect = React.useMemo(() => ({
options: interfaceLanguages.map(({ name, codes }) => ({
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
new file mode 100644
index 000000000..97da1d705
--- /dev/null
+++ b/src/types/global.d.ts
@@ -0,0 +1,13 @@
+interface QtTransport {
+ send: (message: string) => void,
+}
+
+interface Qt {
+ webChannelTransport: QtTransport,
+}
+
+declare global {
+ var qt: Qt | undefined;
+}
+
+export { };
\ No newline at end of file