mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(playback): stream-info popover in the player overlay (#1578)
This commit is contained in:
1 parent
bad8a0991e
commit
e76447975b
72 files changed
+4355
-163
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+19
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+222
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user