This commit is contained in:
AK 2026-07-25 19:27:38 +03:00 committed by GitHub
commit fe8e9997c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 422 additions and 56 deletions

View file

@ -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();

View file

@ -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,

View file

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

View 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' };
};

View file

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