{Array(CONSTANTS.CATALOG_PAGE_SIZE).fill(null).map((_, index) => (
@@ -142,7 +133,7 @@ const Discover = ({ urlParams, queryParams }) => {
))}
:
-
+
{discover.catalog.content.content.map((metaItem, index) => (
{
}
{
- inputsModalOpen && discover.defaultRequest ?
+ inputsModalOpen ?
{selectInputs.map(({ title, options, selected, renderLabelText, onSelect }, index) => (
(
+
+);
+
+module.exports = withCoreSuspender(Discover, DiscoverFallback);
diff --git a/src/routes/Discover/useDiscover.js b/src/routes/Discover/useDiscover.js
index 895b98910..c1555646c 100644
--- a/src/routes/Discover/useDiscover.js
+++ b/src/routes/Discover/useDiscover.js
@@ -5,18 +5,6 @@ const UrlUtils = require('url');
const { useServices } = require('stremio/services');
const { useModelState } = require('stremio/common');
-const init = () => ({
- selected: null,
- selectable: {
- types: [],
- catalogs: [],
- extra: [],
- nextPage: false
- },
- catalog: null,
- defaultRequest: null,
-});
-
const map = (discover) => ({
...discover,
catalog: discover.catalog !== null && discover.catalog.content.type === 'Ready' ?
@@ -67,25 +55,20 @@ const useDiscover = (urlParams, queryParams) => {
};
}
} else {
- const discover = core.transport.getState('discover');
- if (discover.defaultRequest !== null) {
- return {
- action: 'Load',
- args: {
- model: 'CatalogWithFilters',
- args: {
- request: discover.defaultRequest
- }
- }
- };
- }
+ return {
+ action: 'Load',
+ args: {
+ model: 'CatalogWithFilters',
+ args: null
+ }
+ };
}
return {
action: 'Unload'
};
}, [urlParams, queryParams]);
- const discover = useModelState({ model: 'discover', action, map, init });
+ const discover = useModelState({ model: 'discover', action, map, deps: ['ctx'] });
return [discover, loadNextPage];
};
diff --git a/src/routes/Intro/Intro.js b/src/routes/Intro/Intro.js
index a97a113f8..4e2fb74cd 100644
--- a/src/routes/Intro/Intro.js
+++ b/src/routes/Intro/Intro.js
@@ -186,7 +186,6 @@ const Intro = ({ queryParams }) => {
tos: state.termsAccepted,
privacy: state.privacyPolicyAccepted,
marketing: state.marketingAccepted,
- time: new Date(),
from: 'web'
}
}
diff --git a/src/routes/Library/Library.js b/src/routes/Library/Library.js
index b350ce170..c2790824e 100644
--- a/src/routes/Library/Library.js
+++ b/src/routes/Library/Library.js
@@ -5,7 +5,7 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('@stremio/stremio-icons/dom');
const NotFound = require('stremio/routes/NotFound');
-const { Button, Multiselect, MainNavBars, LibItem, Image, ModalDialog, PaginationInput, useProfile, routesRegexp, useBinaryState } = require('stremio/common');
+const { Button, DelayedRenderer, Multiselect, MainNavBars, LibItem, Image, ModalDialog, PaginationInput, useProfile, routesRegexp, useBinaryState, withCoreSuspender } = require('stremio/common');
const useLibrary = require('./useLibrary');
const useSelectableInputs = require('./useSelectableInputs');
const styles = require('./styles');
@@ -85,14 +85,16 @@ const Library = ({ model, urlParams, queryParams }) => {
:
library.selected === null ?
-
-
-
{model === 'library' ? 'Library' : 'Continue Watching'} not loaded!
-
+
+
+
+
{model === 'library' ? 'Library' : 'Continue Watching'} not loaded!
+
+
:
library.catalog.length === 0 ?
@@ -104,7 +106,7 @@ const Library = ({ model, urlParams, queryParams }) => {
Empty {model === 'library' ? 'Library' : 'Continue Watching'}
:
-
+
{library.catalog.map((libItem, index) => (
))}
@@ -132,4 +134,10 @@ Library.propTypes = {
queryParams: PropTypes.instanceOf(URLSearchParams)
};
-module.exports = withModel(Library);
+const LibraryFallback = ({ model }) => (
+
+);
+
+LibraryFallback.propTypes = Library.propTypes;
+
+module.exports = withModel(withCoreSuspender(Library, LibraryFallback));
diff --git a/src/routes/Library/useLibrary.js b/src/routes/Library/useLibrary.js
index cff44771d..d882d889b 100644
--- a/src/routes/Library/useLibrary.js
+++ b/src/routes/Library/useLibrary.js
@@ -3,17 +3,6 @@
const React = require('react');
const { useModelState } = require('stremio/common');
-const init = () => ({
- selected: null,
- selectable: {
- types: [],
- sorts: [],
- prevPage: null,
- nextPage: null
- },
- catalog: []
-});
-
const useLibrary = (model, urlParams, queryParams) => {
const action = React.useMemo(() => ({
action: 'Load',
@@ -28,7 +17,7 @@ const useLibrary = (model, urlParams, queryParams) => {
}
}
}), [urlParams, queryParams]);
- return useModelState({ model, action, init });
+ return useModelState({ model, action });
};
module.exports = useLibrary;
diff --git a/src/routes/MetaDetails/MetaDetails.js b/src/routes/MetaDetails/MetaDetails.js
index 602bdd3c8..144ef6c6c 100644
--- a/src/routes/MetaDetails/MetaDetails.js
+++ b/src/routes/MetaDetails/MetaDetails.js
@@ -2,8 +2,9 @@
const React = require('react');
const PropTypes = require('prop-types');
+const classnames = require('classnames');
const { useServices } = require('stremio/services');
-const { VerticalNavBar, HorizontalNavBar, MetaPreview, ModalDialog, Image } = require('stremio/common');
+const { VerticalNavBar, HorizontalNavBar, MetaPreview, ModalDialog, Image, DelayedRenderer, withCoreSuspender } = require('stremio/common');
const StreamsList = require('./StreamsList');
const VideosList = require('./VideosList');
const useMetaDetails = require('./useMetaDetails');
@@ -86,10 +87,12 @@ const MetaDetails = ({ urlParams, queryParams }) => {
}
{
metaPath === null ?
-
-
-
No meta was selected!
-
+
+
+
+
No meta was selected!
+
+
:
metaDetails.metaItem === null ?
@@ -122,7 +125,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
null
}
(
+
+
+
+);
+
+module.exports = withCoreSuspender(MetaDetails, MetaDetailsFallback);
diff --git a/src/routes/MetaDetails/StreamsList/StreamsList.js b/src/routes/MetaDetails/StreamsList/StreamsList.js
index e62c26633..4c061ef73 100644
--- a/src/routes/MetaDetails/StreamsList/StreamsList.js
+++ b/src/routes/MetaDetails/StreamsList/StreamsList.js
@@ -4,32 +4,70 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const Icon = require('@stremio/stremio-icons/dom');
-const { Button, Image } = require('stremio/common');
+const { Button, Image, Multiselect } = require('stremio/common');
const { useServices } = require('stremio/services');
const Stream = require('./Stream');
const styles = require('./styles');
+const ALL_ADDONS_KEY = 'ALL';
+
const StreamsList = ({ className, ...props }) => {
const { core } = useServices();
- const streams = React.useMemo(() => {
+ const [selectedAddon, setSelectedAddon] = React.useState(ALL_ADDONS_KEY);
+ const onAddonSelected = React.useCallback((event) => {
+ setSelectedAddon(event.value);
+ }, []);
+ const streamsByAddon = React.useMemo(() => {
return props.streams
.filter((streams) => streams.content.type === 'Ready')
- .map((streams) => {
- return streams.content.content.map((stream) => ({
- ...stream,
- onClick: () => {
- core.transport.analytics({
- event: 'StreamClicked',
- args: {
- stream
- }
- });
- },
- addonName: streams.addon.manifest.name
- }));
- })
- .flat(1);
+ .reduce((streamsByAddon, streams) => {
+ streamsByAddon[streams.addon.transportUrl] = {
+ addon: streams.addon,
+ streams: streams.content.content.map((stream) => ({
+ ...stream,
+ onClick: () => {
+ core.transport.analytics({
+ event: 'StreamClicked',
+ args: {
+ stream
+ }
+ });
+ },
+ addonName: streams.addon.manifest.name
+ }))
+ };
+
+ return streamsByAddon;
+ }, {});
}, [props.streams]);
+ const filteredStreams = React.useMemo(() => {
+ return selectedAddon === ALL_ADDONS_KEY ?
+ Object.values(streamsByAddon).map(({ streams }) => streams).flat(1)
+ :
+ streamsByAddon[selectedAddon] ?
+ streamsByAddon[selectedAddon].streams
+ :
+ [];
+ }, [streamsByAddon, selectedAddon]);
+ const selectableOptions = React.useMemo(() => {
+ return {
+ title: 'Select Addon',
+ options: [
+ {
+ value: ALL_ADDONS_KEY,
+ label: 'All',
+ title: 'All'
+ },
+ ...Object.keys(streamsByAddon).map((transportUrl) => ({
+ value: transportUrl,
+ label: streamsByAddon[transportUrl].addon.manifest.name,
+ title: streamsByAddon[transportUrl].addon.manifest.name,
+ }))
+ ],
+ selected: [selectedAddon],
+ onSelect: onAddonSelected
+ };
+ }, [streamsByAddon, selectedAddon]);
return (
{
@@ -45,26 +83,37 @@ const StreamsList = ({ className, ...props }) => {
No streams were found!
:
- streams.length === 0 ?
+ filteredStreams.length === 0 ?
:
-
- {streams.map((stream, index) => (
-
- ))}
-
+
+ {
+ Object.keys(streamsByAddon).length > 1 ?
+
+ :
+ null
+ }
+
+ {filteredStreams.map((stream, index) => (
+
+ ))}
+
+
}
@@ -149,6 +181,7 @@ ControlBar.propTypes = {
subtitlesTracks: PropTypes.array,
audioTracks: PropTypes.array,
metaItem: PropTypes.object,
+ nextVideo: PropTypes.object,
onPlayRequested: PropTypes.func,
onPauseRequested: PropTypes.func,
onMuteRequested: PropTypes.func,
@@ -156,7 +189,8 @@ ControlBar.propTypes = {
onVolumeChangeRequested: PropTypes.func,
onSeekRequested: PropTypes.func,
onToggleSubtitlesMenu: PropTypes.func,
- onToggleInfoMenu: PropTypes.func
+ onToggleInfoMenu: PropTypes.func,
+ onToggleVideosMenu: PropTypes.func
};
module.exports = ControlBar;
diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js
index 913037b0e..a8f7c2c3f 100644
--- a/src/routes/Player/Player.js
+++ b/src/routes/Player/Player.js
@@ -6,11 +6,12 @@ const classnames = require('classnames');
const debounce = require('lodash.debounce');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
-const { HorizontalNavBar, Button, useFullscreen, useBinaryState, useToast, useStreamingServer } = require('stremio/common');
+const { HorizontalNavBar, Button, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender } = require('stremio/common');
const Icon = require('@stremio/stremio-icons/dom');
const BufferingLoader = require('./BufferingLoader');
const ControlBar = require('./ControlBar');
const InfoMenu = require('./InfoMenu');
+const VideosMenu = require('./VideosMenu');
const SubtitlesMenu = require('./SubtitlesMenu');
const Video = require('./Video');
const usePlayer = require('./usePlayer');
@@ -18,14 +19,14 @@ const useSettings = require('./useSettings');
const styles = require('./styles');
const Player = ({ urlParams, queryParams }) => {
- const { core, chromecast } = useServices();
+ const { chromecast, shell } = useServices();
const [forceTranscoding, maxAudioChannels] = React.useMemo(() => {
return [
queryParams.has('forceTranscoding'),
queryParams.has('maxAudioChannels') ? parseInt(queryParams.get('maxAudioChannels'), 10) : null
];
}, [queryParams]);
- const [player, updateLibraryItemState, pushToLibrary] = usePlayer(urlParams);
+ const [player, timeChanged, pausedChanged, ended, pushToLibrary] = usePlayer(urlParams);
const [settings, updateSettings] = useSettings();
const streamingServer = useStreamingServer();
const routeFocused = useRouteFocused();
@@ -38,10 +39,12 @@ const Player = ({ urlParams, queryParams }) => {
const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []);
const [subtitlesMenuOpen, , closeSubtitlesMenu, toggleSubtitlesMenu] = useBinaryState(false);
const [infoMenuOpen, , closeInfoMenu, toggleInfoMenu] = useBinaryState(false);
+ const [videosMenuOpen, , closeVideosMenu, toggleVideosMenu] = useBinaryState(false);
const [error, setError] = React.useState(null);
const [videoState, setVideoState] = React.useReducer(
(videoState, nextVideoState) => ({ ...videoState, ...nextVideoState }),
{
+ manifest: null,
stream: null,
paused: null,
time: null,
@@ -75,6 +78,7 @@ const Player = ({ urlParams, queryParams }) => {
}
}, []);
const onImplementationChanged = React.useCallback((manifest) => {
+ setVideoState({ manifest });
manifest.props.forEach((propName) => {
dispatch({ type: 'observeProp', propName });
});
@@ -93,16 +97,8 @@ const Player = ({ urlParams, queryParams }) => {
setVideoState({ [propName]: propValue });
}, []);
const onEnded = React.useCallback(() => {
+ ended();
pushToLibrary();
- if (player.libraryItem !== null) {
- core.transport.dispatch({
- action: 'Ctx',
- args: {
- action: 'RewindLibraryItem',
- args: player.libraryItem._id
- }
- });
- }
if (player.nextVideo !== null) {
window.location.replace(
typeof player.nextVideo.deepLinks.player === 'string' ?
@@ -204,6 +200,9 @@ const Player = ({ urlParams, queryParams }) => {
if (!event.nativeEvent.infoMenuClosePrevented) {
closeInfoMenu();
}
+ if (!event.nativeEvent.videosMenuClosePrevented) {
+ closeVideosMenu();
+ }
}, []);
const onContainerMouseMove = React.useCallback((event) => {
setImmersed(false);
@@ -224,7 +223,8 @@ const Player = ({ urlParams, queryParams }) => {
setError(null);
if (player.selected === null) {
dispatch({ type: 'command', commandName: 'unload' });
- } else if (streamingServer.baseUrl !== null && streamingServer.baseUrl.type !== 'Loading' && player.metaItem !== null && player.metaItem.type !== 'Loading') {
+ } else if (streamingServer.baseUrl !== null && streamingServer.baseUrl.type !== 'Loading' &&
+ (player.selected.metaRequest === null || (player.metaItem !== null && player.metaItem.type !== 'Loading'))) {
dispatch({
type: 'command',
commandName: 'load',
@@ -263,6 +263,7 @@ const Player = ({ urlParams, queryParams }) => {
}
}, {
chromecastTransport: chromecast.active ? chromecast.transport : null,
+ shellTransport: shell.active ? shell.transport : null,
});
}
}, [streamingServer.baseUrl, player.selected, player.metaItem, forceTranscoding, maxAudioChannels, casting]);
@@ -301,10 +302,17 @@ const Player = ({ urlParams, queryParams }) => {
dispatch({ type: 'setProp', propName: 'extraSubtitlesOutlineColor', propValue: settings.subtitlesOutlineColor });
}, [settings.subtitlesOutlineColor]);
React.useEffect(() => {
- if (videoState.time !== null && !isNaN(videoState.time) && videoState.duration !== null && !isNaN(videoState.duration)) {
- updateLibraryItemState(videoState.time, videoState.duration);
+ if (videoState.time !== null && !isNaN(videoState.time) &&
+ videoState.duration !== null && !isNaN(videoState.duration) &&
+ videoState.manifest !== null && typeof videoState.manifest.name === 'string') {
+ timeChanged(videoState.time, videoState.duration, videoState.manifest.name);
}
- }, [videoState.time, videoState.duration]);
+ }, [videoState.time, videoState.duration, videoState.manifest]);
+ React.useEffect(() => {
+ if (videoState.paused !== null) {
+ pausedChanged(videoState.paused);
+ }
+ }, [videoState.paused]);
React.useEffect(() => {
if ((!Array.isArray(videoState.subtitlesTracks) || videoState.subtitlesTracks.length === 0) &&
(!Array.isArray(videoState.extraSubtitlesTracks) || videoState.extraSubtitlesTracks.length === 0) &&
@@ -315,6 +323,7 @@ const Player = ({ urlParams, queryParams }) => {
React.useEffect(() => {
if (player.metaItem === null || player.metaItem.type !== 'Ready') {
closeInfoMenu();
+ closeVideosMenu();
}
}, [player.metaItem]);
React.useEffect(() => {
@@ -326,6 +335,8 @@ const Player = ({ urlParams, queryParams }) => {
};
}, []);
React.useEffect(() => {
+ const toastFilter = (item) => item?.dataset?.type === 'CoreEvent';
+ toast.addFilter(toastFilter);
const onCastStateChange = () => {
setCasting(chromecast.active && chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED);
};
@@ -341,6 +352,7 @@ const Player = ({ urlParams, queryParams }) => {
chromecast.on('stateChanged', onChromecastServiceStateChange);
onChromecastServiceStateChange();
return () => {
+ toast.removeFilter(toastFilter);
chromecast.off('stateChanged', onChromecastServiceStateChange);
if (chromecast.active) {
chromecast.transport.off(
@@ -396,6 +408,7 @@ const Player = ({ urlParams, queryParams }) => {
}
case 'KeyS': {
closeInfoMenu();
+ closeVideosMenu();
if ((Array.isArray(videoState.subtitlesTracks) && videoState.subtitlesTracks.length > 0) ||
(Array.isArray(videoState.extraSubtitlesTracks) && videoState.extraSubtitlesTracks.length > 0) ||
(Array.isArray(videoState.audioTracks) && videoState.audioTracks.length > 0)) {
@@ -406,15 +419,26 @@ const Player = ({ urlParams, queryParams }) => {
}
case 'KeyI': {
closeSubtitlesMenu();
+ closeVideosMenu();
if (player.metaItem !== null && player.metaItem.type === 'Ready') {
toggleInfoMenu();
}
break;
}
+ case 'KeyV': {
+ closeInfoMenu();
+ closeSubtitlesMenu();
+ if (player.metaItem !== null && player.metaItem.type === 'Ready') {
+ toggleVideosMenu();
+ }
+
+ break;
+ }
case 'Escape': {
closeSubtitlesMenu();
closeInfoMenu();
+ closeVideosMenu();
break;
}
}
@@ -425,7 +449,7 @@ const Player = ({ urlParams, queryParams }) => {
return () => {
window.removeEventListener('keydown', onKeyDown);
};
- }, [player.metaItem, settings.seekTimeDuration, routeFocused, subtitlesMenuOpen, infoMenuOpen, videoState.paused, videoState.time, videoState.volume, videoState.audioTracks, videoState.subtitlesTracks, videoState.extraSubtitlesTracks, toggleSubtitlesMenu, toggleInfoMenu]);
+ }, [player.metaItem, settings.seekTimeDuration, routeFocused, subtitlesMenuOpen, infoMenuOpen, videoState.paused, videoState.time, videoState.volume, videoState.audioTracks, videoState.subtitlesTracks, videoState.extraSubtitlesTracks, toggleSubtitlesMenu, toggleInfoMenu, toggleVideosMenu]);
React.useLayoutEffect(() => {
return () => {
setImmersedDebounced.cancel();
@@ -434,7 +458,7 @@ const Player = ({ urlParams, queryParams }) => {
};
}, []);
return (
-
{
subtitlesTracks={videoState.subtitlesTracks.concat(videoState.extraSubtitlesTracks)}
audioTracks={videoState.audioTracks}
metaItem={player.metaItem}
+ nextVideo={player.nextVideo}
onPlayRequested={onPlayRequested}
onPauseRequested={onPauseRequested}
onMuteRequested={onMuteRequested}
@@ -510,6 +535,7 @@ const Player = ({ urlParams, queryParams }) => {
onSeekRequested={onSeekRequested}
onToggleSubtitlesMenu={toggleSubtitlesMenu}
onToggleInfoMenu={toggleInfoMenu}
+ onToggleVideosMenu={toggleVideosMenu}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
/>
@@ -551,6 +577,16 @@ const Player = ({ urlParams, queryParams }) => {
:
null
}
+ {
+ videosMenuOpen ?
+
+ :
+ null
+ }
);
};
@@ -567,4 +603,8 @@ Player.propTypes = {
queryParams: PropTypes.instanceOf(URLSearchParams)
};
-module.exports = Player;
+const PlayerFallback = () => (
+
+);
+
+module.exports = withCoreSuspender(Player, PlayerFallback);
diff --git a/src/routes/Player/VideosMenu/VideosMenu.js b/src/routes/Player/VideosMenu/VideosMenu.js
new file mode 100644
index 000000000..219a329c2
--- /dev/null
+++ b/src/routes/Player/VideosMenu/VideosMenu.js
@@ -0,0 +1,51 @@
+// Copyright (C) 2017-2022 Smart code 203358507
+
+const React = require('react');
+const PropTypes = require('prop-types');
+const classnames = require('classnames');
+const Video = require('../../MetaDetails/VideosList/Video');
+const styles = require('./styles');
+
+const VideosMenu = ({ className, metaItem, seriesInfo }) => {
+ const onMouseDown = React.useCallback((event) => {
+ event.nativeEvent.videosMenuClosePrevented = true;
+ }, []);
+ const videos = React.useMemo(() => {
+ return seriesInfo && typeof seriesInfo.season === 'number' && Array.isArray(metaItem.videos) ?
+ metaItem.videos.filter(({ season }) => season === seriesInfo.season)
+ :
+ metaItem.videos;
+ }, [metaItem, seriesInfo]);
+ return (
+
+ {
+ videos.map((video, index) => (
+
+ ))
+ }
+
+ );
+};
+
+VideosMenu.propTypes = {
+ className: PropTypes.string,
+ metaItem: PropTypes.object,
+ seriesInfo: PropTypes.shape({
+ season: PropTypes.number,
+ episode: PropTypes.number,
+ }),
+};
+
+module.exports = VideosMenu;
diff --git a/src/routes/Player/VideosMenu/index.js b/src/routes/Player/VideosMenu/index.js
new file mode 100644
index 000000000..e604ab4cc
--- /dev/null
+++ b/src/routes/Player/VideosMenu/index.js
@@ -0,0 +1,5 @@
+// Copyright (C) 2017-2022 Smart code 203358507
+
+const VideosMenu = require('./VideosMenu');
+
+module.exports = VideosMenu;
diff --git a/src/routes/Player/VideosMenu/styles.less b/src/routes/Player/VideosMenu/styles.less
new file mode 100644
index 000000000..47444c72a
--- /dev/null
+++ b/src/routes/Player/VideosMenu/styles.less
@@ -0,0 +1,5 @@
+// Copyright (C) 2017-2022 Smart code 203358507
+
+.videos-menu-container {
+ width: 30rem;
+}
\ No newline at end of file
diff --git a/src/routes/Player/usePlayer.js b/src/routes/Player/usePlayer.js
index 0e728b51f..9acd16396 100644
--- a/src/routes/Player/usePlayer.js
+++ b/src/routes/Player/usePlayer.js
@@ -2,18 +2,7 @@
const React = require('react');
const { useServices } = require('stremio/services');
-const { useModelState } = require('stremio/common');
-
-const init = () => ({
- selected: null,
- metaItem: null,
- subtitles: [],
- nextVideo: null,
- seriesInfo: null,
- libraryItem: null,
- title: null,
- addon: null,
-});
+const { useModelState, useCoreSuspender } = require('stremio/common');
const map = (player) => ({
...player,
@@ -45,8 +34,9 @@ const map = (player) => ({
const usePlayer = (urlParams) => {
const { core } = useServices();
+ const { decodeStream } = useCoreSuspender();
+ const stream = decodeStream(urlParams.stream);
const action = React.useMemo(() => {
- const stream = core.transport.decodeStream(urlParams.stream);
if (stream !== null) {
return {
action: 'Load',
@@ -96,12 +86,12 @@ const usePlayer = (urlParams) => {
};
}
}, [urlParams]);
- const updateLibraryItemState = React.useCallback((time, duration) => {
+ const timeChanged = React.useCallback((time, duration, device) => {
core.transport.dispatch({
action: 'Player',
args: {
- action: 'UpdateLibraryItemState',
- args: { time, duration }
+ action: 'TimeChanged',
+ args: { time, duration, device }
}
}, 'player');
}, []);
@@ -113,8 +103,25 @@ const usePlayer = (urlParams) => {
}
}, 'player');
}, []);
- const player = useModelState({ model: 'player', action, init, map });
- return [player, updateLibraryItemState, pushToLibrary];
+ const ended = React.useCallback(() => {
+ core.transport.dispatch({
+ action: 'Player',
+ args: {
+ action: 'Ended'
+ }
+ }, 'player');
+ }, []);
+ const pausedChanged = React.useCallback((paused) => {
+ core.transport.dispatch({
+ action: 'Player',
+ args: {
+ action: 'PausedChanged',
+ args: { paused }
+ }
+ }, 'player');
+ }, []);
+ const player = useModelState({ model: 'player', action, map });
+ return [player, timeChanged, pausedChanged, ended, pushToLibrary];
};
module.exports = usePlayer;
diff --git a/src/routes/Search/Search.js b/src/routes/Search/Search.js
index 47fb15cba..bea2e7606 100644
--- a/src/routes/Search/Search.js
+++ b/src/routes/Search/Search.js
@@ -5,7 +5,7 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const debounce = require('lodash.debounce');
const Icon = require('@stremio/stremio-icons/dom');
-const { Image, MainNavBars, MetaRow, MetaItem, useDeepEqualMemo, getVisibleChildrenRange } = require('stremio/common');
+const { Image, MainNavBars, MetaRow, MetaItem, useDeepEqualMemo, withCoreSuspender, getVisibleChildrenRange } = require('stremio/common');
const useSearch = require('./useSearch');
const styles = require('./styles');
@@ -47,7 +47,7 @@ const Search = ({ queryParams }) => {
{
query === null ?
-
+
Search for movies, series, YouTube and TV channels
@@ -74,7 +74,7 @@ const Search = ({ queryParams }) => {
return (
{
return (
{
return (
@@ -115,4 +115,10 @@ Search.propTypes = {
queryParams: PropTypes.instanceOf(URLSearchParams)
};
-module.exports = Search;
+const SearchFallback = ({ queryParams }) => (
+
+);
+
+SearchFallback.propTypes = Search.propTypes;
+
+module.exports = withCoreSuspender(Search, SearchFallback);
diff --git a/src/routes/Search/useSearch.js b/src/routes/Search/useSearch.js
index d1ff8e79c..40a46fb76 100644
--- a/src/routes/Search/useSearch.js
+++ b/src/routes/Search/useSearch.js
@@ -4,37 +4,33 @@ const React = require('react');
const { useModelState } = require('stremio/common');
const { useServices } = require('stremio/services');
-const init = () => ({
- selected: null,
- catalogs: []
-});
-
const useSearch = (queryParams) => {
const { core } = useServices();
- React.useEffect(() => {
- let timerId = setTimeout(emitSearchEvent, 500);
- function emitSearchEvent() {
- timerId = null;
- const state = core.transport.getState('search');
- if (state.selected !== null) {
- const [, query] = state.selected.extra.find(([name]) => name === 'search');
- const responses = state.catalogs.filter((catalog) => catalog.content?.type === 'Ready');
- core.transport.analytics({
- event: 'Search',
- args: {
- query,
- responsesCount: responses.length
- }
- });
- }
- }
- return () => {
- if (timerId !== null) {
- clearTimeout(timerId);
- emitSearchEvent();
- }
- };
- }, [queryParams.get('search')]);
+ // TODO: refactor this to be in stremio-core-web
+ // React.useEffect(() => {
+ // let timerId = setTimeout(emitSearchEvent, 500);
+ // function emitSearchEvent() {
+ // timerId = null;
+ // const state = core.transport.getState('search');
+ // if (state.selected !== null) {
+ // const [, query] = state.selected.extra.find(([name]) => name === 'search');
+ // const responses = state.catalogs.filter((catalog) => catalog.content?.type === 'Ready');
+ // core.transport.analytics({
+ // event: 'Search',
+ // args: {
+ // query,
+ // responsesCount: responses.length
+ // }
+ // });
+ // }
+ // }
+ // return () => {
+ // if (timerId !== null) {
+ // clearTimeout(timerId);
+ // emitSearchEvent();
+ // }
+ // };
+ // }, [queryParams.get('search')]);
const action = React.useMemo(() => {
if (queryParams.has('search') && queryParams.get('search').length > 0) {
return {
@@ -63,7 +59,7 @@ const useSearch = (queryParams) => {
}
}, 'search');
}, []);
- const search = useModelState({ model: 'search', action, init });
+ const search = useModelState({ model: 'search', action });
return [search, loadRange];
};
diff --git a/src/routes/Settings/Settings.js b/src/routes/Settings/Settings.js
index bffa9da66..9ca68fd52 100644
--- a/src/routes/Settings/Settings.js
+++ b/src/routes/Settings/Settings.js
@@ -6,7 +6,7 @@ const throttle = require('lodash.throttle');
const Icon = require('@stremio/stremio-icons/dom');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
-const { Button, Checkbox, MainNavBars, Multiselect, ColorInput, TextInput, ModalDialog, useProfile, useStreamingServer, useBinaryState } = require('stremio/common');
+const { Button, Checkbox, MainNavBars, Multiselect, ColorInput, TextInput, ModalDialog, useProfile, useStreamingServer, useBinaryState, withCoreSuspender } = require('stremio/common');
const useProfileSettingsInputs = require('./useProfileSettingsInputs');
const useStreamingServerSettingsInputs = require('./useStreamingServerSettingsInputs');
const styles = require('./styles');
@@ -14,6 +14,7 @@ const styles = require('./styles');
const GENERAL_SECTION = 'general';
const PLAYER_SECTION = 'player';
const STREAMING_SECTION = 'streaming';
+const SHORTCUTS_SECTION = 'shortcuts';
const Settings = () => {
const { core } = useServices();
@@ -93,10 +94,12 @@ const Settings = () => {
const generalSectionRef = React.useRef(null);
const playerSectionRef = React.useRef(null);
const streamingServerSectionRef = React.useRef(null);
+ const shortcutsSectionRef = React.useRef(null);
const sections = React.useMemo(() => ([
{ ref: generalSectionRef, id: GENERAL_SECTION },
{ ref: playerSectionRef, id: PLAYER_SECTION },
{ ref: streamingServerSectionRef, id: STREAMING_SECTION },
+ { ref: shortcutsSectionRef, id: SHORTCUTS_SECTION },
]), []);
const [selectedSectionId, setSelectedSectionId] = React.useState(GENERAL_SECTION);
const updateSelectedSectionId = React.useCallback(() => {
@@ -131,7 +134,7 @@ const Settings = () => {
}, [routeFocused]);
return (
-
+
General
@@ -142,6 +145,9 @@ const Settings = () => {
Streaming server
+
+ Shortcuts
+
App Version: {process.env.VERSION}
{
@@ -424,6 +430,107 @@ const Settings = () => {
null
}
+
+
Shortcuts
+
+
+
+
+
→
+
or
+
⇧ Shift
+
+
+
→
+
+
+
+
+
+
←
+
or
+
⇧ Shift
+
+
+
←
+
+
+
+
+
+
+
Toggle Subtitles Menu
+
+
+ S
+
+
+
+
+
+
+
Navigate Between Menus
+
+
+
+
+
+
{
@@ -449,4 +556,8 @@ const Settings = () => {
);
};
-module.exports = Settings;
+const SettingsFallback = () => (
+
+);
+
+module.exports = withCoreSuspender(Settings, SettingsFallback);
diff --git a/src/routes/Settings/styles.less b/src/routes/Settings/styles.less
index be058dd23..7e297080a 100644
--- a/src/routes/Settings/styles.less
+++ b/src/routes/Settings/styles.less
@@ -315,6 +315,30 @@
}
}
}
+
+ &.shortcut-container {
+ justify-content: center;
+ padding: 0;
+ overflow: visible;
+
+ kbd {
+ flex: 0 1 auto;
+ height: 2.5rem;
+ min-width: 2.5rem;
+ line-height: 2.5rem;
+ padding: 0 1rem;
+ font-weight: 500;
+ color: @color-secondaryvariant1-90;
+ border-radius: 0.25em;
+ box-shadow: 0 4px 0 1px @color-background-40;
+ background-color: @color-background;
+ }
+
+ .label {
+ margin: 0 1rem;
+ color: @color-secondaryvariant1-90;
+ }
+ }
}
}
}
diff --git a/src/routes/Settings/useProfileSettingsInputs.js b/src/routes/Settings/useProfileSettingsInputs.js
index 5534fd6e4..57f444d01 100644
--- a/src/routes/Settings/useProfileSettingsInputs.js
+++ b/src/routes/Settings/useProfileSettingsInputs.js
@@ -5,6 +5,7 @@ const { CONSTANTS, languageNames, useDeepEqualMemo } = require('stremio/common')
const useProfileSettingsInputs = (profile) => {
const { core } = useServices();
+ // TODO combine those useDeepEqualMemo in one
const interfaceLanguageSelect = useDeepEqualMemo(() => ({
options: Object.keys(languageNames).map((code) => ({
value: code,
diff --git a/src/routes/Settings/useStreamingServerSettingsInputs.js b/src/routes/Settings/useStreamingServerSettingsInputs.js
index 0318d0f49..8e1e3405c 100644
--- a/src/routes/Settings/useStreamingServerSettingsInputs.js
+++ b/src/routes/Settings/useStreamingServerSettingsInputs.js
@@ -18,10 +18,10 @@ const cacheSizeToString = (size) => {
const TORRENT_PROFILES = {
default: {
- btDownloadSpeedHardLimit: 2621440,
- btDownloadSpeedSoftLimit: 1677721.6,
+ btDownloadSpeedHardLimit: 3670016,
+ btDownloadSpeedSoftLimit: 2621440,
btHandshakeTimeout: 20000,
- btMaxConnections: 35,
+ btMaxConnections: 55,
btMinPeersForStable: 5,
btRequestTimeout: 4000
},
@@ -40,11 +40,20 @@ const TORRENT_PROFILES = {
btMaxConnections: 200,
btMinPeersForStable: 10,
btRequestTimeout: 4000
+ },
+ 'ultra fast': {
+ btDownloadSpeedHardLimit: 78643200,
+ btDownloadSpeedSoftLimit: 8388608,
+ btHandshakeTimeout: 25000,
+ btMaxConnections: 400,
+ btMinPeersForStable: 10,
+ btRequestTimeout: 6000
}
};
const useStreamingServerSettingsInputs = (streamingServer) => {
const { core } = useServices();
+ // TODO combine those useDeepEqualMemo in one
const cacheSizeSelect = useDeepEqualMemo(() => {
if (streamingServer.settings === null || streamingServer.settings.type !== 'Ready') {
return null;
diff --git a/src/services/Core/Core.js b/src/services/Core/Core.js
index 9716cf2b3..e033396d5 100644
--- a/src/services/Core/Core.js
+++ b/src/services/Core/Core.js
@@ -3,7 +3,7 @@
const EventEmitter = require('eventemitter3');
const CoreTransport = require('./CoreTransport');
-function Core() {
+function Core(args) {
let active = false;
let error = null;
let starting = false;
@@ -66,7 +66,7 @@ function Core() {
}
starting = true;
- transport = new CoreTransport();
+ transport = new CoreTransport(args);
transport.on('init', onTransportInit);
transport.on('error', onTransportError);
onStateChanged();
diff --git a/src/services/Core/CoreTransport.js b/src/services/Core/CoreTransport.js
index 95a8cff34..954dc88dd 100644
--- a/src/services/Core/CoreTransport.js
+++ b/src/services/Core/CoreTransport.js
@@ -1,19 +1,22 @@
// Copyright (C) 2017-2022 Smart code 203358507
const EventEmitter = require('eventemitter3');
-const { default: initialize_api, initialize_runtime, get_state, get_debug_state, dispatch, analytics, decode_stream } = require('@stremio/stremio-core-web');
+const Bridge = require('@stremio/stremio-core-web/bridge');
-function CoreTransport() {
+function CoreTransport(args) {
const events = new EventEmitter();
+ const worker = new Worker(`${process.env.COMMIT_HASH}/scripts/worker.js`);
+ const bridge = new Bridge(window, worker);
- initialize_api(require('@stremio/stremio-core-web/stremio_core_web_bg.wasm'))
- .then(() => initialize_runtime(({ name, args }) => {
- try {
- events.emit(name, args);
- } catch (error) {
- console.error('CoreTransport', error);
- }
- }))
+ window.onCoreEvent = ({ name, args }) => {
+ try {
+ events.emit(name, args);
+ } catch (error) {
+ console.error('CoreTransport', error);
+ }
+ };
+
+ bridge.call(['init'], [args])
.then(() => {
try {
events.emit('init');
@@ -34,28 +37,20 @@ function CoreTransport() {
this.removeAllListeners = function() {
events.removeAllListeners();
};
- this.getState = function(field) {
- return get_state(field);
+ this.getState = async function(field) {
+ return bridge.call(['getState'], [field]);
};
- this.getDebugState = function() {
- return get_debug_state();
+ this.getDebugState = async function() {
+ return bridge.call(['getDebugState'], []);
};
- this.dispatch = function(action, field) {
- try {
- dispatch(action, field);
- } catch (error) {
- console.error('CoreTransport', error);
- }
+ this.dispatch = async function(action, field) {
+ return bridge.call(['dispatch'], [action, field, location.hash]);
};
- this.analytics = function(event) {
- try {
- analytics(event);
- } catch (error) {
- console.error('CoreTransport', error);
- }
+ this.analytics = async function(event) {
+ return bridge.call(['analytics'], [event, location.hash]);
};
- this.decodeStream = function(stream) {
- return decode_stream(stream);
+ this.decodeStream = async function(stream) {
+ return bridge.call(['decodeStream'], [stream]);
};
}
diff --git a/src/services/DragAndDrop/DragAndDrop.js b/src/services/DragAndDrop/DragAndDrop.js
new file mode 100644
index 000000000..a503911d8
--- /dev/null
+++ b/src/services/DragAndDrop/DragAndDrop.js
@@ -0,0 +1,89 @@
+// Copyright (C) 2017-2022 Smart code 203358507
+
+const EventEmitter = require('eventemitter3');
+
+function DragAndDrop({ core }) {
+ let active = false;
+
+ const events = new EventEmitter();
+
+ function onDragOver(event) {
+ event.preventDefault();
+ }
+ async function onDrop(event) {
+ event.preventDefault();
+ if (event.dataTransfer.files instanceof FileList && event.dataTransfer.files.length > 0) {
+ const file = event.dataTransfer.files[0];
+ switch (file.type) {
+ case 'application/x-bittorrent': {
+ try {
+ const torrent = await file.arrayBuffer();
+ core.transport.dispatch({
+ action: 'StreamingServer',
+ args: {
+ action: 'CreateTorrent',
+ args: Array.from(new Uint8Array(torrent))
+ }
+ });
+ } catch (error) {
+ events.emit('error', {
+ message: 'Failed to process file',
+ file: {
+ name: file.name,
+ type: file.type
+ }
+ });
+ }
+ break;
+ }
+ default: {
+ events.emit('error', {
+ message: 'Unsupported file',
+ file: {
+ name: file.name,
+ type: file.type
+ }
+ });
+ }
+ }
+ }
+ }
+ function onStateChanged() {
+ events.emit('stateChanged');
+ }
+
+ Object.defineProperties(this, {
+ active: {
+ configurable: false,
+ enumerable: true,
+ get: function() {
+ return active;
+ }
+ }
+ });
+
+ this.start = function() {
+ if (active) {
+ return;
+ }
+
+ window.addEventListener('dragover', onDragOver);
+ window.addEventListener('drop', onDrop);
+ active = true;
+ onStateChanged();
+ };
+ this.stop = function() {
+ window.removeEventListener('dragover', onDragOver);
+ window.removeEventListener('drop', onDrop);
+ active = false;
+ onStateChanged();
+ };
+ this.on = function(name, listener) {
+ events.on(name, listener);
+ };
+ this.off = function(name, listener) {
+ events.off(name, listener);
+ };
+}
+
+module.exports = DragAndDrop;
diff --git a/src/services/DragAndDrop/index.js b/src/services/DragAndDrop/index.js
new file mode 100644
index 000000000..2bc7650af
--- /dev/null
+++ b/src/services/DragAndDrop/index.js
@@ -0,0 +1,5 @@
+// Copyright (C) 2017-2022 Smart code 203358507
+
+const DragAndDrop = require('./DragAndDrop');
+
+module.exports = DragAndDrop;
diff --git a/src/services/Shell/Shell.js b/src/services/Shell/Shell.js
index bb4d9051f..1e0c31943 100644
--- a/src/services/Shell/Shell.js
+++ b/src/services/Shell/Shell.js
@@ -1,14 +1,31 @@
// Copyright (C) 2017-2022 Smart code 203358507
const EventEmitter = require('eventemitter3');
+const ShellTransport = require('./ShellTransport');
function Shell() {
let active = false;
let error = null;
let starting = false;
+ let transport = null;
const events = new EventEmitter();
+ function onTransportInit() {
+ active = true;
+ error = null;
+ starting = false;
+ onStateChanged();
+ }
+ function onTransportInitError(err) {
+ console.error(err);
+ active = false;
+ error = new Error(err);
+ starting = false;
+ onStateChanged();
+ transport = null;
+ }
+
function onStateChanged() {
events.emit('stateChanged');
}
@@ -34,6 +51,13 @@ function Shell() {
get: function() {
return starting;
}
+ },
+ transport: {
+ configurable: false,
+ enumerable: true,
+ get: function() {
+ return transport;
+ }
}
});
@@ -43,8 +67,10 @@ function Shell() {
}
active = false;
- error = new Error('Stremio Shell API not available');
- starting = false;
+ starting = true;
+ transport = new ShellTransport();
+ transport.on('init', onTransportInit);
+ transport.on('init-error', onTransportInitError);
onStateChanged();
};
this.stop = function() {
diff --git a/src/services/Shell/ShellTransport.js b/src/services/Shell/ShellTransport.js
new file mode 100644
index 000000000..ff2ac845c
--- /dev/null
+++ b/src/services/Shell/ShellTransport.js
@@ -0,0 +1,121 @@
+// Copyright (C) 2017-2022 Smart code 203358507
+
+const EventEmitter = require('eventemitter3');
+
+let shellAvailable = false;
+const shellEvents = new EventEmitter();
+
+const QtMsgTypes = {
+ signal: 1,
+ propertyUpdate: 2,
+ init: 3,
+ idle: 4,
+ debug: 5,
+ invokeMethod: 6,
+ connectToSignal: 7,
+ disconnectFromSignal: 8,
+ setProperty: 9,
+ response: 10,
+};
+const QtObjId = 'transport'; // the ID of our transport object
+
+window.initShellComm = function () {
+ delete window.initShellComm;
+ shellEvents.emit('availabilityChanged');
+};
+
+const initialize = () => {
+ if(!window.qt) return Promise.reject('Qt API not found');
+ return new Promise((resolve) => {
+ function onShellAvailabilityChanged() {
+ shellEvents.off('availabilityChanged', onShellAvailabilityChanged);
+ shellAvailable = true;
+ resolve();
+ }
+ if (shellAvailable) {
+ onShellAvailabilityChanged();
+ } else {
+ shellEvents.on('availabilityChanged', onShellAvailabilityChanged);
+ }
+ });
+};
+
+function ShellTransport() {
+ const events = new EventEmitter();
+
+ this.props = {};
+
+ const shell = this;
+ initialize()
+ .then(() => {
+ const transport = window.qt && window.qt.webChannelTransport;
+ if (!transport) throw 'no viable transport found (qt.webChannelTransport)';
+
+ let id = 0;
+ function send(msg) {
+ msg.id = id++;
+ transport.send(JSON.stringify(msg));
+ }
+
+ transport.onmessage = function (message) {
+ const msg = JSON.parse(message.data);
+ if (msg.id === 0) {
+ const obj = msg.data[QtObjId];
+
+ obj.properties.slice(1).forEach(function (prop) {
+ shell.props[prop[1]] = prop[3];
+ });
+ if (typeof shell.props.shellVersion === 'string') {
+ shell.shellVersionArr = (
+ shell.props.shellVersion.match(/(\d+)\.(\d+)\.(\d+)/) || []
+ )
+ .slice(1, 4)
+ .map(Number);
+ }
+ events.emit('received-props', shell.props);
+
+ obj.signals.forEach(function (sig) {
+ send({
+ type: QtMsgTypes.connectToSignal,
+ object: QtObjId,
+ signal: sig[1],
+ });
+ });
+
+ const onEvent = obj.methods.filter(function (x) {
+ return x[0] === 'onEvent';
+ })[0];
+
+ shell.send = function (ev, args) {
+ send({
+ type: QtMsgTypes.invokeMethod,
+ object: QtObjId,
+ method: onEvent[1],
+ args: [ev, args || {}],
+ });
+ };
+
+ shell.send('app-ready', {}); // signal that we're ready to take events
+ }
+
+ if (msg.object === QtObjId && msg.type === QtMsgTypes.signal)
+ events.emit(msg.args[0], msg.args[1]);
+ events.emit('init');
+ };
+ send({ type: QtMsgTypes.init });
+ }) .catch((error) => {
+ events.emit('init-error', error);
+ });
+
+ this.on = function(name, listener) {
+ events.on(name, listener);
+ };
+ this.off = function(name, listener) {
+ events.off(name, listener);
+ };
+ this.removeAllListeners = function() {
+ events.removeAllListeners();
+ };
+}
+
+module.exports = ShellTransport;
diff --git a/src/services/index.js b/src/services/index.js
index fb944e199..3ed0767f0 100644
--- a/src/services/index.js
+++ b/src/services/index.js
@@ -2,6 +2,7 @@
const Chromecast = require('./Chromecast');
const Core = require('./Core');
+const DragAndDrop = require('./DragAndDrop');
const KeyboardShortcuts = require('./KeyboardShortcuts');
const { ServicesProvider, useServices } = require('./ServicesContext');
const Shell = require('./Shell');
@@ -9,6 +10,7 @@ const Shell = require('./Shell');
module.exports = {
Chromecast,
Core,
+ DragAndDrop,
KeyboardShortcuts,
ServicesProvider,
useServices,
diff --git a/webpack.config.js b/webpack.config.js
index c9c9952d6..93cff5e93 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -16,7 +16,10 @@ const COMMIT_HASH = execSync('git rev-parse HEAD').toString().trim();
module.exports = (env, argv) => ({
mode: argv.mode,
devtool: argv.mode === 'production' ? 'source-map' : 'eval-source-map',
- entry: './src/index.js',
+ entry: {
+ main: './src/index.js',
+ worker: './node_modules/@stremio/stremio-core-web/worker.js'
+ },
output: {
path: path.join(__dirname, 'build'),
filename: `${COMMIT_HASH}/scripts/[name].js`