diff --git a/src/routes/Player/Player.js b/src/routes/Player/Player.js index 30c92ccf8a..1f1394b98e 100644 --- a/src/routes/Player/Player.js +++ b/src/routes/Player/Player.js @@ -63,8 +63,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 078f46074c..b4997d0c2f 100644 --- a/src/routes/Player/StatisticsMenu/StatisticsMenu.js +++ b/src/routes/Player/StatisticsMenu/StatisticsMenu.js @@ -4,58 +4,243 @@ 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 { useToast } = require('stremio/common'); +const { formatDuration } = require('../streamQuality'); 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', labelFallback: 'Checking…', verdictKey: 'PLAYER_QUALITY_VERDICT_CHECKING', verdictFallback: 'Checking stream quality…' }, + 'cached': { tone: 'good', labelKey: 'PLAYER_QUALITY_GOOD', labelFallback: 'Good', verdictKey: 'PLAYER_QUALITY_VERDICT_CACHED', verdictFallback: 'Fully downloaded. It will not buffer.' }, + 'good': { tone: 'good', labelKey: 'PLAYER_QUALITY_GOOD', labelFallback: 'Good', verdictKey: 'PLAYER_QUALITY_VERDICT_GOOD', verdictFallback: 'Smooth playback. No pauses expected.' }, + 'fair': { tone: 'fair', labelKey: 'PLAYER_QUALITY_FAIR', labelFallback: 'Fair', verdictKey: 'PLAYER_QUALITY_VERDICT_FAIR', verdictFallback: 'Playing steadily. Should be fine.' }, + 'poor': { tone: 'poor', labelKey: 'PLAYER_QUALITY_POOR', labelFallback: 'Poor', verdictKey: 'PLAYER_QUALITY_VERDICT_POOR', verdictFallback: 'Playback may pause to keep loading.' }, + 'limited': { tone: 'neutral', labelKey: 'PLAYER_QUALITY_LIMITED', labelFallback: 'Limited', verdictKey: 'PLAYER_QUALITY_VERDICT_LIMITED', verdictFallback: 'Playing on this device. A full quality read is not available here.' }, +}; + +const StatisticsMenu = React.memo(React.forwardRef(({ className, quality, bufferAheadSeconds, keepingUp, peers, speed, completed, infoHash }, ref) => { const { t } = useTranslation(); + const toast = useToast(); + const [detailsOpen, setDetailsOpen] = React.useState(false); + const status = quality && quality.status ? quality.status : 'checking'; + const state = QUALITY[status] || QUALITY.checking; + const ready = status !== 'checking'; + const cached = status === 'cached'; + const score = Math.max(0, Math.min(100, quality && typeof quality.score === 'number' ? quality.score : 0)); + 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 onCopyInfoHash = React.useCallback(() => { + if (!infoHash) { + return; + } + navigator.clipboard.writeText(infoHash) + .then(() => { + toast.show({ + type: 'success', + title: t('COPIED', { defaultValue: 'Copied' }), + message: t('PLAYER_INFO_HASH_COPIED', { defaultValue: 'Info hash copied' }), + timeout: 3000 + }); + }) + .catch((e) => { + console.error(e); + toast.show({ + type: 'error', + title: t('ERROR', { defaultValue: 'Error' }), + message: infoHash, + timeout: 3000 + }); + }); + }, [infoHash, t, toast]); + const onCopyKeyDown = React.useCallback((event) => { + if (event.key === ' ' || event.key === 'Spacebar') { + event.preventDefault(); + onCopyInfoHash(); + } + }, [onCopyInfoHash]); + + const bufferKnown = Number.isFinite(bufferAheadSeconds); + const bufferValue = cached ? + t('PLAYER_SIGNAL_BUFFER_VALUE_CACHED', { defaultValue: 'Whole title' }) + : + bufferKnown ? + formatDuration(bufferAheadSeconds) + : + t('PLAYER_SIGNAL_BUFFER_VALUE_UNKNOWN', { defaultValue: 'Not available' }); + const bufferMeaning = cached ? + t('PLAYER_SIGNAL_BUFFER_MEANING_CACHED', { defaultValue: 'plays without buffering' }) + : + bufferKnown ? + t('PLAYER_SIGNAL_BUFFER_MEANING', { defaultValue: 'safe to watch before it loads more' }) + : + ''; + + const speedMeaning = cached ? + '' + : + keepingUp === true ? + t('PLAYER_SIGNAL_SPEED_MEANING_OK', { defaultValue: 'faster than you’re watching' }) + : + keepingUp === false ? + t('PLAYER_SIGNAL_SPEED_MEANING_SLOW', { defaultValue: 'slower than you’re watching' }) + : + ''; + return (
-
- {t('PLAYER_STATISTICS')} -
-
-
-
- {t('PLAYER_PEERS')} -
-
- { peers } -
+
+
+ {t('PLAYER_STREAM_QUALITY', { defaultValue: 'Stream quality' })}
-
-
- {t('PLAYER_SPEED')} -
-
- {`${speed} ${t('MB_S')}`} -
-
-
-
- {t('PLAYER_COMPLETED')} -
-
- { Math.min(completed, 100) } % -
+
+ {t(state.labelKey, { defaultValue: state.labelFallback })}
-
-
- {t('PLAYER_INFO_HASH')} -
-
- { infoHash } -
+ { + status !== 'limited' ? +
+
+
+
+
+ {t('PLAYER_QUALITY_POOR', { defaultValue: 'Poor' })} + {t('PLAYER_QUALITY_FAIR', { defaultValue: 'Fair' })} + {t('PLAYER_QUALITY_GOOD', { defaultValue: 'Good' })} +
+
+ : + null + } +
+ {t(state.verdictKey, { defaultValue: state.verdictFallback })}
+ { + ready ? + +
+
+
+
+
+ {t('PLAYER_SIGNAL_SOURCES', { defaultValue: 'Sources' })} +
+
+ { peers } +
+
+
+ {t('PLAYER_SIGNAL_SOURCES_MEANING', { defaultValue: 'where your video streams from' })} +
+
+
+
+
+ {t('PLAYER_SIGNAL_BUFFER', { defaultValue: 'Buffer' })} +
+
+ { bufferValue } +
+
+ { + bufferMeaning ? +
+ { bufferMeaning } +
+ : + null + } +
+
+
+
+ {t('PLAYER_SIGNAL_SPEED', { defaultValue: 'Speed' })} +
+
+ { + cached ? + t('PLAYER_SIGNAL_SPEED_VALUE_CACHED', { defaultValue: 'Not needed' }) + : + `${speed} ${t('MB_S', { defaultValue: 'MB/s' })}` + } +
+
+ { + speedMeaning ? +
+ { speedMeaning } +
+ : + null + } +
+
+
+
+ +
+
+
+
+
+ {t('PLAYER_COMPLETED', { defaultValue: 'Completed' })} +
+
+ { Math.min(completed, 100) } % +
+
+
+
+ {t('PLAYER_INFO_HASH', { defaultValue: 'Info hash' })} +
+ +
+
+
+
+
+ + : + null + }
); })); StatisticsMenu.propTypes = { className: PropTypes.string, + quality: PropTypes.shape({ + status: PropTypes.string, + score: PropTypes.number, + }), + bufferAheadSeconds: 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 eef9dc3227..6542c11a0e 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,240 @@ 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: 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; + } + } + } + + .meter { + flex: none; + display: flex; + flex-direction: column; + gap: 0.4rem; + } + + .meter-track { + flex: none; + height: 0.5rem; + border-radius: 0.25rem; + background-color: @color-surface-light5-10; + overflow: hidden; + + .meter-fill { + height: 100%; + border-radius: 0.25rem; + background-color: var(--primary-foreground-color); + transition: width 0.3s ease-out; + + &.tone-good { + background-color: @color-accent3; + } + + &.tone-fair { + background-color: @color-accent5; + } + + &.tone-poor { + background-color: @color-accent1; + } + } + } + + .meter-scale { + flex: none; + display: flex; + flex-direction: row; + justify-content: space-between; + font-size: 0.75rem; + color: var(--primary-foreground-color); + opacity: 0.5; + } + + .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: 1rem; - .stat { - flex: auto; + .signal { + flex: none; display: flex; - flex-direction: row; - gap: 0.5rem; + 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; + } } } - .info-hash { - flex: auto; + .technical { + flex: none; display: flex; flex-direction: column; - gap: 0.5rem; + + .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 { + align-self: flex-start; + font-family: monospace; + font-size: 0.85rem; + color: var(--primary-foreground-color); + word-break: break-all; + cursor: pointer; + padding: 0.4rem 0.6rem; + border-radius: 0.4rem; + background-color: @color-surface-light5-10; + transition: background-color 150ms ease; + + &:hover { + background-color: @color-surface-light5-20; + } + } + } + } + } +} + +@media (prefers-reduced-motion: reduce) { + .statistics-menu-container { + .meter-fill, + .chevron, + .collapsible, + .collapsible-inner { + transition: none; + } } -} \ No newline at end of file +} diff --git a/src/routes/Player/streamQuality.js b/src/routes/Player/streamQuality.js new file mode 100644 index 0000000000..91fddd3ff3 --- /dev/null +++ b/src/routes/Player/streamQuality.js @@ -0,0 +1,92 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +const BUFFER_TARGET_SECONDS = 60; +const SOURCE_TARGET_PEERS = 8; +const BUFFER_WEIGHT = 70; +const KEEP_WEIGHT = 20; +const SOURCE_WEIGHT = 10; +const QUALITY_GOOD = 70; +const QUALITY_FAIR = 35; + +const bufferAhead = (buffered, time) => { + if (!Number.isFinite(buffered) || !Number.isFinite(time)) { + return null; + } + return Math.max(0, (buffered - time) / 1000); +}; + +const averageBitrate = (streamLen, duration) => { + if (!Number.isFinite(streamLen) || !Number.isFinite(duration) || streamLen <= 0 || duration <= 0) { + return null; + } + return streamLen / (duration / 1000); +}; + +const isKeepingUp = (downloadSpeed, streamLen, duration) => { + const bitrate = averageBitrate(streamLen, duration); + if (bitrate === null || !Number.isFinite(downloadSpeed)) { + return null; + } + return downloadSpeed >= bitrate; +}; + +const deriveStreamQuality = ({ ready, cached, bufferAheadSeconds, keepingUp, peers }) => { + if (!ready) { + return { status: 'checking', score: 0 }; + } + if (cached) { + return { status: 'cached', score: 100 }; + } + const bufferKnown = Number.isFinite(bufferAheadSeconds); + const keepKnown = typeof keepingUp === 'boolean'; + if (!bufferKnown && !keepKnown) { + return { status: 'limited', score: 0 }; + } + const signals = []; + if (bufferKnown) { + signals.push([BUFFER_WEIGHT, Math.min(1, Math.max(0, bufferAheadSeconds) / BUFFER_TARGET_SECONDS)]); + } + if (keepKnown) { + signals.push([KEEP_WEIGHT, keepingUp ? 1 : 0]); + } + if (Number.isFinite(peers)) { + signals.push([SOURCE_WEIGHT, Math.min(1, Math.max(0, peers) / SOURCE_TARGET_PEERS)]); + } + const totalWeight = signals.reduce((sum, signal) => sum + signal[0], 0); + const earned = signals.reduce((sum, signal) => sum + signal[0] * signal[1], 0); + const score = Math.max(0, Math.min(99, Math.round((earned / totalWeight) * 100))); + const status = score >= QUALITY_GOOD ? 'good' : score >= QUALITY_FAIR ? 'fair' : 'poor'; + return { status, score }; +}; + +const formatDuration = (seconds) => { + if (!Number.isFinite(seconds) || seconds <= 0) { + return '0 sec'; + } + const total = Math.floor(seconds); + if (total < 60) { + return `${total} sec`; + } + const minutes = Math.floor(total / 60); + if (minutes < 60) { + return `${minutes} min`; + } + const hours = Math.floor(minutes / 60); + const remainder = minutes % 60; + return remainder ? `${hours} h ${remainder} min` : `${hours} h`; +}; + +module.exports = { + BUFFER_TARGET_SECONDS, + SOURCE_TARGET_PEERS, + BUFFER_WEIGHT, + KEEP_WEIGHT, + SOURCE_WEIGHT, + QUALITY_GOOD, + QUALITY_FAIR, + bufferAhead, + averageBitrate, + isKeepingUp, + deriveStreamQuality, + formatDuration, +}; diff --git a/src/routes/Player/useStatistics.js b/src/routes/Player/useStatistics.js index 75a2888b72..1ffbe1fac9 100644 --- a/src/routes/Player/useStatistics.js +++ b/src/routes/Player/useStatistics.js @@ -2,8 +2,19 @@ const React = require('react'); const { useCore } = require('stremio/core'); +const streamQuality = require('./streamQuality'); -const useStatistics = (player, streamingServer) => { +const MB = 1024 * 1024; + +const readinessScore = (statistics) => { + const peerScore = Math.min(1, statistics.peers / 8) * 20; + const minDownload = Math.min(8 * MB, Math.max(2 * MB, statistics.streamLen * 0.008)); + const downloadedScore = Math.min(1, statistics.downloaded / minDownload) * 70; + const speedScore = Math.min(1, statistics.downloadSpeed / (1 * MB)) * 10; + return Math.min(99, peerScore + downloadedScore + speedScore); +}; + +const useStatistics = (player, streamingServer, video) => { const core = useCore(); const [progress, setProgress] = React.useState(0); @@ -51,18 +62,31 @@ const useStatistics = (player, streamingServer) => { 0; }, [statistics]); - React.useEffect(() => { - statistics && setProgress(() => { - const MB = 1024 * 1024; - const peerScore = Math.min(1, statistics.peers / 8) * 20; + const ready = React.useMemo(() => { + if (!statistics) { + return false; + } + return !statistics.infoHash || `${statistics.infoHash}`.toLowerCase() === `${infoHash}`.toLowerCase(); + }, [statistics, infoHash]); - const minDownload = Math.min(8 * MB, Math.max(2 * MB, statistics.streamLen * 0.008)); - const downloadedScore = Math.min(1, statistics.downloaded / minDownload) * 70; + const cached = React.useMemo(() => { + return !!statistics && statistics.streamProgress >= 1; + }, [statistics]); + + const bufferAheadSeconds = React.useMemo(() => { + return streamQuality.bufferAhead(video?.state?.buffered, video?.state?.time); + }, [video?.state?.buffered, video?.state?.time]); - const speedScore = Math.min(1, statistics.downloadSpeed / (1 * MB)) * 10; + const keepingUp = React.useMemo(() => { + return streamQuality.isKeepingUp(statistics?.downloadSpeed, statistics?.streamLen, video?.state?.duration); + }, [statistics, video?.state?.duration]); - return Math.min(99, peerScore + downloadedScore + speedScore); - }); + const quality = React.useMemo(() => { + return streamQuality.deriveStreamQuality({ ready, cached, bufferAheadSeconds, keepingUp, peers }); + }, [ready, cached, bufferAheadSeconds, keepingUp, peers]); + + React.useEffect(() => { + statistics && setProgress(() => readinessScore(statistics)); }, [statistics]); const getStatistics = React.useCallback(() => { @@ -99,6 +123,9 @@ const useStatistics = (player, streamingServer) => { speed, completed, progress, + quality, + bufferAheadSeconds, + keepingUp, }; }; diff --git a/tests/streamQuality.spec.js b/tests/streamQuality.spec.js new file mode 100644 index 0000000000..e0a24dc7ec --- /dev/null +++ b/tests/streamQuality.spec.js @@ -0,0 +1,313 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +const { + bufferAhead, + averageBitrate, + isKeepingUp, + deriveStreamQuality, + formatDuration, +} = require('../src/routes/Player/streamQuality'); + +describe('streamQuality', () => { + describe('bufferAhead (millisecond inputs, seconds output)', () => { + it('typical mid-playback: 75s buffered while watching at 15s', () => { + expect(bufferAhead(75000, 15000)).toBe(60); + }); + it('just opened: 1s buffered at time 0', () => { + expect(bufferAhead(1000, 0)).toBe(1); + }); + it('exactly at the 60s target from time 0', () => { + expect(bufferAhead(60000, 0)).toBe(60); + }); + it('buffered equals time (caught up to the edge) is 0', () => { + expect(bufferAhead(30000, 30000)).toBe(0); + }); + it('buffered one ms behind time clamps to 0', () => { + expect(bufferAhead(15000, 15001)).toBe(0); + }); + it('seek past buffer (playhead ahead of buffered) clamps to 0', () => { + expect(bufferAhead(0, 60000)).toBe(0); + }); + it('huge buffer: fully cached 2h movie', () => { + expect(bufferAhead(7200000, 0)).toBe(7200); + }); + it('negative time (malformed) still yields positive ahead', () => { + expect(bufferAhead(0, -60000)).toBe(60); + }); + it('null buffered is unavailable, returns null (Chromecast path)', () => { + expect(bufferAhead(null, 0)).toBe(null); + }); + it('undefined time is unavailable, returns null', () => { + expect(bufferAhead(60000, undefined)).toBe(null); + }); + it('both undefined (first frame) returns null', () => { + expect(bufferAhead(undefined, undefined)).toBe(null); + }); + it('string inputs are unavailable, return null', () => { + expect(bufferAhead('60000', '0')).toBe(null); + }); + it('NaN buffered returns null (closes the typeof hole)', () => { + expect(bufferAhead(NaN, 0)).toBe(null); + }); + it('NaN time returns null', () => { + expect(bufferAhead(100000, NaN)).toBe(null); + }); + it('Infinity buffered (live stream) returns null, not Infinity', () => { + expect(bufferAhead(Infinity, 0)).toBe(null); + }); + it('both Infinity returns null, not NaN', () => { + expect(bufferAhead(Infinity, Infinity)).toBe(null); + }); + }); + + describe('averageBitrate (millisecond duration, bytes/sec output)', () => { + it('1KB over 1s is 1000 B/s', () => { + expect(averageBitrate(1000, 1000)).toBe(1000); + }); + it('700MB movie over 1h', () => { + expect(averageBitrate(700000000, 3600000)).toBeCloseTo(194444.44, 1); + }); + it('50GB debrid file over 2h', () => { + expect(averageBitrate(50000000000, 7200000)).toBeCloseTo(6944444.44, 1); + }); + it('minimal 1 byte over 1s', () => { + expect(averageBitrate(1, 1000)).toBe(1); + }); + it('streamLen 0 (unknown size) is null', () => { + expect(averageBitrate(0, 1000)).toBe(null); + }); + it('duration 0 (live/unknown) is null', () => { + expect(averageBitrate(1000, 0)).toBe(null); + }); + it('duration null is null', () => { + expect(averageBitrate(1000, null)).toBe(null); + }); + it('streamLen null is null', () => { + expect(averageBitrate(null, 1000)).toBe(null); + }); + it('both undefined is null', () => { + expect(averageBitrate(undefined, undefined)).toBe(null); + }); + it('NaN streamLen is null', () => { + expect(averageBitrate(NaN, 1000)).toBe(null); + }); + it('NaN duration is null', () => { + expect(averageBitrate(1000, NaN)).toBe(null); + }); + it('Infinity duration (live) is null, not 0', () => { + expect(averageBitrate(1000, Infinity)).toBe(null); + }); + it('Infinity streamLen is null', () => { + expect(averageBitrate(Infinity, 1000)).toBe(null); + }); + it('negative streamLen is null', () => { + expect(averageBitrate(-1000, 1000)).toBe(null); + }); + it('negative duration is null', () => { + expect(averageBitrate(1000, -1000)).toBe(null); + }); + }); + + describe('isKeepingUp', () => { + it('speed exactly equals bitrate is true (inclusive)', () => { + expect(isKeepingUp(1000, 1000, 1000)).toBe(true); + }); + it('speed just below bitrate is false', () => { + expect(isKeepingUp(999, 1000, 1000)).toBe(false); + }); + it('fast debrid far above bitrate is true', () => { + expect(isKeepingUp(10000000, 700000000, 3600000)).toBe(true); + }); + it('slow trickle below high bitrate is false', () => { + expect(isKeepingUp(50000, 700000000, 3600000)).toBe(false); + }); + it('streamLen 0 is null (no bitrate to compare)', () => { + expect(isKeepingUp(1000, 0, 1000)).toBe(null); + }); + it('duration 0 (live) is null', () => { + expect(isKeepingUp(1000, 1000, 0)).toBe(null); + }); + it('downloadSpeed null is null even with a valid bitrate', () => { + expect(isKeepingUp(null, 1000, 1000)).toBe(null); + }); + it('downloadSpeed undefined is null', () => { + expect(isKeepingUp(undefined, 1000, 1000)).toBe(null); + }); + it('downloadSpeed 0 (dead torrent) vs positive bitrate is false', () => { + expect(isKeepingUp(0, 1000, 1000)).toBe(false); + }); + it('both streamLen and duration falsy is null', () => { + expect(isKeepingUp(5000, 0, 0)).toBe(null); + }); + it('negative speed vs positive bitrate is false', () => { + expect(isKeepingUp(-500, 1000, 1000)).toBe(false); + }); + it('NaN downloadSpeed is null (cannot make a claim)', () => { + expect(isKeepingUp(NaN, 1000, 1000)).toBe(null); + }); + it('Infinity downloadSpeed is null (cannot make a claim)', () => { + expect(isKeepingUp(Infinity, 1000, 1000)).toBe(null); + }); + it('live Infinity duration is null', () => { + expect(isKeepingUp(1, 1000, Infinity)).toBe(null); + }); + }); + + describe('deriveStreamQuality (scores only available signals, redistributes weight)', () => { + it('not ready is checking/0 regardless of other fields', () => { + expect(deriveStreamQuality({ ready: false })).toEqual({ status: 'checking', score: 0 }); + }); + it('empty object (ready undefined) is checking/0, no throw', () => { + expect(deriveStreamQuality({})).toEqual({ status: 'checking', score: 0 }); + }); + it('ready false beats cached true (ready checked first)', () => { + expect(deriveStreamQuality({ ready: false, cached: true })).toEqual({ status: 'checking', score: 0 }); + }); + it('fully cached is cached/100', () => { + expect(deriveStreamQuality({ ready: true, cached: true })).toEqual({ status: 'cached', score: 100 }); + }); + it('cached ignores zero buffer and peers', () => { + expect(deriveStreamQuality({ ready: true, cached: true, bufferAheadSeconds: 0, peers: 0 })).toEqual({ status: 'cached', score: 100 }); + }); + + it('all maxed caps at 99 not 100', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 60, keepingUp: true, peers: 8 })).toEqual({ status: 'good', score: 99 }); + }); + it('full buffer + keeping up, no peers is good/90', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 60, keepingUp: true, peers: 0 })).toEqual({ status: 'good', score: 90 }); + }); + it('full buffer alone (0 peers, keepingUp unknown) redistributes to good/88', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 60, peers: 0 })).toEqual({ status: 'good', score: 88 }); + }); + it('mid buffer 30s + keeping up + 8 peers is fair/65', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 30, keepingUp: true, peers: 8 })).toEqual({ status: 'fair', score: 65 }); + }); + it('mid buffer 30s + not keeping up + 8 peers is fair/45', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 30, keepingUp: false, peers: 8 })).toEqual({ status: 'fair', score: 45 }); + }); + it('truly dead torrent (0 buffer, not keeping up, 0 peers) is poor/0', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 0, keepingUp: false, peers: 0 })).toEqual({ status: 'poor', score: 0 }); + }); + it('peers is the only signal (no buffer, no keepingUp) is limited, not a graded verdict', () => { + expect(deriveStreamQuality({ ready: true, cached: false, peers: 0 })).toEqual({ status: 'limited', score: 0 }); + }); + + it('cast healthy (buffer unavailable, keeping up, 8 peers) is good/99, not a false poor', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: null, keepingUp: true, peers: 8 })).toEqual({ status: 'good', score: 99 }); + }); + it('cast unhealthy (buffer unavailable, not keeping up, 0 peers) is poor/0', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: null, keepingUp: false, peers: 0 })).toEqual({ status: 'poor', score: 0 }); + }); + it('cast mediocre (buffer unavailable, keeping up, 0 peers) is fair/67', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: null, keepingUp: true, peers: 0 })).toEqual({ status: 'fair', score: 67 }); + }); + it('peers only, healthy swarm (buffer + keepingUp both unavailable) is limited, not a false good', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: null, keepingUp: null, peers: 8 })).toEqual({ status: 'limited', score: 0 }); + }); + it('peers only, weak swarm (buffer + keepingUp both unavailable) is limited, not a false poor', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: null, keepingUp: null, peers: 3 })).toEqual({ status: 'limited', score: 0 }); + }); + it('a single playback-evidence signal (keepingUp) lifts it out of limited', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: null, keepingUp: true, peers: 0 })).toEqual({ status: 'fair', score: 67 }); + }); + it('a single playback-evidence signal (buffer) lifts it out of limited', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 30, keepingUp: null, peers: null })).toEqual({ status: 'fair', score: 50 }); + }); + + it('unknown keepingUp does not dock: full buffer + 8 peers is good/99', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 60, keepingUp: null, peers: 8 })).toEqual({ status: 'good', score: 99 }); + }); + it('unknown keepingUp, mid buffer 30s + 8 peers is fair/56', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 30, keepingUp: null, peers: 8 })).toEqual({ status: 'fair', score: 56 }); + }); + + it('NaN buffer drops out; healthy source signals still score good/99', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: NaN, keepingUp: true, peers: 8 })).toEqual({ status: 'good', score: 99 }); + }); + it('live cast (Infinity buffer, unknown keepingUp) has no playback evidence, is limited', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: Infinity, keepingUp: null, peers: 8 })).toEqual({ status: 'limited', score: 0 }); + }); + it('no measurable signal at all is limited (no playback evidence)', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: null, keepingUp: null, peers: NaN })).toEqual({ status: 'limited', score: 0 }); + }); + + it('negative buffer (seek past) is clamped, with 0 peers is poor/0', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: -50, peers: 0 })).toEqual({ status: 'poor', score: 0 }); + }); + it('negative peers never push the score below 0', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 0, keepingUp: false, peers: -5 })).toEqual({ status: 'poor', score: 0 }); + }); + it('huge over-target buffer and peers still clamp to 99', () => { + expect(deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: 9999, keepingUp: true, peers: 9999 })).toEqual({ status: 'good', score: 99 }); + }); + it('score is always within 0..99 for arbitrary inputs', () => { + const r = deriveStreamQuality({ ready: true, cached: false, bufferAheadSeconds: -9e9, keepingUp: false, peers: -9e9 }); + expect(r.score).toBeGreaterThanOrEqual(0); + expect(r.score).toBeLessThanOrEqual(99); + expect(Number.isFinite(r.score)).toBe(true); + }); + }); + + describe('formatDuration (never over-reports — floors)', () => { + it('typical seconds', () => { + expect(formatDuration(30)).toBe('30 sec'); + }); + it('just under a minute', () => { + expect(formatDuration(59)).toBe('59 sec'); + }); + it('exactly 60s is 1 min', () => { + expect(formatDuration(60)).toBe('1 min'); + }); + it('89s floors to 1 min (never over-reports)', () => { + expect(formatDuration(89)).toBe('1 min'); + }); + it('90s floors to 1 min', () => { + expect(formatDuration(90)).toBe('1 min'); + }); + it('119s floors to 1 min', () => { + expect(formatDuration(119)).toBe('1 min'); + }); + it('120s is 2 min', () => { + expect(formatDuration(120)).toBe('2 min'); + }); + it('3599s is 59 min (never rounds up to 1 h)', () => { + expect(formatDuration(3599)).toBe('59 min'); + }); + it('exactly 3600s is 1 h', () => { + expect(formatDuration(3600)).toBe('1 h'); + }); + it('3660s is 1 h 1 min', () => { + expect(formatDuration(3660)).toBe('1 h 1 min'); + }); + it('5400s is 1 h 30 min', () => { + expect(formatDuration(5400)).toBe('1 h 30 min'); + }); + it('7200s is 2 h exactly', () => { + expect(formatDuration(7200)).toBe('2 h'); + }); + it('exactly 0 is 0 sec', () => { + expect(formatDuration(0)).toBe('0 sec'); + }); + it('negative is 0 sec', () => { + expect(formatDuration(-5)).toBe('0 sec'); + }); + it('null is 0 sec', () => { + expect(formatDuration(null)).toBe('0 sec'); + }); + it('undefined is 0 sec', () => { + expect(formatDuration(undefined)).toBe('0 sec'); + }); + it('NaN is 0 sec', () => { + expect(formatDuration(NaN)).toBe('0 sec'); + }); + it('Infinity is 0 sec, never "Infinity h"', () => { + expect(formatDuration(Infinity)).toBe('0 sec'); + }); + it('sub-second floors to 0 sec', () => { + expect(formatDuration(0.4)).toBe('0 sec'); + }); + it('fractional seconds floor', () => { + expect(formatDuration(30.9)).toBe('30 sec'); + }); + }); +});