feat(playback): stream-info popover in the player overlay (#1578)

This commit is contained in:
Lars Emig authored and GitHub committed 2026-09-10 21:33:41 +02:00
1 parent bad8a0991e
commit e76447975b
72 files changed
+4355 -163

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
---
type: feature
area: playback
highlight: See what a stream really is while it plays
---
The player overlay now shows stream information: resolution, playback and source
frame rates, stream and codec bitrates, audio details, buffer and dropped frames.
Available in built-in web players and experimental frame-copy Embedded MPV.
Unknown or unavailable values stay hidden; measured frame rates reflect dropped frames and
stalls.
+3 -1
View File
@@ -39,7 +39,9 @@ jobs:
electron-e2e-tests:
name: Electron E2E on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 45
# macOS's sequential Electron suite exceeded 45m while still passing
# tests; retain the full suite with room for setup and retries.
timeout-minutes: ${{ matrix.os == 'macos-latest' && 60 || 45 }}
env:
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1'
NX_SKIP_NX_CACHE: true
+16
View File
@@ -383,6 +383,22 @@ Key files:
## Shared Player Controls
- Stream info popover: an `info` button in the top-right corner of the shared
controls overlay shows live stream data — resolution + aspect ratio, measured
frame rate, video/audio codec and bitrate, audio channels and sample rate,
container, buffer, and dropped frames. Rendered only when the engine reports
something (`capabilities.streamStats`), sampled once a second and only while
the popover is open. Web engines read the `<video>` element plus the active
HLS/Shaka/VHS rendition; embedded MPV gets the numbers from observed mpv
properties on the session snapshot (frame-copy engine only — the native-view
dock does not mount the shared controls). See
`docs/architecture/player-controls-contract.md` ("Stream info popover") and
`docs/architecture/embedded-mpv-native.md` ("Stream Stats Properties").
Web FPS excludes dropped frames and uses a fresh measurement window on open;
nominal FPS and aggregate rendition bitrate have separate rows. Unknown
video bitrate is never filled with aggregate bandwidth. MPV clears dimensions
on a new file and clears individual diagnostics on unavailable-property events.
- The Embedded MPV native-view dock follows app theme tokens as a solid app
surface, including Material icon-button disabled states. Over-video loading,
stalled and feedback overlays keep a paired light-on-dark palette. Video
+15
View File
@@ -1090,6 +1090,21 @@ app as a real argument, so it is not an option.
external fallback only for clear transferable DASH; PWA capability and
KODIPROP DRM still suppress it. Details in
`docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
- Stream info popover: an `info` button in the top-right corner of the shared
controls overlay shows live stream data — resolution + aspect ratio, measured
frame rate, video/audio codec and bitrate, audio channels and sample rate,
container, buffer, and dropped frames. Rendered only when the engine reports
something (`capabilities.streamStats`), sampled once a second and only while
the popover is open. Web engines read the `<video>` element plus the active
HLS/Shaka/VHS rendition; embedded MPV gets the numbers from observed mpv
properties on the session snapshot (frame-copy engine only — the native-view
dock does not mount the shared controls). See
`docs/architecture/player-controls-contract.md` ("Stream info popover") and
`docs/architecture/embedded-mpv-native.md` ("Stream Stats Properties").
Web FPS excludes dropped frames and uses a fresh measurement window on open;
nominal FPS and aggregate rendition bitrate have separate rows. Unknown
video bitrate is never filled with aggregate bandwidth. MPV clears dimensions
on a new file and clears individual diagnostics on unavailable-property events.
- External players: MPV, VLC (via IPC to Electron backend)
- Display sleep during playback: `PlaybackKeepAwakeService`
(`apps/web/src/app/services/playback-keep-awake.service.ts`) watches every
@@ -2,7 +2,10 @@ import assert = require('node:assert/strict');
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, it } from 'node:test';
import { isMeaningfulNativePlaybackSnapshot } from './embedded-mpv-frame-copy-packaged-fixtures';
import {
createWavFixture,
isMeaningfulNativePlaybackSnapshot,
} from './embedded-mpv-frame-copy-packaged-fixtures';
import './embedded-mpv-frame-copy-packaged-filesystem.tests';
import { resolvePackagedElectronLaunchArgs } from './electron-test-fixtures';
import packagedPlaywrightConfig from '../playwright.packaged.config';
@@ -25,6 +28,20 @@ describe('packaged Electron launch arguments', () => {
});
});
describe('audio-only MPV fixture', () => {
it('declares the full mono PCM body and sample rate', () => {
const wav = createWavFixture();
assert.equal(wav.toString('ascii', 0, 4), 'RIFF');
assert.equal(wav.toString('ascii', 8, 16), 'WAVEfmt ');
assert.equal(wav.readUInt32LE(4), wav.length - 8);
assert.equal(wav.readUInt16LE(20), 1);
assert.equal(wav.readUInt16LE(22), 1);
assert.equal(wav.readUInt32LE(24), 8000);
assert.equal(wav.readUInt32LE(40), wav.length - 44);
assert.equal(wav.length - 44, 8000 * 2 * 30);
});
});
describe('native-view playback proof', () => {
it('requires the loaded URL, a playing or paused state, and positive duration', () => {
const expectedUrl = 'http://127.0.0.1:3210/fixture.y4m';
@@ -62,6 +62,26 @@ function createY4mFixture(): Buffer {
return Buffer.concat(chunks);
}
/** Silent mono PCM lets the real MPV smoke revoke audio observations. */
export function createWavFixture(): Buffer {
const sampleRate = 8000;
const dataBytes = sampleRate * 2 * 30;
const body = Buffer.alloc(44 + dataBytes);
body.write('RIFF', 0);
body.writeUInt32LE(36 + dataBytes, 4);
body.write('WAVEfmt ', 8);
body.writeUInt32LE(16, 16);
body.writeUInt16LE(1, 20); // PCM
body.writeUInt16LE(1, 22); // mono
body.writeUInt32LE(sampleRate, 24);
body.writeUInt32LE(sampleRate * 2, 28);
body.writeUInt16LE(2, 32);
body.writeUInt16LE(16, 34);
body.write('data', 36);
body.writeUInt32LE(dataBytes, 40);
return body;
}
async function listen(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const onError = (error: Error) => {
@@ -91,9 +111,15 @@ async function closeServer(server: Server): Promise<void> {
});
}
export async function createLocalMediaServer(): Promise<LocalMediaServer> {
const body = createY4mFixture();
const resourcePath = '/embedded-mpv-frame-copy-smoke.y4m';
export async function createLocalMediaServer(options?: {
body: Buffer;
resourcePath: string;
contentType: string;
}): Promise<LocalMediaServer> {
const body = options?.body ?? createY4mFixture();
const resourcePath =
options?.resourcePath ?? '/embedded-mpv-frame-copy-smoke.y4m';
const contentType = options?.contentType ?? 'video/x-yuv4mpeg';
const server = createServer((request, response) => {
const pathname = (request.url ?? '').split('?')[0];
if (pathname !== resourcePath) {
@@ -106,7 +132,7 @@ export async function createLocalMediaServer(): Promise<LocalMediaServer> {
response.writeHead(200, {
'Accept-Ranges': 'bytes',
'Content-Length': body.length,
'Content-Type': 'video/x-yuv4mpeg',
'Content-Type': contentType,
});
response.end(request.method === 'HEAD' ? undefined : body);
return;
@@ -133,7 +159,7 @@ export async function createLocalMediaServer(): Promise<LocalMediaServer> {
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${body.length}`,
'Content-Type': 'video/x-yuv4mpeg',
'Content-Type': contentType,
});
response.end(
request.method === 'HEAD'
@@ -5,13 +5,14 @@ import {
test,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import { writeFileSync } from 'fs';
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import {
assertNativeFallbackPrerequisites,
cleanupPackagedFrameCopySmoke,
closeAndWaitForExit,
createLocalMediaServer,
createWavFixture,
getEmbeddedMpvSupport,
getLatestSession,
installEmbeddedMpvSessionCapture,
@@ -121,6 +122,10 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
await installFrameCanvasAndSessionCapture(launchedFrameCopyApp);
const created = await launchedFrameCopyApp.mainWindow.evaluate(
async () => {
// Decode audio without requiring a sound device on CI.
await window.electron.updateSettings({
embeddedMpvExtraOptions: 'ao=null',
});
return window.electron.createEmbeddedMpvSession(
{ x: 0, y: 0, width: 320, height: 180 },
'Packaged frame-copy smoke',
@@ -320,6 +325,142 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
{ timeout: 10000 }
)
.toBe('paused');
// These values must originate in real libmpv property observations,
// survive helper JSON/preload mapping, and change with the source.
await expect
.poll(
async () =>
(
await getLatestSession(
launchedFrameCopyApp,
created.id
)
)?.stats
)
.toMatchObject({
videoCodec: expect.any(String),
container: expect.any(String),
droppedFrames: expect.any(Number),
});
const originalStats = (await getLatestSession(
launchedFrameCopyApp,
created.id
))!.stats!;
const alternateMedia = await createLocalMediaServer({
body: readFileSync(
join(
__dirname,
'../../web-e2e/src/fixtures/playback/episode.webm'
)
),
resourcePath: '/stream-stats-switch.webm',
contentType: 'video/webm',
});
media = {
url: mediaServer.url,
close: async () => {
await Promise.all([
mediaServer.close(),
alternateMedia.close(),
]);
},
};
const audioMedia = await createLocalMediaServer({
body: createWavFixture(),
resourcePath: '/stream-stats-audio.wav',
contentType: 'audio/wav',
});
const videoMedia = media;
media = {
url: videoMedia.url,
close: async () => {
await Promise.all([videoMedia.close(), audioMedia.close()]);
},
};
// mpv uses its built-in demuxer name "mkv" for WebM.
for (const [streamUrl, videoCodec, container] of [
[alternateMedia.url, 'vp8', 'mkv'],
[
mediaServer.url,
originalStats.videoCodec,
originalStats.container,
],
] as const) {
await launchedFrameCopyApp.mainWindow.evaluate(
async ({ sessionId, streamUrl }) => {
await window.electron.loadEmbeddedMpvPlayback(
sessionId,
{
streamUrl,
title: 'Stream stats source switch',
isLive: false,
}
);
},
{ sessionId: created.id, streamUrl }
);
await expect
.poll(
() =>
getLatestSession(launchedFrameCopyApp, created.id),
{ timeout: 15000 }
)
.toMatchObject({
streamUrl,
stats: { videoCodec, container },
});
}
// Audio-only replacements must not retain the previous video size.
await launchedFrameCopyApp.mainWindow.evaluate(
async ({ sessionId, streamUrl }) => {
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
streamUrl,
title: 'Audio-only statistics',
isLive: false,
});
},
{ sessionId: created.id, streamUrl: audioMedia.url }
);
await expect
.poll(
() => getLatestSession(launchedFrameCopyApp, created.id),
{ timeout: 15000 }
)
.toMatchObject({
streamUrl: audioMedia.url,
videoWidth: 0,
videoHeight: 0,
stats: {
audioCodec: 'pcm_s16le',
audioChannels: expect.any(String),
audioSampleRateHz: 8000,
},
});
// Revoking the only audio track emits MPV_FORMAT_NONE without a
// START_FILE reset. Previously these values stayed cached forever.
await launchedFrameCopyApp.mainWindow.evaluate(
async (sessionId) => {
await window.electron.setEmbeddedMpvAudioTrack(
sessionId,
-1
);
},
created.id
);
await expect
.poll(async () => {
const stats = (
await getLatestSession(launchedFrameCopyApp, created.id)
)?.stats;
return {
codec: stats?.audioCodec ?? null,
channels: stats?.audioChannels ?? null,
sampleRate: stats?.audioSampleRateHz ?? null,
};
})
.toEqual({ codec: null, channels: null, sampleRate: null });
await launchedFrameCopyApp.mainWindow.evaluate(
async (sessionId) => {
window.electron.detachEmbeddedMpvFrameView?.();
@@ -0,0 +1,96 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
channelItemByTitle,
closeElectronApp,
expect,
goToDashboard,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
test,
} from './electron-test-fixtures';
import { expectOverlayContrastOnWhite } from './theme-contrast';
const streamHost = 'https://stream-info-fixture.test';
const media = readFileSync(
join(__dirname, '../../web-e2e/src/fixtures/playback/episode.webm')
);
const playlist = [
'#EXTM3U',
'#EXTINF:-1 tvg-id="info-one" group-title="News",Positive News TV',
`${streamHost}/one.webm`,
].join('\n');
/**
* The popover's numbers come from the live `<video>` element, so this is the
* one check that cannot be made in a unit test: a real playing stream must
* produce real rows.
*/
test('@playback @electron stream info popover reports the playing stream', async ({
dataDir,
}) => {
// A fresh profile already defaults to Video.js with shared controls on.
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
try {
const playlistPath = join(dataDir, 'stream-info.m3u');
writeFileSync(playlistPath, playlist);
await page.route(`${streamHost}/**`, (route) =>
route.fulfill({
status: 200,
contentType: 'video/webm',
body: media,
})
);
await goToDashboard(page);
await importM3uPlaylistFromNativeDialog(app, playlistPath);
await page.waitForURL(/\/workspace\/playlists\/.+/);
await channelItemByTitle(page, 'Positive News TV').first().click();
const video = page.locator('app-web-player-view video');
await expect(video).toHaveCount(1);
await expect
.poll(() =>
video.evaluate(
(element: HTMLVideoElement) =>
element.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
)
)
.toBe(true);
const controls = page.locator('app-player-controls');
const infoButton = controls.getByTestId(
'player-controls-stream-info-button'
);
await expect(infoButton).toBeVisible();
// The icon sits over live video, so it is only readable because of the
// top scrim. Assert that against a white backing — the worst case the
// scrim exists for, and something no unit test can see (the SCSS is
// not loaded there).
const scrim = controls.getByTestId('player-controls-top-scrim');
await expect(scrim).toHaveCount(1);
expect(
await scrim.evaluate(
(element) => getComputedStyle(element).backgroundImage
)
).toContain('gradient');
await infoButton.hover();
await expectOverlayContrastOnWhite(controls, infoButton);
await infoButton.click();
const panel = controls.getByTestId('player-controls-stream-info-panel');
await expect(panel).toBeVisible();
// The element knows its own size as soon as it has data, so the
// resolution row is the one that must always be there.
const rows = panel.locator('.player-controls__stats-row');
await expect.poll(() => rows.first().innerText()).toMatch(/\d+ × \d+/);
// Closing must dismiss the panel (and with it the sampling loop).
await infoButton.click();
await expect(panel).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
@@ -61,6 +61,57 @@ struct SnapshotState {
double volume = 1; /* 0..1 */
int64_t videoWidth = 0; /* mpv dwidth/dheight; 0 = unknown */
int64_t videoHeight = 0;
/* Stream diagnostics for the player's info popover. Sentinels mean
* "mpv has not answered yet" so a row is omitted rather than shown as 0. */
double fps = 0; /* estimated-vf-fps; <=0 = unknown */
double videoBitrate = 0; /* bits/s; <=0 = unknown */
double audioBitrate = 0;
std::string videoCodec; /* video-format, e.g. "h264" */
std::string audioCodec; /* audio-codec-name, e.g. "aac" */
std::string audioChannels; /* audio-params/channels, e.g. "5.1" */
int64_t audioSampleRate = 0; /* audio-params/samplerate; 0 unknown */
std::string container; /* file-format, e.g. "mpegts" */
double cacheDuration = -1; /* demuxer-cache-duration; <0 = unknown */
int64_t droppedFrames = -1; /* frame-drop-count; <0 = unknown */
int64_t decoderDroppedFrames = -1;
// MPV_FORMAT_NONE revokes just this observation, not unrelated values.
bool clearUnavailableStreamProperty(const std::string& name) {
if (name == "estimated-vf-fps") { fps = 0; }
else if (name == "video-bitrate") { videoBitrate = 0; }
else if (name == "audio-bitrate") { audioBitrate = 0; }
else if (name == "video-format") { videoCodec.clear(); }
else if (name == "audio-codec-name") { audioCodec.clear(); }
else if (name == "audio-params/channels") { audioChannels.clear(); }
else if (name == "audio-params/samplerate") { audioSampleRate = 0; }
else if (name == "file-format") { container.clear(); }
else if (name == "demuxer-cache-duration") { cacheDuration = -1; }
else if (name == "frame-drop-count") { droppedFrames = -1; }
else if (name == "decoder-frame-drop-count") { decoderDroppedFrames = -1; }
else if (name == "dwidth") { videoWidth = 0; }
else if (name == "dheight") { videoHeight = 0; }
else { return false; }
return true;
}
/* Every new file starts from "nothing reported yet": the popover must
* never show the previous stream's codec or bitrate. Kept next to the
* fields so adding one cannot forget the reset. */
void clearStreamStats() {
videoWidth = 0;
videoHeight = 0;
fps = 0;
videoBitrate = 0;
audioBitrate = 0;
videoCodec.clear();
audioCodec.clear();
audioChannels.clear();
audioSampleRate = 0;
container.clear();
cacheDuration = -1;
droppedFrames = -1;
decoderDroppedFrames = -1;
}
std::string streamUrl;
std::vector<TrackInfo> audioTracks;
int64_t selectedAudioTrackId = -1;
@@ -135,6 +186,50 @@ std::string tracksJson(const std::vector<TrackInfo>& tracks,
return out;
}
/* Compose the `stats` object of the snapshot event. Always emitted, even when
* empty: the adapter merges snapshot events field by field, so an omitted key
* would leave the previous stream's numbers in place across a channel switch.
* Caller holds g_state.mutex. */
std::string composeStatsJsonLocked() {
const SnapshotState& s = g_state.snapshot;
JsonWriter stats;
if (s.fps > 0) {
stats.num("fps", s.fps);
}
if (s.videoBitrate > 0) {
stats.num("videoBitrateBps", s.videoBitrate);
}
if (s.audioBitrate > 0) {
stats.num("audioBitrateBps", s.audioBitrate);
}
if (!s.videoCodec.empty()) {
stats.str("videoCodec", s.videoCodec);
}
if (!s.audioCodec.empty()) {
stats.str("audioCodec", s.audioCodec);
}
if (!s.audioChannels.empty()) {
stats.str("audioChannels", s.audioChannels);
}
if (s.audioSampleRate > 0) {
stats.num("audioSampleRateHz", (double)s.audioSampleRate);
}
if (!s.container.empty()) {
stats.str("container", s.container);
}
if (s.cacheDuration >= 0) {
stats.num("bufferedAheadSeconds", s.cacheDuration);
}
if (s.droppedFrames >= 0 || s.decoderDroppedFrames >= 0) {
/* mpv counts render-time and decoder drops separately; the popover
* shows the one number a viewer cares about. */
const int64_t dropped = std::max<int64_t>(0, s.droppedFrames) +
std::max<int64_t>(0, s.decoderDroppedFrames);
stats.num("droppedFrames", (double)dropped);
}
return stats.finish();
}
/* Compose the snapshot event. Caller holds g_state.mutex. */
std::string composeSnapshotLocked() {
const SnapshotState& s = g_state.snapshot;
@@ -142,10 +237,9 @@ std::string composeSnapshotLocked() {
writer.str("event", "snapshot");
writer.str("status", s.status);
writer.num("positionSeconds", std::max(0.0, s.positionSeconds));
if (s.videoWidth > 0 && s.videoHeight > 0) {
writer.num("videoWidth", (double)s.videoWidth);
writer.num("videoHeight", (double)s.videoHeight);
}
// Snapshots merge in main; publish zero so an old size cannot survive.
writer.num("videoWidth", (double)s.videoWidth);
writer.num("videoHeight", (double)s.videoHeight);
if (s.durationSeconds >= 0) {
writer.num("durationSeconds", s.durationSeconds);
} else {
@@ -169,6 +263,7 @@ std::string composeSnapshotLocked() {
}
writer.num("playbackSpeed", s.playbackSpeed);
writer.str("aspectOverride", s.aspectOverride);
writer.raw("stats", composeStatsJsonLocked());
JsonWriter recording;
recording.boolean("active", s.recordingActive);
if (!s.recordingTargetPath.empty())
@@ -302,6 +397,16 @@ void handlePropertyChange(const mpv_event_property& property) {
const std::string name = property.name ? property.name : "";
SnapshotState& s = g_state.snapshot;
if (property.format == MPV_FORMAT_NONE) {
if (s.clearUnavailableStreamProperty(name)) {
if (name == "dwidth" || name == "dheight") {
applyRenderSizeLocked();
}
markDirtyLocked();
}
return;
}
if (name == "time-pos" && property.format == MPV_FORMAT_DOUBLE &&
property.data) {
s.positionSeconds = *static_cast<double*>(property.data);
@@ -360,6 +465,41 @@ void handlePropertyChange(const mpv_event_property& property) {
s.videoHeight = value;
}
applyRenderSizeLocked();
} else if (name == "estimated-vf-fps" &&
property.format == MPV_FORMAT_DOUBLE && property.data) {
s.fps = *static_cast<double*>(property.data);
} else if (name == "video-bitrate" &&
property.format == MPV_FORMAT_DOUBLE && property.data) {
s.videoBitrate = *static_cast<double*>(property.data);
} else if (name == "audio-bitrate" &&
property.format == MPV_FORMAT_DOUBLE && property.data) {
s.audioBitrate = *static_cast<double*>(property.data);
} else if (name == "demuxer-cache-duration" &&
property.format == MPV_FORMAT_DOUBLE && property.data) {
s.cacheDuration = *static_cast<double*>(property.data);
} else if (name == "audio-params/samplerate" &&
property.format == MPV_FORMAT_INT64 && property.data) {
s.audioSampleRate = *static_cast<int64_t*>(property.data);
} else if (name == "frame-drop-count" &&
property.format == MPV_FORMAT_INT64 && property.data) {
s.droppedFrames = *static_cast<int64_t*>(property.data);
} else if (name == "decoder-frame-drop-count" &&
property.format == MPV_FORMAT_INT64 && property.data) {
s.decoderDroppedFrames = *static_cast<int64_t*>(property.data);
} else if ((name == "video-format" || name == "audio-codec-name" ||
name == "audio-params/channels" || name == "file-format") &&
property.format == MPV_FORMAT_STRING && property.data) {
const char* value = *static_cast<char**>(property.data);
const std::string text = value ? value : "";
if (name == "video-format") {
s.videoCodec = text;
} else if (name == "audio-codec-name") {
s.audioCodec = text;
} else if (name == "audio-params/channels") {
s.audioChannels = text;
} else if (name == "file-format") {
s.container = text;
}
} else if (name == "video-aspect-override" &&
property.format == MPV_FORMAT_STRING && property.data) {
const char* value = *static_cast<char**>(property.data);
@@ -390,6 +530,7 @@ void runMpvEventLoop() {
s.status = "loading";
s.error.clear();
s.engineError = false;
s.clearStreamStats();
s.audioTracks.clear();
s.selectedAudioTrackId = -1;
s.subtitleTracks.clear();
@@ -921,6 +1062,26 @@ int main(int argc, char** argv) {
mpv_observe_property(g_state.mpv, 11, "eof-reached", MPV_FORMAT_FLAG);
mpv_observe_property(g_state.mpv, 12, "dwidth", MPV_FORMAT_INT64);
mpv_observe_property(g_state.mpv, 13, "dheight", MPV_FORMAT_INT64);
/* Stream diagnostics behind the player's info popover. They change often,
* but the snapshot emit stays throttled, so this adds no event traffic. */
mpv_observe_property(g_state.mpv, 14, "estimated-vf-fps",
MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 15, "video-bitrate", MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 16, "audio-bitrate", MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 17, "video-format", MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 18, "audio-codec-name",
MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 19, "file-format", MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 20, "demuxer-cache-duration",
MPV_FORMAT_DOUBLE);
mpv_observe_property(g_state.mpv, 21, "frame-drop-count",
MPV_FORMAT_INT64);
mpv_observe_property(g_state.mpv, 22, "decoder-frame-drop-count",
MPV_FORMAT_INT64);
mpv_observe_property(g_state.mpv, 23, "audio-params/channels",
MPV_FORMAT_STRING);
mpv_observe_property(g_state.mpv, 24, "audio-params/samplerate",
MPV_FORMAT_INT64);
g_state.pipeline.onGenerationChanged = [](const std::string& name,
int width, int height,
@@ -110,6 +110,55 @@ struct SessionSnapshot {
int64_t selectedSubtitleTrackId = -1;
double playbackSpeed = 1.0;
std::string aspectOverride = "no";
// Stream diagnostics for the player's info popover. Sentinels mean "mpv
// has not answered yet", so an unknown value omits its row instead of
// reporting a zero.
double fps = 0.0; // estimated-vf-fps; <=0 = unknown
double videoBitrate = 0.0; // bits/s; <=0 = unknown
double audioBitrate = 0.0;
std::string videoCodec; // video-format, e.g. "h264"
std::string audioCodec; // audio-codec-name, e.g. "aac"
std::string audioChannels; // audio-params/channels, e.g. "5.1"
int64_t audioSampleRate = 0; // audio-params/samplerate; 0 unknown
std::string container; // file-format, e.g. "mpegts"
double cacheDuration = -1.0; // demuxer-cache-duration; <0 = unknown
int64_t droppedFrames = -1; // frame-drop-count; <0 = unknown
int64_t decoderDroppedFrames = -1;
// MPV_FORMAT_NONE revokes just this observation, not unrelated values.
bool clearUnavailableStreamProperty(const std::string& name) {
if (name == "estimated-vf-fps") { fps = 0; }
else if (name == "video-bitrate") { videoBitrate = 0; }
else if (name == "audio-bitrate") { audioBitrate = 0; }
else if (name == "video-format") { videoCodec.clear(); }
else if (name == "audio-codec-name") { audioCodec.clear(); }
else if (name == "audio-params/channels") { audioChannels.clear(); }
else if (name == "audio-params/samplerate") { audioSampleRate = 0; }
else if (name == "file-format") { container.clear(); }
else if (name == "demuxer-cache-duration") { cacheDuration = -1; }
else if (name == "frame-drop-count") { droppedFrames = -1; }
else if (name == "decoder-frame-drop-count") { decoderDroppedFrames = -1; }
else { return false; }
return true;
}
// Every new file starts from "nothing reported yet": the popover must
// never show the previous stream's codec or bitrate. Kept next to the
// fields so adding one cannot forget the reset.
void clearStreamStats()
{
fps = 0.0;
videoBitrate = 0.0;
audioBitrate = 0.0;
videoCodec.clear();
audioCodec.clear();
audioChannels.clear();
audioSampleRate = 0;
container.clear();
cacheDuration = -1.0;
droppedFrames = -1;
decoderDroppedFrames = -1;
}
bool recordingActive = false;
std::string recordingTargetPath;
std::string recordingStartedAt;
@@ -1174,6 +1223,7 @@ void runEventLoop(const std::shared_ptr<Session>& session)
session->snapshot.status = SessionStatus::Loading;
session->snapshot.error.clear();
session->snapshot.engineError = false;
session->snapshot.clearStreamStats();
session->snapshot.audioTracks.clear();
session->snapshot.selectedAudioTrackId = -1;
session->snapshot.subtitleTracks.clear();
@@ -1222,6 +1272,11 @@ void runEventLoop(const std::shared_ptr<Session>& session)
const std::string propertyName = property->name;
if (property->format == MPV_FORMAT_NONE) {
session->snapshot.clearUnavailableStreamProperty(propertyName);
break;
}
if (propertyName == "time-pos" &&
property->format == MPV_FORMAT_DOUBLE &&
property->data) {
@@ -1347,6 +1402,67 @@ void runEventLoop(const std::shared_ptr<Session>& session)
break;
}
if (property->format == MPV_FORMAT_DOUBLE && property->data) {
const double value = *static_cast<double*>(property->data);
if (propertyName == "estimated-vf-fps") {
session->snapshot.fps = value;
break;
}
if (propertyName == "video-bitrate") {
session->snapshot.videoBitrate = value;
break;
}
if (propertyName == "audio-bitrate") {
session->snapshot.audioBitrate = value;
break;
}
if (propertyName == "demuxer-cache-duration") {
session->snapshot.cacheDuration = value;
break;
}
}
if (property->format == MPV_FORMAT_INT64 && property->data) {
const int64_t value =
*static_cast<int64_t*>(property->data);
if (propertyName == "frame-drop-count") {
session->snapshot.droppedFrames = value;
break;
}
if (propertyName == "decoder-frame-drop-count") {
session->snapshot.decoderDroppedFrames = value;
break;
}
if (propertyName == "audio-params/samplerate") {
session->snapshot.audioSampleRate = value;
break;
}
}
if (property->format == MPV_FORMAT_STRING && property->data) {
// MPV_FORMAT_STRING hands over a char**.
const char* const* textValue =
static_cast<char**>(property->data);
const std::string text =
(textValue && *textValue) ? *textValue : "";
if (propertyName == "video-format") {
session->snapshot.videoCodec = text;
break;
}
if (propertyName == "audio-codec-name") {
session->snapshot.audioCodec = text;
break;
}
if (propertyName == "audio-params/channels") {
session->snapshot.audioChannels = text;
break;
}
if (propertyName == "file-format") {
session->snapshot.container = text;
break;
}
}
break;
}
case MPV_EVENT_LOG_MESSAGE: {
@@ -1809,6 +1925,74 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
MPV_FORMAT_STRING
);
mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);
// Stream diagnostics behind the player's info popover. The renderer pulls
// snapshots on its own cadence, so observing these adds no IPC traffic.
mpv_observe_property(
session->handle,
12,
"estimated-vf-fps",
MPV_FORMAT_DOUBLE
);
mpv_observe_property(
session->handle,
13,
"video-bitrate",
MPV_FORMAT_DOUBLE
);
mpv_observe_property(
session->handle,
14,
"audio-bitrate",
MPV_FORMAT_DOUBLE
);
mpv_observe_property(
session->handle,
15,
"video-format",
MPV_FORMAT_STRING
);
mpv_observe_property(
session->handle,
16,
"audio-codec-name",
MPV_FORMAT_STRING
);
mpv_observe_property(
session->handle,
17,
"file-format",
MPV_FORMAT_STRING
);
mpv_observe_property(
session->handle,
18,
"demuxer-cache-duration",
MPV_FORMAT_DOUBLE
);
mpv_observe_property(
session->handle,
19,
"frame-drop-count",
MPV_FORMAT_INT64
);
mpv_observe_property(
session->handle,
20,
"decoder-frame-drop-count",
MPV_FORMAT_INT64
);
mpv_observe_property(
session->handle,
21,
"audio-params/channels",
MPV_FORMAT_STRING
);
mpv_observe_property(
session->handle,
22,
"audio-params/samplerate",
MPV_FORMAT_INT64
);
session->running.store(true);
session->eventThread = std::thread(runEventLoop, session);
@@ -2453,6 +2637,86 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info)
return env.Undefined();
}
/** Serializes the optional `stats` object; omits it when nothing is known. */
void writeStreamStats(
Napi::Env env,
Napi::Object result,
const SessionSnapshot& snapshot
)
{
auto stats = Napi::Object::New(env);
bool hasStats = false;
if (snapshot.fps > 0.0) {
stats.Set("fps", Napi::Number::New(env, snapshot.fps));
hasStats = true;
}
if (snapshot.videoBitrate > 0.0) {
stats.Set(
"videoBitrateBps",
Napi::Number::New(env, snapshot.videoBitrate)
);
hasStats = true;
}
if (snapshot.audioBitrate > 0.0) {
stats.Set(
"audioBitrateBps",
Napi::Number::New(env, snapshot.audioBitrate)
);
hasStats = true;
}
if (!snapshot.videoCodec.empty()) {
stats.Set("videoCodec", Napi::String::New(env, snapshot.videoCodec));
hasStats = true;
}
if (!snapshot.audioCodec.empty()) {
stats.Set("audioCodec", Napi::String::New(env, snapshot.audioCodec));
hasStats = true;
}
if (!snapshot.audioChannels.empty()) {
stats.Set(
"audioChannels",
Napi::String::New(env, snapshot.audioChannels)
);
hasStats = true;
}
if (snapshot.audioSampleRate > 0) {
stats.Set(
"audioSampleRateHz",
Napi::Number::New(
env,
static_cast<double>(snapshot.audioSampleRate)
)
);
hasStats = true;
}
if (!snapshot.container.empty()) {
stats.Set("container", Napi::String::New(env, snapshot.container));
hasStats = true;
}
if (snapshot.cacheDuration >= 0.0) {
stats.Set(
"bufferedAheadSeconds",
Napi::Number::New(env, snapshot.cacheDuration)
);
hasStats = true;
}
if (snapshot.droppedFrames >= 0 || snapshot.decoderDroppedFrames >= 0) {
// mpv counts render-time and decoder drops separately; the popover
// shows the one number a viewer cares about.
const int64_t dropped =
std::max<int64_t>(0, snapshot.droppedFrames) +
std::max<int64_t>(0, snapshot.decoderDroppedFrames);
stats.Set(
"droppedFrames",
Napi::Number::New(env, static_cast<double>(dropped))
);
hasStats = true;
}
if (hasStats) {
result.Set("stats", stats);
}
}
Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
@@ -2562,6 +2826,8 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
"aspectOverride",
Napi::String::New(env, snapshot.aspectOverride)
);
writeStreamStats(env, result, snapshot);
if (snapshot.recordingActive ||
!snapshot.recordingTargetPath.empty() ||
!snapshot.recordingError.empty()) {
@@ -127,6 +127,54 @@ struct SessionSnapshot {
int64_t selectedSubtitleTrackId = -1;
double playbackSpeed = 1.0;
std::string aspectOverride = "no";
/** Stream diagnostics for the player's info popover. Sentinels mean "not
* reported yet", so an unknown value omits its row instead of showing 0. */
double fps = 0.0; /* estimated-vf-fps; <=0 = unknown */
double videoBitrate = 0.0; /* bits/s; <=0 = unknown */
double audioBitrate = 0.0;
std::string videoCodec; /* video-format, e.g. "h264" */
std::string audioCodec; /* audio-codec-name, e.g. "aac" */
std::string audioChannels; /* audio-params/channels, e.g. "5.1" */
int64_t audioSampleRate = 0; /* audio-params/samplerate; 0 unknown */
std::string container; /* file-format, e.g. "mpegts" */
double cacheDuration = -1.0; /* demuxer-cache-duration; <0 unknown */
int64_t droppedFrames = -1; /* frame-drop-count; <0 = unknown */
int64_t decoderDroppedFrames = -1;
// MPV_FORMAT_NONE revokes just this observation, not unrelated values.
bool clearUnavailableStreamProperty(const std::string& name) {
if (name == "estimated-vf-fps") { fps = 0; }
else if (name == "video-bitrate") { videoBitrate = 0; }
else if (name == "audio-bitrate") { audioBitrate = 0; }
else if (name == "video-format") { videoCodec.clear(); }
else if (name == "audio-codec-name") { audioCodec.clear(); }
else if (name == "audio-params/channels") { audioChannels.clear(); }
else if (name == "audio-params/samplerate") { audioSampleRate = 0; }
else if (name == "file-format") { container.clear(); }
else if (name == "demuxer-cache-duration") { cacheDuration = -1; }
else if (name == "frame-drop-count") { droppedFrames = -1; }
else if (name == "decoder-frame-drop-count") { decoderDroppedFrames = -1; }
else { return false; }
return true;
}
/** Every new file starts from "nothing reported yet": the popover must
* never show the previous stream's codec or bitrate. Kept next to the
* fields so adding one cannot forget the reset. */
void clearStreamStats()
{
fps = 0.0;
videoBitrate = 0.0;
audioBitrate = 0.0;
videoCodec.clear();
audioCodec.clear();
audioChannels.clear();
audioSampleRate = 0;
container.clear();
cacheDuration = -1.0;
droppedFrames = -1;
decoderDroppedFrames = -1;
}
bool recordingActive = false;
std::string recordingTargetPath;
std::string recordingStartedAt;
@@ -1002,6 +1050,13 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
queryLinuxMpvInteger(socketPath, "track-list/count");
const auto selectedAudioTrackId = queryLinuxMpvInteger(socketPath, "aid");
// No stream-stats polling here: this backend drives the out-of-process
// native-view engine, whose legacy dock does not mount the shared
// controls that own the stream-info popover, so every property would be
// one socket round trip on the same pass that publishes position, pause
// and EOF for a value nothing can render. The in-process engines get the
// same fields for free from `mpv_observe_property` instead.
int64_t cachedTrackCount = -1;
{
std::lock_guard<std::mutex> lock(session->mutex);
@@ -1442,6 +1497,7 @@ void runEventLoop(std::shared_ptr<Session> session)
case MPV_EVENT_START_FILE:
session->snapshot.status = SessionStatus::Loading;
session->snapshot.error.clear();
session->snapshot.clearStreamStats();
break;
case MPV_EVENT_FILE_LOADED:
session->snapshot.status =
@@ -1477,10 +1533,17 @@ void runEventLoop(std::shared_ptr<Session> session)
case MPV_EVENT_PROPERTY_CHANGE: {
const auto* property =
static_cast<mpv_event_property*>(event->data);
if (!property || !property->name || !property->data) {
if (!property || !property->name) {
break;
}
const std::string name(property->name);
if (property->format == MPV_FORMAT_NONE) {
session->snapshot.clearUnavailableStreamProperty(name);
break;
}
if (!property->data) {
break;
}
if (name == "time-pos" && property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.positionSeconds =
*static_cast<double*>(property->data);
@@ -1546,6 +1609,51 @@ void runEventLoop(std::shared_ptr<Session> session)
*static_cast<char**>(property->data);
session->snapshot.aspectOverride =
value && value[0] ? value : "no";
} else if (name == "estimated-vf-fps" &&
property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.fps =
*static_cast<double*>(property->data);
} else if (name == "video-bitrate" &&
property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.videoBitrate =
*static_cast<double*>(property->data);
} else if (name == "audio-bitrate" &&
property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.audioBitrate =
*static_cast<double*>(property->data);
} else if (name == "demuxer-cache-duration" &&
property->format == MPV_FORMAT_DOUBLE) {
session->snapshot.cacheDuration =
*static_cast<double*>(property->data);
} else if (name == "frame-drop-count" &&
property->format == MPV_FORMAT_INT64) {
session->snapshot.droppedFrames =
*static_cast<int64_t*>(property->data);
} else if (name == "decoder-frame-drop-count" &&
property->format == MPV_FORMAT_INT64) {
session->snapshot.decoderDroppedFrames =
*static_cast<int64_t*>(property->data);
} else if (name == "audio-params/samplerate" &&
property->format == MPV_FORMAT_INT64) {
session->snapshot.audioSampleRate =
*static_cast<int64_t*>(property->data);
} else if ((name == "video-format" ||
name == "audio-codec-name" ||
name == "audio-params/channels" ||
name == "file-format") &&
property->format == MPV_FORMAT_STRING) {
const char* value =
*static_cast<char**>(property->data);
const std::string text = value ? value : "";
if (name == "video-format") {
session->snapshot.videoCodec = text;
} else if (name == "audio-codec-name") {
session->snapshot.audioCodec = text;
} else if (name == "audio-params/channels") {
session->snapshot.audioChannels = text;
} else if (name == "file-format") {
session->snapshot.container = text;
}
}
break;
}
@@ -1786,6 +1894,30 @@ Napi::Value CreateSession(const Napi::CallbackInfo& info)
MPV_FORMAT_STRING
);
mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);
/* Stream diagnostics behind the player's info popover. The renderer pulls
* snapshots on its own cadence, so observing these adds no IPC traffic. */
mpv_observe_property(
session->handle, 12, "estimated-vf-fps", MPV_FORMAT_DOUBLE);
mpv_observe_property(
session->handle, 13, "video-bitrate", MPV_FORMAT_DOUBLE);
mpv_observe_property(
session->handle, 14, "audio-bitrate", MPV_FORMAT_DOUBLE);
mpv_observe_property(
session->handle, 15, "video-format", MPV_FORMAT_STRING);
mpv_observe_property(
session->handle, 16, "audio-codec-name", MPV_FORMAT_STRING);
mpv_observe_property(
session->handle, 17, "file-format", MPV_FORMAT_STRING);
mpv_observe_property(
session->handle, 18, "demuxer-cache-duration", MPV_FORMAT_DOUBLE);
mpv_observe_property(
session->handle, 19, "frame-drop-count", MPV_FORMAT_INT64);
mpv_observe_property(
session->handle, 20, "decoder-frame-drop-count", MPV_FORMAT_INT64);
mpv_observe_property(
session->handle, 21, "audio-params/channels", MPV_FORMAT_STRING);
mpv_observe_property(
session->handle, 22, "audio-params/samplerate", MPV_FORMAT_INT64);
session->running.store(true);
session->eventThread = std::thread(runEventLoop, session);
@@ -2375,6 +2507,77 @@ void writeTracks(
result.Set(key, output);
}
/** Serializes the optional `stats` object; omits it when nothing is known. */
void writeStreamStats(
Napi::Env env,
Napi::Object result,
const SessionSnapshot& snapshot)
{
auto stats = Napi::Object::New(env);
bool any = false;
if (snapshot.fps > 0.0) {
stats.Set("fps", Napi::Number::New(env, snapshot.fps));
any = true;
}
if (snapshot.videoBitrate > 0.0) {
stats.Set(
"videoBitrateBps",
Napi::Number::New(env, snapshot.videoBitrate));
any = true;
}
if (snapshot.audioBitrate > 0.0) {
stats.Set(
"audioBitrateBps",
Napi::Number::New(env, snapshot.audioBitrate));
any = true;
}
if (!snapshot.videoCodec.empty()) {
stats.Set("videoCodec", Napi::String::New(env, snapshot.videoCodec));
any = true;
}
if (!snapshot.audioCodec.empty()) {
stats.Set("audioCodec", Napi::String::New(env, snapshot.audioCodec));
any = true;
}
if (!snapshot.audioChannels.empty()) {
stats.Set(
"audioChannels",
Napi::String::New(env, snapshot.audioChannels));
any = true;
}
if (snapshot.audioSampleRate > 0) {
stats.Set(
"audioSampleRateHz",
Napi::Number::New(
env, static_cast<double>(snapshot.audioSampleRate)));
any = true;
}
if (!snapshot.container.empty()) {
stats.Set("container", Napi::String::New(env, snapshot.container));
any = true;
}
if (snapshot.cacheDuration >= 0.0) {
stats.Set(
"bufferedAheadSeconds",
Napi::Number::New(env, snapshot.cacheDuration));
any = true;
}
if (snapshot.droppedFrames >= 0 || snapshot.decoderDroppedFrames >= 0) {
/* mpv counts render-time and decoder drops separately; the popover
* shows the one number a viewer cares about. */
const int64_t dropped =
std::max<int64_t>(0, snapshot.droppedFrames) +
std::max<int64_t>(0, snapshot.decoderDroppedFrames);
stats.Set(
"droppedFrames",
Napi::Number::New(env, static_cast<double>(dropped)));
any = true;
}
if (any) {
result.Set("stats", stats);
}
}
Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
{
Napi::Env env = info.Env();
@@ -2423,6 +2626,7 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
writeTracks(env, result, "subtitleTracks", snapshot.subtitleTracks);
result.Set("playbackSpeed", Napi::Number::New(env, snapshot.playbackSpeed));
result.Set("aspectOverride", Napi::String::New(env, snapshot.aspectOverride));
writeStreamStats(env, result, snapshot);
if (
snapshot.recordingActive ||
!snapshot.recordingTargetPath.empty() ||
@@ -88,6 +88,58 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
expect(snapshot?.streamUrl).toBe('http://stream');
});
it('carries the helper stats object into the cached snapshot', () => {
const sessionId = createSession();
child.emitStdout({
event: 'snapshot',
status: 'playing',
positionSeconds: 1,
durationSeconds: null,
volume: 1,
streamUrl: 'http://stream',
stats: {
fps: 25,
videoBitrateBps: 3_500_000,
videoCodec: 'h264',
container: 'mpegts',
bufferedAheadSeconds: 4.5,
droppedFrames: 1,
},
});
expect(adapter.getSessionSnapshot(sessionId)?.stats).toEqual({
fps: 25,
videoBitrateBps: 3_500_000,
videoCodec: 'h264',
container: 'mpegts',
bufferedAheadSeconds: 4.5,
droppedFrames: 1,
});
});
it('replaces stats wholesale so a new stream drops the previous numbers', () => {
const sessionId = createSession();
child.emitStdout({
event: 'snapshot',
status: 'playing',
positionSeconds: 1,
streamUrl: 'http://stream-one',
stats: { videoCodec: 'h264', fps: 25 },
});
// The helper emits `stats` on every snapshot, empty included: the
// adapter merges field by field, so an omitted key would keep the
// previous channel's codec on screen after a switch.
child.emitStdout({
event: 'snapshot',
status: 'loading',
positionSeconds: 0,
streamUrl: 'http://stream-two',
stats: {},
});
expect(adapter.getSessionSnapshot(sessionId)?.stats).toEqual({});
});
it('publishes shm generations through onFrameSourceChanged', () => {
const sessionId = createSession();
child.emitStdout({
@@ -16,12 +16,8 @@ describe('Embedded MPV native source recording invariants', () => {
const widCommonSource = readSource(
'../../../native/src/embedded_mpv_wid_common.h'
);
const win32Source = readSource(
'../../../native/src/embedded_mpv_win32.cc'
);
const linuxSource = readSource(
'../../../native/src/embedded_mpv_linux.cc'
);
const win32Source = readSource('../../../native/src/embedded_mpv_win32.cc');
const linuxSource = readSource('../../../native/src/embedded_mpv_linux.cc');
const buildScriptSource = readSource('../../../build-embedded-mpv.js');
const buildAndMakeWorkflowSource = readSource(
'../../../../../.github/workflows/build-and-make.yaml'
@@ -184,6 +180,153 @@ describe('Embedded MPV native source recording invariants', () => {
}
});
it('observes the stream-stats properties on every embedded engine', () => {
// The info popover renders whatever the engine reports, so a backend
// that stops observing one of these silently loses a row. Source-text
// assertions are the only guard the C++ has in CI.
const observedProperties = [
'estimated-vf-fps',
'video-bitrate',
'audio-bitrate',
'video-format',
'audio-codec-name',
'file-format',
'demuxer-cache-duration',
'frame-drop-count',
'decoder-frame-drop-count',
'audio-params/channels',
'audio-params/samplerate',
];
for (const property of observedProperties) {
expect(nativeSource).toContain(`"${property}"`);
expect(widCommonSource).toContain(`"${property}"`);
expect(frameHelperSource).toContain(`"${property}"`);
}
// Observing is push-based and free, which is the only reason the
// native-view backends carry these fields at all: their legacy dock
// cannot render them. The Linux backend has no observe mechanism —
// it would have to poll each property over the JSON IPC socket on
// the same pass that publishes position, pause and EOF — so it must
// not collect them at all.
for (const property of observedProperties) {
expect(widCommonSource).not.toContain(`socketPath, "${property}"`);
}
});
it('serializes stream stats as an optional snapshot object', () => {
// An absent value must omit its key: the renderer treats a missing
// field as "unknown" and a zero as a real measurement.
// Both native-view backends serialize through the same named helper
// so the two can be read side by side while porting.
for (const source of [nativeSource, widCommonSource]) {
expect(source).toContain('void writeStreamStats(');
expect(source).toContain('result.Set("stats", stats);');
expect(source).toContain(
'writeStreamStats(env, result, snapshot);'
);
}
expect(frameHelperSource).toContain('composeStatsJsonLocked');
// Unconditional on the frame-copy path: its snapshots are merged, so
// an omitted key would keep the previous stream's numbers.
expect(frameHelperSource).toContain(
'writer.raw("stats", composeStatsJsonLocked());'
);
});
it('clears stream stats when a new file starts so rows never go stale', () => {
// One reset per backend that collects stats, invoked wherever a new
// file begins. The wid backend has a single call site: its Linux half
// polls nothing, so there is nothing there to go stale.
for (const source of [
nativeSource,
widCommonSource,
frameHelperSource,
]) {
expect(source).toContain('void clearStreamStats()');
}
expect(nativeSource).toContain('session->snapshot.clearStreamStats();');
expect(
widCommonSource.match(/snapshot\.clearStreamStats\(\);/g)
).toHaveLength(1);
expect(frameHelperSource).toContain('s.clearStreamStats();');
});
it('clears frame-copy dimensions and publishes the reset through merged snapshots', () => {
const reset = sourceFunctionBody(
frameHelperSource,
'void clearStreamStats()',
'reset'
);
expect(reset).toContain('videoWidth = 0;');
expect(reset).toContain('videoHeight = 0;');
const snapshot = sourceFunctionBody(
frameHelperSource,
'std::string composeSnapshotLocked()',
'snapshot'
);
expect(snapshot).toContain(
'writer.num("videoWidth", (double)s.videoWidth);'
);
expect(snapshot).toContain(
'writer.num("videoHeight", (double)s.videoHeight);'
);
expect(snapshot).not.toContain('if (s.videoWidth > 0');
});
it('restores unknown sentinels when observed diagnostics become unavailable', () => {
const fields = [
['estimated-vf-fps', 'fps = 0'],
['video-bitrate', 'videoBitrate = 0'],
['audio-bitrate', 'audioBitrate = 0'],
['video-format', 'videoCodec.clear()'],
['audio-codec-name', 'audioCodec.clear()'],
['audio-params/channels', 'audioChannels.clear()'],
['audio-params/samplerate', 'audioSampleRate = 0'],
['file-format', 'container.clear()'],
['demuxer-cache-duration', 'cacheDuration = -1'],
['frame-drop-count', 'droppedFrames = -1'],
['decoder-frame-drop-count', 'decoderDroppedFrames = -1'],
];
for (const source of [
nativeSource,
widCommonSource,
frameHelperSource,
]) {
const reset = sourceFunctionBody(
source,
'bool clearUnavailableStreamProperty(',
'unavailable property'
);
for (const [property, assignment] of fields) {
expect(reset).toContain(
`name == "${property}") { ${assignment}; }`
);
}
const event = source.indexOf('MPV_EVENT_PROPERTY_CHANGE');
const handler =
source === frameHelperSource
? sourceFunctionBody(
source,
'void handlePropertyChange(',
'property handler'
)
: source.slice(
event,
source.indexOf('case MPV_EVENT_', event + 5)
);
expect(handler).toContain('MPV_FORMAT_NONE');
expect(handler).toContain('clearUnavailableStreamProperty(');
const dataGuard = handler.indexOf('!property->data');
if (dataGuard >= 0) {
expect(handler.indexOf('MPV_FORMAT_NONE')).toBeLessThan(
dataGuard
);
}
}
});
it('maps keep-open eof-reached property changes to an ended session status', () => {
expect(nativeSource).toContain(
'mpv_observe_property(session->handle, 11, "eof-reached", MPV_FORMAT_FLAG);'
@@ -83,6 +83,12 @@ interface MockSnapshot {
streamUrl: string;
audioTracks?: never[];
selectedAudioTrackId?: number | null;
stats?: {
fps?: number;
videoBitrateBps?: number;
videoCodec?: string;
droppedFrames?: number;
};
recording?: {
active: boolean;
targetPath?: string;
@@ -553,6 +559,38 @@ describe('EmbeddedMpvNativeService power blocker', () => {
});
});
it('passes engine stream stats through to the renderer session', () => {
startSession('s1', snapshot('playing'));
addon.getSessionSnapshot.mockReturnValue(
snapshot('playing', {
stats: {
fps: 50,
videoBitrateBps: 6_000_000,
videoCodec: 'h264',
droppedFrames: 0,
},
})
);
expect(service.setVolume('s1', 1)?.stats).toEqual({
fps: 50,
videoBitrateBps: 6_000_000,
videoCodec: 'h264',
droppedFrames: 0,
});
});
it('omits the stats key entirely when the engine reports none', () => {
// An empty object would make the renderer render an empty popover
// instead of hiding the info button.
startSession('s1', snapshot('playing'));
addon.getSessionSnapshot.mockReturnValue(
snapshot('playing', { stats: {} })
);
expect(service.setVolume('s1', 1)).not.toHaveProperty('stats');
});
it('seekBy forwards the delta to the addon as a relative seek and refreshes the snapshot', () => {
startSession('s1', snapshot('playing', { positionSeconds: 10 }));
addon.getSessionSnapshot.mockReturnValue(
@@ -19,6 +19,7 @@ import {
EmbeddedMpvRecordingState,
EmbeddedMpvSession,
EmbeddedMpvSessionStatus,
EmbeddedMpvStreamStats,
EmbeddedMpvSubtitleStyle,
EmbeddedMpvSubtitleTrack,
EmbeddedMpvEngine,
@@ -74,6 +75,7 @@ export interface NativeEmbeddedMpvSessionSnapshot {
aspectOverride?: string;
videoWidth?: number;
videoHeight?: number;
stats?: EmbeddedMpvStreamStats;
recording?: EmbeddedMpvRecordingState;
error?: string;
/**
@@ -1250,6 +1252,9 @@ export class EmbeddedMpvNativeService {
videoHeight: snapshot.videoHeight,
}
: {}),
...(snapshot.stats && Object.keys(snapshot.stats).length > 0
? { stats: snapshot.stats }
: {}),
recording: snapshot.recording ?? { active: false },
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "افتراضي",
"SUBTITLE_COLOR_WHITE": "أبيض",
"SUBTITLE_COLOR_YELLOW": "أصفر",
"SUBTITLE_COLOR_CYAN": "سماوي"
"SUBTITLE_COLOR_CYAN": "سماوي",
"STREAM_INFO": "معلومات البث",
"STATS_RESOLUTION": "الدقة",
"STATS_FRAME_RATE": "معدل الإطارات",
"STATS_NOMINAL_FRAME_RATE": "معدل إطارات المصدر",
"STATS_STREAM_BITRATE": "معدل بت البث",
"STATS_VIDEO": "الفيديو",
"STATS_AUDIO": "الصوت",
"STATS_AUDIO_CHANNELS": "القنوات",
"STATS_SAMPLE_RATE": "معدل العينات",
"STATS_CONTAINER": "الحاوية",
"STATS_BUFFER": "التخزين المؤقت",
"STATS_DROPPED_FRAMES": "الإطارات المفقودة",
"STATS_UNAVAILABLE": "لا توجد بيانات بث بعد"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "افتراضي",
"SUBTITLE_COLOR_WHITE": "أبيض",
"SUBTITLE_COLOR_YELLOW": "أصفر",
"SUBTITLE_COLOR_CYAN": "سماوي"
"SUBTITLE_COLOR_CYAN": "سماوي",
"STREAM_INFO": "معلومات ديال البث",
"STATS_RESOLUTION": "الدقة",
"STATS_FRAME_RATE": "معدل الإطارات",
"STATS_NOMINAL_FRAME_RATE": "معدل إطارات المصدر",
"STATS_STREAM_BITRATE": "معدل بت البث",
"STATS_VIDEO": "الفيديو",
"STATS_AUDIO": "الصوت",
"STATS_AUDIO_CHANNELS": "القنوات",
"STATS_SAMPLE_RATE": "معدل العينات",
"STATS_CONTAINER": "الحاوية",
"STATS_BUFFER": "التخزين المؤقت",
"STATS_DROPPED_FRAMES": "الإطارات اللي تضاعو",
"STATS_UNAVAILABLE": "مازال ماكاينش داتا ديال البث"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Па змаўчанні",
"SUBTITLE_COLOR_WHITE": "Белы",
"SUBTITLE_COLOR_YELLOW": "Жоўты",
"SUBTITLE_COLOR_CYAN": "Блакітны"
"SUBTITLE_COLOR_CYAN": "Блакітны",
"STREAM_INFO": "Звесткі пра струмень",
"STATS_RESOLUTION": "Раздзяляльнасць",
"STATS_FRAME_RATE": "Частата кадраў",
"STATS_NOMINAL_FRAME_RATE": "Заяўленая частата",
"STATS_STREAM_BITRATE": "Агульны бітрэйт",
"STATS_VIDEO": "Відэа",
"STATS_AUDIO": "Аўдыё",
"STATS_AUDIO_CHANNELS": "Каналы",
"STATS_SAMPLE_RATE": "Частата дыскрэтызацыі",
"STATS_CONTAINER": "Кантэйнер",
"STATS_BUFFER": "Буфер",
"STATS_DROPPED_FRAMES": "Страчаныя кадры",
"STATS_UNAVAILABLE": "Дадзеных пра струмень пакуль няма"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Standard",
"SUBTITLE_COLOR_WHITE": "Weiß",
"SUBTITLE_COLOR_YELLOW": "Gelb",
"SUBTITLE_COLOR_CYAN": "Cyan"
"SUBTITLE_COLOR_CYAN": "Cyan",
"STREAM_INFO": "Stream-Info",
"STATS_RESOLUTION": "Auflösung",
"STATS_FRAME_RATE": "Bildrate",
"STATS_NOMINAL_FRAME_RATE": "Quellbildrate",
"STATS_STREAM_BITRATE": "Stream-Bitrate",
"STATS_VIDEO": "Video",
"STATS_AUDIO": "Audio",
"STATS_AUDIO_CHANNELS": "Kanäle",
"STATS_SAMPLE_RATE": "Abtastrate",
"STATS_CONTAINER": "Container",
"STATS_BUFFER": "Puffer",
"STATS_DROPPED_FRAMES": "Verworfene Bilder",
"STATS_UNAVAILABLE": "Noch keine Stream-Daten"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Προεπιλογή",
"SUBTITLE_COLOR_WHITE": "Λευκό",
"SUBTITLE_COLOR_YELLOW": "Κίτρινο",
"SUBTITLE_COLOR_CYAN": "Κυανό"
"SUBTITLE_COLOR_CYAN": "Κυανό",
"STREAM_INFO": "Πληροφορίες ροής",
"STATS_RESOLUTION": "Ανάλυση",
"STATS_FRAME_RATE": "Ρυθμός καρέ",
"STATS_NOMINAL_FRAME_RATE": "Ρυθμός καρέ πηγής",
"STATS_STREAM_BITRATE": "Ρυθμός bit ροής",
"STATS_VIDEO": "Βίντεο",
"STATS_AUDIO": "Ήχος",
"STATS_AUDIO_CHANNELS": "Κανάλια",
"STATS_SAMPLE_RATE": "Ρυθμός δειγματοληψίας",
"STATS_CONTAINER": "Κοντέινερ",
"STATS_BUFFER": "Ενδιάμεση μνήμη",
"STATS_DROPPED_FRAMES": "Απορριφθέντα καρέ",
"STATS_UNAVAILABLE": "Δεν υπάρχουν ακόμη δεδομένα ροής"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Default",
"SUBTITLE_COLOR_WHITE": "White",
"SUBTITLE_COLOR_YELLOW": "Yellow",
"SUBTITLE_COLOR_CYAN": "Cyan"
"SUBTITLE_COLOR_CYAN": "Cyan",
"STREAM_INFO": "Stream info",
"STATS_RESOLUTION": "Resolution",
"STATS_FRAME_RATE": "Frame rate",
"STATS_NOMINAL_FRAME_RATE": "Source frame rate",
"STATS_STREAM_BITRATE": "Stream bitrate",
"STATS_VIDEO": "Video",
"STATS_AUDIO": "Audio",
"STATS_AUDIO_CHANNELS": "Channels",
"STATS_SAMPLE_RATE": "Sample rate",
"STATS_CONTAINER": "Container",
"STATS_BUFFER": "Buffer",
"STATS_DROPPED_FRAMES": "Dropped frames",
"STATS_UNAVAILABLE": "No stream data yet"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Predeterminado",
"SUBTITLE_COLOR_WHITE": "Blanco",
"SUBTITLE_COLOR_YELLOW": "Amarillo",
"SUBTITLE_COLOR_CYAN": "Cian"
"SUBTITLE_COLOR_CYAN": "Cian",
"STREAM_INFO": "Información del stream",
"STATS_RESOLUTION": "Resolución",
"STATS_FRAME_RATE": "Velocidad de fotogramas",
"STATS_NOMINAL_FRAME_RATE": "Fotogramas de origen",
"STATS_STREAM_BITRATE": "Tasa de bits del flujo",
"STATS_VIDEO": "Vídeo",
"STATS_AUDIO": "Audio",
"STATS_AUDIO_CHANNELS": "Canales",
"STATS_SAMPLE_RATE": "Frecuencia de muestreo",
"STATS_CONTAINER": "Contenedor",
"STATS_BUFFER": "Búfer",
"STATS_DROPPED_FRAMES": "Fotogramas descartados",
"STATS_UNAVAILABLE": "Aún no hay datos del stream"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Par défaut",
"SUBTITLE_COLOR_WHITE": "Blanc",
"SUBTITLE_COLOR_YELLOW": "Jaune",
"SUBTITLE_COLOR_CYAN": "Cyan"
"SUBTITLE_COLOR_CYAN": "Cyan",
"STREAM_INFO": "Infos du flux",
"STATS_RESOLUTION": "Résolution",
"STATS_FRAME_RATE": "Fréquence d'images",
"STATS_NOMINAL_FRAME_RATE": "Cadence de la source",
"STATS_STREAM_BITRATE": "Débit du flux",
"STATS_VIDEO": "Vidéo",
"STATS_AUDIO": "Audio",
"STATS_AUDIO_CHANNELS": "Canaux",
"STATS_SAMPLE_RATE": "Fréquence d'échantillonnage",
"STATS_CONTAINER": "Conteneur",
"STATS_BUFFER": "Tampon",
"STATS_DROPPED_FRAMES": "Images perdues",
"STATS_UNAVAILABLE": "Pas encore de données du flux"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Alapértelmezett",
"SUBTITLE_COLOR_WHITE": "Fehér",
"SUBTITLE_COLOR_YELLOW": "Sárga",
"SUBTITLE_COLOR_CYAN": "Cián"
"SUBTITLE_COLOR_CYAN": "Cián",
"STREAM_INFO": "Adatfolyam-infó",
"STATS_RESOLUTION": "Felbontás",
"STATS_FRAME_RATE": "Képkockasebesség",
"STATS_NOMINAL_FRAME_RATE": "Forrás képkockasebessége",
"STATS_STREAM_BITRATE": "Adatfolyam bitrátája",
"STATS_VIDEO": "Videó",
"STATS_AUDIO": "Hang",
"STATS_AUDIO_CHANNELS": "Csatornák",
"STATS_SAMPLE_RATE": "Mintavételezési frekvencia",
"STATS_CONTAINER": "Konténer",
"STATS_BUFFER": "Puffer",
"STATS_DROPPED_FRAMES": "Eldobott képkockák",
"STATS_UNAVAILABLE": "Még nincsenek adatfolyam-adatok"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Predefinito",
"SUBTITLE_COLOR_WHITE": "Bianco",
"SUBTITLE_COLOR_YELLOW": "Giallo",
"SUBTITLE_COLOR_CYAN": "Ciano"
"SUBTITLE_COLOR_CYAN": "Ciano",
"STREAM_INFO": "Info stream",
"STATS_RESOLUTION": "Risoluzione",
"STATS_FRAME_RATE": "Frequenza fotogrammi",
"STATS_NOMINAL_FRAME_RATE": "Frequenza fotogrammi sorgente",
"STATS_STREAM_BITRATE": "Bitrate del flusso",
"STATS_VIDEO": "Video",
"STATS_AUDIO": "Audio",
"STATS_AUDIO_CHANNELS": "Canali",
"STATS_SAMPLE_RATE": "Frequenza di campionamento",
"STATS_CONTAINER": "Contenitore",
"STATS_BUFFER": "Buffer",
"STATS_DROPPED_FRAMES": "Fotogrammi persi",
"STATS_UNAVAILABLE": "Nessun dato dello stream"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "デフォルト",
"SUBTITLE_COLOR_WHITE": "白",
"SUBTITLE_COLOR_YELLOW": "黄色",
"SUBTITLE_COLOR_CYAN": "シアン"
"SUBTITLE_COLOR_CYAN": "シアン",
"STREAM_INFO": "ストリーム情報",
"STATS_RESOLUTION": "解像度",
"STATS_FRAME_RATE": "フレームレート",
"STATS_NOMINAL_FRAME_RATE": "ソースのフレームレート",
"STATS_STREAM_BITRATE": "ストリームのビットレート",
"STATS_VIDEO": "ビデオ",
"STATS_AUDIO": "オーディオ",
"STATS_AUDIO_CHANNELS": "チャンネル",
"STATS_SAMPLE_RATE": "サンプリングレート",
"STATS_CONTAINER": "コンテナ",
"STATS_BUFFER": "バッファ",
"STATS_DROPPED_FRAMES": "ドロップフレーム",
"STATS_UNAVAILABLE": "ストリームデータはまだありません"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "기본값",
"SUBTITLE_COLOR_WHITE": "흰색",
"SUBTITLE_COLOR_YELLOW": "노란색",
"SUBTITLE_COLOR_CYAN": "청록색"
"SUBTITLE_COLOR_CYAN": "청록색",
"STREAM_INFO": "스트림 정보",
"STATS_RESOLUTION": "해상도",
"STATS_FRAME_RATE": "프레임 속도",
"STATS_NOMINAL_FRAME_RATE": "소스 프레임 레이트",
"STATS_STREAM_BITRATE": "스트림 비트레이트",
"STATS_VIDEO": "비디오",
"STATS_AUDIO": "오디오",
"STATS_AUDIO_CHANNELS": "채널",
"STATS_SAMPLE_RATE": "샘플링 레이트",
"STATS_CONTAINER": "컨테이너",
"STATS_BUFFER": "버퍼",
"STATS_DROPPED_FRAMES": "손실된 프레임",
"STATS_UNAVAILABLE": "아직 스트림 데이터가 없습니다"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Standaard",
"SUBTITLE_COLOR_WHITE": "Wit",
"SUBTITLE_COLOR_YELLOW": "Geel",
"SUBTITLE_COLOR_CYAN": "Cyaan"
"SUBTITLE_COLOR_CYAN": "Cyaan",
"STREAM_INFO": "Streaminfo",
"STATS_RESOLUTION": "Resolutie",
"STATS_FRAME_RATE": "Framerate",
"STATS_NOMINAL_FRAME_RATE": "Bronframesnelheid",
"STATS_STREAM_BITRATE": "Streambitrate",
"STATS_VIDEO": "Video",
"STATS_AUDIO": "Audio",
"STATS_AUDIO_CHANNELS": "Kanalen",
"STATS_SAMPLE_RATE": "Samplefrequentie",
"STATS_CONTAINER": "Container",
"STATS_BUFFER": "Buffer",
"STATS_DROPPED_FRAMES": "Verloren frames",
"STATS_UNAVAILABLE": "Nog geen streamgegevens"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Domyślny",
"SUBTITLE_COLOR_WHITE": "Biały",
"SUBTITLE_COLOR_YELLOW": "Żółty",
"SUBTITLE_COLOR_CYAN": "Cyjan"
"SUBTITLE_COLOR_CYAN": "Cyjan",
"STREAM_INFO": "Informacje o strumieniu",
"STATS_RESOLUTION": "Rozdzielczość",
"STATS_FRAME_RATE": "Liczba klatek",
"STATS_NOMINAL_FRAME_RATE": "Klatkaż źródła",
"STATS_STREAM_BITRATE": "Przepływność strumienia",
"STATS_VIDEO": "Wideo",
"STATS_AUDIO": "Dźwięk",
"STATS_AUDIO_CHANNELS": "Kanały",
"STATS_SAMPLE_RATE": "Częstotliwość próbkowania",
"STATS_CONTAINER": "Kontener",
"STATS_BUFFER": "Bufor",
"STATS_DROPPED_FRAMES": "Utracone klatki",
"STATS_UNAVAILABLE": "Brak danych strumienia"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Predefinição",
"SUBTITLE_COLOR_WHITE": "Branco",
"SUBTITLE_COLOR_YELLOW": "Amarelo",
"SUBTITLE_COLOR_CYAN": "Ciano"
"SUBTITLE_COLOR_CYAN": "Ciano",
"STREAM_INFO": "Informações do stream",
"STATS_RESOLUTION": "Resolução",
"STATS_FRAME_RATE": "Taxa de quadros",
"STATS_NOMINAL_FRAME_RATE": "Taxa de quadros da origem",
"STATS_STREAM_BITRATE": "Taxa de bits do fluxo",
"STATS_VIDEO": "Vídeo",
"STATS_AUDIO": "Áudio",
"STATS_AUDIO_CHANNELS": "Canais",
"STATS_SAMPLE_RATE": "Taxa de amostragem",
"STATS_CONTAINER": "Contêiner",
"STATS_BUFFER": "Buffer",
"STATS_DROPPED_FRAMES": "Quadros perdidos",
"STATS_UNAVAILABLE": "Ainda sem dados do stream"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "По умолчанию",
"SUBTITLE_COLOR_WHITE": "Белый",
"SUBTITLE_COLOR_YELLOW": "Жёлтый",
"SUBTITLE_COLOR_CYAN": "Голубой"
"SUBTITLE_COLOR_CYAN": "Голубой",
"STREAM_INFO": "Сведения о потоке",
"STATS_RESOLUTION": "Разрешение",
"STATS_FRAME_RATE": "Частота кадров",
"STATS_NOMINAL_FRAME_RATE": "Заявленная частота",
"STATS_STREAM_BITRATE": "Общий битрейт",
"STATS_VIDEO": "Видео",
"STATS_AUDIO": "Аудио",
"STATS_AUDIO_CHANNELS": "Каналы",
"STATS_SAMPLE_RATE": "Частота дискретизации",
"STATS_CONTAINER": "Контейнер",
"STATS_BUFFER": "Буфер",
"STATS_DROPPED_FRAMES": "Пропущенные кадры",
"STATS_UNAVAILABLE": "Данных о потоке пока нет"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "Varsayılan",
"SUBTITLE_COLOR_WHITE": "Beyaz",
"SUBTITLE_COLOR_YELLOW": "Sarı",
"SUBTITLE_COLOR_CYAN": "Camgöbeği"
"SUBTITLE_COLOR_CYAN": "Camgöbeği",
"STREAM_INFO": "Yayın bilgisi",
"STATS_RESOLUTION": "Çözünürlük",
"STATS_FRAME_RATE": "Kare hızı",
"STATS_NOMINAL_FRAME_RATE": "Kaynak kare hızı",
"STATS_STREAM_BITRATE": "Akış bit hızı",
"STATS_VIDEO": "Video",
"STATS_AUDIO": "Ses",
"STATS_AUDIO_CHANNELS": "Kanallar",
"STATS_SAMPLE_RATE": "Örnekleme hızı",
"STATS_CONTAINER": "Kapsayıcı",
"STATS_BUFFER": "Arabellek",
"STATS_DROPPED_FRAMES": "Atlanan kareler",
"STATS_UNAVAILABLE": "Henüz yayın verisi yok"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "默认",
"SUBTITLE_COLOR_WHITE": "白色",
"SUBTITLE_COLOR_YELLOW": "黄色",
"SUBTITLE_COLOR_CYAN": "青色"
"SUBTITLE_COLOR_CYAN": "青色",
"STREAM_INFO": "流信息",
"STATS_RESOLUTION": "分辨率",
"STATS_FRAME_RATE": "帧率",
"STATS_NOMINAL_FRAME_RATE": "源帧率",
"STATS_STREAM_BITRATE": "流总码率",
"STATS_VIDEO": "视频",
"STATS_AUDIO": "音频",
"STATS_AUDIO_CHANNELS": "声道",
"STATS_SAMPLE_RATE": "采样率",
"STATS_CONTAINER": "容器",
"STATS_BUFFER": "缓冲",
"STATS_DROPPED_FRAMES": "丢帧",
"STATS_UNAVAILABLE": "暂无流数据"
}
},
"PLAYBACK_DIAGNOSTICS": {
+14 -1
View File
@@ -595,7 +595,20 @@
"SUBTITLE_COLOR_DEFAULT": "預設",
"SUBTITLE_COLOR_WHITE": "白色",
"SUBTITLE_COLOR_YELLOW": "黃色",
"SUBTITLE_COLOR_CYAN": "青色"
"SUBTITLE_COLOR_CYAN": "青色",
"STREAM_INFO": "串流資訊",
"STATS_RESOLUTION": "解析度",
"STATS_FRAME_RATE": "影格率",
"STATS_NOMINAL_FRAME_RATE": "來源影格率",
"STATS_STREAM_BITRATE": "串流總位元率",
"STATS_VIDEO": "視訊",
"STATS_AUDIO": "音訊",
"STATS_AUDIO_CHANNELS": "聲道",
"STATS_SAMPLE_RATE": "取樣率",
"STATS_CONTAINER": "容器",
"STATS_BUFFER": "緩衝",
"STATS_DROPPED_FRAMES": "丟棄影格",
"STATS_UNAVAILABLE": "尚無串流資料"
}
},
"PLAYBACK_DIAGNOSTICS": {
+83
View File
@@ -486,6 +486,89 @@ The renderer learns which features the loaded addon binary supports through the
Linux audio-track discovery works differently from macOS/Windows because the hand-rolled JSON IPC reply parser only understands scalar `data` values: the poll loop reads `track-list/count` every tick and walks the scalar `track-list/N/{type,id,title,lang,default,forced}` sub-properties only when the count changes. The selected track is reconciled from the scalar `aid` property on every tick (`aid` reads back non-numeric when audio is disabled, which maps to "no selection"). Track switching still goes through `set_property aid` over the same socket.
## Stream Stats Properties
The shared controls' stream-info popover (top-right `info` button, contract in
[player-controls-contract.md](./player-controls-contract.md#stream-info-popover))
renders whatever the engine reports as `EmbeddedMpvSession.stats`. Every field
is optional: an absent key means "mpv has not answered yet" and the row is
omitted, while a zero (dropped frames, for instance) is a real measurement.
**Where the popover actually appears:** `app-player-controls` mounts only under
the frame-copy engine (`@if (isFrameCopyEngine() && isSupported())` in
`embedded-mpv-player.component.html`); the native-view engine keeps its legacy
controls dock, which has no info affordance. So today the popover is a
frame-copy-only surface. The macOS and Windows native-view backends plumb the
properties anyway — `mpv_observe_property` is push-based, so the rows appear
for free whenever those engines adopt the shared controls — but do not describe
the feature as available there. The Linux out-of-process backend deliberately
does **not**, for the reason given under "Cost" below.
The frame-copy helper and both in-process native-view backends observe these
mpv properties at session init and map them into their session snapshot:
| mpv property | `stats` field |
| ----------------------------------------------- | ------------------------ |
| `estimated-vf-fps` | `fps` |
| `video-bitrate` | `videoBitrateBps` |
| `audio-bitrate` | `audioBitrateBps` |
| `video-format` | `videoCodec` |
| `audio-codec-name` | `audioCodec` |
| `audio-params/channels` | `audioChannels` |
| `audio-params/samplerate` | `audioSampleRateHz` |
| `file-format` | `container` |
| `demuxer-cache-duration` | `bufferedAheadSeconds` |
| `frame-drop-count` + `decoder-frame-drop-count` | `droppedFrames` (summed) |
The resolution row has a different source: it comes from `dwidth`/`dheight` on
`EmbeddedMpvSession.videoWidth`/`videoHeight`, which **only the frame-copy
helper observes** (it needs them to size its frames anyway). The native-view
backends report no size, so a future shared-controls dock there would show
every row except resolution until they observe those two properties too.
`video-format` and `audio-codec-name` are used rather than `video-codec` /
`audio-codec`: the popover wants `h264` and `aac`, not
`H.264/AVC (High Profile)`.
**Cost.** Observation is free in event terms: libmpv pushes property changes,
macOS and Windows build the snapshot lazily when the renderer pulls it, and the
frame-copy helper already throttles snapshot emission to 250 ms, so the extra
properties never increase IPC traffic. Linux has no such mechanism — its
process-isolated backend would have to `get_property` each field as its own
JSON IPC round trip inside `refreshLinuxMpvSnapshot`, the same pass that
publishes position, pause and EOF, where a slow answer delays real playback
state. Eleven round trips per tick for a dock that cannot render them is not a
trade worth making, so that backend collects no stats at all and its snapshot
leaves every field at its default. This joins the features the Linux
out-of-process path already does not export (subtitles, speed, aspect,
recording). A source-text invariant in `embedded-mpv-native-source.spec.ts`
asserts the properties are never passed to the `queryLinuxMpv*` helpers, so
re-adding the polling fails CI.
Each backend that collects stats clears these fields when a new file starts
(`MPV_EVENT_START_FILE`), so a channel switch can never leave the previous
stream's codec or bitrate on screen. Frame-copy also clears `dwidth`/`dheight`
and publishes both dimensions as zero until the new file reports them; omission
would retain the previous size in the merged snapshot. Zero dimensions map to
unknown in the controls. An observed `MPV_FORMAT_NONE` restores only that
property's unknown sentinel, including when an audio/video track disappears
within the same file. The frame-copy helper emits its `stats`
object on **every** snapshot, empty object included: the adapter merges helper
snapshots field by field, so an omitted key would survive the switch that the
clear was meant to perform. macOS and Windows build a fresh snapshot object per
pull and simply leave unknown keys out.
`EmbeddedMpvNativeService` then omits the `stats` key entirely when the engine
reported nothing, which is what keeps the info button hidden on an engine that
does not report these properties at all.
The packaged Linux frame-copy smoke verifies real diagnostic snapshots and
switches Y4M → WebM → Y4M in the same session, checking the reported codec and
container on each source. It then loads audio-only PCM to verify dimensions
are cleared, and disables the audio track to verify unavailable observations
remove codec/channel/sample-rate rows without a new-file reset. This complements renderer mapping/unit coverage with
the actual libmpv → helper JSON → main/preload path.
## Session End And Series Navigation
`EmbeddedMpvSessionStatus` includes `ended` for successful EOF only. The native addon maps `MPV_EVENT_END_FILE` to:
+99 -2
View File
@@ -175,6 +175,8 @@ interface PlayerController {
readonly capabilities: Signal<PlayerControlsCapabilities>;
readonly state: Signal<PlayerControlsState>;
readonly commands: PlayerControlsCommands;
/** Optional; see "Stream info popover". */
readonly streamStats?: PlayerStreamStatsSource;
}
```
@@ -183,7 +185,8 @@ interface PlayerController {
`PlayerControlsCapabilities` contains booleans for `seek`, `volume`,
`audioTracks`, `subtitles`, `externalSubtitles`, `subtitleDelay`,
`subtitleStyle`, `qualityLevels`, `playbackSpeed`, `aspectRatio`,
`recording`, `pictureInPicture`, `fullscreen`, and `seriesNavigation`.
`recording`, `pictureInPicture`, `fullscreen`, `seriesNavigation`, and
`streamStats`.
The default is all-false. An adapter enables only features that its engine and
current runtime support. Capability flags primarily control whether optional UI
@@ -284,6 +287,91 @@ It owns only transient presentation behavior:
- `ControlsTimeline` — scrub state and timeline projections; and
- `controls-view-model.ts` — derived display state.
### Stream info popover
An `info` button in the **top-right corner** of the overlay opens a popover with
live technical data about the stream: resolution with its derived aspect ratio,
measured and declared frame rates, aggregate stream bitrate, video codec/bitrate,
audio codec/bitrate, audio channel
layout, audio sample rate, container, buffered-ahead seconds, and dropped
frames. Rows whose value is unknown are omitted; a popover with no rows at all
shows a short "no data yet" line.
The button renders only when `capabilities.streamStats` is true, which an
adapter sets from what its engine can actually report — engines that report
nothing never show the affordance.
Stats are **pulled, not pushed**:
```ts
interface PlayerStreamStatsSource {
sample(): PlayerStreamStats | null;
reset?(): void; // reset rolling measurements when opening the panel
}
```
`PlayerControlsState` deliberately does not carry them. Bitrate, buffer and
frame counters move every second, and folding them into the state signal would
re-run every derived control signal on every tick even while nobody is looking.
`ControlsStreamStats` owns a 1s sampling loop that runs **only while the popover
is open** (an effect keyed on `menus.statsOpen()`, so every close path — toggle,
Escape, capability loss, teardown — stops it), and it drops its snapshot on
close so a reopen never shows the previous stream's numbers. On open it also
calls the source's optional `reset()` before sampling, so a closed interval
(including time paused) cannot contaminate the next frame-rate measurement.
Formatting lives in `stream-stats-format.utils.ts` as pure functions:
`buildStreamStatsRows()` returns `{ labelKey, value }` pairs and the template
only translates and prints them. Aspect ratios snap to a named ratio (16:9,
2.35:1, …) within 1%, fall back to a greatest-common-divisor reduction while
both terms stay small, and to a decimal ratio otherwise.
Per engine:
- **Web engines** (`WebVideoStreamStatsSampler`): resolution, buffered-ahead,
dropped/total frames and the _measured_ frame rate (presented-frame delta
between two samples, so it reflects what the machine actually renders) come
from the `<video>` element, so they work on every source kind. The engine adds
bitrate, codecs, channel layout, sample rate and container through the
optional `getEngineStats` hook — `WebVideoSourceStats` reads the active HLS
level plus its audio rendition or the active Shaka variant, and
`VjsQualityLevels.getActiveLevelStats()` reads the VHS `selectedIndex`
rendition. Presented frames are `totalVideoFrames - droppedVideoFrames`; the
untouched total remains the denominator for the drop percentage. FPS uses a
monotonic wall clock, reports zero on a stall, and is unknown before the
second sample or while paused/initially loading. Once frames have arrived,
readiness falling to `HAVE_METADATA` during starvation retains the measurement
window so the stall still reports zero. The manifest rate is a separate
`nominalFps` row and never fills in for measured FPS. Aggregate HLS/VHS and
Shaka rendition bandwidth goes into `streamBitrateBps`; video/audio rates
remain unknown unless separately reported. HLS fragment `realBitrate` is not
used because alternate audio may be absent from that measurement; Shaka's
playback-rate-scaled `getStats().streamBandwidth` is not a source bitrate.
A native `<video src>` source contributes nothing extra.
- **Embedded MPV**: the numbers ride along on the session snapshot
(`EmbeddedMpvSession.stats`), so `sample()` is a pure read of the current
snapshot. A new file clears the old dimensions, and unavailable MPV properties
clear their previous values; zero dimensions are unknown. This reaches the
user under the **frame-copy engine only** — that
is the one engine that mounts `app-player-controls`; the native-view dock has
no info affordance, though its backends plumb the properties for parity. See
[embedded-mpv-native.md](./embedded-mpv-native.md#stream-stats-properties).
### Top scrim
`.player-controls__top-scrim` is a single pointer-transparent gradient at the
top of the player, mirroring the bottom bar's stops so both edges read as one
system. It renders whenever there is top chrome to back — the fullscreen media
title or the corner buttons — and fades with the controls without sliding (a
moving scrim edge is visible against video in a way a moving control is not).
One element, not a background per consumer: the title and the corner overlap,
and two gradients would darken the overlap twice. The title therefore carries
no background of its own, and a windowed player with no title still gets a
scrim for its corner buttons — which is the only reason a white-on-video icon
is readable over bright footage. `stream-info.e2e.ts` guards that with the
same ≥3:1 contrast-on-white assertion the theme suite uses for the bottom bar.
### Fullscreen media title
The component accepts an optional `mediaTitle` input
@@ -291,7 +379,8 @@ The component accepts an optional `mediaTitle` input
movie title, channel name, or series name, plus an optional second line such
as the `S01E03` episode label. The overlay renders at the top of the player
only in fullscreen while the controls are revealed, follows the same
auto-hide transition as the bottom bar, and is pointer-transparent. Outside
auto-hide transition as the bottom bar, and is pointer-transparent. Its
backdrop comes from the shared top scrim above. Outside
fullscreen the surrounding page chrome already names the content, so the
overlay stays hidden.
@@ -1192,6 +1281,12 @@ libs/ui/playback/src/lib/player-controls/
├── controls-menu-selection.ts
├── controls-menu-state.ts
├── controls-shortcuts.ts
├── controls-chrome-interactions.ts
├── controls-stream-stats.ts
├── player-stream-stats.model.ts
├── positive-number.util.ts
├── stream-stats-format.utils.ts
├── web-video-stream-stats.ts
├── legacy-player-shortcuts.ts
├── controls-surface.ts
├── controls-view-model.ts
@@ -1230,6 +1325,7 @@ libs/ui/playback/src/lib/embedded-mpv-player/
├── embedded-mpv-controls.adapter.ts
├── embedded-mpv-controls-recording.ts
├── embedded-mpv-controls-recording-feedback.ts
├── embedded-mpv-stream-stats.ts
├── embedded-mpv-player.component.ts
├── embedded-mpv-player.component.html
└── embedded-mpv-session-controller.ts
@@ -1246,6 +1342,7 @@ libs/ui/playback/src/lib/web-video-support/
├── web-video-hls-controls.ts
├── web-video-native-text-tracks.ts
├── web-video-shaka-controls.ts
├── web-video-source-stats.ts
├── web-video-source-tracks.ts
└── web-video-source-controls.bridge.ts
```
@@ -91,6 +91,32 @@ export interface EmbeddedMpvRecordingState {
error?: string;
}
/**
* Live stream diagnostics mpv reports for the player's info popover. Every
* field is optional: a property mpv has not answered yet (or an engine that
* does not observe it) simply omits its row rather than reporting a zero.
*/
export interface EmbeddedMpvStreamStats {
/** mpv `estimated-vf-fps` — the measured, not the container, rate. */
fps?: number;
/** mpv `video-bitrate` / `audio-bitrate`, in bits per second. */
videoBitrateBps?: number;
audioBitrateBps?: number;
/** mpv `video-format` / `audio-codec-name`, e.g. `h264` / `aac`. */
videoCodec?: string;
audioCodec?: string;
/** mpv `audio-params/channels`, e.g. `stereo` or `5.1`. */
audioChannels?: string;
/** mpv `audio-params/samplerate`, in Hz. */
audioSampleRateHz?: number;
/** mpv `file-format`, e.g. `mpegts` or `hls`. */
container?: string;
/** mpv `demuxer-cache-duration`: media buffered ahead, in seconds. */
bufferedAheadSeconds?: number;
/** mpv `frame-drop-count` plus `decoder-frame-drop-count`. */
droppedFrames?: number;
}
export interface EmbeddedMpvRecordingStartOptions {
directory?: string;
title?: string;
@@ -119,6 +145,8 @@ export interface EmbeddedMpvSession {
/** Source video size (mpv dwidth/dheight); frame-copy engine only. */
videoWidth?: number;
videoHeight?: number;
/** Live diagnostics for the info popover; absent when mpv reports none. */
stats?: EmbeddedMpvStreamStats;
recording?: EmbeddedMpvRecordingState;
startedAt: string;
updatedAt: string;
@@ -210,6 +210,7 @@ describe('EmbeddedMpvControlsAdapter', () => {
pictureInPicture: false,
fullscreen: true,
seriesNavigation: true,
streamStats: false,
});
controller.support.set(
@@ -239,9 +240,25 @@ describe('EmbeddedMpvControlsAdapter', () => {
pictureInPicture: false,
fullscreen: true,
seriesNavigation: true,
streamStats: false,
});
});
it('advertises stream stats only once mpv reports some', () => {
configure();
expect(adapter.capabilities().streamStats).toBe(false);
expect(adapter.streamStats.sample()).toBeNull();
controller.session.set(
session({ videoWidth: 1920, videoHeight: 1080, stats: { fps: 25 } })
);
expect(adapter.capabilities().streamStats).toBe(true);
expect(adapter.streamStats.sample()).toEqual(
expect.objectContaining({ width: 1920, height: 1080, fps: 25 })
);
});
it('uses semantic live detection and disables seek and series navigation for live playback', () => {
configure();
expect(adapter.state().isLive).toBe(false);
@@ -31,6 +31,7 @@ import {
PlayerControlsState,
PlayerStatus,
} from '../player-controls/player-controls.model';
import type { PlayerStreamStatsSource } from '../player-controls/player-stream-stats.model';
import { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
import {
audioTrackLabel,
@@ -41,6 +42,7 @@ import { resolveRecordingFeedback } from './embedded-mpv-controls-recording-feed
import { EmbeddedMpvControlsRecording } from './embedded-mpv-controls-recording';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
import { EmbeddedMpvSubtitleSettings } from './embedded-mpv-subtitle-settings';
import { toPlayerStreamStats } from './embedded-mpv-stream-stats';
export interface EmbeddedMpvControlsContext {
readonly playback: Signal<ResolvedPortalPlayback>;
@@ -103,6 +105,17 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
: JSON.stringify([playbackIdentity, sessionId]);
});
private readonly recordingTick = signal(Date.now());
/**
* A boolean (not the stats object) so a session update that only moves the
* numbers never invalidates the capability set downstream.
*/
private readonly hasStreamStats = computed(
() => toPlayerStreamStats(this.controller.session()) !== null
);
readonly streamStats: PlayerStreamStatsSource = {
sample: () => toPlayerStreamStats(this.controller.session()),
};
readonly capabilities = computed<PlayerControlsCapabilities>(() => {
const context = this.configuredContext();
@@ -128,6 +141,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController {
pictureInPicture: false,
fullscreen: true,
seriesNavigation: !isLive && context.seriesNavigation() !== null,
streamStats: this.hasStreamStats(),
};
});
@@ -0,0 +1,120 @@
import type { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
import { toPlayerStreamStats } from './embedded-mpv-stream-stats';
function createSession(
overrides: Partial<EmbeddedMpvSession> = {}
): EmbeddedMpvSession {
return {
id: 'session-1',
title: 'Channel',
streamUrl: 'http://example.test/stream.ts',
status: 'playing',
positionSeconds: 12,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
startedAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:10.000Z',
...overrides,
};
}
describe('toPlayerStreamStats', () => {
it('maps mpv properties onto the shared stats shape', () => {
const stats = toPlayerStreamStats(
createSession({
videoWidth: 1920,
videoHeight: 1080,
stats: {
fps: 50.04,
videoBitrateBps: 6_200_000,
audioBitrateBps: 192_000,
videoCodec: 'h264',
audioCodec: 'aac',
audioChannels: '5.1',
audioSampleRateHz: 48_000,
container: 'mpegts',
bufferedAheadSeconds: 18.5,
droppedFrames: 3,
},
})
);
expect(stats).toEqual({
width: 1920,
height: 1080,
fps: 50.04,
nominalFps: null,
streamBitrateBps: null,
videoBitrateBps: 6_200_000,
audioBitrateBps: 192_000,
videoCodec: 'h264',
audioCodec: 'aac',
audioChannels: '5.1',
audioSampleRateHz: 48_000,
container: 'mpegts',
bufferedAheadSeconds: 18.5,
droppedFrames: 3,
// mpv counts drops but never presented frames.
totalFrames: null,
});
});
it('keeps the size rows when only dwidth/dheight are known', () => {
const stats = toPlayerStreamStats(
createSession({ videoWidth: 1280, videoHeight: 720 })
);
expect(stats).toEqual(
expect.objectContaining({
width: 1280,
height: 720,
fps: null,
videoCodec: null,
})
);
});
it('returns null when the engine reported nothing usable', () => {
expect(toPlayerStreamStats(createSession())).toBeNull();
expect(toPlayerStreamStats(createSession({ stats: {} }))).toBeNull();
expect(toPlayerStreamStats(null)).toBeNull();
});
it('hides the info capability after dimensions reset to unknown', () => {
expect(
toPlayerStreamStats(
createSession({
videoWidth: 0,
videoHeight: 0,
stats: {},
})
)
).toBeNull();
});
it('drops blank codec strings instead of rendering empty rows', () => {
const stats = toPlayerStreamStats(
createSession({
stats: { videoCodec: ' ', audioCodec: 'aac' },
})
);
expect(stats).toEqual(
expect.objectContaining({ videoCodec: null, audioCodec: 'aac' })
);
});
it('keeps a zero drop counter — it is a real measurement', () => {
const stats = toPlayerStreamStats(
createSession({ stats: { droppedFrames: 0 } })
);
expect(stats?.droppedFrames).toBe(0);
});
});
@@ -0,0 +1,60 @@
import type { EmbeddedMpvSession } from '@iptvnator/shared/interfaces';
import {
hasStreamStatsData,
type PlayerStreamStats,
} from '../player-controls/player-stream-stats.model';
/**
* Projects an mpv session snapshot onto the shared stream-stats shape.
*
* The numbers arrive already sampled by the main process (mpv pushes observed
* properties), so this is a pure mapping — no polling of its own. Returns null
* when the engine reported nothing usable, which is what keeps the info button
* hidden on engines that do not observe these properties.
*/
export function toPlayerStreamStats(
session: EmbeddedMpvSession | null | undefined
): PlayerStreamStats | null {
if (!session) {
return null;
}
const stats = session.stats;
// Every field is listed explicitly (no spread of a neutral default): a new
// stat then fails to compile here until mpv's source for it is decided,
// instead of silently reporting null forever.
const mapped: PlayerStreamStats = {
width: positiveOrNull(session.videoWidth),
height: positiveOrNull(session.videoHeight),
fps: finiteOrNull(stats?.fps),
nominalFps: null,
streamBitrateBps: null,
videoBitrateBps: finiteOrNull(stats?.videoBitrateBps),
audioBitrateBps: finiteOrNull(stats?.audioBitrateBps),
videoCodec: nonEmptyOrNull(stats?.videoCodec),
audioCodec: nonEmptyOrNull(stats?.audioCodec),
audioChannels: nonEmptyOrNull(stats?.audioChannels),
audioSampleRateHz: finiteOrNull(stats?.audioSampleRateHz),
container: nonEmptyOrNull(stats?.container),
bufferedAheadSeconds: finiteOrNull(stats?.bufferedAheadSeconds),
droppedFrames: finiteOrNull(stats?.droppedFrames),
// mpv counts drops, not presented frames, so a drop rate cannot be
// derived here the way it can from a <video> element.
totalFrames: null,
};
return hasStreamStatsData(mapped) ? mapped : null;
}
function finiteOrNull(value: number | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function nonEmptyOrNull(value: string | undefined): string | null {
return value?.trim() ? value.trim() : null;
}
function positiveOrNull(value: number | undefined): number | null {
const finite = finiteOrNull(value);
return finite !== null && finite > 0 ? finite : null;
}
@@ -0,0 +1,116 @@
import { signal } from '@angular/core';
import type { ControlsSurface } from './controls-surface';
import type { ControlsVisibility } from './controls-visibility';
export interface ControlsChromeInteractionsConfig {
surface: ControlsSurface;
visibility: ControlsVisibility;
reveal: (options?: { scheduleHide?: boolean }) => void;
}
/**
* Pointer/focus state machine of the controls chrome.
*
* It answers one question for the auto-hide logic — "is the user currently
* working the controls?" — and distinguishes pointer activity (reveals, never
* pins) from keyboard ownership (pins them open until focus leaves).
*
* Every chrome region binds to one shared instance (the bottom bar and the
* top-right corner today): the pin belongs to the interaction, not to a DOM
* region, so moving the pointer from the corner into the bar must not let the
* controls hide mid-journey. {@link onFocusOut} stays per-region on purpose —
* its `currentTarget.contains()` check asks whether focus left *that* region,
* and a focus move between regions re-arms the hide timer before the arriving
* region pins it again.
*/
export class ControlsChromeInteractions {
readonly hovered = signal(false);
readonly focused = signal(false);
constructor(private readonly config: ControlsChromeInteractionsConfig) {}
/** True while the controls must stay on screen for the user's sake. */
get engaged(): boolean {
return this.hovered() || this.focused();
}
onPointerEnter(): void {
this.hovered.set(true);
this.config.reveal({ scheduleHide: false });
}
onPointerLeave(): void {
this.hovered.set(false);
this.config.visibility.scheduleHide();
}
/**
* A pointer press anywhere in the chrome hands the interaction over to the
* pointer: a keyboard pin set by an earlier Tab is released here, because
* the press may not produce any focus event at all (clicking the control
* that is already focused) or only a focus transfer inside the bar, which
* {@link onFocusOut} deliberately ignores.
*/
onPointerDown(): void {
this.focused.set(false);
}
/**
* A key press that bubbles out of a control inside the chrome means the
* keyboard is operating that control (Space/Enter on a button, arrows on
* a slider): the bar is pinned exactly as if the control had been focused
* with Tab. A completed pointer click no longer leaves its control
* focused ({@link onClick}), but a press released off the control does,
* without a pin, and the key press then produces no new focus event, so
* this is the only place that hands ownership back to the keyboard.
*/
onKeyDown(): void {
this.focused.set(true);
this.config.reveal({ scheduleHide: false });
}
/**
* A pointer click leaves the clicked control focused ({@link onFocusIn}),
* and a focused control captures the keyboard: Space and Enter activate
* it again instead of toggling playback, and the playback shortcuts
* yield to any interactive element in the key's path — after a click on
* the fullscreen button, Space left fullscreen instead of pausing. The
* focus was never the keyboard's, so it is released once the click
* completes; keyboard activation (an empty click `pointerType`) keeps
* focus where Tab put it.
*/
onClick(event: MouseEvent): void {
const bar = event.currentTarget;
if (
bar instanceof HTMLElement &&
this.config.surface.wasPointerClick(event)
) {
this.config.surface.releasePointerFocus(bar);
}
}
onFocusIn(event: FocusEvent): void {
// Chromium moves focus to a clicked <button>. That focus is a side
// effect of the click, not keyboard navigation: it must reveal like
// any pointer activity, but never pin the bar open; `onClick` drops it
// again once the click completes (issue: fullscreen button left the
// controls on screen until a click-to-pause on the viewport).
if (this.config.surface.wasPointerInteraction(event)) {
this.focused.set(false);
this.config.reveal();
return;
}
this.focused.set(true);
this.config.reveal({ scheduleHide: false });
}
onFocusOut(event: FocusEvent): void {
const bar = event.currentTarget as HTMLElement | null;
const next = event.relatedTarget;
if (bar && next instanceof Node && bar.contains(next)) {
return;
}
this.focused.set(false);
this.config.visibility.scheduleHide();
}
}
@@ -1,3 +1,5 @@
import { positiveOrNull } from './positive-number.util';
export function formatTime(value: number | null | undefined): string {
const safeValue = Math.max(0, Math.floor(value ?? 0));
const hours = Math.floor(safeValue / 3600);
@@ -24,6 +26,30 @@ export function volumeLabel(value: number): string {
return `Volume ${Math.round(value * 100)}%`;
}
/**
* Formats a bitrate as e.g. `12 Mbps` / `4.6 Mbps` / `128 kbps`.
*
* Shared by the quality-level labels and the stream-info popover on purpose:
* both can render the *same* rendition in the same overlay, and two spellings
* of one number read as two different numbers.
*/
export function formatBitrate(
bitsPerSecond: number | null | undefined
): string | null {
const value = positiveOrNull(bitsPerSecond);
if (value === null) {
return null;
}
if (value >= 1_000_000) {
const megabits = value / 1_000_000;
// A decimal stops carrying information once the integer part does.
const rendered =
megabits >= 10 ? Math.round(megabits) : megabits.toFixed(1);
return `${rendered} Mbps`;
}
return `${Math.round(value / 1000)} kbps`;
}
/** Rounds and formats a playback rate as e.g. `1.5×` for tooltips. */
export function speedLabel(speed: number): string {
const value = Math.round(speed * 100) / 100;
@@ -35,6 +35,7 @@ describe('ControlsMenuState', () => {
'quality',
'speed',
'aspect',
'stats',
] as const)(
'closes an open %s menu when it becomes unavailable',
(menu) => {
@@ -48,6 +49,7 @@ describe('ControlsMenuState', () => {
quality: menu !== 'quality',
speed: menu !== 'speed',
aspect: menu !== 'aspect',
stats: menu !== 'stats',
});
expect(changed).toBe(true);
@@ -67,6 +69,7 @@ describe('ControlsMenuState', () => {
quality: true,
speed: true,
aspect: true,
stats: true,
})
).toBe(false);
expect(menus.speedOpen()).toBe(true);
@@ -85,6 +88,7 @@ describe('ControlsMenuState', () => {
quality: false,
speed: false,
aspect: false,
stats: false,
})
).toBe(true);
expect(menus.anyOpen()).toBe(false);
@@ -97,6 +101,7 @@ describe('ControlsMenuState', () => {
['quality', {}, { qualityLevels: [] }],
['speed', { playbackSpeed: false }, {}],
['aspect', { aspectRatio: false }, {}],
['stats', { streamStats: false }, {}],
] as const)(
'maps runtime controller state to %s menu availability',
(menu, capabilityOverrides, stateOverrides) => {
@@ -113,6 +118,7 @@ describe('ControlsMenuState', () => {
qualityLevels: true,
playbackSpeed: true,
aspectRatio: true,
streamStats: true,
...capabilityOverrides,
},
{
@@ -11,6 +11,7 @@ const CONTROL_MENUS = [
'quality',
'speed',
'aspect',
'stats',
] as const;
export type ControlsMenu = (typeof CONTROL_MENUS)[number];
@@ -40,6 +41,7 @@ function getControlsMenuAvailability(
state.qualityLevels.length > 1,
speed: showControls && capabilities.playbackSpeed,
aspect: showControls && capabilities.aspectRatio,
stats: showControls && capabilities.streamStats,
};
}
@@ -54,6 +56,7 @@ export class ControlsMenuState {
readonly qualityOpen = signal(false);
readonly speedOpen = signal(false);
readonly aspectOpen = signal(false);
readonly statsOpen = signal(false);
readonly anyOpen = computed(
() =>
@@ -62,7 +65,8 @@ export class ControlsMenuState {
this.subtitleOpen() ||
this.qualityOpen() ||
this.speedOpen() ||
this.aspectOpen()
this.aspectOpen() ||
this.statsOpen()
);
toggle(menu: ControlsMenu): void {
@@ -91,6 +95,7 @@ export class ControlsMenuState {
this.qualityOpen.set(false);
this.speedOpen.set(false);
this.aspectOpen.set(false);
this.statsOpen.set(false);
}
reconcile(availability: ControlsMenuAvailability): boolean {
@@ -129,6 +134,8 @@ export class ControlsMenuState {
return this.speedOpen;
case 'aspect':
return this.aspectOpen;
case 'stats':
return this.statsOpen;
}
}
}
@@ -0,0 +1,158 @@
import {
ControlsStreamStats,
STREAM_STATS_SAMPLE_INTERVAL_MS,
} from './controls-stream-stats';
import { type PlayerStreamStatsSource } from './player-stream-stats.model';
import { WebVideoStreamStatsSampler } from './web-video-stream-stats';
import { emptyStreamStats } from './stream-stats.spec-helpers';
describe('ControlsStreamStats', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
const createSource = (
sample: jest.Mock = jest.fn(() => emptyStreamStats({ fps: 25 }))
): { source: PlayerStreamStatsSource; sample: jest.Mock } => ({
source: { sample: sample as PlayerStreamStatsSource['sample'] },
sample,
});
it('samples nothing until it is started', () => {
const { source, sample } = createSource();
const stats = new ControlsStreamStats(() => source);
jest.advanceTimersByTime(5 * STREAM_STATS_SAMPLE_INTERVAL_MS);
expect(sample).not.toHaveBeenCalled();
expect(stats.rows()).toEqual([]);
});
it('samples immediately on start, then on the interval', () => {
const { source, sample } = createSource();
const stats = new ControlsStreamStats(() => source);
stats.start();
expect(sample).toHaveBeenCalledTimes(1);
expect(stats.hasRows()).toBe(true);
jest.advanceTimersByTime(STREAM_STATS_SAMPLE_INTERVAL_MS * 3);
expect(sample).toHaveBeenCalledTimes(4);
});
it('stops sampling and forgets the snapshot when closed', () => {
const { source, sample } = createSource();
const stats = new ControlsStreamStats(() => source);
stats.start();
stats.stop();
jest.advanceTimersByTime(STREAM_STATS_SAMPLE_INTERVAL_MS * 3);
expect(sample).toHaveBeenCalledTimes(1);
// A reopen must show fresh numbers, never the previous stream's.
expect(stats.rows()).toEqual([]);
});
it('starts a fresh frame measurement when reopened after a long pause', () => {
const quality = { totalVideoFrames: 100, droppedVideoFrames: 0 };
const video = {
videoWidth: 640,
videoHeight: 360,
paused: false,
readyState: 4,
buffered: { length: 0 },
getVideoPlaybackQuality: () => quality,
} as unknown as HTMLVideoElement;
const sampler = new WebVideoStreamStatsSampler(
() => video,
() => null
);
const stats = new ControlsStreamStats(() => sampler);
stats.start();
quality.totalVideoFrames += 30;
jest.advanceTimersByTime(1000);
expect(stats.rows()).toContainEqual({
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_FRAME_RATE',
value: '30 fps',
});
stats.stop();
jest.advanceTimersByTime(60000);
quality.totalVideoFrames += 30;
stats.start();
expect(
stats
.rows()
.some((row) => row.labelKey.endsWith('STATS_FRAME_RATE'))
).toBe(false);
quality.totalVideoFrames += 30;
jest.advanceTimersByTime(1000);
expect(stats.rows()).toContainEqual({
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_FRAME_RATE',
value: '30 fps',
});
stats.dispose();
});
it('restarting does not leave a second interval running', () => {
const { source, sample } = createSource();
const stats = new ControlsStreamStats(() => source);
stats.start();
stats.start();
sample.mockClear();
jest.advanceTimersByTime(STREAM_STATS_SAMPLE_INTERVAL_MS);
expect(sample).toHaveBeenCalledTimes(1);
});
it('renders rows from the sampled snapshot', () => {
const { source } = createSource(
jest.fn(() => emptyStreamStats({ width: 1920, height: 1080 }))
);
const stats = new ControlsStreamStats(() => source);
stats.start();
expect(stats.rows()).toEqual([
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_RESOLUTION',
value: '1920 × 1080 · 16:9',
},
]);
});
it('survives an engine that throws while tearing down', () => {
const { source } = createSource(
jest.fn(() => {
throw new Error('engine gone');
})
);
const stats = new ControlsStreamStats(() => source);
expect(() => stats.start()).not.toThrow();
expect(stats.hasRows()).toBe(false);
});
it('treats a controller without a stats source as empty', () => {
const stats = new ControlsStreamStats(() => undefined);
stats.start();
expect(stats.hasRows()).toBe(false);
});
it('clears its interval on dispose', () => {
const { source, sample } = createSource();
const stats = new ControlsStreamStats(() => source);
stats.start();
stats.dispose();
jest.advanceTimersByTime(STREAM_STATS_SAMPLE_INTERVAL_MS * 2);
expect(sample).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,70 @@
import { computed, signal } from '@angular/core';
import type {
PlayerStreamStats,
PlayerStreamStatsSource,
} from './player-stream-stats.model';
import { buildStreamStatsRows } from './stream-stats-format.utils';
/**
* How often the open popover re-reads the engine. Fast enough that a bitrate
* or buffer change is visible while watching a row, slow enough that the
* numbers stay readable instead of flickering.
*/
export const STREAM_STATS_SAMPLE_INTERVAL_MS = 1000;
/**
* Owns the stream-info popover's sampling loop.
*
* Sampling runs only between {@link start} and {@link stop} — i.e. only while
* the popover is open — so a closed panel costs nothing, and the engine's own
* frame-rate accounting is not driven by a UI nobody is looking at.
*/
export class ControlsStreamStats {
private readonly snapshot = signal<PlayerStreamStats | null>(null);
private timer: ReturnType<typeof setInterval> | null = null;
readonly rows = computed(() => buildStreamStatsRows(this.snapshot()));
readonly hasRows = computed(() => this.rows().length > 0);
constructor(
private readonly source: () =>
PlayerStreamStatsSource | null | undefined
) {}
start(): void {
this.stop();
try {
this.source()?.reset?.();
} catch {
// Diagnostic sources must never break playback during teardown.
}
this.sample();
this.timer = setInterval(
() => this.sample(),
STREAM_STATS_SAMPLE_INTERVAL_MS
);
}
/** Idempotent; drops the snapshot so a reopen never shows stale numbers. */
stop(): void {
if (this.timer !== null) {
clearInterval(this.timer);
this.timer = null;
}
this.snapshot.set(null);
}
dispose(): void {
this.stop();
}
private sample(): void {
try {
this.snapshot.set(this.source()?.sample() ?? null);
} catch {
// A stats read is diagnostic only: an engine that throws mid-teardown
// must never take the controls down with it.
this.snapshot.set(null);
}
}
}
@@ -1,10 +1,13 @@
export * from './player-controls.model';
export * from './player-stream-stats.model';
export * from './player-controls-defaults';
export * from './subtitle-style';
export * from './player-controls.component';
export * from './controls-menu-state';
export * from './controls-subtitle-settings';
export * from './controls-chrome-interactions';
export * from './controls-feedback';
export * from './controls-stream-stats';
export * from './controls-fullscreen';
export * from './controls-shortcuts';
export * from './legacy-player-shortcuts';
@@ -17,4 +20,5 @@ export * from './controls-volume';
export * from './controls-format.utils';
export * from './web-player-controls.flag';
export * from './web-video-controls.adapter';
export * from './web-video-stream-stats';
export * from './web-video-controls.host';
@@ -20,6 +20,7 @@ export const DEFAULT_PLAYER_CAPABILITIES: PlayerControlsCapabilities = {
pictureInPicture: false,
fullscreen: false,
seriesNavigation: false,
streamStats: false,
};
export const DEFAULT_SPEED_PRESETS: ReadonlyArray<PlayerPreset<number>> = [
@@ -9,6 +9,15 @@
</div>
}
@if (showTopScrim()) {
<div
class="player-controls__top-scrim"
[class.player-controls__top-scrim--visible]="controlsAreVisible()"
data-test-id="player-controls-top-scrim"
aria-hidden="true"
></div>
}
@if (fullscreenMediaTitle(); as mediaTitle) {
<div
class="player-controls__title"
@@ -26,18 +35,79 @@
</div>
}
@if (showControls() && capabilities().streamStats) {
<div
class="player-controls__corner"
[class.player-controls__corner--visible]="controlsAreVisible()"
(pointerenter)="chrome.onPointerEnter()"
(pointerleave)="chrome.onPointerLeave()"
(pointerdown)="chrome.onPointerDown()"
(click)="chrome.onClick($event)"
(keydown)="chrome.onKeyDown()"
(focusin)="chrome.onFocusIn($event)"
(focusout)="chrome.onFocusOut($event)"
>
<div class="player-controls__popover-anchor">
<button
mat-icon-button
type="button"
class="player-controls__button"
data-test-id="player-controls-stream-info-button"
(click)="toggleMenu('stats')"
[attr.aria-expanded]="menus.statsOpen()"
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.STREAM_INFO' | translate"
[matTooltip]="'EMBEDDED_MPV.PLAYER.STREAM_INFO' | translate"
matTooltipPosition="below"
>
<mat-icon>info_outline</mat-icon>
</button>
@if (menus.statsOpen()) {
<div
class="player-controls__panel player-controls__stats-popover"
role="dialog"
data-test-id="player-controls-stream-info-panel"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.STREAM_INFO' | translate
"
>
<div class="player-controls__menu-title">
{{ 'EMBEDDED_MPV.PLAYER.STREAM_INFO' | translate }}
</div>
@if (streamStats.hasRows()) {
<dl class="player-controls__stats-list">
@for (row of streamStats.rows(); track row.labelKey) {
<div class="player-controls__stats-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd>{{ row.value }}</dd>
</div>
}
</dl>
} @else {
<div class="player-controls__stats-empty">
{{
'EMBEDDED_MPV.PLAYER.STATS_UNAVAILABLE'
| translate
}}
</div>
}
</div>
}
</div>
</div>
}
@if (showControls()) {
<div
#controlsBar
class="player-controls__bar"
[class.player-controls__bar--visible]="controlsAreVisible()"
(pointerenter)="onBarPointerEnter()"
(pointerleave)="onBarPointerLeave()"
(pointerdown)="onBarPointerDown()"
(click)="onBarClick($event)"
(keydown)="onBarKeyDown()"
(focusin)="onBarFocusIn($event)"
(focusout)="onBarFocusOut($event)"
(pointerenter)="chrome.onPointerEnter()"
(pointerleave)="chrome.onPointerLeave()"
(pointerdown)="chrome.onPointerDown()"
(click)="chrome.onClick($event)"
(keydown)="chrome.onKeyDown()"
(focusin)="chrome.onFocusIn($event)"
(focusout)="chrome.onFocusOut($event)"
>
<div class="player-controls__transport">
<button
@@ -90,6 +90,25 @@ describe('PlayerControlsComponent fullscreen media title', () => {
jest.useRealTimers();
});
it('backs the fullscreen title with the shared top scrim', () => {
// The engine reports no stats here, so the title is the only reason
// for the scrim to exist — it must still get one.
fixture.componentRef.setInput('mediaTitle', {
primary: 'Breaking Code',
secondary: null,
});
fixture.detectChanges();
const scrim = () =>
fixture.nativeElement.querySelector(
'[data-test-id="player-controls-top-scrim"]'
);
expect(scrim()).toBeNull();
enterFullscreen();
expect(scrim()).not.toBeNull();
});
it('stays hidden outside fullscreen even when a media title is set', () => {
fixture.componentRef.setInput('mediaTitle', {
primary: 'Some Movie',
@@ -72,6 +72,39 @@
}
}
// Backdrop for every piece of top chrome — the fullscreen media title and the
// corner buttons. Without it a white-on-video icon is invisible over bright
// footage. Deliberately one element rather than a background per consumer: two
// overlapping gradients would darken the overlap twice.
//
// The stops are the bottom bar's, mirrored, so the two edges of the player read
// as one system.
.player-controls__top-scrim {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 1;
// Mirrors the bar's 48px top padding + 64px min-height.
height: 112px;
opacity: 0;
// Purely decorative: clicks belong to the video surface underneath.
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.78) 0%,
rgba(0, 0, 0, 0.45) 45%,
transparent 100%
);
// Fades with the controls, but does not slide: a moving scrim edge is
// visible against video in a way a moving control is not.
transition: opacity 180ms ease-out;
}
.player-controls__top-scrim--visible {
opacity: 1;
}
.player-controls__title {
position: absolute;
top: 0;
@@ -81,24 +114,23 @@
display: flex;
flex-direction: column;
gap: 4px;
// Generous bottom padding lets the scrim fall below the text and fade out.
// Bottom padding keeps the text inside the top scrim's opaque half.
padding: 20px 24px 40px;
opacity: 0;
// Purely informative: never intercept clicks meant for the video surface.
pointer-events: none;
transform: translateY(-8px);
// Mirror of the bottom bar's scrim so the title reads over bright video.
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.35) 55%,
transparent 100%
);
transition:
opacity 180ms ease-out,
transform 200ms ease-out;
}
// Only when the corner button is actually rendered: keep the fullscreen title
// from running underneath it (a long title ellipsizes at the full width).
.player-controls__title:has(~ .player-controls__corner) {
padding-right: 64px;
}
.player-controls__title--visible {
opacity: 1;
transform: translateY(0);
@@ -125,6 +157,29 @@
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
// Top-right corner chrome (stream-info button). It fades with the bottom bar
// but is its own layer: the title scrim above it is click-through, so the
// button opts back into pointer events on its own.
.player-controls__corner {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
display: flex;
opacity: 0;
pointer-events: none;
transform: translateY(-8px);
transition:
opacity 180ms ease-out,
transform 200ms ease-out;
}
.player-controls__corner--visible {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.player-controls__bar {
position: absolute;
right: 0;
@@ -286,7 +341,9 @@
color: rgba(255, 255, 255, 0.38);
}
:host .player-controls__bar :is(button[mat-icon-button]:hover) {
:host
:is(.player-controls__bar, .player-controls__corner)
:is(button[mat-icon-button]:hover) {
color: #ffffff;
background: rgba(255, 255, 255, 0.16);
}
@@ -454,6 +511,58 @@
white-space: nowrap;
}
// Anchored under the corner button (the only popover that opens downward);
// the label/value grid keeps the numbers on a common right edge.
.player-controls__stats-popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
display: flex;
flex-direction: column;
min-width: 240px;
max-width: min(360px, calc(100cqw - 24px), 80vw);
max-height: min(calc(100cqh - 96px), 70vh);
padding: 10px 12px;
overflow-y: auto;
scrollbar-width: thin;
z-index: 3;
}
.player-controls__stats-list {
display: grid;
gap: 6px;
margin: 0;
}
.player-controls__stats-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 16px;
align-items: baseline;
}
.player-controls__stats-row dt {
color: var(--pc-text-dim);
font-size: 0.74rem;
white-space: nowrap;
}
.player-controls__stats-row dd {
margin: 0;
overflow: hidden;
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.player-controls__stats-empty {
padding: 2px;
color: var(--pc-text-dim);
font-size: 0.8rem;
}
.player-controls__track-list {
display: flex;
flex-direction: column;
@@ -657,6 +766,12 @@
// button clusters. Container-based on purpose: a small inline player inside a
// wide desktop window needs the same treatment as a phone viewport.
@container player-controls (max-width: 640px) {
// Matches the bar's reduced padding below: on a small inline player two
// full-height scrims would tint most of the picture.
.player-controls__top-scrim {
height: 88px;
}
.player-controls__bar {
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas:
@@ -739,14 +739,14 @@ describe('PlayerControlsComponent', () => {
describe('bar-hover state', () => {
const bar = () => query('.player-controls__bar') as HTMLElement | null;
it('tracks barHovered on bar pointerenter / pointerleave', () => {
expect(fixture.componentInstance.barHovered()).toBe(false);
it('tracks chrome hover on bar pointerenter / pointerleave', () => {
expect(fixture.componentInstance.chrome.hovered()).toBe(false);
bar()?.dispatchEvent(new MouseEvent('pointerenter'));
expect(fixture.componentInstance.barHovered()).toBe(true);
expect(fixture.componentInstance.chrome.hovered()).toBe(true);
bar()?.dispatchEvent(new MouseEvent('pointerleave'));
expect(fixture.componentInstance.barHovered()).toBe(false);
expect(fixture.componentInstance.chrome.hovered()).toBe(false);
});
});
@@ -0,0 +1,222 @@
import { WritableSignal, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { STREAM_STATS_SAMPLE_INTERVAL_MS } from './controls-stream-stats';
import {
DEFAULT_PLAYER_CAPABILITIES,
createEmptyControlsState,
} from './player-controls-defaults';
import { PlayerControlsComponent } from './player-controls.component';
import type {
PlayerControlsCapabilities,
PlayerControlsCommands,
PlayerControlsState,
PlayerController,
} from './player-controls.model';
import { type PlayerStreamStats } from './player-stream-stats.model';
import { emptyStreamStats } from './stream-stats.spec-helpers';
function createFakeController(sample: jest.Mock) {
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
...DEFAULT_PLAYER_CAPABILITIES,
streamStats: true,
});
const state: WritableSignal<PlayerControlsState> = signal(
createEmptyControlsState()
);
const commands = {
togglePlay: jest.fn(),
seekTo: jest.fn(),
seekBy: jest.fn(),
setVolume: jest.fn(),
setAudioTrack: jest.fn(),
setSubtitleTrack: jest.fn(),
addExternalSubtitleFile: jest.fn(),
setSubtitleDelay: jest.fn(),
setSubtitleStyle: jest.fn(),
setQualityLevel: jest.fn(),
setPlaybackSpeed: jest.fn(),
setAspectRatio: jest.fn(),
toggleRecording: jest.fn(),
togglePictureInPicture: jest.fn(),
} as jest.Mocked<PlayerControlsCommands>;
const controller: PlayerController = {
capabilities,
state,
commands,
streamStats: { sample: sample as () => PlayerStreamStats | null },
};
return { controller, capabilities, state };
}
describe('PlayerControlsComponent stream info', () => {
let fixture: ComponentFixture<PlayerControlsComponent>;
let fake: ReturnType<typeof createFakeController>;
let sample: jest.Mock;
let surface: HTMLElement;
const queryButton = (): HTMLButtonElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="player-controls-stream-info-button"]'
);
const queryPanel = (): HTMLElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="player-controls-stream-info-panel"]'
);
const queryScrim = (): HTMLElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="player-controls-top-scrim"]'
);
const openPanel = () => {
queryButton()?.click();
fixture.detectChanges();
};
beforeEach(async () => {
jest.useFakeTimers();
localStorage.removeItem('volume');
await TestBed.configureTestingModule({
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
}).compileComponents();
surface = document.createElement('div');
document.body.appendChild(surface);
sample = jest.fn((): PlayerStreamStats =>
emptyStreamStats({ width: 1920, height: 1080, fps: 50 })
);
fake = createFakeController(sample);
fixture = TestBed.createComponent(PlayerControlsComponent);
fixture.componentRef.setInput('controller', fake.controller);
fixture.componentRef.setInput('playerSurface', surface);
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy();
surface.remove();
jest.useRealTimers();
});
it('renders the corner button only when the engine reports stats', () => {
expect(queryButton()).not.toBeNull();
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
streamStats: false,
});
fixture.detectChanges();
expect(queryButton()).toBeNull();
});
it('backs the corner button with a scrim, in windowed playback too', () => {
// Without it a white icon is invisible over bright video, and the
// media title's scrim only exists in fullscreen.
expect(queryScrim()).not.toBeNull();
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
streamStats: false,
});
fixture.detectChanges();
// No corner and no title left to back.
expect(queryScrim()).toBeNull();
});
it('fades the scrim together with the controls', () => {
fake.state.set({
...createEmptyControlsState(),
status: 'playing',
});
fixture.detectChanges();
const visibleClass = 'player-controls__top-scrim--visible';
expect(queryScrim()?.classList).toContain(visibleClass);
fixture.componentInstance.menus.closeAll();
jest.advanceTimersByTime(10_000);
fixture.detectChanges();
expect(fixture.componentInstance.controlsAreVisible()).toBe(false);
expect(queryScrim()?.classList).not.toContain(visibleClass);
});
it('opens the popover with a row per known value', () => {
expect(queryPanel()).toBeNull();
openPanel();
const rows = fixture.nativeElement.querySelectorAll(
'.player-controls__stats-row'
);
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('1920 × 1080 · 16:9');
expect(rows[1].textContent).toContain('50 fps');
});
it('samples only while the popover is open', () => {
jest.advanceTimersByTime(STREAM_STATS_SAMPLE_INTERVAL_MS * 2);
expect(sample).not.toHaveBeenCalled();
openPanel();
expect(sample).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(STREAM_STATS_SAMPLE_INTERVAL_MS * 2);
expect(sample).toHaveBeenCalledTimes(3);
openPanel();
expect(queryPanel()).toBeNull();
sample.mockClear();
jest.advanceTimersByTime(STREAM_STATS_SAMPLE_INTERVAL_MS * 3);
expect(sample).not.toHaveBeenCalled();
});
it('stops sampling when the engine loses the capability mid-playback', () => {
openPanel();
sample.mockClear();
fake.capabilities.set({
...DEFAULT_PLAYER_CAPABILITIES,
streamStats: false,
});
fake.state.set({ ...createEmptyControlsState() });
fixture.detectChanges();
jest.advanceTimersByTime(STREAM_STATS_SAMPLE_INTERVAL_MS * 2);
expect(queryPanel()).toBeNull();
expect(sample).not.toHaveBeenCalled();
});
it('shows a placeholder while the engine has no numbers yet', () => {
sample.mockReturnValue(null);
openPanel();
expect(
fixture.nativeElement.querySelector('.player-controls__stats-empty')
).not.toBeNull();
expect(
fixture.nativeElement.querySelectorAll(
'.player-controls__stats-row'
)
).toHaveLength(0);
});
it('keeps the controls pinned open while the popover is up', () => {
fake.state.set({
...createEmptyControlsState(),
status: 'playing',
});
fixture.detectChanges();
openPanel();
expect(fixture.componentInstance.menus.anyOpen()).toBe(true);
expect(fixture.componentInstance.controlsAreVisible()).toBe(true);
});
});
@@ -8,18 +8,19 @@ import {
inject,
input,
output,
signal,
untracked,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ControlsChromeInteractions } from './controls-chrome-interactions';
import { ControlsFeedback } from './controls-feedback';
import { ControlsFullscreen } from './controls-fullscreen';
import { ControlsMenuSelection } from './controls-menu-selection';
import { ControlsMenuState } from './controls-menu-state';
import { type ControlsMenu, ControlsMenuState } from './controls-menu-state';
import { ControlsShortcuts } from './controls-shortcuts';
import { ControlsStreamStats } from './controls-stream-stats';
import { ControlsSurface } from './controls-surface';
import { ControlsTimeline } from './controls-timeline';
import { ControlsVisibility } from './controls-visibility';
@@ -69,8 +70,6 @@ export class PlayerControlsComponent implements OnDestroy {
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
readonly previousEpisodeRequested = output<void>();
readonly nextEpisodeRequested = output<void>();
readonly barHovered = signal(false);
private readonly barFocused = signal(false);
readonly menus = new ControlsMenuState();
readonly feedback = new ControlsFeedback();
readonly anyMenuOpen = this.menus.anyOpen;
@@ -118,6 +117,14 @@ export class PlayerControlsComponent implements OnDestroy {
canAdjustVolume: () => this.capabilities().volume,
reveal: (options) => this.reveal(options),
});
readonly chrome = new ControlsChromeInteractions({
surface: this.surface,
visibility: this.visibility,
reveal: (options) => this.reveal(options),
});
readonly streamStats = new ControlsStreamStats(
() => this.controller().streamStats
);
readonly state = computed(() => this.controller().state());
readonly capabilities = computed(() => this.controller().capabilities());
@@ -141,6 +148,18 @@ export class PlayerControlsComponent implements OnDestroy {
return mediaTitle?.primary?.trim() ? mediaTitle : null;
});
/**
* The top scrim is shared by every piece of top chrome (the fullscreen
* media title and the corner buttons): rendering one per consumer would
* stack two gradients and darken the overlap twice.
*/
readonly showTopScrim = computed(
() =>
this.showControls() &&
(this.capabilities().streamStats ||
this.fullscreenMediaTitle() !== null)
);
private readonly vm = createControlsViewModel({
state: this.state,
capabilities: this.capabilities,
@@ -214,6 +233,16 @@ export class PlayerControlsComponent implements OnDestroy {
this.volume.reconcileController(controller, volume)
);
});
effect((onCleanup) => {
// Every close path (toggle, Escape, capability loss, teardown)
// runs through the signal, so the sampler can never outlive the
// panel it feeds.
if (!this.menus.statsOpen()) {
return;
}
untracked(() => this.streamStats.start());
onCleanup(() => this.streamStats.stop());
});
effect((onCleanup) => {
const surface = this.playerSurface();
if (!surface || !this.hideCursor()) {
@@ -259,6 +288,7 @@ export class PlayerControlsComponent implements OnDestroy {
this.fullscreen.dispose();
this.volume.dispose();
this.surface.dispose();
this.streamStats.dispose();
}
formatTime = formatTime;
speedLabel = speedLabel;
@@ -315,9 +345,7 @@ export class PlayerControlsComponent implements OnDestroy {
}
this.nextEpisodeRequested.emit();
}
toggleMenu(
menu: 'audio' | 'subtitle' | 'quality' | 'speed' | 'aspect'
): void {
toggleMenu(menu: ControlsMenu): void {
this.menus.toggle(menu);
this.reveal();
}
@@ -363,88 +391,10 @@ export class PlayerControlsComponent implements OnDestroy {
this.visibility.reveal(options);
}
onBarPointerEnter(): void {
this.barHovered.set(true);
this.reveal({ scheduleHide: false });
}
onBarPointerLeave(): void {
this.barHovered.set(false);
this.visibility.scheduleHide();
}
/**
* A pointer press anywhere in the bar hands the interaction over to the
* pointer: a keyboard pin set by an earlier Tab is released here, because
* the press may not produce any focus event at all (clicking the control
* that is already focused) or only a focus transfer inside the bar, which
* `onBarFocusOut` deliberately ignores.
*/
onBarPointerDown(): void {
this.barFocused.set(false);
}
/**
* A key press that bubbles out of a control inside the bar means the
* keyboard is operating that control (Space/Enter on a button, arrows on
* a slider): the bar is pinned exactly as if the control had been focused
* with Tab. A completed pointer click no longer leaves its control
* focused (`onBarClick`), but a press released off the control does,
* without a pin, and the key press then produces no new focus event, so
* this is the only place that hands ownership back to the keyboard.
*/
onBarKeyDown(): void {
this.barFocused.set(true);
this.reveal({ scheduleHide: false });
}
/**
* A pointer click leaves the clicked control focused (`onBarFocusIn`),
* and a focused control captures the keyboard: Space and Enter activate
* it again instead of toggling playback, and the playback shortcuts
* yield to any interactive element in the key's path — after a click on
* the fullscreen button, Space left fullscreen instead of pausing. The
* focus was never the keyboard's, so it is released once the click
* completes; keyboard activation (an empty click `pointerType`) keeps
* focus where Tab put it.
*/
onBarClick(event: MouseEvent): void {
const bar = event.currentTarget;
if (bar instanceof HTMLElement && this.surface.wasPointerClick(event)) {
this.surface.releasePointerFocus(bar);
}
}
onBarFocusIn(event: FocusEvent): void {
// Chromium moves focus to a clicked <button>. That focus is a side
// effect of the click, not keyboard navigation: it must reveal like
// any pointer activity, but never pin the bar open; `onBarClick`
// drops it again once the click completes (issue: fullscreen button
// left the controls on screen until a click-to-pause on the viewport).
if (this.surface.wasPointerInteraction(event)) {
this.barFocused.set(false);
this.reveal();
return;
}
this.barFocused.set(true);
this.reveal({ scheduleHide: false });
}
onBarFocusOut(event: FocusEvent): void {
const bar = event.currentTarget as HTMLElement | null;
const next = event.relatedTarget;
if (bar && next instanceof Node && bar.contains(next)) {
return;
}
this.barFocused.set(false);
this.visibility.scheduleHide();
}
private canHide(): boolean {
return (
this.isPlaying() &&
!this.barHovered() &&
!this.barFocused() &&
!this.chrome.engaged &&
!this.menus.anyOpen() &&
!this.state().statusMessage
);
@@ -1,5 +1,6 @@
import type { Signal } from '@angular/core';
import type { PlayerSubtitleStyle } from '@iptvnator/shared/interfaces';
import type { PlayerStreamStatsSource } from './player-stream-stats.model';
export type PlayerStatus =
| 'idle'
@@ -29,6 +30,8 @@ export interface PlayerControlsCapabilities {
pictureInPicture: boolean;
fullscreen: boolean;
seriesNavigation: boolean;
/** Engine can report live stream data (resolution, frame rate, bitrate). */
streamStats: boolean;
}
// Canonical shape lives in @iptvnator/shared/interfaces so the Electron main
@@ -139,4 +142,10 @@ export interface PlayerController {
readonly capabilities: Signal<PlayerControlsCapabilities>;
readonly state: Signal<PlayerControlsState>;
readonly commands: PlayerControlsCommands;
/**
* Optional live stream diagnostics behind the controls' info popover.
* Absent (or paired with a false `streamStats` capability) means the
* engine reports nothing and the info button is not rendered.
*/
readonly streamStats?: PlayerStreamStatsSource;
}
@@ -0,0 +1,76 @@
/**
* Live technical data about the stream that is playing right now, shown in the
* controls' stream-info popover.
*
* Every field is nullable and independently optional: engines report wildly
* different subsets (a `<video>` element knows its frame counters but not the
* container, mpv knows the codec but has no notion of "total frames"), and a
* row is simply omitted when its value is unknown rather than rendered as a
* fake zero.
*/
export interface PlayerStreamStats {
/** Displayed video size in pixels (mpv `dwidth`/`dheight`). */
width: number | null;
height: number | null;
/** Presented-frame rate on web engines; filter-output estimate on MPV. */
fps: number | null;
/** Declared source rate; never substitutes for a measured playback stall. */
nominalFps: number | null;
/** Aggregate rendition bitrate, including audio, when declared. */
streamBitrateBps: number | null;
/** Video bitrate in bits per second. */
videoBitrateBps: number | null;
/** Audio bitrate in bits per second. */
audioBitrateBps: number | null;
/** Engine-reported codec names, e.g. `h264` / `avc1.640028`. */
videoCodec: string | null;
audioCodec: string | null;
/**
* Channel layout as the engine names it: a count (`6`) from web engines,
* a layout string (`5.1`, `stereo`) from mpv.
*/
audioChannels: string | number | null;
/** Audio sampling rate in Hz. */
audioSampleRateHz: number | null;
/** Container/format name, e.g. `mpegts` or `hls`. */
container: string | null;
/** Media buffered ahead of the playhead, in seconds. */
bufferedAheadSeconds: number | null;
/** Frames dropped since playback started. */
droppedFrames: number | null;
/** Total frames (displayed plus dropped); denominator for the drop rate. */
totalFrames: number | null;
}
/**
* Pull-based stats provider an engine adapter exposes on its
* {@link PlayerController}.
*
* Pull rather than push on purpose: the numbers move continuously (bitrate and
* buffer change every second), and folding them into `PlayerControlsState`
* would re-run every derived control signal on every tick even while nobody is
* looking at them. The controls sample this only while the popover is open.
*/
export interface PlayerStreamStatsSource {
/**
* Reads a fresh snapshot. Called on a ~1s cadence while the popover is
* open, so implementations must be cheap and side-effect free apart from
* their own sampling bookkeeping (frame-rate deltas).
*/
sample(): PlayerStreamStats | null;
/** Start a new measurement window when the popover opens. */
reset?(): void;
}
/** True when at least one field carries a usable value. */
export function hasStreamStatsData(
stats: PlayerStreamStats | null | undefined
): boolean {
if (!stats) {
return false;
}
return Object.values(stats).some((value) =>
typeof value === 'number' ? Number.isFinite(value) : Boolean(value)
);
}
@@ -0,0 +1,15 @@
/**
* The one positive-finite guard the controls' numeric readers share.
*
* Engines report "unknown" in every dialect there is — `undefined`, `null`,
* `0`, `-1`, `NaN` — and every reader needs the same answer: a usable number,
* or null. Keeping one implementation is what stops those dialects from being
* re-interpreted slightly differently per engine.
*/
export function positiveOrNull(
value: number | null | undefined
): number | null {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: null;
}
@@ -0,0 +1,286 @@
import { formatBitrate } from './controls-format.utils';
import {
buildStreamStatsRows,
formatAspectRatio,
formatAudioChannels,
formatSampleRate,
formatBufferedSeconds,
formatCodec,
formatDroppedFrames,
formatFrameRate,
formatResolution,
} from './stream-stats-format.utils';
import { emptyStreamStats } from './stream-stats.spec-helpers';
describe('stream stats formatting', () => {
describe('formatResolution', () => {
it('renders both dimensions', () => {
expect(formatResolution(1920, 1080)).toBe('1920 × 1080');
});
it('returns null when a dimension is missing or zero', () => {
expect(formatResolution(0, 1080)).toBeNull();
expect(formatResolution(1920, null)).toBeNull();
});
});
describe('formatAspectRatio', () => {
it('names common ratios', () => {
expect(formatAspectRatio(1920, 1080)).toBe('16:9');
expect(formatAspectRatio(1024, 768)).toBe('4:3');
// Raw PAL pixels really are 5:4; mpv's dwidth already corrects for
// the display aspect, so no 4:3 special case belongs here.
expect(formatAspectRatio(720, 576)).toBe('5:4');
expect(formatAspectRatio(1080, 1920)).toBe('9:16');
});
it('snaps a near-miss to the closest known ratio', () => {
// 1920x816 is 2.3529 — cinema scope, not the exact 21:9 value.
expect(formatAspectRatio(1920, 816)).toBe('2.35:1');
});
it('reduces an unusual but small ratio exactly', () => {
expect(formatAspectRatio(1000, 700)).toBe('10:7');
});
it('falls back to a decimal ratio when the reduction is unreadable', () => {
expect(formatAspectRatio(1000, 437)).toBe('2.29:1');
});
});
describe('formatFrameRate', () => {
it('drops the decimals of a whole rate', () => {
expect(formatFrameRate(50)).toBe('50 fps');
expect(formatFrameRate(24.999)).toBe('25 fps');
});
it('keeps two decimals for pulled-down rates', () => {
expect(formatFrameRate(29.97)).toBe('29.97 fps');
});
it('preserves a measured zero and omits unknown or invalid rates', () => {
expect(formatFrameRate(null)).toBeNull();
expect(formatFrameRate(0)).toBe('0 fps');
expect(formatFrameRate(-1)).toBeNull();
expect(formatFrameRate(NaN)).toBeNull();
});
});
describe('formatBitrate (shared with the quality-level labels)', () => {
it('uses Mbps above one megabit', () => {
expect(formatBitrate(4_600_000)).toBe('4.6 Mbps');
});
it('drops the decimal once the integer part carries the value', () => {
expect(formatBitrate(12_000_000)).toBe('12 Mbps');
});
it('uses kbps below one megabit', () => {
expect(formatBitrate(128_000)).toBe('128 kbps');
});
it('returns null for unknown bitrates', () => {
expect(formatBitrate(null)).toBeNull();
expect(formatBitrate(0)).toBeNull();
});
});
describe('formatCodec', () => {
it('strips the profile suffix of an engine codec id', () => {
expect(formatCodec('avc1.640028')).toBe('avc1');
expect(formatCodec('mp4a.40.2')).toBe('mp4a');
});
it('passes a plain codec name through', () => {
expect(formatCodec('h264')).toBe('h264');
});
it('returns null for blank input', () => {
expect(formatCodec(' ')).toBeNull();
expect(formatCodec(null)).toBeNull();
});
});
describe('formatAudioChannels', () => {
it('names the layouts people recognise', () => {
expect(formatAudioChannels(2)).toBe('Stereo');
expect(formatAudioChannels(6)).toBe('5.1');
expect(formatAudioChannels(8)).toBe('7.1');
expect(formatAudioChannels(1)).toBe('Mono');
});
it('accepts a numeric string from a web manifest', () => {
expect(formatAudioChannels('6')).toBe('5.1');
});
it('normalizes an mpv layout name', () => {
expect(formatAudioChannels('stereo')).toBe('Stereo');
expect(formatAudioChannels('5.1(side)')).toBe('5.1(side)');
});
it('falls back to a plain count for unusual layouts', () => {
expect(formatAudioChannels(3)).toBe('3 ch');
});
it('returns null when unknown', () => {
expect(formatAudioChannels(null)).toBeNull();
expect(formatAudioChannels(' ')).toBeNull();
expect(formatAudioChannels(0)).toBeNull();
});
});
describe('formatSampleRate', () => {
it('renders kHz without trailing zeros', () => {
expect(formatSampleRate(48_000)).toBe('48 kHz');
expect(formatSampleRate(44_100)).toBe('44.1 kHz');
expect(formatSampleRate(22_050)).toBe('22.05 kHz');
});
it('returns null when unknown', () => {
expect(formatSampleRate(null)).toBeNull();
expect(formatSampleRate(0)).toBeNull();
});
});
describe('formatBufferedSeconds', () => {
it('renders one decimal, including zero', () => {
expect(formatBufferedSeconds(12.34)).toBe('12.3 s');
expect(formatBufferedSeconds(0)).toBe('0.0 s');
});
it('returns null when unknown', () => {
expect(formatBufferedSeconds(null)).toBeNull();
});
});
describe('formatDroppedFrames', () => {
it('adds the drop rate when a total is known', () => {
expect(formatDroppedFrames(12, 1200)).toBe('12 (1.00%)');
});
it('marks a vanishingly small rate rather than rounding it to zero', () => {
expect(formatDroppedFrames(1, 1_000_000)).toBe('1 (<0.01%)');
});
it('reports the bare count without a total', () => {
expect(formatDroppedFrames(3, null)).toBe('3');
});
it('returns null when the counter itself is unknown', () => {
expect(formatDroppedFrames(null, 1200)).toBeNull();
});
});
describe('buildStreamStatsRows', () => {
it('emits one row per known value, in reading order', () => {
const rows = buildStreamStatsRows(
emptyStreamStats({
width: 1920,
height: 1080,
fps: 50,
videoCodec: 'h264',
videoBitrateBps: 4_600_000,
audioCodec: 'aac',
audioBitrateBps: 128_000,
audioChannels: 6,
audioSampleRateHz: 48_000,
container: 'mpegts',
bufferedAheadSeconds: 8.2,
droppedFrames: 2,
totalFrames: 4000,
})
);
expect(rows).toEqual([
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_RESOLUTION',
value: '1920 × 1080 · 16:9',
},
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_FRAME_RATE',
value: '50 fps',
},
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_VIDEO',
value: 'h264 · 4.6 Mbps',
},
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_AUDIO',
value: 'aac · 128 kbps',
},
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_AUDIO_CHANNELS',
value: '5.1',
},
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_SAMPLE_RATE',
value: '48 kHz',
},
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_CONTAINER',
value: 'mpegts',
},
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_BUFFER',
value: '8.2 s',
},
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_DROPPED_FRAMES',
value: '2 (0.05%)',
},
]);
});
it('omits the aspect suffix when only one dimension is known', () => {
const rows = buildStreamStatsRows(
emptyStreamStats({ width: 1920, height: null })
);
expect(rows).toEqual([]);
});
it('keeps a codec row without its bitrate and vice versa', () => {
const rows = buildStreamStatsRows(
emptyStreamStats({
videoCodec: 'hevc',
audioBitrateBps: 96_000,
})
);
expect(rows).toEqual([
{ labelKey: 'EMBEDDED_MPV.PLAYER.STATS_VIDEO', value: 'hevc' },
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_AUDIO',
value: '96 kbps',
},
]);
});
it('labels nominal FPS and aggregate bitrate separately from measured FPS and video', () => {
const rows = buildStreamStatsRows({
...emptyStreamStats({ fps: 0, videoCodec: 'h264' }),
nominalFps: 30,
streamBitrateBps: 3_000_000,
});
expect(rows).toEqual([
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_FRAME_RATE',
value: '0 fps',
},
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_NOMINAL_FRAME_RATE',
value: '30 fps',
},
{
labelKey: 'EMBEDDED_MPV.PLAYER.STATS_STREAM_BITRATE',
value: '3.0 Mbps',
},
{ labelKey: 'EMBEDDED_MPV.PLAYER.STATS_VIDEO', value: 'h264' },
]);
});
it('returns nothing for an absent snapshot', () => {
expect(buildStreamStatsRows(null)).toEqual([]);
});
});
});
@@ -0,0 +1,273 @@
import { formatBitrate } from './controls-format.utils';
import type { PlayerStreamStats } from './player-stream-stats.model';
import { positiveOrNull } from './positive-number.util';
/**
* Turns raw {@link PlayerStreamStats} into the display rows of the stream-info
* popover. Pure functions only — the component just translates `labelKey` and
* prints `value`, so every formatting decision is unit-testable in isolation.
*/
export interface StreamStatsRow {
/** Translation key of the row label. */
labelKey: string;
/** Display-ready value; rows without one are never emitted. */
value: string;
}
/**
* Ratios worth naming. A raw greatest-common-divisor reduction is exact but
* unreadable for real streams (1920x816 reduces to 40:17), so a measured ratio
* snaps to the closest entry here when it is within {@link ASPECT_TOLERANCE}.
*/
const KNOWN_ASPECT_RATIOS: ReadonlyArray<{ value: number; label: string }> = [
{ value: 1, label: '1:1' },
{ value: 5 / 4, label: '5:4' },
{ value: 4 / 3, label: '4:3' },
{ value: 3 / 2, label: '3:2' },
{ value: 16 / 10, label: '16:10' },
{ value: 16 / 9, label: '16:9' },
{ value: 2, label: '2:1' },
{ value: 21 / 9, label: '21:9' },
{ value: 2.35, label: '2.35:1' },
{ value: 2.39, label: '2.39:1' },
{ value: 9 / 16, label: '9:16' },
];
/** Relative distance (1%) at which a measured ratio adopts a known label. */
const ASPECT_TOLERANCE = 0.01;
/** Beyond this, a reduced ratio is less readable than a decimal one. */
const MAX_ASPECT_TERM = 32;
function greatestCommonDivisor(a: number, b: number): number {
let left = Math.round(a);
let right = Math.round(b);
while (right !== 0) {
[left, right] = [right, left % right];
}
return left || 1;
}
export function formatResolution(
width: number | null,
height: number | null
): string | null {
const pixelWidth = positiveOrNull(width);
const pixelHeight = positiveOrNull(height);
if (pixelWidth === null || pixelHeight === null) {
return null;
}
return `${Math.round(pixelWidth)} × ${Math.round(pixelHeight)}`;
}
export function formatAspectRatio(
width: number | null,
height: number | null
): string | null {
const pixelWidth = positiveOrNull(width);
const pixelHeight = positiveOrNull(height);
if (pixelWidth === null || pixelHeight === null) {
return null;
}
const ratio = pixelWidth / pixelHeight;
let closest: { label: string; distance: number } | null = null;
for (const known of KNOWN_ASPECT_RATIOS) {
const distance = Math.abs(ratio - known.value);
if (
distance <= known.value * ASPECT_TOLERANCE &&
(closest === null || distance < closest.distance)
) {
closest = { label: known.label, distance };
}
}
if (closest) {
return closest.label;
}
const divisor = greatestCommonDivisor(pixelWidth, pixelHeight);
const reducedWidth = Math.round(pixelWidth) / divisor;
const reducedHeight = Math.round(pixelHeight) / divisor;
if (reducedWidth <= MAX_ASPECT_TERM && reducedHeight <= MAX_ASPECT_TERM) {
return `${reducedWidth}:${reducedHeight}`;
}
return `${ratio.toFixed(2)}:1`;
}
/**
* "50 fps" for broadcast rates, "29.97 fps" for pulled-down ones. The
* whole-number window is deliberately narrow: 29.97 and 23.976 are meaningful
* rates in their own right, not measurement noise around 30 and 24.
*/
export function formatFrameRate(fps: number | null): string | null {
const rate = fps;
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0) {
return null;
}
const rounded = Math.round(rate);
const label =
Math.abs(rate - rounded) < 0.01 ? `${rounded}` : rate.toFixed(2);
return `${label} fps`;
}
/**
* Engine codec ids carry a profile/level suffix (`avc1.640028`, `mp4a.40.2`)
* that says nothing at a glance; the family alone is what the row is for.
*/
export function formatCodec(codec: string | null): string | null {
const trimmed = codec?.trim();
if (!trimmed) {
return null;
}
const [family] = trimmed.split('.');
return family || trimmed;
}
/** Channel counts people recognise; anything else keeps its raw count. */
const CHANNEL_LAYOUT_LABELS: Readonly<Record<number, string>> = {
1: 'Mono',
2: 'Stereo',
6: '5.1',
8: '7.1',
};
/**
* Web engines report a channel *count* (`"6"`), mpv a layout *name*
* (`"5.1"`, `"stereo"`); both end up as the same label.
*/
export function formatAudioChannels(
channels: string | number | null
): string | null {
if (typeof channels === 'number') {
return formatChannelCount(channels);
}
const trimmed = channels?.trim();
if (!trimmed) {
return null;
}
if (/^\d+$/.test(trimmed)) {
return formatChannelCount(Number(trimmed));
}
const lowercase = trimmed.toLowerCase();
if (lowercase === 'mono' || lowercase === 'stereo') {
return lowercase === 'mono' ? 'Mono' : 'Stereo';
}
return trimmed;
}
function formatChannelCount(count: number): string | null {
const channels = positiveOrNull(count);
if (channels === null) {
return null;
}
const rounded = Math.round(channels);
return CHANNEL_LAYOUT_LABELS[rounded] ?? `${rounded} ch`;
}
/** "48 kHz", "44.1 kHz" — trailing zeros are noise at this size. */
export function formatSampleRate(hertz: number | null): string | null {
const rate = positiveOrNull(hertz);
if (rate === null) {
return null;
}
const label = (rate / 1000).toFixed(2).replace(/\.?0+$/, '');
return `${label} kHz`;
}
export function formatBufferedSeconds(seconds: number | null): string | null {
if (typeof seconds !== 'number' || !Number.isFinite(seconds)) {
return null;
}
return `${Math.max(0, seconds).toFixed(1)} s`;
}
/**
* "12" alone is meaningless; against a total it becomes a drop rate, which is
* the number that actually says whether decoding is keeping up.
*/
export function formatDroppedFrames(
dropped: number | null,
total: number | null
): string | null {
if (typeof dropped !== 'number' || !Number.isFinite(dropped)) {
return null;
}
const count = Math.max(0, Math.round(dropped));
const frameCount = positiveOrNull(total);
if (frameCount === null) {
return `${count}`;
}
const percent = (count / frameCount) * 100;
return `${count} (${percent < 0.01 && percent > 0 ? '<0.01' : percent.toFixed(2)}%)`;
}
/** Joins a codec with its bitrate: "h264 · 4.6 Mbps", or whichever is known. */
function joinCodecAndBitrate(
codec: string | null,
bitrateBps: number | null
): string | null {
const parts = [formatCodec(codec), formatBitrate(bitrateBps)].filter(
(part): part is string => part !== null
);
return parts.length > 0 ? parts.join(' · ') : null;
}
export function buildStreamStatsRows(
stats: PlayerStreamStats | null
): StreamStatsRow[] {
if (!stats) {
return [];
}
const resolution = formatResolution(stats.width, stats.height);
const aspect = formatAspectRatio(stats.width, stats.height);
const candidates: ReadonlyArray<[string, string | null]> = [
[
'EMBEDDED_MPV.PLAYER.STATS_RESOLUTION',
resolution && aspect ? `${resolution} · ${aspect}` : resolution,
],
['EMBEDDED_MPV.PLAYER.STATS_FRAME_RATE', formatFrameRate(stats.fps)],
[
'EMBEDDED_MPV.PLAYER.STATS_NOMINAL_FRAME_RATE',
formatFrameRate(positiveOrNull(stats.nominalFps)),
],
[
'EMBEDDED_MPV.PLAYER.STATS_STREAM_BITRATE',
formatBitrate(stats.streamBitrateBps),
],
[
'EMBEDDED_MPV.PLAYER.STATS_VIDEO',
joinCodecAndBitrate(stats.videoCodec, stats.videoBitrateBps),
],
[
'EMBEDDED_MPV.PLAYER.STATS_AUDIO',
joinCodecAndBitrate(stats.audioCodec, stats.audioBitrateBps),
],
[
'EMBEDDED_MPV.PLAYER.STATS_AUDIO_CHANNELS',
formatAudioChannels(stats.audioChannels),
],
[
'EMBEDDED_MPV.PLAYER.STATS_SAMPLE_RATE',
formatSampleRate(stats.audioSampleRateHz),
],
[
'EMBEDDED_MPV.PLAYER.STATS_CONTAINER',
stats.container?.trim() || null,
],
[
'EMBEDDED_MPV.PLAYER.STATS_BUFFER',
formatBufferedSeconds(stats.bufferedAheadSeconds),
],
[
'EMBEDDED_MPV.PLAYER.STATS_DROPPED_FRAMES',
formatDroppedFrames(stats.droppedFrames, stats.totalFrames),
],
];
return candidates
.filter((entry): entry is [string, string] => entry[1] !== null)
.map(([labelKey, value]) => ({ labelKey, value }));
}
@@ -0,0 +1,32 @@
import type { PlayerStreamStats } from './player-stream-stats.model';
/**
* A snapshot with nothing reported yet, for specs that care about one or two
* fields and want the rest absent.
*
* Deliberately a test helper rather than a published constant: production
* mappers list every field explicitly, so that a newly added stat fails to
* compile until its source is decided instead of defaulting to null.
*/
export function emptyStreamStats(
overrides: Partial<PlayerStreamStats> = {}
): PlayerStreamStats {
return {
width: null,
height: null,
fps: null,
nominalFps: null,
streamBitrateBps: null,
videoBitrateBps: null,
audioBitrateBps: null,
videoCodec: null,
audioCodec: null,
audioChannels: null,
audioSampleRateHz: null,
container: null,
bufferedAheadSeconds: null,
droppedFrames: null,
totalFrames: null,
...overrides,
};
}
@@ -32,6 +32,10 @@ import {
WEB_VIDEO_EVENTS,
} from './web-video-controls.media-helpers';
import { WebVideoPictureInPictureController } from './web-video-picture-in-picture.controller';
import {
type WebVideoEngineStats,
WebVideoStreamStatsSampler,
} from './web-video-stream-stats';
/**
* Engine-agnostic accessors a web engine injects so the adapter can read/select
@@ -53,6 +57,12 @@ export interface WebVideoControlsOptions extends WebVideoMetadataOptions {
getQualityLevels?: () => PlayerTrack[];
setQualityLevel?: (id: number) => void | Promise<void>;
isAutoQualityEnabled?: () => boolean;
/**
* Bitrate/codec/container of the active rendition. Optional: the element
* itself covers resolution, frame rate, buffer and dropped frames, so an
* engine without a manifest simply reports fewer rows.
*/
getEngineStats?: () => WebVideoEngineStats | null;
}
interface WebVideoControlsContext {
@@ -72,6 +82,14 @@ export class WebVideoControlsAdapter implements PlayerController {
/** Bumped whenever DOM or engine-specific state must be re-read. */
private readonly tick = signal(0);
/**
* Pull-based and sampled only while the info popover is open, so the frame
* counters are read exactly when someone is looking at them.
*/
readonly streamStats = new WebVideoStreamStatsSampler(
() => this.video,
() => this.opts.getEngineStats?.() ?? null
);
private readonly pictureInPicture = new WebVideoPictureInPictureController(
() => ({
generation: this.bindingGeneration,
@@ -128,6 +146,8 @@ export class WebVideoControlsAdapter implements PlayerController {
recording: false,
pictureInPicture: pictureInPicture.supported,
seriesNavigation: !isLive && this.seriesNavigation() !== null,
// The element always knows its own size and frame counters.
streamStats: true,
};
});
@@ -235,6 +255,7 @@ export class WebVideoControlsAdapter implements PlayerController {
const generation = this.bindingGeneration;
this.video = video;
this.opts = opts;
this.streamStats.reset();
const onEvent = () => {
if (this.video === video && this.bindingGeneration === generation) {
@@ -270,6 +291,7 @@ export class WebVideoControlsAdapter implements PlayerController {
this.detachFn = null;
this.video = null;
this.opts = {};
this.streamStats.reset();
this.pictureInPicture.release(previousVideo);
this.refresh();
}
@@ -0,0 +1,280 @@
import {
type WebVideoEngineStats,
WebVideoStreamStatsSampler,
} from './web-video-stream-stats';
interface FakeVideoOptions {
videoWidth?: number;
videoHeight?: number;
currentTime?: number;
paused?: boolean;
readyState?: number;
buffered?: Array<[number, number]>;
quality?: { totalVideoFrames: number; droppedVideoFrames: number } | null;
}
function createVideo(options: FakeVideoOptions = {}): HTMLVideoElement {
const ranges = options.buffered ?? [];
return {
videoWidth: options.videoWidth ?? 0,
videoHeight: options.videoHeight ?? 0,
currentTime: options.currentTime ?? 0,
paused: options.paused ?? false,
readyState: options.readyState ?? 4,
buffered: {
length: ranges.length,
start: (index: number) => ranges[index][0],
end: (index: number) => ranges[index][1],
},
getVideoPlaybackQuality:
options.quality === null
? undefined
: () =>
options.quality ?? {
totalVideoFrames: 0,
droppedVideoFrames: 0,
},
} as unknown as HTMLVideoElement;
}
describe('WebVideoStreamStatsSampler', () => {
afterEach(() => {
jest.useRealTimers();
});
it('reads size, buffer and frame counters off the element', () => {
const sampler = new WebVideoStreamStatsSampler(
() =>
createVideo({
videoWidth: 1280,
videoHeight: 720,
currentTime: 30,
buffered: [[0, 42.5]],
quality: {
totalVideoFrames: 900,
droppedVideoFrames: 4,
},
}),
() => null
);
expect(sampler.sample()).toEqual(
expect.objectContaining({
width: 1280,
height: 720,
bufferedAheadSeconds: 12.5,
droppedFrames: 4,
totalFrames: 900,
})
);
});
it('measures the frame rate from the delta between two samples', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
let totalVideoFrames = 1000;
const sampler = new WebVideoStreamStatsSampler(
() =>
createVideo({
quality: { totalVideoFrames, droppedVideoFrames: 0 },
}),
() => null
);
// Nothing to compare the first sample against.
expect(sampler.sample()?.fps).toBeNull();
jest.advanceTimersByTime(1000);
totalVideoFrames = 1050;
expect(sampler.sample()?.fps).toBeCloseTo(50, 5);
});
it('keeps the declared rate separate from measured playback', () => {
const engine: WebVideoEngineStats = { nominalFps: 25 };
const sampler = new WebVideoStreamStatsSampler(
() =>
createVideo({
quality: { totalVideoFrames: 10, droppedVideoFrames: 0 },
}),
() => engine
);
expect(sampler.sample()).toMatchObject({ fps: null, nominalFps: 25 });
});
it('reports no rate rather than zero while playback is paused', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
const sampler = new WebVideoStreamStatsSampler(
() =>
createVideo({
quality: { totalVideoFrames: 500, droppedVideoFrames: 0 },
paused: true,
}),
() => null
);
sampler.sample();
jest.advanceTimersByTime(1000);
expect(sampler.sample()?.fps).toBeNull();
});
it('subtracts dropped frames from measured FPS but preserves total for drop percentage', () => {
jest.useFakeTimers();
const quality = { totalVideoFrames: 1000, droppedVideoFrames: 10 };
const sampler = new WebVideoStreamStatsSampler(
() => createVideo({ quality }),
() => ({ nominalFps: 30 })
);
sampler.sample();
jest.advanceTimersByTime(1000);
quality.totalVideoFrames += 30;
quality.droppedVideoFrames += 15;
expect(sampler.sample()).toMatchObject({
fps: 15,
nominalFps: 30,
totalFrames: 1030,
droppedFrames: 25,
});
});
it('reports zero measured FPS during a stall instead of substituting the nominal rate', () => {
jest.useFakeTimers();
const sampler = new WebVideoStreamStatsSampler(
() =>
createVideo({
quality: { totalVideoFrames: 100, droppedVideoFrames: 0 },
}),
() => ({ nominalFps: 30 })
);
sampler.sample();
jest.advanceTimersByTime(1000);
expect(sampler.sample()).toMatchObject({ fps: 0, nominalFps: 30 });
});
it('keeps measuring a started stream when buffer starvation drops readiness to metadata', () => {
jest.useFakeTimers();
const options: FakeVideoOptions = {
readyState: 4,
quality: { totalVideoFrames: 100, droppedVideoFrames: 0 },
};
const sampler = new WebVideoStreamStatsSampler(
() => createVideo(options),
() => ({ nominalFps: 30 })
);
sampler.sample();
options.readyState = 1;
jest.advanceTimersByTime(1000);
expect(sampler.sample()).toMatchObject({ fps: 0, nominalFps: 30 });
});
it('does not measure before media data arrives or across paused samples', () => {
jest.useFakeTimers();
let options: FakeVideoOptions = { readyState: 0 };
const sampler = new WebVideoStreamStatsSampler(
() => createVideo(options),
() => null
);
sampler.sample();
jest.advanceTimersByTime(1000);
expect(sampler.sample()?.fps).toBeNull();
options = { quality: { totalVideoFrames: 100, droppedVideoFrames: 0 } };
expect(sampler.sample()?.fps).toBeNull();
options.paused = true;
jest.advanceTimersByTime(1000);
expect(sampler.sample()?.fps).toBeNull();
options.paused = false;
jest.advanceTimersByTime(1000);
options.quality!.totalVideoFrames += 30;
expect(sampler.sample()?.fps).toBeNull();
});
it('merges engine-supplied codec, bitrate and container fields', () => {
const sampler = new WebVideoStreamStatsSampler(
() => createVideo({ videoWidth: 1920, videoHeight: 1080 }),
() => ({
videoBitrateBps: 5_000_000,
audioBitrateBps: 192_000,
videoCodec: 'avc1.640028',
audioCodec: 'mp4a.40.2',
container: 'HLS',
})
);
expect(sampler.sample()).toEqual(
expect.objectContaining({
videoBitrateBps: 5_000_000,
audioBitrateBps: 192_000,
videoCodec: 'avc1.640028',
audioCodec: 'mp4a.40.2',
container: 'HLS',
})
);
});
it('uses the engine rendition size only until the element reports one', () => {
let width = 0;
const sampler = new WebVideoStreamStatsSampler(
() =>
createVideo({ videoWidth: width, videoHeight: width * 0.5625 }),
() => ({ width: 1920, height: 1080 })
);
expect(sampler.sample()?.width).toBe(1920);
width = 1280;
expect(sampler.sample()?.width).toBe(1280);
});
it('reports an unbuffered element as unknown, not as zero seconds', () => {
const sampler = new WebVideoStreamStatsSampler(
() => createVideo({ buffered: [] }),
() => null
);
expect(sampler.sample()?.bufferedAheadSeconds).toBeNull();
});
it('returns zero when ranges exist but none holds the playhead', () => {
const sampler = new WebVideoStreamStatsSampler(
() =>
createVideo({
currentTime: 100,
buffered: [[0, 42]],
}),
() => null
);
expect(sampler.sample()?.bufferedAheadSeconds).toBe(0);
});
it('yields nothing without a bound element', () => {
const sampler = new WebVideoStreamStatsSampler(
() => null,
() => null
);
expect(sampler.sample()).toBeNull();
});
it('drops frame history on reset so a new source starts measuring fresh', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
let totalVideoFrames = 1000;
const sampler = new WebVideoStreamStatsSampler(
() =>
createVideo({
quality: { totalVideoFrames, droppedVideoFrames: 0 },
}),
() => null
);
sampler.sample();
sampler.reset();
jest.advanceTimersByTime(1000);
totalVideoFrames = 1050;
// Without the reset this would have reported 50 fps across the seam.
expect(sampler.sample()?.fps).toBeNull();
});
});
@@ -0,0 +1,161 @@
import type {
PlayerStreamStats,
PlayerStreamStatsSource,
} from './player-stream-stats.model';
import { positiveOrNull } from './positive-number.util';
/**
* What an engine adds on top of what the `<video>` element already knows.
* Every field is optional: a native `<video src>` source supplies none of it,
* HLS and Shaka supply most, and the element still covers resolution, frame
* rate, buffer and frame drops on its own.
*/
export interface WebVideoEngineStats {
streamBitrateBps?: number | null;
videoBitrateBps?: number | null;
audioBitrateBps?: number | null;
videoCodec?: string | null;
audioCodec?: string | null;
/** Channel count or layout of the active audio rendition. */
audioChannels?: string | number | null;
audioSampleRateHz?: number | null;
container?: string | null;
/**
* Manifest-declared frame rate, displayed separately from measured FPS.
*/
nominalFps?: number | null;
/** Rendition size; used only before the element reports its own. */
width?: number | null;
height?: number | null;
}
/** Below this the frame delta is too small to divide into a stable rate. */
const MIN_FPS_SAMPLE_SECONDS = 0.25;
/** Treat a range as the current one when the playhead is within this margin. */
const BUFFER_RANGE_TOLERANCE_SECONDS = 0.25;
interface FrameSample {
frames: number;
timestamp: number;
}
/**
* Reads live stream stats from a `<video>` element, plus whatever the engine
* bound to it can add.
*
* Frame rate is *measured* (presented frames between two samples) rather than
* declared, so it reflects what the machine actually renders — the number that
* matters when playback looks choppy.
*/
export class WebVideoStreamStatsSampler implements PlayerStreamStatsSource {
private lastFrameSample: FrameSample | null = null;
constructor(
private readonly getVideo: () => HTMLVideoElement | null,
private readonly getEngineStats: () => WebVideoEngineStats | null
) {}
/** Drops frame-rate history so a new source never inherits a stale delta. */
reset(): void {
this.lastFrameSample = null;
}
sample(): PlayerStreamStats | null {
const video = this.getVideo();
if (!video) {
this.lastFrameSample = null;
return null;
}
const engine = this.getEngineStats() ?? {};
const quality = readPlaybackQuality(video);
const measuredFps = this.measureFrameRate(
video.paused ||
video.ended ||
!quality ||
(video.readyState < 2 && quality.totalVideoFrames === 0)
? undefined
: quality.totalVideoFrames - quality.droppedVideoFrames
);
return {
width: positiveOrNull(video.videoWidth) ?? engine.width ?? null,
height: positiveOrNull(video.videoHeight) ?? engine.height ?? null,
fps: measuredFps,
nominalFps: positiveOrNull(engine.nominalFps),
streamBitrateBps: engine.streamBitrateBps ?? null,
videoBitrateBps: engine.videoBitrateBps ?? null,
audioBitrateBps: engine.audioBitrateBps ?? null,
videoCodec: engine.videoCodec ?? null,
audioCodec: engine.audioCodec ?? null,
audioChannels: engine.audioChannels ?? null,
audioSampleRateHz: engine.audioSampleRateHz ?? null,
container: engine.container ?? null,
bufferedAheadSeconds: readBufferedAhead(video),
droppedFrames: quality?.droppedVideoFrames ?? null,
totalFrames: quality?.totalVideoFrames ?? null,
};
}
/**
* Presented frames per wall-clock second. A stall is a measured zero;
* paused/initial loading playback has no measurement window. A decreasing counter
* starts a fresh baseline after a source reset.
*/
private measureFrameRate(totalFrames: number | undefined): number | null {
if (typeof totalFrames !== 'number' || !Number.isFinite(totalFrames)) {
this.lastFrameSample = null;
return null;
}
const timestamp = performance.now();
const previous = this.lastFrameSample;
this.lastFrameSample = { frames: totalFrames, timestamp };
if (!previous) {
return null;
}
const elapsedSeconds = (timestamp - previous.timestamp) / 1000;
const frameDelta = totalFrames - previous.frames;
if (elapsedSeconds < MIN_FPS_SAMPLE_SECONDS || frameDelta < 0) {
return null;
}
return frameDelta / elapsedSeconds;
}
}
function readPlaybackQuality(
video: HTMLVideoElement
): VideoPlaybackQuality | null {
try {
return video.getVideoPlaybackQuality?.() ?? null;
} catch {
return null;
}
}
/** Seconds of continuously buffered media ahead of the playhead. */
function readBufferedAhead(video: HTMLVideoElement): number | null {
try {
const buffered = video.buffered;
if (!buffered || buffered.length === 0) {
return null;
}
const position = video.currentTime;
for (let index = 0; index < buffered.length; index += 1) {
const start = buffered.start(index);
const end = buffered.end(index);
if (
position >= start - BUFFER_RANGE_TOLERANCE_SECONDS &&
position <= end
) {
return Math.max(0, end - position);
}
}
// Ranges exist but none holds the playhead: nothing is buffered ahead.
return 0;
} catch {
return null;
}
}
@@ -36,6 +36,16 @@ export interface ShakaVariantTrackLike {
* so quality filtering pins to it rather than to `language`.
*/
audioId?: number | null;
// Descriptive fields the stream-info popover reads. Optional because
// Shaka leaves them null for streams whose manifest does not declare them.
videoCodec?: string | null;
audioCodec?: string | null;
videoBandwidth?: number | null;
audioBandwidth?: number | null;
channelsCount?: number | null;
audioSamplingRate?: number | null;
frameRate?: number | null;
mimeType?: string | null;
}
export type { ShakaErrorLike } from '@iptvnator/playback/util';
@@ -30,6 +30,7 @@ export class VjsPlayerControlsBridge {
getQualityLevels: () => this.qualityLevels.getQualityLevels(),
setQualityLevel: (id) => this.qualityLevels.setQualityLevel(id),
isAutoQualityEnabled: () => this.qualityLevels.isAutoQualityEnabled(),
getEngineStats: () => this.qualityLevels.getActiveLevelStats(),
};
constructor(private readonly config: VjsPlayerControlsBridgeConfig) {
@@ -10,6 +10,8 @@ class FakeQualityLevelList {
Set<EventListenerOrEventListenerObject>
>();
private levels: VideoJsQualityLevel[] = [];
/** VHS reports the rendition on screen here; -1 until it picks one. */
selectedIndex = -1;
readonly addEventListener = jest.fn(
(event: string, listener: EventListenerOrEventListenerObject): void => {
@@ -266,4 +268,40 @@ describe('VjsQualityLevels', () => {
throwing.bind();
expect(throwing.getQualityLevels()).toEqual([]);
});
describe('getActiveLevelStats', () => {
it('describes the rendition VHS reports as selected', () => {
const { helper, levelList } = createHarness([
level({ height: 1080, width: 1920, bitrate: 8_000_000 }),
level({ height: 720, width: 1280, bitrate: 4_000_000 }),
]);
levelList.selectedIndex = 1;
expect(helper.getActiveLevelStats()).toEqual({
streamBitrateBps: 4_000_000,
width: 1280,
height: 720,
});
});
it('reports nothing while VHS has selected no rendition', () => {
const { helper, levelList } = createHarness([
level({ height: 1080, bitrate: 8_000_000 }),
]);
levelList.selectedIndex = -1;
expect(helper.getActiveLevelStats()).toBeNull();
});
it('nulls out the fields a rendition does not declare', () => {
const { helper, levelList } = createHarness([level({})]);
levelList.selectedIndex = 0;
expect(helper.getActiveLevelStats()).toEqual({
streamBitrateBps: null,
width: null,
height: null,
});
});
});
});
@@ -2,6 +2,8 @@ import {
AUTO_QUALITY_LEVEL_ID,
type PlayerTrack,
} from '../player-controls/player-controls.model';
import { positiveOrNull } from '../player-controls/positive-number.util';
import type { WebVideoEngineStats } from '../player-controls/web-video-stream-stats';
import { buildQualityLevelLabels } from '../web-video-support/quality-level-labels';
import type {
VideoJsPlayer,
@@ -109,6 +111,32 @@ export class VjsQualityLevels {
return this.readManualLevel(this.listLevels()) === null;
}
/**
* Bitrate/size of the rendition VHS is currently playing, for the
* stream-info popover. `selectedIndex` is VHS's own answer to "which one
* is on screen", so it stays correct under ABR as well as manual picks.
*
* Read-only, unlike everything else here. It lives on this collaborator
* rather than in its own file (the way `WebVideoSourceStats` is split off
* from `WebVideoSourceTracks`) because the VHS level list has no accessor
* of its own: splitting it would mean publishing `levelList` purely to
* describe it.
*/
getActiveLevelStats(): WebVideoEngineStats | null {
const selectedIndex = this.levelList?.selectedIndex ?? -1;
const level =
selectedIndex >= 0 ? this.listLevels()[selectedIndex] : undefined;
if (!level) {
return null;
}
return {
streamBitrateBps: positiveOrNull(level.bitrate),
width: positiveOrNull(level.width),
height: positiveOrNull(level.height),
};
}
private readManualLevel(
levels: VideoJsQualityLevel[]
): VideoJsQualityLevel | null {
@@ -1,3 +1,6 @@
import { formatBitrate } from '../player-controls/controls-format.utils';
import { positiveOrNull } from '../player-controls/positive-number.util';
/** Rendition facts an engine can report for one quality level. */
export interface QualityLevelFacts {
height?: number | null;
@@ -47,21 +50,8 @@ function baseLabel(level: QualityLevelFacts, index: number): string {
return formatBitrate(level.bitrate) ?? `Level ${index + 1}`;
}
function formatBitrate(bitrate: number | null | undefined): string | null {
const value = readPositive(bitrate);
if (value === null) {
return null;
}
if (value >= 1_000_000) {
const mbps = value / 1_000_000;
const rendered = mbps >= 10 ? Math.round(mbps).toString() : mbps.toFixed(1);
return `${rendered} Mbps`;
}
return `${Math.round(value / 1000)} kbps`;
}
/** Dimensions become label text, so they are rounded to whole pixels. */
function readPositive(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.round(value)
: null;
const positive = positiveOrNull(value);
return positive === null ? null : Math.round(positive);
}
@@ -1,6 +1,7 @@
import type { PlayerTrack } from '../player-controls/player-controls.model';
import type { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
import { pickExternalSubtitleFile } from './web-video-external-subtitles';
import { WebVideoSourceStats } from './web-video-source-stats';
import { WebVideoSubtitleStyle } from './web-video-subtitle-style';
import {
type WebVideoControlsSource,
@@ -19,6 +20,7 @@ export interface WebVideoSourceControlsBridgeConfig {
export class WebVideoSourceControlsBridge {
private readonly config: WebVideoSourceControlsBridgeConfig;
private readonly tracks: WebVideoSourceTracks;
private readonly sourceStats = new WebVideoSourceStats();
private readonly subtitleStyle = new WebVideoSubtitleStyle();
private attached = false;
private destroyed = false;
@@ -55,6 +57,7 @@ export class WebVideoSourceControlsBridge {
getQualityLevels: () => this.tracks.getQualityLevels(),
setQualityLevel: (id) => this.tracks.setQualityLevel(id),
isAutoQualityEnabled: () => this.tracks.isAutoQualityEnabled(),
getEngineStats: () => this.sourceStats.read(),
});
this.attached = true;
}
@@ -65,6 +68,7 @@ export class WebVideoSourceControlsBridge {
}
this.tracks.setSource(source);
this.sourceStats.setSource(source);
this.config.adapter.refresh();
}
@@ -83,6 +87,7 @@ export class WebVideoSourceControlsBridge {
}
this.tracks.clearSource();
this.sourceStats.setSource(null);
this.config.adapter.refresh();
}
@@ -0,0 +1,246 @@
import type Hls from 'hls.js';
import type {
ShakaPlayerLike,
ShakaVariantTrackLike,
} from '../shaka-engine/shaka-module.types';
import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
import { WebVideoSourceStats } from './web-video-source-stats';
function createHls(overrides: Partial<Hls> = {}): Hls {
return {
currentLevel: 0,
loadLevel: 0,
audioTrack: 0,
audioTracks: [
{
audioCodec: 'mp4a.40.2',
bitrate: 128_000,
channels: '6',
},
],
levels: [
{
bitrate: 5_000_000,
realBitrate: 4_800_000,
width: 1920,
height: 1080,
videoCodec: 'avc1.640028',
audioCodec: 'mp4a.40.2',
frameRate: 25,
},
],
...overrides,
} as unknown as Hls;
}
function createShakaSession(player: ShakaPlayerLike | null): ShakaVideoSession {
return { getPlayer: () => player } as unknown as ShakaVideoSession;
}
function createVariant(
overrides: Partial<ShakaVariantTrackLike> = {}
): ShakaVariantTrackLike {
return {
id: 1,
active: true,
language: 'en',
label: null,
width: 1280,
height: 720,
bandwidth: 3_000_000,
videoBandwidth: 2_800_000,
audioBandwidth: 128_000,
videoCodec: 'avc1.4d401f',
audioCodec: 'mp4a.40.2',
frameRate: 30,
channelsCount: 6,
audioSamplingRate: 48_000,
mimeType: 'video/mp4',
...overrides,
};
}
describe('WebVideoSourceStats', () => {
it('reports nothing before a source is bound', () => {
expect(new WebVideoSourceStats().read()).toBeNull();
});
describe('HLS', () => {
it('describes the active level', () => {
const stats = new WebVideoSourceStats();
stats.setSource({ kind: 'hls', hls: createHls() });
expect(stats.read()).toEqual({
// HLS declares aggregate bandwidth; fragments may omit alternate audio.
streamBitrateBps: 5_000_000,
videoBitrateBps: null,
// Audio details come from the selected audio rendition.
audioBitrateBps: 128_000,
videoCodec: 'avc1.640028',
audioCodec: 'mp4a.40.2',
audioChannels: '6',
container: 'HLS',
nominalFps: 25,
width: 1920,
height: 1080,
});
});
it('falls back to the declared bitrate before fragments land', () => {
const stats = new WebVideoSourceStats();
stats.setSource({
kind: 'hls',
hls: createHls({
levels: [
{
bitrate: 5_000_000,
realBitrate: 0,
width: 1920,
height: 1080,
frameRate: 25,
},
] as unknown as Hls['levels'],
}),
});
expect(stats.read()).toMatchObject({
videoBitrateBps: null,
streamBitrateBps: 5_000_000,
});
});
it('keeps the video level codec when no audio rendition exists', () => {
const stats = new WebVideoSourceStats();
stats.setSource({
kind: 'hls',
hls: createHls({
audioTracks: [] as unknown as Hls['audioTracks'],
}),
});
expect(stats.read()).toEqual(
expect.objectContaining({
audioCodec: 'mp4a.40.2',
audioBitrateBps: null,
audioChannels: null,
})
);
});
it('uses the load level while ABR has not settled', () => {
const stats = new WebVideoSourceStats();
stats.setSource({
kind: 'hls',
hls: createHls({ currentLevel: -1, loadLevel: 0 }),
});
expect(stats.read()?.width).toBe(1920);
});
it('still names the container when no level is known yet', () => {
const stats = new WebVideoSourceStats();
stats.setSource({
kind: 'hls',
hls: createHls({ currentLevel: -1, loadLevel: -1 }),
});
expect(stats.read()).toEqual({ container: 'HLS' });
});
});
describe('Shaka', () => {
it('describes the active variant', () => {
const player = {
getVariantTracks: () => [
createVariant({ active: false, id: 0 }),
createVariant(),
],
getStats: () => ({ streamBandwidth: 2_900_000 }),
} as unknown as ShakaPlayerLike;
const stats = new WebVideoSourceStats();
stats.setSource({
kind: 'shaka',
session: createShakaSession(player),
});
expect(stats.read()).toEqual({
videoBitrateBps: 2_800_000,
streamBitrateBps: 3_000_000,
audioBitrateBps: 128_000,
videoCodec: 'avc1.4d401f',
audioCodec: 'mp4a.40.2',
audioChannels: 6,
audioSampleRateHz: 48_000,
container: 'video/mp4',
nominalFps: 30,
width: 1280,
height: 720,
});
});
it('keeps total bandwidth out of the video bitrate when its separate rate is unknown', () => {
const player = {
getVariantTracks: () => [
createVariant({ videoBandwidth: null }),
],
getStats: () => ({ streamBandwidth: 2_900_000 }),
} as unknown as ShakaPlayerLike;
const stats = new WebVideoSourceStats();
stats.setSource({
kind: 'shaka',
session: createShakaSession(player),
});
expect(stats.read()).toMatchObject({
videoBitrateBps: null,
streamBitrateBps: 3_000_000,
audioBitrateBps: 128_000,
});
});
it('works with a player build that exposes no getStats', () => {
const player = {
getVariantTracks: () => [createVariant()],
} as unknown as ShakaPlayerLike;
const stats = new WebVideoSourceStats();
stats.setSource({
kind: 'shaka',
session: createShakaSession(player),
});
expect(stats.read()?.videoBitrateBps).toBe(2_800_000);
});
it('reports nothing without a player', () => {
const stats = new WebVideoSourceStats();
stats.setSource({
kind: 'shaka',
session: createShakaSession(null),
});
expect(stats.read()).toBeNull();
});
});
it('names the container of a raw MPEG-TS source', () => {
const stats = new WebVideoSourceStats();
stats.setSource({ kind: 'mpegts' });
expect(stats.read()).toEqual({ container: 'MPEG-TS' });
});
it('leaves a native source to the element alone', () => {
const stats = new WebVideoSourceStats();
stats.setSource({ kind: 'native' });
expect(stats.read()).toBeNull();
});
it('forgets the engine when the source is cleared', () => {
const stats = new WebVideoSourceStats();
stats.setSource({ kind: 'hls', hls: createHls() });
stats.setSource(null);
expect(stats.read()).toBeNull();
});
});
@@ -0,0 +1,97 @@
import type Hls from 'hls.js';
import { positiveOrNull } from '../player-controls/positive-number.util';
import type { WebVideoEngineStats } from '../player-controls/web-video-stream-stats';
import type { ShakaPlayerLike } from '../shaka-engine/shaka-module.types';
import type { WebVideoControlsSource } from './web-video-source-tracks';
/**
* Reads the active rendition's bitrate, codecs and container from whichever
* engine is driving the current source.
*
* Deliberately separate from {@link WebVideoSourceTracks}: nothing here selects
* anything, it only describes what is playing, and the info popover is the one
* caller. Everything the `<video>` element already knows (resolution, frame
* rate, buffer, dropped frames) is read by the sampler instead.
*/
export class WebVideoSourceStats {
private source: WebVideoControlsSource | null = null;
setSource(source: WebVideoControlsSource | null): void {
this.source = source;
}
read(): WebVideoEngineStats | null {
if (!this.source) {
return null;
}
switch (this.source.kind) {
case 'hls':
return readHlsStats(this.source.hls);
case 'shaka':
return readShakaStats(this.source.session.getPlayer());
case 'mpegts':
return { container: 'MPEG-TS' };
case 'native':
return null;
}
}
}
function readHlsStats(hls: Hls): WebVideoEngineStats | null {
// `currentLevel` is -1 while ABR has not settled; `loadLevel` is the one
// being fetched and is the best available answer in that window.
const index = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
const level = index >= 0 ? hls.levels?.[index] : undefined;
if (!level) {
return { container: 'HLS' };
}
// The audio rendition carries the details the video level cannot: its own
// declared bitrate, the codec actually selected, and the channel count.
const audioTrack = hls.audioTracks?.[hls.audioTrack];
return {
// BANDWIDTH describes the entire variant. Fragment realBitrate can
// include muxed audio or omit alternate audio, so it is not a reliable
// separate video bitrate or an aggregate rendition bitrate.
streamBitrateBps: positiveOrNull(level.bitrate),
videoBitrateBps: null,
audioBitrateBps: positiveOrNull(audioTrack?.bitrate),
videoCodec: level.videoCodec ?? null,
audioCodec: audioTrack?.audioCodec ?? level.audioCodec ?? null,
audioChannels: audioTrack?.channels ?? null,
container: 'HLS',
nominalFps: positiveOrNull(level.frameRate),
width: positiveOrNull(level.width),
height: positiveOrNull(level.height),
};
}
function readShakaStats(
player: ShakaPlayerLike | null
): WebVideoEngineStats | null {
if (!player) {
return null;
}
const variant = player.getVariantTracks().find((track) => track.active);
if (!variant) {
return null;
}
return {
// getStats().streamBandwidth also includes playback rate; use the
// manifest's rendition rate and keep unknown component rates unknown.
streamBitrateBps: positiveOrNull(variant.bandwidth),
videoBitrateBps: positiveOrNull(variant.videoBandwidth),
audioBitrateBps: positiveOrNull(variant.audioBandwidth),
videoCodec: variant.videoCodec ?? null,
audioCodec: variant.audioCodec ?? null,
audioChannels: positiveOrNull(variant.channelsCount),
audioSampleRateHz: positiveOrNull(variant.audioSamplingRate),
container: variant.mimeType ?? null,
nominalFps: positiveOrNull(variant.frameRate),
width: positiveOrNull(variant.width),
height: positiveOrNull(variant.height),
};
}