diff --git a/package-lock.json b/package-lock.json index 575d6312f..f4b88a1e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8117,9 +8117,9 @@ "integrity": "sha512-yT3No1gIWKLV2BhQIeSgG94EzXxmEqXJLulO+pFpziqWNUbmmEKeE+nRvW5wtoIK4SLy+v0bLd0b6HBH3KFfWw==" }, "@stremio/stremio-core-web": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.22.0.tgz", - "integrity": "sha512-PqvIXKwYmRYwzh0BFCEb9orZ3533OVLmLNbV1mCf9dmHG+BX+mREv9IB5+/yJZal2GMLnEBPfgeNxvIDYU6sww==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.23.0.tgz", + "integrity": "sha512-eZyBEWuB90y6lTG47Y4lKUnlM4bpVhmKh96DUHMPtMKOsyHRutOkuKXYjAwP0WjCbe57/vbalI04Fout7SZwWg==", "requires": { "@babel/runtime": "7.10.0" }, diff --git a/package.json b/package.json index fe95fed64..a421cbe3b 100755 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@babel/runtime": "7.12.5", "@sentry/browser": "5.11.1", "@stremio/stremio-colors": "4.0.1", - "@stremio/stremio-core-web": "0.22.0", + "@stremio/stremio-core-web": "0.23.0", "@stremio/stremio-icons": "3.0.5", "@stremio/stremio-video": "0.0.7", "a-color-picker": "1.2.1", diff --git a/src/common/CONSTANTS.js b/src/common/CONSTANTS.js index f5702fd4e..ff9d18e58 100644 --- a/src/common/CONSTANTS.js +++ b/src/common/CONSTANTS.js @@ -3,6 +3,7 @@ const CHROMECAST_RECEIVER_APP_ID = '1634F54B'; const SUBTITLES_SIZES = [75, 100, 125, 150, 175, 200, 250]; const SUBTITLES_FONTS = ['Roboto', 'Arial', 'Halvetica', 'Times New Roman', 'Verdana', 'Courier', 'Lucida Console', 'sans-serif', 'serif', 'monospace']; +const SEEK_TIME_DURATIONS = [5000, 10000, 15000, 20000, 25000, 30000]; const CATALOG_PREVIEW_SIZE = 10; const CATALOG_PAGE_SIZE = 100; const NONE_EXTRA_VALUE = 'None'; @@ -28,6 +29,7 @@ module.exports = { CHROMECAST_RECEIVER_APP_ID, SUBTITLES_SIZES, SUBTITLES_FONTS, + SEEK_TIME_DURATIONS, CATALOG_PREVIEW_SIZE, CATALOG_PAGE_SIZE, NONE_EXTRA_VALUE, diff --git a/src/common/MetaPreview/MetaPreview.js b/src/common/MetaPreview/MetaPreview.js index 1099ea546..a9424b8fd 100644 --- a/src/common/MetaPreview/MetaPreview.js +++ b/src/common/MetaPreview/MetaPreview.js @@ -23,7 +23,7 @@ const ALLOWED_LINK_REDIRECTS = [ routesRegexp.metadetails.regexp ]; -const MetaPreview = ({ className, compact, name, logo, background, runtime, releaseInfo, released, description, links, trailerStreams, inLibrary, toggleInLibrary }) => { +const MetaPreview = ({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary }) => { const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false); const linksGroups = React.useMemo(() => { return Array.isArray(links) ? @@ -70,6 +70,21 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele : new Map(); }, [links]); + const showHref = React.useMemo(() => { + return deepLinks ? + typeof deepLinks.player === 'string' ? + deepLinks.player + : + typeof deepLinks.metaDetailsStreams === 'string' ? + deepLinks.metaDetailsStreams + : + typeof deepLinks.metaDetailsVideos === 'string' ? + deepLinks.metaDetailsVideos + : + null + : + null; + }, [deepLinks]); const trailerHref = React.useMemo(() => { if (!Array.isArray(trailerStreams) || trailerStreams.length === 0) { return null; @@ -195,7 +210,19 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele null } { - linksGroups.has(CONSTANTS.SHARE_LINK_CATEGORY) ? + typeof showHref === 'string' && compact ? + + : + null + } + { + linksGroups.has(CONSTANTS.SHARE_LINK_CATEGORY) && !compact ? { className={styles['addon-url-input']} type={'text'} placeholder={'Paste addon URL'} + autoFocus={true} onSubmit={addAddonOnSubmit} /> diff --git a/src/routes/Discover/Discover.js b/src/routes/Discover/Discover.js index 56ea7401d..bbccda7a5 100644 --- a/src/routes/Discover/Discover.js +++ b/src/routes/Discover/Discover.js @@ -165,6 +165,7 @@ const Discover = ({ urlParams, queryParams }) => { releaseInfo={selectedMetaItem.releaseInfo} released={selectedMetaItem.released} description={selectedMetaItem.description} + deepLinks={selectedMetaItem.deepLinks} trailerStreams={selectedMetaItem.trailerStreams} inLibrary={selectedMetaItem.inLibrary} toggleInLibrary={selectedMetaItem.inLibrary ? removeFromLibrary : addToLibrary} diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index d54124e02..191f0423c 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -6,13 +6,15 @@ const classnames = require('classnames'); const debounce = require('lodash.debounce'); const { useRouteFocused } = require('stremio-router'); const { useServices } = require('stremio/services'); -const { HorizontalNavBar, useDeepEqualEffect, useFullscreen, useBinaryState, useToast, useStreamingServer } = require('stremio/common'); +const { HorizontalNavBar, Button, useDeepEqualEffect, useFullscreen, useBinaryState, useToast, useStreamingServer } = require('stremio/common'); +const Icon = require('@stremio/stremio-icons/dom'); const BufferingLoader = require('./BufferingLoader'); const ControlBar = require('./ControlBar'); const InfoMenu = require('./InfoMenu'); const SubtitlesMenu = require('./SubtitlesMenu'); const Video = require('./Video'); const usePlayer = require('./usePlayer'); +const usePlaylist = require('./usePlaylist'); const useSettings = require('./useSettings'); const styles = require('./styles'); @@ -22,6 +24,7 @@ const Player = ({ urlParams, queryParams }) => { return queryParams.has('forceTranscoding'); }, [queryParams]); const [player, updateLibraryItemState, pushToLibrary] = usePlayer(urlParams); + const playlist = usePlaylist(player); const [settings, updateSettings] = useSettings(); const streamingServer = useStreamingServer(); const routeFocused = useRouteFocused(); @@ -233,7 +236,7 @@ const Player = ({ urlParams, queryParams }) => { streamingServer.selected.transportUrl : null, - chromecastTransport: chromecast.transport + chromecastTransport: chromecast.active ? chromecast.transport : null } }); } @@ -331,14 +334,16 @@ const Player = ({ urlParams, queryParams }) => { } case 'ArrowRight': { if (!subtitlesMenuOpen && !infoMenuOpen && videoState.time !== null) { - onSeekRequested(videoState.time + 15000); + const seekTimeMultiplier = event.shiftKey ? 3 : 1; + onSeekRequested(videoState.time + (settings.seekTimeDuration * seekTimeMultiplier)); } break; } case 'ArrowLeft': { if (!subtitlesMenuOpen && !infoMenuOpen && videoState.time !== null) { - onSeekRequested(videoState.time - 15000); + const seekTimeMultiplier = event.shiftKey ? 3 : 1; + onSeekRequested(videoState.time - (settings.seekTimeDuration * seekTimeMultiplier)); } break; @@ -386,7 +391,7 @@ const Player = ({ urlParams, queryParams }) => { return () => { window.removeEventListener('keydown', onKeyDown); }; - }, [player, routeFocused, subtitlesMenuOpen, infoMenuOpen, videoState.paused, videoState.time, videoState.volume, videoState.subtitlesTracks, toggleSubtitlesMenu, toggleInfoMenu]); + }, [player, settings.seekTimeDuration, routeFocused, subtitlesMenuOpen, infoMenuOpen, videoState.paused, videoState.time, videoState.volume, videoState.subtitlesTracks, toggleSubtitlesMenu, toggleInfoMenu]); React.useLayoutEffect(() => { return () => { setImmersedDebounced.cancel(); @@ -415,18 +420,30 @@ const Player = ({ urlParams, queryParams }) => { videoState.buffering ? : - error !== null ? -
-
{error.message}
-
- : - null + null }
+ { + error !== null ? +
+
{error.message}
+ { + playlist ? + + : + null + } +
+ : + null + } { subtitlesMenuOpen || infoMenuOpen ?
diff --git a/src/routes/Player/styles.less b/src/routes/Player/styles.less index 8c84da749..e4fe58c55 100644 --- a/src/routes/Player/styles.less +++ b/src/routes/Player/styles.less @@ -40,7 +40,7 @@ html:not(.active-slider-within) { &.error-layer { display: flex; - flex-direction: row; + flex-direction: column; align-items: center; justify-content: center; background-color: @color-background-dark5; @@ -53,6 +53,39 @@ html:not(.active-slider-within) { color: @color-surface-light5-90; text-align: center; } + + .playlist-button { + flex: none; + display: flex; + flex-direction: row; + align-items: center; + height: 3.5rem; + max-width: 16rem; + margin-top: 1.5rem; + padding: 0.5rem 1rem; + background-color: @color-accent3; + + &:hover, &:focus { + background-color: @color-accent3-light1; + } + + .icon { + flex: none; + width: 1.5rem; + height: 1.5rem; + margin-right: 1rem; + fill: @color-surface-light5-90; + } + + .label { + flex: 1; + max-height: 2.4em; + font-size: 1.1rem; + font-weight: 500; + color: @color-surface-light5-90; + text-align: center; + } + } } &.nav-bar-layer { diff --git a/src/routes/Player/usePlaylist.js b/src/routes/Player/usePlaylist.js new file mode 100644 index 000000000..d2d343217 --- /dev/null +++ b/src/routes/Player/usePlaylist.js @@ -0,0 +1,16 @@ +const React = require('react'); + +const usePlaylist = (player) => { + return React.useMemo(() => { + if (player.selected === null || typeof player.selected.stream.url !== 'string') { + return null; + } + + const name = `${player.title}.m3u`; + const m3u = `#EXTM3U\n\n#EXTINF:0,${encodeURIComponent(player.title)}\n${encodeURI(player.selected.stream.url)}`; + const href = `data:application/octet-stream;charset=utf-8;base64,${window.btoa(m3u)}`; + return { name, href }; + }, [player]); +}; + +module.exports = usePlaylist; diff --git a/src/routes/Settings/Settings.js b/src/routes/Settings/Settings.js index 546912994..85d014f5f 100644 --- a/src/routes/Settings/Settings.js +++ b/src/routes/Settings/Settings.js @@ -27,6 +27,7 @@ const Settings = () => { subtitlesTextColorInput, subtitlesBackgroundColorInput, subtitlesOutlineColorInput, + seekTimeDurationSelect, bingeWatchingCheckbox, playInBackgroundCheckbox, playInExternalPlayerCheckbox, @@ -303,6 +304,15 @@ const Settings = () => { {...subtitlesOutlineColorInput} />
+
+
+
Rewind & Fast-forward duration
+
+ +
Auto-play next episode
diff --git a/src/routes/Settings/useProfileSettingsInputs.js b/src/routes/Settings/useProfileSettingsInputs.js index 26619afce..8cb558146 100644 --- a/src/routes/Settings/useProfileSettingsInputs.js +++ b/src/routes/Settings/useProfileSettingsInputs.js @@ -110,6 +110,28 @@ const useProfileSettingsInputs = (profile) => { }); } }), [profile.settings]); + const seekTimeDurationSelect = useDeepEqualMemo(() => ({ + options: CONSTANTS.SEEK_TIME_DURATIONS.map((size) => ({ + value: `${size}`, + label: `${size / 1000} seconds` + })), + selected: [`${profile.settings.seekTimeDuration}`], + renderLabelText: () => { + return `${profile.settings.seekTimeDuration / 1000} seconds`; + }, + onSelect: (event) => { + core.transport.dispatch({ + action: 'Ctx', + args: { + action: 'UpdateSettings', + args: { + ...profile.settings, + seekTimeDuration: parseInt(event.value, 10) + } + } + }); + } + }), [profile.settings]); const bingeWatchingCheckbox = useDeepEqualMemo(() => ({ checked: profile.settings.bingeWatching, onClick: () => { @@ -192,6 +214,7 @@ const useProfileSettingsInputs = (profile) => { subtitlesTextColorInput, subtitlesBackgroundColorInput, subtitlesOutlineColorInput, + seekTimeDurationSelect, bingeWatchingCheckbox, playInBackgroundCheckbox, playInExternalPlayerCheckbox,