This commit is contained in:
Timothy Z. 2026-06-19 12:39:39 +00:00 committed by GitHub
commit 2dfd5311ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 194 additions and 24 deletions

View file

@ -0,0 +1,56 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React, { forwardRef, memo, MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import Option from '../OptionsMenu/Option';
import styles from './styles.less';
type CastDevice = {
id: string,
name: string,
};
type Props = {
className: string,
devices: CastDevice[],
loading: boolean,
onDeviceSelected: (deviceId: string) => void,
};
const CastDevicesMenu = memo(forwardRef<HTMLDivElement, Props>(({ className, devices, loading, onDeviceSelected }, ref) => {
const { t } = useTranslation();
const onMouseDown = (event: MouseEvent) => {
// @ts-expect-error: Property 'castDevicesMenuClosePrevented' does not exist on type 'MouseEvent'.
event.nativeEvent.castDevicesMenuClosePrevented = true;
};
return (
<div ref={ref} className={classNames(className, styles['cast-devices-menu-container'])} onMouseDown={onMouseDown}>
{
devices.length > 0 ?
devices.map(({ id, name }) => (
<Option
key={id}
icon={'cast'}
label={t('PLAYER_PLAY_IN', { device: name })}
deviceId={id}
onClick={onDeviceSelected}
/>
))
:
<div className={styles['message']}>
{
loading ?
t('STREAM_LOADING')
:
t('PLAYER_NO_CAST_DEVICES_FOUND')
}
</div>
}
</div>
);
}));
export default CastDevicesMenu;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2026 Smart code 203358507
import CastDevicesMenu from './CastDevicesMenu';
export default CastDevicesMenu;

View file

@ -0,0 +1,13 @@
// Copyright (C) 2017-2026 Smart code 203358507
.cast-devices-menu-container {
width: 16rem;
padding: 1rem;
.message {
padding: 1rem;
color: var(--primary-foreground-color);
opacity: 0.7;
text-align: center;
}
}

View file

@ -39,6 +39,8 @@ const ControlBar = React.forwardRef(({
onToggleSpeedMenu,
onToggleSideDrawer,
onToggleOptionsMenu,
shellCastSupported,
onToggleCastDevicesMenu,
videoScale,
videoScaleLabel,
onVideoScaleChanged,
@ -68,6 +70,9 @@ const ControlBar = React.forwardRef(({
const onStatisticsButtonMouseDown = React.useCallback((event) => {
event.nativeEvent.statisticsMenuClosePrevented = true;
}, []);
const onCastDevicesButtonMouseDown = React.useCallback((event) => {
event.nativeEvent.castDevicesMenuClosePrevented = true;
}, []);
const onPlayPauseButtonClick = React.useCallback(() => {
if (paused) {
if (typeof onPlayRequested === 'function') {
@ -95,9 +100,19 @@ const ControlBar = React.forwardRef(({
}
}
}, [muted, onMuteRequested, onUnmuteRequested]);
const castButtonDisabled = platform.shell.active ? !shellCastSupported : !chromecastServiceActive;
const onChromecastButtonClick = React.useCallback(() => {
if (platform.shell.active) {
if (shellCastSupported && typeof onToggleCastDevicesMenu === 'function') {
onToggleCastDevicesMenu();
}
return;
}
if (castButtonDisabled) {
return;
}
chromecast.transport.requestSession();
}, []);
}, [castButtonDisabled, platform.shell.active, shellCastSupported, onToggleCastDevicesMenu]);
React.useEffect(() => {
const onStateChanged = () => {
setChromecastServiceActive(chromecast.active);
@ -163,7 +178,7 @@ const ControlBar = React.forwardRef(({
<Button className={classnames(styles['control-bar-button'], { 'disabled': playbackSpeed === null })} tabIndex={-1} onMouseDown={onSpeedButtonMouseDown} onClick={onToggleSpeedMenu}>
<Icon className={styles['icon']} name={'speed'} />
</Button>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !chromecastServiceActive })} tabIndex={-1} onClick={onChromecastButtonClick}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': castButtonDisabled })} tabIndex={-1} onMouseDown={onCastDevicesButtonMouseDown} onClick={onChromecastButtonClick}>
<Icon className={styles['icon']} name={'cast'} />
</Button>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !Array.isArray(subtitlesTracks) || subtitlesTracks.length === 0 })} tabIndex={-1} onMouseDown={onSubtitlesButtonMouseDown} onClick={onToggleSubtitlesMenu}>
@ -222,6 +237,8 @@ ControlBar.propTypes = {
onToggleSpeedMenu: PropTypes.func,
onToggleSideDrawer: PropTypes.func,
onToggleOptionsMenu: PropTypes.func,
shellCastSupported: PropTypes.bool,
onToggleCastDevicesMenu: PropTypes.func,
onToggleStatisticsMenu: PropTypes.func,
onMouseOver: PropTypes.func,
onMouseMove: PropTypes.func,

View file

@ -4,27 +4,26 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useTranslation } = require('react-i18next');
const { useCore } = require('stremio/core');
const { usePlatform, useToast } = require('stremio/common');
const { default: usePlayOnDevice } = require('../usePlayOnDevice');
const Option = require('./Option');
const styles = require('./styles');
const OptionsMenu = React.memo(React.forwardRef(({ className, stream, playbackDevices, extraSubtitlesTracks, selectedExtraSubtitlesTrackId }, ref) => {
const { t } = useTranslation();
const core = useCore();
const platform = usePlatform();
const toast = useToast();
const [streamingUrl, downloadUrl, magnetUrl] = React.useMemo(() => {
const { streamingUrl, playOnDevice } = usePlayOnDevice(stream);
const [downloadUrl, magnetUrl] = React.useMemo(() => {
return stream !== null ?
stream.deepLinks &&
stream.deepLinks.externalPlayer &&
[
stream.deepLinks.externalPlayer.streaming,
stream.deepLinks.externalPlayer.download,
stream.deepLinks.externalPlayer.magnet,
]
:
[null, null, null];
[null, null];
}, [stream]);
const externalDevices = React.useMemo(() => {
return playbackDevices.filter(({ type }) => type === 'external');
@ -89,20 +88,6 @@ const OptionsMenu = React.memo(React.forwardRef(({ className, stream, playbackDe
subtitlesTrackUrl && platform.openExternal(subtitlesTrackUrl);
}, [subtitlesTrackUrl]);
const onExternalDeviceRequested = React.useCallback((deviceId) => {
if (streamingUrl) {
core.transport.dispatch({
action: 'StreamingServer',
args: {
action: 'PlayOnDevice',
args: {
device: deviceId,
source: streamingUrl,
}
}
});
}
}, [streamingUrl]);
const onMouseDown = React.useCallback((event) => {
event.nativeEvent.optionsMenuClosePrevented = true;
}, []);
@ -161,7 +146,7 @@ const OptionsMenu = React.memo(React.forwardRef(({ className, stream, playbackDe
label={t('PLAYER_PLAY_IN', { device: name })}
deviceId={id}
disabled={stream === null}
onClick={onExternalDeviceRequested}
onClick={playOnDevice}
/>
))
}

View file

@ -21,12 +21,14 @@ const ControlBar = require('./ControlBar');
const NextVideoPopup = require('./NextVideoPopup');
const StatisticsMenu = require('./StatisticsMenu');
const OptionsMenu = require('./OptionsMenu');
const { default: CastDevicesMenu } = require('./CastDevicesMenu');
const SubtitlesMenu = require('./SubtitlesMenu');
const { default: AudioMenu } = require('./AudioMenu');
const SpeedMenu = require('./SpeedMenu');
const { default: SideDrawerButton } = require('./SideDrawerButton');
const { default: SideDrawer } = require('./SideDrawer');
const usePlayer = require('./usePlayer');
const { default: usePlayOnDevice } = require('./usePlayOnDevice');
const useStatistics = require('./useStatistics');
const useVideo = require('./useVideo');
const { default: useSubtitles } = require('./useSubtitles');
@ -40,6 +42,8 @@ const findTrackById = (tracks, id) => tracks.find((track) => track.id === id);
const GAMEPAD_HANDLER_ID = 'player';
const CAST_DEVICES_REFRESH_INTERVAL = 5000;
const Player = () => {
const { stream, streamTransportUrl, metaTransportUrl, type, id, videoId } = useParams();
const urlParams = React.useMemo(() => ({
@ -97,12 +101,13 @@ const Player = () => {
const [audioMenuOpen, , closeAudioMenu, toggleAudioMenu] = useBinaryState(false);
const [speedMenuOpen, , closeSpeedMenu, toggleSpeedMenu] = useBinaryState(false);
const [statisticsMenuOpen, , closeStatisticsMenu, toggleStatisticsMenu] = useBinaryState(false);
const [castDevicesMenuOpen, , closeCastDevicesMenu, toggleCastDevicesMenu] = useBinaryState(false);
const [nextVideoPopupOpen, openNextVideoPopup, closeNextVideoPopup] = useBinaryState(false);
const [sideDrawerOpen, , closeSideDrawer, toggleSideDrawer] = useBinaryState(false);
const menusOpen = React.useMemo(() => {
return optionsMenuOpen || subtitlesMenuOpen || audioMenuOpen || speedMenuOpen || statisticsMenuOpen || sideDrawerOpen || nextVideoPopupOpen;
}, [optionsMenuOpen, subtitlesMenuOpen, audioMenuOpen, speedMenuOpen, statisticsMenuOpen, sideDrawerOpen, nextVideoPopupOpen]);
return optionsMenuOpen || subtitlesMenuOpen || audioMenuOpen || speedMenuOpen || statisticsMenuOpen || castDevicesMenuOpen || sideDrawerOpen || nextVideoPopupOpen;
}, [optionsMenuOpen, subtitlesMenuOpen, audioMenuOpen, speedMenuOpen, statisticsMenuOpen, castDevicesMenuOpen, sideDrawerOpen, nextVideoPopupOpen]);
const closeMenus = React.useCallback(() => {
closeOptionsMenu();
@ -110,9 +115,49 @@ const Player = () => {
closeAudioMenu();
closeSpeedMenu();
closeStatisticsMenu();
closeCastDevicesMenu();
closeSideDrawer();
}, []);
const castDevices = React.useMemo(() => {
const seen = new Set();
return playbackDevices
.filter(({ type }) => type === 'chromecast' || type === 'tv')
.sort((a, b) => a.type === b.type ? 0 : a.type === 'chromecast' ? -1 : 1)
.filter(({ name }) => seen.has(name) ? false : seen.add(name));
}, [playbackDevices]);
const [castDevicesSearching, setCastDevicesSearching] = React.useState(false);
const castDevicesLoading = platform.shell.active && (castDevicesSearching || (streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Loading'));
const { streamingUrl: castStreamingUrl, playOnDevice } = usePlayOnDevice(player.selected?.stream ?? null);
const shellCastSupported = platform.shell.active && castStreamingUrl !== null;
const refreshCastDevices = React.useCallback(() => {
if (platform.shell.active) {
core.transport.dispatch({
action: 'StreamingServer',
args: {
action: 'RefreshPlaybackDevices',
}
});
}
}, [platform.shell.active]);
const onCastDeviceSelected = React.useCallback((deviceId) => {
playOnDevice(deviceId);
closeCastDevicesMenu();
}, [playOnDevice]);
React.useEffect(() => {
if (castDevicesMenuOpen && platform.shell.active) {
setCastDevicesSearching(true);
refreshCastDevices();
const interval = setInterval(refreshCastDevices, CAST_DEVICES_REFRESH_INTERVAL);
const timeout = setTimeout(() => setCastDevicesSearching(false), CAST_DEVICES_REFRESH_INTERVAL);
return () => {
clearInterval(interval);
clearTimeout(timeout);
setCastDevicesSearching(false);
};
}
}, [castDevicesMenuOpen, refreshCastDevices]);
const {
streamSubtitles,
allSubtitleTracks,
@ -297,6 +342,9 @@ const Player = () => {
if (!event.nativeEvent.statisticsMenuClosePrevented) {
closeStatisticsMenu();
}
if (!event.nativeEvent.castDevicesMenuClosePrevented) {
closeCastDevicesMenu();
}
closeSideDrawer();
}, []);
@ -898,6 +946,8 @@ const Player = () => {
onVolumeChangeRequested={onVolumeChangeRequested}
onSeekRequested={onSeekRequested}
onToggleOptionsMenu={toggleOptionsMenu}
shellCastSupported={shellCastSupported}
onToggleCastDevicesMenu={toggleCastDevicesMenu}
onToggleSubtitlesMenu={toggleSubtitlesMenu}
onToggleAudioMenu={toggleAudioMenu}
onToggleSpeedMenu={toggleSpeedMenu}
@ -933,6 +983,14 @@ const Player = () => {
{...statistics}
/>
</Transition>
<Transition when={castDevicesMenuOpen} name={'fade'}>
<CastDevicesMenu
className={classnames(styles['layer'], styles['menu-layer'])}
devices={castDevices}
loading={castDevicesLoading}
onDeviceSelected={onCastDeviceSelected}
/>
</Transition>
<Transition when={sideDrawerOpen} name={'slide-left'}>
<SideDrawer
className={classnames(styles['layer'], styles['side-drawer-layer'])}

View file

@ -0,0 +1,36 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useCallback, useMemo } from 'react';
import { useCore } from 'stremio/core';
type Stream = {
deepLinks?: {
externalPlayer?: {
streaming?: string | null,
},
},
} | null;
const usePlayOnDevice = (stream: Stream) => {
const core = useCore();
const streamingUrl = useMemo(() => {
return stream?.deepLinks?.externalPlayer?.streaming ?? null;
}, [stream]);
const playOnDevice = useCallback((deviceId: string) => {
if (streamingUrl) {
core.transport.dispatch({
action: 'StreamingServer',
args: {
action: 'PlayOnDevice',
args: {
device: deviceId,
source: streamingUrl,
}
}
});
}
}, [streamingUrl]);
return { streamingUrl, playOnDevice };
};
export default usePlayOnDevice;