feat: add statistics menu on player

This commit is contained in:
Tim 2023-06-05 12:49:55 +02:00
parent b1f9abb0c9
commit e542e5d550
6 changed files with 293 additions and 14 deletions

View file

@ -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 = ({
<Icon className={styles['icon']} icon={'ic_more'} />
</Button>
<div className={classnames(styles['control-bar-buttons-menu-container'], { 'open': buttonsMenuOpen })}>
<Button className={classnames(styles['control-bar-button'], { 'disabled': statistics === null || statistics.type === 'Err' || stream === null || typeof stream.infoHash !== 'string' || typeof stream.fileIdx !== 'number' })} tabIndex={-1} onMouseDown={onStatisticsButtonMouseDown} onClick={onStatisticsButtonClick}>
<Icon className={styles['icon']} icon={'ic_network'} />
</Button>
<Button className={classnames(styles['control-bar-button'], { 'disabled': playbackSpeed === null })} tabIndex={-1} onMouseDown={onSpeedButtonMouseDown} onClick={onSpeedButtonClick}>
<Icon className={styles['icon']} icon={'ic_speedometer'} />
</Button>
<Button className={classnames(styles['control-bar-button'], 'disabled')} tabIndex={-1}>
<Icon className={styles['icon']} icon={'ic_network'} />
</Button>
<Button className={classnames(styles['control-bar-button'], { 'disabled': metaItem === null || metaItem.type !== 'Ready' })} tabIndex={-1} onMouseDown={onInfoButtonMouseDown} onClick={onInfoButtonClick}>
<Icon className={styles['icon']} icon={'ic_info'} />
</Button>
@ -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;

View file

@ -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 (
<div className={classnames(styles['player-container'], { [styles['immersed']]: immersed && !casting && videoState.paused !== null && !videoState.paused && !subtitlesMenuOpen && !infoMenuOpen && !speedMenuOpen && !videosMenuOpen && !nextVideoPopupOpen && !optionsMenuOpen })}
<div className={classnames(styles['player-container'], { [styles['immersed']]: immersed && !casting && videoState.paused !== null && !videoState.paused && !subtitlesMenuOpen && !infoMenuOpen && !speedMenuOpen && !videosMenuOpen && !nextVideoPopupOpen && !optionsMenuOpen && !statisticsMenuOpen })}
onMouseDown={onContainerMouseDown}
onMouseMove={onContainerMouseMove}
onMouseOver={onContainerMouseMove}
@ -608,7 +650,7 @@ const Player = ({ urlParams, queryParams }) => {
null
}
{
subtitlesMenuOpen || infoMenuOpen || videosMenuOpen || speedMenuOpen || optionsMenuOpen ?
subtitlesMenuOpen || infoMenuOpen || videosMenuOpen || speedMenuOpen || optionsMenuOpen || statisticsMenuOpen ?
<div className={styles['layer']} />
:
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 ?
<StatisticsMenu
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player.selected !== null ? player.selected.stream : null}
statistics={streamingServer.statistics}
/>
:
null
}
{
subtitlesMenuOpen ?
<SubtitlesMenu

View file

@ -0,0 +1,79 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const classNames = require('classnames');
const PropTypes = require('prop-types');
const styles = require('./styles.less');
const StatisticsMenu = ({ className, stream, statistics }) => {
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 (
<div className={classNames(className, styles['statistics-menu-container'])}>
<div className={styles['title']}>
Statistics
</div>
<div className={styles['stats']}>
<div className={styles['stat']}>
<div className={styles['label']}>
Peers
</div>
<div className={styles['value']}>
{ peers }
</div>
</div>
<div className={styles['stat']}>
<div className={styles['label']}>
Speed
</div>
<div className={styles['value']}>
{ speed } MB/s
</div>
</div>
<div className={styles['stat']}>
<div className={styles['label']}>
Completed
</div>
<div className={styles['value']}>
{ completed } %
</div>
</div>
</div>
<div className={styles['info-hash']}>
<div className={styles['label']}>
Info Hash
</div>
<div className={styles['value']}>
{ stream.infoHash }
</div>
</div>
</div>
);
};
StatisticsMenu.propTypes = {
className: PropTypes.string,
stream: PropTypes.object,
statistics: PropTypes.object,
};
module.exports = StatisticsMenu;

View file

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

View file

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

View file

@ -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<string> | null,
selected: {
transportUrl: string,
} | null,
selected: Selected | null,
settings: Loadable<StreamingServerSettings> | null,
torrent: [string, Loadable<Torrent>] | null,
statistics: Loadable<Statistics> | null,
};