mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-08-17 16:45:48 +00:00
Merge 8d386fc422 into daf74b0ec9
This commit is contained in:
commit
fe8e9997c7
5 changed files with 422 additions and 56 deletions
|
|
@ -68,8 +68,8 @@ const Player = () => {
|
|||
const [player, videoParamsChanged, streamStateChanged, timeChanged, seek, pausedChanged, ended, nextVideo] = usePlayer(urlParams);
|
||||
const [settings] = useSettings();
|
||||
const streamingServer = useStreamingServer();
|
||||
const statistics = useStatistics(player, streamingServer);
|
||||
const video = useVideo();
|
||||
const statistics = useStatistics(player, streamingServer, video);
|
||||
const routeFocused = useRouteFocused();
|
||||
const platform = usePlatform();
|
||||
const toast = useToast();
|
||||
|
|
|
|||
|
|
@ -4,58 +4,180 @@ const React = require('react');
|
|||
const { useTranslation } = require('react-i18next');
|
||||
const classNames = require('classnames');
|
||||
const PropTypes = require('prop-types');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { Button } = require('stremio/components');
|
||||
const formatTime = require('../ControlBar/SeekBar/formatTime');
|
||||
const styles = require('./styles.less');
|
||||
|
||||
const StatisticsMenu = React.memo(React.forwardRef(({ className, peers, speed, completed, infoHash }, ref) => {
|
||||
const QUALITY = {
|
||||
'checking': { tone: 'neutral', labelKey: 'PLAYER_QUALITY_CHECKING' },
|
||||
'limited': { tone: 'neutral', labelKey: 'PLAYER_QUALITY_LIMITED' },
|
||||
'good': { tone: 'good', labelKey: 'PLAYER_QUALITY_GOOD' },
|
||||
'fair': { tone: 'fair', labelKey: 'PLAYER_QUALITY_FAIR' },
|
||||
'poor': { tone: 'poor', labelKey: 'PLAYER_QUALITY_POOR' },
|
||||
};
|
||||
|
||||
const VERDICTS = {
|
||||
'checking': 'PLAYER_QUALITY_VERDICT_CHECKING',
|
||||
'no-buffer-data': 'PLAYER_QUALITY_VERDICT_LIMITED',
|
||||
'cached': 'PLAYER_QUALITY_VERDICT_CACHED',
|
||||
'keeping-up': 'PLAYER_QUALITY_VERDICT_KEEPING_UP',
|
||||
'buffer-healthy': 'PLAYER_QUALITY_VERDICT_BUFFER_HEALTHY',
|
||||
'draining': 'PLAYER_QUALITY_VERDICT_DRAINING',
|
||||
'buffer-low': 'PLAYER_QUALITY_VERDICT_BUFFER_LOW',
|
||||
};
|
||||
|
||||
const StatisticsMenu = React.memo(React.forwardRef(({ className, quality, qualityReason, bufferRunway, keepingUp, peers, speed, completed, infoHash }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
||||
const state = QUALITY[quality] || QUALITY['checking'];
|
||||
const verdictKey = VERDICTS[qualityReason] || VERDICTS['checking'];
|
||||
const ready = quality !== 'checking';
|
||||
const cached = qualityReason === 'cached';
|
||||
const bufferKnown = Number.isFinite(bufferRunway);
|
||||
|
||||
const onMouseDown = React.useCallback((event) => {
|
||||
event.nativeEvent.statisticsMenuClosePrevented = true;
|
||||
}, []);
|
||||
const toggleDetails = React.useCallback(() => {
|
||||
setDetailsOpen((open) => !open);
|
||||
}, []);
|
||||
const onToggleKeyDown = React.useCallback((event) => {
|
||||
if (event.key === ' ' || event.key === 'Spacebar') {
|
||||
event.preventDefault();
|
||||
setDetailsOpen((open) => !open);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const bufferValue = cached ?
|
||||
t('PLAYER_SIGNAL_BUFFER_VALUE_CACHED')
|
||||
:
|
||||
bufferKnown ?
|
||||
formatTime(bufferRunway * 1000)
|
||||
:
|
||||
t('PLAYER_SIGNAL_BUFFER_VALUE_UNKNOWN');
|
||||
|
||||
const speedMeaning = !cached && keepingUp === true ?
|
||||
t('PLAYER_SIGNAL_SPEED_MEANING_OK')
|
||||
:
|
||||
!cached && keepingUp === false ?
|
||||
t('PLAYER_SIGNAL_SPEED_MEANING_SLOW')
|
||||
:
|
||||
'';
|
||||
|
||||
return (
|
||||
<div ref={ref} className={classNames(className, styles['statistics-menu-container'])} onMouseDown={onMouseDown}>
|
||||
<div className={styles['title']}>
|
||||
{t('PLAYER_STATISTICS')}
|
||||
</div>
|
||||
<div className={styles['stats']}>
|
||||
<div className={styles['stat']}>
|
||||
<div className={styles['label']}>
|
||||
{t('PLAYER_PEERS')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ peers }
|
||||
</div>
|
||||
<div className={styles['header']}>
|
||||
<div className={styles['title']}>
|
||||
{t('PLAYER_STREAM_QUALITY')}
|
||||
</div>
|
||||
<div className={styles['stat']}>
|
||||
<div className={styles['label']}>
|
||||
{t('PLAYER_SPEED')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{`${speed} ${t('MB_S')}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['stat']}>
|
||||
<div className={styles['label']}>
|
||||
{t('PLAYER_COMPLETED')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ Math.min(completed, 100) } %
|
||||
</div>
|
||||
<div className={classNames(styles['status-label'], styles[`tone-${state.tone}`])}>
|
||||
{t(state.labelKey)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['info-hash']}>
|
||||
<div className={styles['label']}>
|
||||
{t('PLAYER_INFO_HASH')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ infoHash }
|
||||
</div>
|
||||
<div className={styles['verdict']}>
|
||||
{t(verdictKey)}
|
||||
</div>
|
||||
{
|
||||
ready ?
|
||||
<React.Fragment>
|
||||
<div className={styles['divider']} />
|
||||
<div className={styles['signals']}>
|
||||
<div className={styles['signal']}>
|
||||
<div className={styles['signal-head']}>
|
||||
<div className={styles['label']}>
|
||||
{t('PLAYER_SIGNAL_SOURCES')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ peers }
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['meaning']}>
|
||||
{t('PLAYER_SIGNAL_SOURCES_MEANING')}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['signal']}>
|
||||
<div className={styles['signal-head']}>
|
||||
<div className={styles['label']}>
|
||||
{t('PLAYER_SIGNAL_BUFFER')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ bufferValue }
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
!cached && bufferKnown ?
|
||||
<div className={styles['meaning']}>
|
||||
{t('PLAYER_SIGNAL_BUFFER_MEANING')}
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className={styles['signal']}>
|
||||
<div className={styles['signal-head']}>
|
||||
<div className={styles['label']}>
|
||||
{t('PLAYER_SPEED')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{`${speed} ${t('MB_S')}`}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
speedMeaning ?
|
||||
<div className={styles['meaning']}>
|
||||
{ speedMeaning }
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['divider']} />
|
||||
<div className={styles['technical']}>
|
||||
<Button className={styles['technical-toggle']} onClick={toggleDetails} onKeyDown={onToggleKeyDown} role={'button'} aria-expanded={detailsOpen} aria-controls={'statistics-technical-details'}>
|
||||
<Icon className={classNames(styles['chevron'], { [styles['open']]: detailsOpen })} name={'caret-down'} aria-hidden={true} />
|
||||
<div className={styles['technical-label']}>
|
||||
{t('PLAYER_MORE_DETAILS')}
|
||||
</div>
|
||||
</Button>
|
||||
<div className={classNames(styles['collapsible'], { [styles['open']]: detailsOpen })}>
|
||||
<div className={styles['collapsible-inner']}>
|
||||
<div className={styles['technical-content']} id={'statistics-technical-details'} aria-hidden={!detailsOpen}>
|
||||
<div className={styles['detail-row']}>
|
||||
<div className={styles['label']}>
|
||||
{t('PLAYER_COMPLETED')}
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ Math.min(completed, 100) } %
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles['info-hash']}>
|
||||
<div className={styles['label']}>
|
||||
{t('PLAYER_INFO_HASH')}
|
||||
</div>
|
||||
<div className={styles['info-hash-value']}>
|
||||
{ infoHash }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}));
|
||||
|
||||
StatisticsMenu.propTypes = {
|
||||
className: PropTypes.string,
|
||||
quality: PropTypes.string,
|
||||
qualityReason: PropTypes.string,
|
||||
bufferRunway: PropTypes.number,
|
||||
keepingUp: PropTypes.bool,
|
||||
peers: PropTypes.number,
|
||||
speed: PropTypes.number,
|
||||
completed: PropTypes.number,
|
||||
|
|
|
|||
|
|
@ -5,16 +5,10 @@
|
|||
.statistics-menu-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
gap: 1rem;
|
||||
width: 30rem;
|
||||
padding: 1.5rem;
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-weight: 700;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: none;
|
||||
font-weight: 500;
|
||||
|
|
@ -28,26 +22,186 @@
|
|||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex: auto;
|
||||
.header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
.stat {
|
||||
flex: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
.title {
|
||||
flex: none;
|
||||
font-weight: 700;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.status-label {
|
||||
flex: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--primary-foreground-color);
|
||||
|
||||
&.tone-good {
|
||||
color: @color-accent3;
|
||||
}
|
||||
|
||||
&.tone-fair {
|
||||
color: @color-accent5;
|
||||
}
|
||||
|
||||
&.tone-poor {
|
||||
color: @color-accent1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-hash {
|
||||
flex: auto;
|
||||
.verdict {
|
||||
flex: none;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex: none;
|
||||
height: 1px;
|
||||
background-color: @color-surface-light5-10;
|
||||
}
|
||||
|
||||
.signals {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 1rem;
|
||||
|
||||
.signal {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
|
||||
.signal-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.meaning {
|
||||
flex: none;
|
||||
font-size: 0.85rem;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.technical {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.technical-toggle {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.5;
|
||||
transition: transform 0.2s ease-out;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.technical-label {
|
||||
flex: none;
|
||||
font-weight: 500;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.chevron,
|
||||
.technical-label {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.25s ease-out;
|
||||
|
||||
&.open {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.collapsible-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease-out;
|
||||
}
|
||||
|
||||
&.open .collapsible-inner {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.technical-content {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding-top: 0.75rem;
|
||||
|
||||
.detail-row {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-hash {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
.info-hash-value {
|
||||
flex: none;
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--primary-foreground-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.statistics-menu-container {
|
||||
.chevron,
|
||||
.collapsible,
|
||||
.collapsible-inner {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
src/routes/Player/streamQuality.ts
Normal file
62
src/routes/Player/streamQuality.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
export const LOW_BUFFER_RUNWAY_SECONDS = 10;
|
||||
|
||||
export type StreamQualityStatus = 'checking' | 'limited' | 'good' | 'fair' | 'poor';
|
||||
|
||||
export type StreamQualityReason = 'checking' | 'no-buffer-data' | 'cached' | 'keeping-up' | 'buffer-healthy' | 'draining' | 'buffer-low';
|
||||
|
||||
export type StreamQuality = {
|
||||
status: StreamQualityStatus,
|
||||
reason: StreamQualityReason,
|
||||
};
|
||||
|
||||
type StreamQualityInput = {
|
||||
ready: boolean,
|
||||
cached: boolean,
|
||||
bufferRunway: number | null,
|
||||
keepingUp: boolean | null,
|
||||
};
|
||||
|
||||
const playbackRate = (playbackSpeed: unknown): number => {
|
||||
return typeof playbackSpeed === 'number' && Number.isFinite(playbackSpeed) && playbackSpeed > 0 ? playbackSpeed : 1;
|
||||
};
|
||||
|
||||
export const bufferRunwaySeconds = (buffered: unknown, time: unknown, playbackSpeed: unknown): number | null => {
|
||||
if (typeof buffered !== 'number' || !Number.isFinite(buffered) || typeof time !== 'number' || !Number.isFinite(time)) {
|
||||
return null;
|
||||
}
|
||||
return Math.floor(Math.max(0, buffered - time) / 1000 / playbackRate(playbackSpeed));
|
||||
};
|
||||
|
||||
export const isKeepingUp = (downloadSpeed: unknown, streamLen: unknown, duration: unknown, playbackSpeed: unknown): boolean | null => {
|
||||
if (typeof downloadSpeed !== 'number' || !Number.isFinite(downloadSpeed) ||
|
||||
typeof streamLen !== 'number' || !Number.isFinite(streamLen) || streamLen <= 0 ||
|
||||
typeof duration !== 'number' || !Number.isFinite(duration) || duration <= 0) {
|
||||
return null;
|
||||
}
|
||||
const requiredDownloadSpeed = (streamLen / (duration / 1000)) * playbackRate(playbackSpeed);
|
||||
return downloadSpeed >= requiredDownloadSpeed;
|
||||
};
|
||||
|
||||
export const deriveStreamQuality = ({ ready, cached, bufferRunway, keepingUp }: StreamQualityInput): StreamQuality => {
|
||||
if (!ready) {
|
||||
return { status: 'checking', reason: 'checking' };
|
||||
}
|
||||
if (cached) {
|
||||
return { status: 'good', reason: 'cached' };
|
||||
}
|
||||
if (bufferRunway === null) {
|
||||
return { status: 'limited', reason: 'no-buffer-data' };
|
||||
}
|
||||
if (keepingUp === true) {
|
||||
return { status: 'good', reason: 'keeping-up' };
|
||||
}
|
||||
if (bufferRunway < LOW_BUFFER_RUNWAY_SECONDS) {
|
||||
return { status: 'poor', reason: 'buffer-low' };
|
||||
}
|
||||
if (keepingUp === false) {
|
||||
return { status: 'fair', reason: 'draining' };
|
||||
}
|
||||
return { status: 'good', reason: 'buffer-healthy' };
|
||||
};
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
const React = require('react');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { bufferRunwaySeconds, isKeepingUp, deriveStreamQuality } = require('./streamQuality');
|
||||
|
||||
const useStatistics = (player, streamingServer) => {
|
||||
const useStatistics = (player, streamingServer, video) => {
|
||||
const core = useCore();
|
||||
|
||||
const [progress, setProgress] = React.useState(0);
|
||||
|
|
@ -51,6 +52,29 @@ const useStatistics = (player, streamingServer) => {
|
|||
0;
|
||||
}, [statistics]);
|
||||
|
||||
const ready = React.useMemo(() => {
|
||||
if (!statistics) {
|
||||
return false;
|
||||
}
|
||||
return !statistics.infoHash || `${statistics.infoHash}`.toLowerCase() === `${infoHash}`.toLowerCase();
|
||||
}, [statistics, infoHash]);
|
||||
|
||||
const cached = React.useMemo(() => {
|
||||
return !!statistics && statistics.streamProgress >= 1;
|
||||
}, [statistics]);
|
||||
|
||||
const bufferRunway = React.useMemo(() => {
|
||||
return bufferRunwaySeconds(video.state.buffered, video.state.time, video.state.playbackSpeed);
|
||||
}, [video.state.buffered, video.state.time, video.state.playbackSpeed]);
|
||||
|
||||
const keepingUp = React.useMemo(() => {
|
||||
return isKeepingUp(statistics?.downloadSpeed, statistics?.streamLen, video.state.duration, video.state.playbackSpeed);
|
||||
}, [statistics, video.state.duration, video.state.playbackSpeed]);
|
||||
|
||||
const quality = React.useMemo(() => {
|
||||
return deriveStreamQuality({ ready, cached, bufferRunway, keepingUp });
|
||||
}, [ready, cached, bufferRunway, keepingUp]);
|
||||
|
||||
React.useEffect(() => {
|
||||
statistics && setProgress(() => {
|
||||
const MB = 1024 * 1024;
|
||||
|
|
@ -99,6 +123,10 @@ const useStatistics = (player, streamingServer) => {
|
|||
speed,
|
||||
completed,
|
||||
progress,
|
||||
quality: quality.status,
|
||||
qualityReason: quality.reason,
|
||||
bufferRunway,
|
||||
keepingUp,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue