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.
This commit is contained in:
mayur 2026-05-20 08:33:18 +00:00
parent 87ccc591df
commit 6ba73a48f2
10 changed files with 366 additions and 22 deletions

View file

@ -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,
};

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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 / <a href> 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;

View file

@ -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;

View file

@ -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 (
<BottomSheet
title={t('MOBILE_PLAYER_PICKER_TITLE', { defaultValue: 'Play with' })}
show={show}
onClose={onClose}
>
<div className={styles['picker-content']}>
{
!hasStreaming ?
<div className={styles['hint']}>
{t('MOBILE_PLAYER_HTTP_ONLY_HINT', { defaultValue: 'External players need an HTTP stream (e.g. Real-Debrid). Torrent-only sources may not work.' })}
</div>
:
null
}
<div className={styles['players-grid']}>
{CONSTANTS.IOS_MOBILE_PICKER_PLAYERS.map((player) => {
const disabled = player.requiresStreaming && !hasStreaming;
const label = t(player.label, { defaultValue: player.label, keySeparator: false });
return (
<Button
key={player.id}
className={styles['player-option']}
title={label}
disabled={disabled}
onClick={() => onPlayerClick(player)}
>
<div className={styles['player-icon-wrap']}>
<Icon className={styles['player-icon']} name={player.icon} />
</div>
<div className={styles['player-label']}>{label}</div>
</Button>
);
})}
</div>
</div>
</BottomSheet>
);
};
MobilePlayerPicker.propTypes = {
show: PropTypes.bool,
streamingUrl: PropTypes.string,
onClose: PropTypes.func.isRequired,
onSelectPlayer: PropTypes.func.isRequired,
};
module.exports = MobilePlayerPicker;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2023 Smart code 203358507
const MobilePlayerPicker = require('./MobilePlayerPicker');
module.exports = MobilePlayerPicker;

View file

@ -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);
}
}
}

View file

@ -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 (
<Button className={classnames(className, styles['stream-container'])} title={addonName} href={href} target={target} download={download} onClick={onClick} {...props}>
<Button className={classnames(className, styles['stream-container'])} title={addonName} href={streamButtonHref} target={target} download={download} onClick={onClick} {...props}>
<div className={styles['info-container']}>
{
typeof thumbnail === 'string' && thumbnail.length > 0 ?
@ -232,7 +323,7 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
{children}
</Button>
);
}, [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
<div className={styles['context-menu-title']}>
{description}
</div>
<Button className={styles['context-menu-option-container']} title={t('CTX_PLAY')}>
<Button
className={styles['context-menu-option-container']}
title={t('CTX_PLAY')}
onClick={useMobilePlayerPicker ? onPlayButtonClick : appDeepLinkHref ? onAppDeepLinkPlayClick : undefined}
href={useMobilePlayerPicker || appDeepLinkHref ? null : href}
>
<Icon className={styles['menu-icon']} name={'play'} />
<div className={styles['context-menu-option-label']}>{t('CTX_PLAY')}</div>
</Button>
@ -267,25 +363,39 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
}
</div>
);
}, [copyStreamLink, onClick]);
}, [copyStreamLink, copyMagnetLink, copyDownloadLink, description, streamLink, magnetLink, downloadLink, useMobilePlayerPicker, onPlayButtonClick, onAppDeepLinkPlayClick, appDeepLinkHref, href]);
React.useEffect(() => {
if (!routeFocused) {
closeMenu();
closePlayerPicker();
}
}, [routeFocused]);
return (
<Popup
className={className}
onMouseUp={popupLabelOnMouseUp}
onLongPress={popupLabelOnLongPress}
onContextMenu={popupLabelOnContextMenu}
open={menuOpen}
onCloseRequest={closeMenu}
renderLabel={renderLabel}
renderMenu={renderMenu}
/>
<React.Fragment>
<Popup
className={className}
onMouseUp={popupLabelOnMouseUp}
onLongPress={popupLabelOnLongPress}
onContextMenu={popupLabelOnContextMenu}
open={menuOpen}
onCloseRequest={closeMenu}
renderLabel={renderLabel}
renderMenu={renderMenu}
/>
{
useMobilePlayerPicker ?
<MobilePlayerPicker
show={playerPickerOpen}
streamingUrl={streamLink}
onClose={closePlayerPicker}
onSelectPlayer={onMobilePlayerSelect}
/>
:
null
}
</React.Fragment>
);
};