From 6ba73a48f2f53de49ecbefefcacbd936af6c61cf Mon Sep 17 00:00:00 2001 From: mayur Date: Wed, 20 May 2026 08:33:18 +0000 Subject: [PATCH] feat(ios): add mobile player picker for external playback On iOS mobile, stream taps open a bottom sheet to choose Stremio, VLC, Infuse, or Outplayer instead of the broken in-app player. Adds iOS URL builders, safe custom-scheme opening, and Platform bypass for app links. --- src/common/CONSTANTS.js | 9 ++ src/common/Platform/Platform.tsx | 10 +- src/common/buildIosPlayerUrl.js | 40 +++++ src/common/index.js | 6 + src/common/openAppDeepLink.js | 29 ++++ src/common/usePWA.js | 12 +- .../MobilePlayerPicker/MobilePlayerPicker.js | 69 ++++++++ .../Stream/MobilePlayerPicker/index.js | 5 + .../Stream/MobilePlayerPicker/styles.less | 60 +++++++ .../MetaDetails/StreamsList/Stream/Stream.js | 148 +++++++++++++++--- 10 files changed, 366 insertions(+), 22 deletions(-) create mode 100644 src/common/buildIosPlayerUrl.js create mode 100644 src/common/openAppDeepLink.js create mode 100644 src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/MobilePlayerPicker.js create mode 100644 src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/index.js create mode 100644 src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/styles.less diff --git a/src/common/CONSTANTS.js b/src/common/CONSTANTS.js index 85b2ada08..48636d1b8 100644 --- a/src/common/CONSTANTS.js +++ b/src/common/CONSTANTS.js @@ -54,6 +54,14 @@ const SUPPORTED_LOCAL_SUBTITLES = [ 'text/vtt', ]; +/** Per-tap player picker on iOS */ +const IOS_MOBILE_PICKER_PLAYERS = [ + { id: 'stremio', label: 'Stremio', icon: 'play', requiresStreaming: false }, + { id: 'vlc', label: 'VLC', icon: 'vlc', requiresStreaming: true }, + { id: 'infuse', label: 'Infuse', icon: 'play', requiresStreaming: true }, + { id: 'outplayer', label: 'Outplayer', icon: 'play', requiresStreaming: true }, +]; + const EXTERNAL_PLAYERS = [ { label: 'EXTERNAL_PLAYER_DISABLED', @@ -143,6 +151,7 @@ module.exports = { MIME_SIGNATURES, SUPPORTED_LOCAL_SUBTITLES, EXTERNAL_PLAYERS, + IOS_MOBILE_PICKER_PLAYERS, WHITELISTED_HOSTS, PROTOCOL, }; diff --git a/src/common/Platform/Platform.tsx b/src/common/Platform/Platform.tsx index bb22d7f99..ae2269d49 100644 --- a/src/common/Platform/Platform.tsx +++ b/src/common/Platform/Platform.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext } from 'react'; import { WHITELISTED_HOSTS } from 'stremio/common/CONSTANTS'; +import openAppDeepLink, { isAppDeepLink } from 'stremio/common/openAppDeepLink'; import { name, isMobile } from './device'; import useShell from './shell/useShell'; @@ -21,9 +22,14 @@ const PlatformProvider = ({ children }: Props) => { const openExternal = (url: string) => { try { - const { hostname } = new URL(url); + if (isAppDeepLink(url)) { + openAppDeepLink(url); + return; + } + + const parsed = new URL(url); const isWhitelisted = WHITELISTED_HOSTS.some((host: string) => - hostname === host || hostname.endsWith('.' + host) + parsed.hostname === host || parsed.hostname.endsWith('.' + host) ); const finalUrl = !isWhitelisted ? `https://www.stremio.com/warning#${encodeURIComponent(url)}` : url; diff --git a/src/common/buildIosPlayerUrl.js b/src/common/buildIosPlayerUrl.js new file mode 100644 index 000000000..cb94e22db --- /dev/null +++ b/src/common/buildIosPlayerUrl.js @@ -0,0 +1,40 @@ +// Copyright (C) 2017-2023 Smart code 203358507 +// URL schemes aligned with stremio-core deep_links/mod.rs + +const STREMIO_PLAYER_SUCCESS = 'stremio%3A%2F%2F%2Fplayer%3FexternalPlayerSuccess%3D1'; +const STREMIO_PLAYER_ERROR = 'stremio%3A%2F%2F%2Fplayer%3FexternalPlayerSuccess%3D0'; + +/** + * @param {string | null | undefined} streamingUrl HTTP stream from streaming server + * @param {string} playerId + * @param {{ playlist?: string | null }} [options] + * @returns {string | null} + */ +const buildIosPlayerUrl = (streamingUrl, playerId, options = {}) => { + if (playerId === 'stremio') { + return null; + } + + if (!streamingUrl || typeof streamingUrl !== 'string') { + return playerId === 'm3u' && options.playlist ? options.playlist : null; + } + + const urlEncoded = encodeURIComponent(streamingUrl); + + switch (playerId) { + case 'vlc': + return `vlc-x-callback://x-callback-url/stream?url=${urlEncoded}`; + case 'outplayer': + return streamingUrl.replace(/^https?:\/\//i, 'outplayer://'); + case 'infuse': + return `infuse://x-callback-url/play?x-success=${STREMIO_PLAYER_SUCCESS}&x-error=${STREMIO_PLAYER_ERROR}&url=${urlEncoded}`; + case 'vidhub': + return `open-vidhub://x-callback-url/open?on-success=${STREMIO_PLAYER_SUCCESS}&on-failed=${STREMIO_PLAYER_ERROR}&url=${urlEncoded}`; + case 'm3u': + return options.playlist ?? null; + default: + return null; + } +}; + +module.exports = buildIosPlayerUrl; diff --git a/src/common/index.js b/src/common/index.js index b99b88c2f..3cc13ff35 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -7,6 +7,9 @@ const { ToastProvider, useToast } = require('./Toast'); const { TooltipProvider, Tooltip } = require('./Tooltips'); const { ShortcutsProvider, useShortcuts, onShortcut } = require('./Shortcuts'); const CONSTANTS = require('./CONSTANTS'); +const buildIosPlayerUrl = require('./buildIosPlayerUrl'); +const openAppDeepLink = require('./openAppDeepLink'); +const usePWA = require('./usePWA'); const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender'); const getVisibleChildrenRange = require('./getVisibleChildrenRange'); const interfaceLanguages = require('./interfaceLanguages.json'); @@ -45,6 +48,9 @@ module.exports = { TooltipProvider, Tooltip, CONSTANTS, + buildIosPlayerUrl, + openAppDeepLink, + usePWA, withCoreSuspender, useCoreSuspender, getVisibleChildrenRange, diff --git a/src/common/openAppDeepLink.js b/src/common/openAppDeepLink.js new file mode 100644 index 000000000..d2974fb6f --- /dev/null +++ b/src/common/openAppDeepLink.js @@ -0,0 +1,29 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +/** + * Opens a custom URL scheme (vlc-x-callback:, outplayer:, …) on iOS. + * window.location.assign / in PWA often rewrite schemes to https://host//path. + */ +const openAppDeepLink = (url) => { + if (typeof url !== 'string' || url.length === 0) { + return; + } + + const anchor = document.createElement('a'); + anchor.href = url; + anchor.rel = 'noopener noreferrer'; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); +}; + +const isAppDeepLink = (url) => { + if (typeof url !== 'string' || url.length === 0) { + return false; + } + return /^[a-z][a-z0-9+.-]*:/i.test(url) && !/^https?:\/\//i.test(url); +}; + +module.exports = openAppDeepLink; +module.exports.isAppDeepLink = isAppDeepLink; diff --git a/src/common/usePWA.js b/src/common/usePWA.js index a16eff432..9ddf98016 100644 --- a/src/common/usePWA.js +++ b/src/common/usePWA.js @@ -2,9 +2,18 @@ const React = require('react'); +const isDisplayModeInstalled = () => ( + window.matchMedia('(display-mode: standalone)').matches || + window.matchMedia('(display-mode: fullscreen)').matches || + window.matchMedia('(display-mode: minimal-ui)').matches +); + const usePWA = () => { const isPWA = React.useMemo(() => { - const isIOSPWA = window.navigator.standalone; + const isIOSPWA = Boolean( + window.navigator.standalone === true || + isDisplayModeInstalled() + ); const isAndroidPWA = window.matchMedia('(display-mode: standalone)').matches; return [isIOSPWA, isAndroidPWA]; }, []); @@ -12,3 +21,4 @@ const usePWA = () => { }; module.exports = usePWA; +module.exports.isDisplayModeInstalled = isDisplayModeInstalled; diff --git a/src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/MobilePlayerPicker.js b/src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/MobilePlayerPicker.js new file mode 100644 index 000000000..81888640b --- /dev/null +++ b/src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/MobilePlayerPicker.js @@ -0,0 +1,69 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +const React = require('react'); +const PropTypes = require('prop-types'); +const { useTranslation } = require('react-i18next'); +const { default: Icon } = require('@stremio/stremio-icons/react'); +const { BottomSheet, Button } = require('stremio/components'); +const { CONSTANTS } = require('stremio/common'); +const styles = require('./styles'); + +const MobilePlayerPicker = ({ show, streamingUrl, onClose, onSelectPlayer }) => { + const { t } = useTranslation(); + const hasStreaming = typeof streamingUrl === 'string' && streamingUrl.length > 0; + + const onPlayerClick = React.useCallback((player) => { + if (player.requiresStreaming && !hasStreaming) { + return; + } + onSelectPlayer(player.id); + }, [hasStreaming, onSelectPlayer]); + + return ( + +
+ { + !hasStreaming ? +
+ {t('MOBILE_PLAYER_HTTP_ONLY_HINT', { defaultValue: 'External players need an HTTP stream (e.g. Real-Debrid). Torrent-only sources may not work.' })} +
+ : + null + } +
+ {CONSTANTS.IOS_MOBILE_PICKER_PLAYERS.map((player) => { + const disabled = player.requiresStreaming && !hasStreaming; + const label = t(player.label, { defaultValue: player.label, keySeparator: false }); + return ( + + ); + })} +
+
+
+ ); +}; + +MobilePlayerPicker.propTypes = { + show: PropTypes.bool, + streamingUrl: PropTypes.string, + onClose: PropTypes.func.isRequired, + onSelectPlayer: PropTypes.func.isRequired, +}; + +module.exports = MobilePlayerPicker; diff --git a/src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/index.js b/src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/index.js new file mode 100644 index 000000000..80d894cf6 --- /dev/null +++ b/src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/index.js @@ -0,0 +1,5 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +const MobilePlayerPicker = require('./MobilePlayerPicker'); + +module.exports = MobilePlayerPicker; diff --git a/src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/styles.less b/src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/styles.less new file mode 100644 index 000000000..9e8ea68e5 --- /dev/null +++ b/src/routes/MetaDetails/StreamsList/Stream/MobilePlayerPicker/styles.less @@ -0,0 +1,60 @@ +@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; + +.picker-content { + padding: 0 1rem 1.5rem; + + .hint { + font-size: 0.85rem; + color: var(--color-placeholder); + margin-bottom: 1rem; + } + + .players-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + } + + .player-option { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1rem 0.5rem; + border-radius: var(--border-radius); + min-height: 5.5rem; + + &:hover, + &:focus { + background-color: var(--overlay-color); + } + + &:disabled { + opacity: 0.35; + } + + .player-icon-wrap { + width: 3rem; + height: 3rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: var(--secondary-accent-color); + + .player-icon { + width: 1.75rem; + height: 1.75rem; + color: var(--primary-foreground-color); + } + } + + .player-label { + font-size: 0.75rem; + font-weight: 500; + text-align: center; + color: var(--primary-foreground-color); + } + } +} diff --git a/src/routes/MetaDetails/StreamsList/Stream/Stream.js b/src/routes/MetaDetails/StreamsList/Stream/Stream.js index f5de3937e..216f32e05 100644 --- a/src/routes/MetaDetails/StreamsList/Stream/Stream.js +++ b/src/routes/MetaDetails/StreamsList/Stream/Stream.js @@ -6,10 +6,12 @@ const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); const { t } = require('i18next'); const { useCore } = require('stremio/core'); -const { useProfile, usePlatform, useToast, useBinaryState } = require('stremio/common'); +const { useProfile, usePlatform, useToast, useBinaryState, buildIosPlayerUrl, openAppDeepLink } = require('stremio/common'); +const { isAppDeepLink } = require('stremio/common/openAppDeepLink'); const { Button, Image, Popup } = require('stremio/components'); const { useRouteFocused } = require('stremio-router'); const StreamPlaceholder = require('./StreamPlaceholder'); +const MobilePlayerPicker = require('./MobilePlayerPicker'); const styles = require('./styles'); const Stream = ({ className, videoId, videoReleased, addonName, name, description, thumbnail, progress, deepLinks, ...props }) => { @@ -19,7 +21,10 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio const core = useCore(); const routeFocused = useRouteFocused(); + const useMobilePlayerPicker = platform.name === 'ios' && platform.isMobile; + const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false); + const [playerPickerOpen, openPlayerPicker, closePlayerPicker] = useBinaryState(false); const popupLabelOnMouseUp = React.useCallback((event) => { if (!event.nativeEvent.togglePopupPrevented) { @@ -97,6 +102,12 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio return deepLinks?.externalPlayer?.magnet; }, [deepLinks]); + const appDeepLinkHref = React.useMemo(() => { + return platform.name === 'ios' && isAppDeepLink(href) ? href : null; + }, [platform.name, href]); + + const streamButtonHref = useMobilePlayerPicker || appDeepLinkHref ? null : href; + const markVideoAsWatched = React.useCallback(() => { if (typeof videoId === 'string') { core.transport.dispatch({ @@ -109,11 +120,93 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio } }, [videoId, videoReleased]); + const onStreamAnalytics = React.useCallback(() => { + if (typeof props.onClick === 'function') { + props.onClick({ nativeEvent: {} }); + } + }, [props.onClick]); + + const openNativePlayer = React.useCallback(() => { + if (typeof deepLinks?.player === 'string') { + window.location = deepLinks.player; + } + }, [deepLinks]); + + const onMobilePlayerSelect = React.useCallback((playerId) => { + if (playerId === 'stremio') { + openNativePlayer(); + } else { + const url = buildIosPlayerUrl(streamLink, playerId, { + playlist: deepLinks?.externalPlayer?.playlist, + }); + + if (url) { + openAppDeepLink(url); + toast.show({ + type: 'success', + title: t('MOBILE_PLAYER_OPENED_EXTERNAL', { defaultValue: 'Stream opened in external player' }), + timeout: 4000, + }); + } else { + toast.show({ + type: 'error', + title: t('MOBILE_PLAYER_NO_HTTP_STREAM', { defaultValue: 'This stream has no HTTP URL for external players.' }), + timeout: 5000, + }); + } + } + + closePlayerPicker(); + closeMenu(); + markVideoAsWatched(); + onStreamAnalytics(); + }, [streamLink, deepLinks, markVideoAsWatched, onStreamAnalytics, openNativePlayer, closePlayerPicker, closeMenu]); + + const onPlayButtonClick = React.useCallback((event) => { + event.preventDefault(); + closeMenu(); + openPlayerPicker(); + }, [closeMenu, openPlayerPicker]); + + const onAppDeepLinkPlayClick = React.useCallback((event) => { + event.preventDefault(); + closeMenu(); + if (appDeepLinkHref) { + openAppDeepLink(appDeepLinkHref); + markVideoAsWatched(); + toast.show({ + type: 'success', + title: 'Stream opened in external player', + timeout: 4000 + }); + onStreamAnalytics(); + } + }, [appDeepLinkHref, closeMenu, markVideoAsWatched, onStreamAnalytics]); + const onClick = React.useCallback((event) => { if (event.nativeEvent.togglePopupPrevented) { return; } + if (useMobilePlayerPicker) { + event.preventDefault(); + openPlayerPicker(); + return; + } + + if (appDeepLinkHref) { + event.preventDefault(); + openAppDeepLink(appDeepLinkHref); + markVideoAsWatched(); + toast.show({ + type: 'success', + title: 'Stream opened in external player', + timeout: 4000 + }); + onStreamAnalytics(); + return; + } + if (profile.settings.playerType !== null) { markVideoAsWatched(); toast.show({ @@ -123,10 +216,8 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio }); } - if (typeof props.onClick === 'function') { - props.onClick(event); - } - }, [props.onClick, profile.settings, markVideoAsWatched]); + onStreamAnalytics(); + }, [useMobilePlayerPicker, appDeepLinkHref, openPlayerPicker, profile.settings, markVideoAsWatched, onStreamAnalytics]); const copyMagnetLink = React.useCallback((event) => { event.preventDefault(); @@ -200,7 +291,7 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio const renderLabel = React.useMemo(() => function renderLabel({ className, children, ...props }) { return ( - ); - }, [thumbnail, progress, addonName, name, description, href, target, download, onClick]); + }, [thumbnail, progress, addonName, name, description, streamButtonHref, target, download, onClick]); const renderMenu = React.useMemo(() => function renderMenu() { return ( @@ -240,7 +331,12 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
{description}
- @@ -267,25 +363,39 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio } ); - }, [copyStreamLink, onClick]); + }, [copyStreamLink, copyMagnetLink, copyDownloadLink, description, streamLink, magnetLink, downloadLink, useMobilePlayerPicker, onPlayButtonClick, onAppDeepLinkPlayClick, appDeepLinkHref, href]); React.useEffect(() => { if (!routeFocused) { closeMenu(); + closePlayerPicker(); } }, [routeFocused]); return ( - + + + { + useMobilePlayerPicker ? + + : + null + } + ); };