+
+ {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');
+ });
+ });
+});