+
+ {t('PLAYER_STREAM_QUALITY')}
-
-
- {t('PLAYER_SPEED')}
-
-
- {`${speed} ${t('MB_S')}`}
-
-
-
-
- {t('PLAYER_COMPLETED')}
-
-
- { Math.min(completed, 100) } %
-
+
+ {t(state.labelKey)}
-
-
- {t('PLAYER_INFO_HASH')}
-
-
- { infoHash }
-
+
+ {t(verdictKey)}
+ {
+ ready ?
+
+
+
+
+
+
+ {t('PLAYER_SIGNAL_SOURCES')}
+
+
+ { peers }
+
+
+
+ {t('PLAYER_SIGNAL_SOURCES_MEANING')}
+
+
+
+
+
+ {t('PLAYER_SIGNAL_BUFFER')}
+
+
+ { bufferValue }
+
+
+ {
+ !cached && bufferKnown ?
+
+ {t('PLAYER_SIGNAL_BUFFER_MEANING')}
+
+ :
+ null
+ }
+
+
+
+
+ {t('PLAYER_SPEED')}
+
+
+ {`${speed} ${t('MB_S')}`}
+
+
+ {
+ speedMeaning ?
+
+ { speedMeaning }
+
+ :
+ null
+ }
+
+
+
+
+
+
+
+
+
+
+ {t('PLAYER_COMPLETED')}
+
+
+ { Math.min(completed, 100) } %
+
+
+
+
+ {t('PLAYER_INFO_HASH')}
+
+
+ { infoHash }
+
+
+
+
+
+
+
+ :
+ null
+ }
);
}));
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,
diff --git a/src/routes/Player/StatisticsMenu/styles.less b/src/routes/Player/StatisticsMenu/styles.less
index eef9dc322..e6adea37c 100644
--- a/src/routes/Player/StatisticsMenu/styles.less
+++ b/src/routes/Player/StatisticsMenu/styles.less
@@ -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;
+ }
+ }
}
-}
\ No newline at end of file
+
+ .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;
+ }
+ }
+}
diff --git a/src/routes/Player/streamQuality.ts b/src/routes/Player/streamQuality.ts
new file mode 100644
index 000000000..731dab751
--- /dev/null
+++ b/src/routes/Player/streamQuality.ts
@@ -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' };
+};
diff --git a/src/routes/Player/useStatistics.js b/src/routes/Player/useStatistics.js
index 75a2888b7..6ede6204c 100644
--- a/src/routes/Player/useStatistics.js
+++ b/src/routes/Player/useStatistics.js
@@ -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,
};
};