From 221b9c8f0f9bf567dd711a52c764a17053e31c84 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Thu, 2 Jun 2022 14:23:51 -0700 Subject: [PATCH] Add playback performance metrics. Closes #1930 --- web/components/stores/ClientConfigStore.tsx | 10 +++ web/components/video/OwncastPlayer.tsx | 20 ++++-- .../components/video}/metrics/playback.js | 71 ++++++++++--------- web/components/video/player.tsx | 4 +- web/interfaces/server-status.model.ts | 2 + 5 files changed, 68 insertions(+), 39 deletions(-) rename {webroot/js => web/components/video}/metrics/playback.js (86%) diff --git a/web/components/stores/ClientConfigStore.tsx b/web/components/stores/ClientConfigStore.tsx index 2eb37a5af..ccd1988af 100644 --- a/web/components/stores/ClientConfigStore.tsx +++ b/web/components/stores/ClientConfigStore.tsx @@ -82,6 +82,11 @@ export const fatalErrorStateAtom = atom({ default: null, }); +export const clockSkewAtom = atom({ + key: 'clockSkewAtom', + default: 0.0, +}); + // Chat is visible if the user wishes it to be visible AND the required // chat state is set. export const isChatVisibleSelector = selector({ @@ -132,6 +137,7 @@ export function ClientConfigStore() { const setChatDisplayName = useSetRecoilState(chatDisplayNameAtom); const setClientConfig = useSetRecoilState(clientConfigStateAtom); const setServerStatus = useSetRecoilState(serverStatusState); + const setClockSkew = useSetRecoilState(clockSkewAtom); const [chatMessages, setChatMessages] = useRecoilState(chatMessagesAtom); const [accessToken, setAccessToken] = useRecoilState(accessTokenAtom); const setAppState = useSetRecoilState(appStateAtom); @@ -170,6 +176,10 @@ export function ClientConfigStore() { try { const status = await ServerStatusService.getStatus(); setServerStatus(status); + const { serverTime } = status; + + const clockSkew = new Date(serverTime).getTime() - Date.now(); + setClockSkew(clockSkew); if (status.online) { sendEvent(AppStateEvent.Online); diff --git a/web/components/video/OwncastPlayer.tsx b/web/components/video/OwncastPlayer.tsx index 3a8d26263..85f0ed380 100644 --- a/web/components/video/OwncastPlayer.tsx +++ b/web/components/video/OwncastPlayer.tsx @@ -1,15 +1,17 @@ -import React from 'react'; -import { useRecoilState } from 'recoil'; +import React, { useEffect } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { useHotkeys } from 'react-hotkeys-hook'; import VideoJS from './player'; import ViewerPing from './viewer-ping'; import VideoPoster from './VideoPoster'; import { getLocalStorage, setLocalStorage } from '../../utils/localStorage'; -import { isVideoPlayingAtom } from '../stores/ClientConfigStore'; +import { isVideoPlayingAtom, clockSkewAtom } from '../stores/ClientConfigStore'; +import PlaybackMetrics from './metrics/playback'; const PLAYER_VOLUME = 'owncast_volume'; const ping = new ViewerPing(); +let playbackMetrics = null; interface Props { source: string; @@ -20,6 +22,7 @@ export default function OwncastPlayer(props: Props) { const playerRef = React.useRef(null); const { source, online } = props; const [videoPlaying, setVideoPlaying] = useRecoilState(isVideoPlayingAtom); + const clockSkew = useRecoilValue(clockSkewAtom); const setSavedVolume = () => { try { @@ -113,7 +116,7 @@ export default function OwncastPlayer(props: Props) { ], }; - const handlePlayerReady = player => { + const handlePlayerReady = (player, videojs) => { playerRef.current = player; setSavedVolume(); @@ -147,8 +150,17 @@ export default function OwncastPlayer(props: Props) { }); player.on('volumechange', handleVolume); + + playbackMetrics = new PlaybackMetrics(player, videojs); + playbackMetrics.setClockSkew(clockSkew); }; + useEffect(() => { + if (playbackMetrics) { + playbackMetrics.setClockSkew(clockSkew); + } + }, [clockSkew]); + return (
{online && ( diff --git a/webroot/js/metrics/playback.js b/web/components/video/metrics/playback.js similarity index 86% rename from webroot/js/metrics/playback.js rename to web/components/video/metrics/playback.js index d059f94f0..cca3b2456 100644 --- a/webroot/js/metrics/playback.js +++ b/web/components/video/metrics/playback.js @@ -1,7 +1,30 @@ -import { URL_PLAYBACK_METRICS } from '../utils/constants.js'; +/* eslint-disable no-plusplus */ +const URL_PLAYBACK_METRICS = `/api/metrics/playback`; const METRICS_SEND_INTERVAL = 10000; const MAX_VALID_LATENCY_SECONDS = 40; // Anything > this gets thrown out. +function getCurrentlyPlayingSegment(tech) { + const targetMedia = tech.vhs.playlists.media(); + const snapshotTime = tech.currentTime(); + let segment; + + // Iterate trough available segments and get first within which snapshot_time is + // eslint-disable-next-line no-plusplus + for (let i = 0, l = targetMedia.segments.length; i < l; i++) { + // Note: segment.end may be undefined or is not properly set + if (snapshotTime < targetMedia.segments[i].end) { + segment = targetMedia.segments[i]; + break; + } + } + + if (!segment) { + [segment] = targetMedia.segments; + } + + return segment; +} + class PlaybackMetrics { constructor(player, videojs) { this.player = player; @@ -37,11 +60,13 @@ class PlaybackMetrics { const oldVjsXhrCallback = videojs.xhr; // Override the xhr function to track segment download time. + // eslint-disable-next-line no-param-reassign videojs.Vhs.xhr = (...args) => { if (args[0].uri.match('.ts')) { const start = new Date(); const cb = args[1]; + // eslint-disable-next-line no-param-reassign args[1] = (request, error, response) => { const end = new Date(); const delta = end.getTime() - start.getTime(); @@ -70,7 +95,7 @@ class PlaybackMetrics { const tech = this.player.tech({ IWillNotUseThisInPlugins: true }); this.supportsDetailedMetrics = !!tech; - tech.on('usage', (e) => { + tech.on('usage', e => { if (e.name === 'vhs-unknown-waiting') { this.setIsBuffering(true); } @@ -83,7 +108,7 @@ class PlaybackMetrics { // Variant changed const trackElements = this.player.textTracks(); - trackElements.addEventListener('cuechange', (c) => { + trackElements.addEventListener('cuechange', () => { this.incrementQualityVariantChanges(); }); } @@ -99,7 +124,7 @@ class PlaybackMetrics { clearInterval(this.collectPlaybackMetricsTimer); } - handleBuffering(e) { + handleBuffering() { this.incrementErrorCount(1); this.setIsBuffering(true); } @@ -199,19 +224,22 @@ class PlaybackMetrics { return; } + // If we're paused then do nothing. + if (this.player.paused()) { + return; + } + const errorCount = this.errors; - var data; + let data; if (this.supportsDetailedMetrics) { - const average = (arr) => arr.reduce((p, c) => p + c, 0) / arr.length; + const average = arr => arr.reduce((p, c) => p + c, 0) / arr.length; const averageDownloadDuration = average(this.segmentDownloadTime) / 1000; - const roundedAverageDownloadDuration = - Math.round(averageDownloadDuration * 1000) / 1000; + const roundedAverageDownloadDuration = Math.round(averageDownloadDuration * 1000) / 1000; const averageBandwidth = average(this.bandwidthTracking) / 1000; - const roundedAverageBandwidth = - Math.round(averageBandwidth * 1000) / 1000; + const roundedAverageBandwidth = Math.round(averageBandwidth * 1000) / 1000; const averageLatency = average(this.latencyTracking) / 1000; const roundedAverageLatency = Math.round(averageLatency * 1000) / 1000; @@ -252,26 +280,3 @@ class PlaybackMetrics { } export default PlaybackMetrics; - -function getCurrentlyPlayingSegment(tech, old_segment = null) { - var target_media = tech.vhs.playlists.media(); - var snapshot_time = tech.currentTime(); - var segment; - - // Iterate trough available segments and get first within which snapshot_time is - for (var i = 0, l = target_media.segments.length; i < l; i++) { - // Note: segment.end may be undefined or is not properly set - if (snapshot_time < target_media.segments[i].end) { - segment = target_media.segments[i]; - break; - } - } - - // Null segment_time in case it's lower then 0. - if (!segment) { - segment = target_media.segments[0]; - segment_time = 0; - } - - return segment; -} diff --git a/web/components/video/player.tsx b/web/components/video/player.tsx index a1e3489e4..4831a9766 100644 --- a/web/components/video/player.tsx +++ b/web/components/video/player.tsx @@ -9,7 +9,7 @@ require('video.js/dist/video-js.css'); // import { PLAYER_VOLUME, URL_STREAM } from '../../utils/constants.js'; interface Props { options: any; - onReady: (player: videojs.Player) => void; + onReady: (player: videojs.Player, vjsInstance: videojs) => void; } export function VideoJS(props: Props) { @@ -25,7 +25,7 @@ export function VideoJS(props: Props) { // eslint-disable-next-line no-multi-assign const player = (playerRef.current = videojs(videoElement, options, () => { player.log('player is ready'); - return onReady && onReady(player); + return onReady && onReady(player, videojs); })); // TODO: Add airplay support, video settings menu, latency compensator, etc. diff --git a/web/interfaces/server-status.model.ts b/web/interfaces/server-status.model.ts index c940b6fb6..c91dcaf7d 100644 --- a/web/interfaces/server-status.model.ts +++ b/web/interfaces/server-status.model.ts @@ -5,11 +5,13 @@ export interface ServerStatus { lastDisconnectTime?: Date; versionNumber?: string; streamTitle?: string; + serverTime: Date; } export function makeEmptyServerStatus(): ServerStatus { return { online: false, viewerCount: 0, + serverTime: new Date(), }; }