From e542e5d5500f50eec873ff9d64df4d5058334aae Mon Sep 17 00:00:00 2001 From: Tim Date: Mon, 5 Jun 2023 12:49:55 +0200 Subject: [PATCH] feat: add statistics menu on player --- src/routes/Player/ControlBar/ControlBar.js | 20 ++++- src/routes/Player/Player.js | 71 ++++++++++++++-- .../Player/StatisticsMenu/StatisticsMenu.js | 79 ++++++++++++++++++ src/routes/Player/StatisticsMenu/index.js | 4 + src/routes/Player/StatisticsMenu/styles.less | 52 ++++++++++++ src/types/models/StremingServer.d.ts | 81 ++++++++++++++++++- 6 files changed, 293 insertions(+), 14 deletions(-) create mode 100644 src/routes/Player/StatisticsMenu/StatisticsMenu.js create mode 100644 src/routes/Player/StatisticsMenu/index.js create mode 100644 src/routes/Player/StatisticsMenu/styles.less diff --git a/src/routes/Player/ControlBar/ControlBar.js b/src/routes/Player/ControlBar/ControlBar.js index 2ffc02c7d..5e4a9f3ee 100644 --- a/src/routes/Player/ControlBar/ControlBar.js +++ b/src/routes/Player/ControlBar/ControlBar.js @@ -24,6 +24,8 @@ const ControlBar = ({ audioTracks, metaItem, nextVideo, + stream, + statistics, onPlayRequested, onPauseRequested, onMuteRequested, @@ -35,6 +37,7 @@ const ControlBar = ({ onToggleSpeedMenu, onToggleVideosMenu, onToggleOptionsMenu, + onToggleStatisticsMenu, ...props }) => { const { chromecast } = useServices(); @@ -55,6 +58,9 @@ const ControlBar = ({ const onOptionsButtonMouseDown = React.useCallback((event) => { event.nativeEvent.optionsMenuClosePrevented = true; }, []); + const onStatisticsButtonMouseDown = React.useCallback((event) => { + event.nativeEvent.statisticsMenuClosePrevented = true; + }, []); const onPlayPauseButtonClick = React.useCallback(() => { if (paused) { if (typeof onPlayRequested === 'function') { @@ -111,6 +117,11 @@ const ControlBar = ({ onToggleOptionsMenu(); } }, [onToggleOptionsMenu]); + const onStatisticsButtonClick = React.useCallback(() => { + if (typeof onToggleStatisticsMenu === 'function') { + onToggleStatisticsMenu(); + } + }, [onToggleStatisticsMenu]); const onChromecastButtonClick = React.useCallback(() => { chromecast.transport.requestSession(); }, []); @@ -165,12 +176,12 @@ const ControlBar = ({
+ - @@ -209,6 +220,8 @@ ControlBar.propTypes = { audioTracks: PropTypes.array, metaItem: PropTypes.object, nextVideo: PropTypes.object, + stream: PropTypes.object, + statistics: PropTypes.object, onPlayRequested: PropTypes.func, onPauseRequested: PropTypes.func, onMuteRequested: PropTypes.func, @@ -220,6 +233,7 @@ ControlBar.propTypes = { onToggleSpeedMenu: PropTypes.func, onToggleVideosMenu: PropTypes.func, onToggleOptionsMenu: PropTypes.func, + onToggleStatisticsMenu: PropTypes.func, }; module.exports = ControlBar; diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index a37ceb588..f2b171596 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -13,6 +13,7 @@ const Icon = require('@stremio/stremio-icons/dom'); const BufferingLoader = require('./BufferingLoader'); const ControlBar = require('./ControlBar'); const NextVideoPopup = require('./NextVideoPopup'); +const StatisticsMenu = require('./StatisticsMenu'); const InfoMenu = require('./InfoMenu'); const OptionsMenu = require('./OptionsMenu'); const VideosMenu = require('./VideosMenu'); @@ -81,6 +82,7 @@ const Player = ({ urlParams, queryParams }) => { const [speedMenuOpen, , closeSpeedMenu, toggleSpeedMenu] = useBinaryState(false); const [videosMenuOpen, , closeVideosMenu, toggleVideosMenu] = useBinaryState(false); const [nextVideoPopupOpen, openNextVideoPopup, closeNextVideoPopup] = useBinaryState(false); + const [statisticsMenuOpen, , closeStatisticsMenu, toggleStatisticsMenu] = useBinaryState(false); const nextVideoPopupDismissed = React.useRef(false); const defaultSubtitlesSelected = React.useRef(false); const defaultAudioTrackSelected = React.useRef(false); @@ -234,6 +236,9 @@ const Player = ({ urlParams, queryParams }) => { if (!event.nativeEvent.videosMenuClosePrevented) { closeVideosMenu(); } + if (!event.nativeEvent.statisticsMenuClosePrevented) { + closeStatisticsMenu(); + } }, []); const onContainerMouseMove = React.useCallback((event) => { setImmersed(false); @@ -356,6 +361,26 @@ const Player = ({ urlParams, queryParams }) => { } } }, [player.nextVideo, videoState.time, videoState.duration]); + React.useEffect(() => { + if (player.selected && player.selected.stream && typeof player.selected.stream.infoHash === 'string' && typeof player.selected.stream.fileIdx === 'number') { + const { infoHash, fileIdx } = player.selected.stream; + const getStatistics = () => { + core.transport.dispatch({ + action: 'StreamingServer', + args: { + action: 'GetStatistics', + args: { + infoHash, + fileIdx, + } + } + }); + }; + getStatistics(); + const statisticsInterval = setInterval(getStatistics, 5000); + return () => clearInterval(statisticsInterval); + } + }, [player.selected]); React.useEffect(() => { if (!defaultSubtitlesSelected.current) { const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang); @@ -445,7 +470,7 @@ const Player = ({ urlParams, queryParams }) => { const onKeyDown = (event) => { switch (event.code) { case 'Space': { - if (!subtitlesMenuOpen && !infoMenuOpen && !videosMenuOpen && !speedMenuOpen && !optionsMenuOpen && videoState.paused !== null) { + if (!subtitlesMenuOpen && !infoMenuOpen && !videosMenuOpen && !speedMenuOpen && !optionsMenuOpen && !statisticsMenuOpen && videoState.paused !== null) { if (videoState.paused) { onPlayRequested(); } else { @@ -456,7 +481,7 @@ const Player = ({ urlParams, queryParams }) => { break; } case 'ArrowRight': { - if (!subtitlesMenuOpen && !infoMenuOpen && !videosMenuOpen && !speedMenuOpen && !optionsMenuOpen && videoState.time !== null) { + if (!subtitlesMenuOpen && !infoMenuOpen && !videosMenuOpen && !speedMenuOpen && !optionsMenuOpen && !statisticsMenuOpen && videoState.time !== null) { const seekTimeMultiplier = event.shiftKey ? 3 : 1; onSeekRequested(videoState.time + (settings.seekTimeDuration * seekTimeMultiplier)); } @@ -464,7 +489,7 @@ const Player = ({ urlParams, queryParams }) => { break; } case 'ArrowLeft': { - if (!subtitlesMenuOpen && !infoMenuOpen && !videosMenuOpen && !speedMenuOpen && !optionsMenuOpen && videoState.time !== null) { + if (!subtitlesMenuOpen && !infoMenuOpen && !videosMenuOpen && !speedMenuOpen && !optionsMenuOpen && !statisticsMenuOpen && videoState.time !== null) { const seekTimeMultiplier = event.shiftKey ? 3 : 1; onSeekRequested(videoState.time - (settings.seekTimeDuration * seekTimeMultiplier)); } @@ -472,14 +497,14 @@ const Player = ({ urlParams, queryParams }) => { break; } case 'ArrowUp': { - if (!subtitlesMenuOpen && !infoMenuOpen && !videosMenuOpen && !speedMenuOpen && !optionsMenuOpen && videoState.volume !== null) { + if (!subtitlesMenuOpen && !infoMenuOpen && !videosMenuOpen && !speedMenuOpen && !optionsMenuOpen && !statisticsMenuOpen && videoState.volume !== null) { onVolumeChangeRequested(videoState.volume + 5); } break; } case 'ArrowDown': { - if (!subtitlesMenuOpen && !infoMenuOpen && !videosMenuOpen && !speedMenuOpen && !optionsMenuOpen && videoState.volume !== null) { + if (!subtitlesMenuOpen && !infoMenuOpen && !videosMenuOpen && !speedMenuOpen && !optionsMenuOpen && !statisticsMenuOpen && videoState.volume !== null) { onVolumeChangeRequested(videoState.volume - 5); } @@ -490,6 +515,7 @@ const Player = ({ urlParams, queryParams }) => { closeInfoMenu(); closeSpeedMenu(); closeVideosMenu(); + closeStatisticsMenu(); 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)) { @@ -503,6 +529,7 @@ const Player = ({ urlParams, queryParams }) => { closeSubtitlesMenu(); closeSpeedMenu(); closeVideosMenu(); + closeStatisticsMenu(); if (player.metaItem !== null && player.metaItem.type === 'Ready') { toggleInfoMenu(); } @@ -514,6 +541,7 @@ const Player = ({ urlParams, queryParams }) => { closeInfoMenu(); closeSubtitlesMenu(); closeVideosMenu(); + closeStatisticsMenu(); if (videoState.playbackSpeed !== null) { toggleSpeedMenu(); } @@ -525,18 +553,32 @@ const Player = ({ urlParams, queryParams }) => { closeInfoMenu(); closeSubtitlesMenu(); closeSpeedMenu(); + closeStatisticsMenu(); if (player.metaItem !== null && player.metaItem.type === 'Ready') { toggleVideosMenu(); } break; } + case 'KeyD': { + closeOptionsMenu(); + closeInfoMenu(); + closeSubtitlesMenu(); + closeSpeedMenu(); + closeVideosMenu(); + if (streamingServer.statistics !== null && streamingServer.statistics.type !== 'Err' && player.selected && typeof player.selected.stream.infoHash === 'string' && typeof player.selected.stream.fileIdx === 'number') { + toggleStatisticsMenu(); + } + + break; + } case 'Escape': { closeOptionsMenu(); closeSubtitlesMenu(); closeInfoMenu(); closeSpeedMenu(); closeVideosMenu(); + closeStatisticsMenu(); onDismissNextVideoPopup(); break; } @@ -548,7 +590,7 @@ const Player = ({ urlParams, queryParams }) => { return () => { window.removeEventListener('keydown', onKeyDown); }; - }, [player.metaItem, settings.seekTimeDuration, routeFocused, subtitlesMenuOpen, infoMenuOpen, videosMenuOpen, speedMenuOpen, optionsMenuOpen, videoState.paused, videoState.time, videoState.volume, videoState.audioTracks, videoState.subtitlesTracks, videoState.extraSubtitlesTracks, videoState.playbackSpeed, toggleSubtitlesMenu, toggleInfoMenu, toggleVideosMenu]); + }, [player.metaItem, player.selected, settings.seekTimeDuration, routeFocused, subtitlesMenuOpen, infoMenuOpen, videosMenuOpen, speedMenuOpen, optionsMenuOpen, statisticsMenuOpen, videoState.paused, videoState.time, videoState.volume, videoState.audioTracks, videoState.subtitlesTracks, videoState.extraSubtitlesTracks, videoState.playbackSpeed, toggleSubtitlesMenu, toggleInfoMenu, toggleVideosMenu, toggleStatisticsMenu]); React.useLayoutEffect(() => { return () => { setImmersedDebounced.cancel(); @@ -557,7 +599,7 @@ const Player = ({ urlParams, queryParams }) => { }; }, []); return ( -
{ null } { - subtitlesMenuOpen || infoMenuOpen || videosMenuOpen || speedMenuOpen || optionsMenuOpen ? + subtitlesMenuOpen || infoMenuOpen || videosMenuOpen || speedMenuOpen || optionsMenuOpen || statisticsMenuOpen ?
: null @@ -633,6 +675,8 @@ const Player = ({ urlParams, queryParams }) => { audioTracks={videoState.audioTracks} metaItem={player.metaItem} nextVideo={player.nextVideo} + stream={player.selected !== null ? player.selected.stream : null} + statistics={streamingServer.statistics} onPlayRequested={onPlayRequested} onPauseRequested={onPauseRequested} onMuteRequested={onMuteRequested} @@ -644,6 +688,7 @@ const Player = ({ urlParams, queryParams }) => { onToggleInfoMenu={toggleInfoMenu} onToggleSpeedMenu={toggleSpeedMenu} onToggleVideosMenu={toggleVideosMenu} + onToggleStatisticsMenu={toggleStatisticsMenu} onMouseMove={onBarMouseMove} onMouseOver={onBarMouseMove} /> @@ -659,6 +704,16 @@ const Player = ({ urlParams, queryParams }) => { : null } + { + statisticsMenuOpen ? + + : + null + } { subtitlesMenuOpen ? { + const peers = React.useMemo(() => { + return statistics.type === 'Ready' && statistics.content?.peers ? + statistics.content.peers + : + 0; + }, [statistics]); + + const speed = React.useMemo(() => { + return statistics.type === 'Ready' && statistics.content?.downloadSpeed ? + (statistics.content.downloadSpeed / 1000 / 1000).toFixed(2) + : + 0; + }, [statistics]); + + const completed = React.useMemo(() => { + return statistics.type === 'Ready' && statistics.content?.streamProgress ? + (statistics.content.streamProgress * 100).toFixed(2) + : + 0; + }, [statistics]); + + return ( +
+
+ Statistics +
+
+
+
+ Peers +
+
+ { peers } +
+
+
+
+ Speed +
+
+ { speed } MB/s +
+
+
+
+ Completed +
+
+ { completed } % +
+
+
+
+
+ Info Hash +
+
+ { stream.infoHash } +
+
+
+ ); +}; + +StatisticsMenu.propTypes = { + className: PropTypes.string, + stream: PropTypes.object, + statistics: PropTypes.object, +}; + +module.exports = StatisticsMenu; diff --git a/src/routes/Player/StatisticsMenu/index.js b/src/routes/Player/StatisticsMenu/index.js new file mode 100644 index 000000000..fc0c982a6 --- /dev/null +++ b/src/routes/Player/StatisticsMenu/index.js @@ -0,0 +1,4 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +const StatisticsMenu = require('./StatisticsMenu'); +module.exports = StatisticsMenu; diff --git a/src/routes/Player/StatisticsMenu/styles.less b/src/routes/Player/StatisticsMenu/styles.less new file mode 100644 index 000000000..d25f14c41 --- /dev/null +++ b/src/routes/Player/StatisticsMenu/styles.less @@ -0,0 +1,52 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; + +.statistics-menu-container { + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 30rem; + padding: 1.5rem; + + .title { + flex: none; + font-weight: 600; + color: @color-surface-light5-90; + } + + .label { + flex: none; + font-weight: 500; + color: @color-surface-light5-50; + } + + .value { + flex: none; + font-weight: 500; + color: @color-surface-light5-90; + } + + .stats { + flex: auto; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + gap: 1rem; + + .stat { + flex: auto; + display: flex; + flex-direction: row; + gap: 0.5rem; + } + } + + .info-hash { + flex: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + } +} \ No newline at end of file diff --git a/src/types/models/StremingServer.d.ts b/src/types/models/StremingServer.d.ts index 2a0495cc3..4f12f4a76 100644 --- a/src/types/models/StremingServer.d.ts +++ b/src/types/models/StremingServer.d.ts @@ -25,11 +25,86 @@ type StreamingServerSettings = { serverVersion: string, }; +type SFile = { + name: string, + path: string, + length: number, + offset: number, +}; + +type Source = { + last_started: string, + numFound: number, + numFoundUniq: number, + numRequests: number, + url: string, +} + +type Growler = { + flood: number, + pulse: number, +} + +type PeerSearch = { + max: number, + min: number, + sources: string[], +} + +type SwarmCap = { + maxSpeed: number, + minPeers: number, +} + +type Options = { + connections: number, + dht: boolean, + growler: Growler, + handshakeTimeout: number, + path: string, + peerSearch: PeerSearch, + swarmCap: SwarmCap, + timeout: number, + tracker: boolean, + virtual: boolean, +} + +type Statistics = { + name: string, + infoHash: string, + files: SFile[], + sources: Source[], + opts: Options, + downloadSpeed: number, + uploadSpeed: number, + downloaded: number, + uploaded: number, + unchoked: number, + peers: number, + queued: number, + unique: number, + connectionTries: number, + peerSearchRunning: boolean, + streamLen: number, + streamName: string, + streamProgress: number, + swarmConnections: number, + swarmPaused: boolean, + swarmSize: number, +}; + +type Selected = { + transportUrl: string, + statistics: { + infoHash: string, + fileIdx: number, + } | null +}; + type StreamingServer = { baseUrl: Loadable | null, - selected: { - transportUrl: string, - } | null, + selected: Selected | null, settings: Loadable | null, torrent: [string, Loadable] | null, + statistics: Loadable | null, }; \ No newline at end of file