diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 0e1ac9295..210abcd3d 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -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(); diff --git a/src/routes/Player/StatisticsMenu/StatisticsMenu.js b/src/routes/Player/StatisticsMenu/StatisticsMenu.js index 078f46074..aa412f579 100644 --- a/src/routes/Player/StatisticsMenu/StatisticsMenu.js +++ b/src/routes/Player/StatisticsMenu/StatisticsMenu.js @@ -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 (
-
- {t('PLAYER_STATISTICS')} -
-
-
-
- {t('PLAYER_PEERS')} -
-
- { peers } -
+
+
+ {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, }; };