From c50536ff81c0c0725aa532b76319ca01017ff9f8 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Tue, 29 Mar 2022 17:33:32 -0700 Subject: [PATCH] Pull player metrics out of the player. Support safari errors/buffering events --- controllers/playbackMetrics.go | 15 ++- webroot/js/components/player.js | 128 +++------------------- webroot/js/metrics/playback.js | 188 ++++++++++++++++++++++++++++---- 3 files changed, 191 insertions(+), 140 deletions(-) diff --git a/controllers/playbackMetrics.go b/controllers/playbackMetrics.go index 00242e7c5..b18bc8fbf 100644 --- a/controllers/playbackMetrics.go +++ b/controllers/playbackMetrics.go @@ -36,8 +36,17 @@ func ReportPlaybackMetrics(w http.ResponseWriter, r *http.Request) { clientID := utils.GenerateClientIDFromRequest(r) metrics.RegisterPlaybackErrorCount(clientID, request.Errors) - metrics.RegisterPlayerBandwidth(clientID, request.Bandwidth) - metrics.RegisterPlayerLatency(clientID, request.Latency) - metrics.RegisterPlayerSegmentDownloadDuration(clientID, request.DownloadDuration) + if request.Bandwidth != 0.0 { + metrics.RegisterPlayerBandwidth(clientID, request.Bandwidth) + } + + if request.Latency != 0.0 { + metrics.RegisterPlayerLatency(clientID, request.Latency) + } + + if request.DownloadDuration != 0.0 { + metrics.RegisterPlayerSegmentDownloadDuration(clientID, request.DownloadDuration) + } + metrics.RegisterQualityVariantChangesCount(clientID, request.QualityVariantChanges) } diff --git a/webroot/js/components/player.js b/webroot/js/components/player.js index 5031d3a1e..dcc8b5d07 100644 --- a/webroot/js/components/player.js +++ b/webroot/js/components/player.js @@ -39,45 +39,13 @@ const VIDEO_OPTIONS = { export const POSTER_DEFAULT = `/img/logo.png`; export const POSTER_THUMB = `/thumbnail.jpg`; -function getCurrentlyPlayingSegment(tech, old_segment = null) { - var target_media = tech.vhs.playlists.media(); - var snapshot_time = tech.currentTime(); - - var segment; - var segment_time; - - // Itinerate 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_time = Math.max( - 0, - snapshot_time - (segment.end - segment.duration) - ); - // Because early segments don't have end property - } else { - segment = target_media.segments[0]; - segment_time = 0; - } - - return segment; -} - class OwncastPlayer { constructor() { window.VIDEOJS_NO_DYNAMIC_STYLE = true; // style override - this.playbackMetrics = new PlaybackMetrics(); - this.vjsPlayer = null; this.latencyCompensator = null; + this.playbackMetrics = null; this.appPlayerReadyCallback = null; this.appPlayerPlayingCallback = null; @@ -91,8 +59,6 @@ class OwncastPlayer { this.handleVolume = this.handleVolume.bind(this); this.handleEnded = this.handleEnded.bind(this); this.handleError = this.handleError.bind(this); - this.handleWaiting = this.handleWaiting.bind(this); - this.handleNoLongerBuffering = this.handleNoLongerBuffering.bind(this); this.addQualitySelector = this.addQualitySelector.bind(this); this.qualitySelectionMenu = null; } @@ -101,26 +67,6 @@ class OwncastPlayer { this.addAirplay(); this.addQualitySelector(); - // Keep a reference of the standard vjs xhr function. - const oldVjsXhrCallback = videojs.xhr; - - // Override the xhr function to track segment download time. - videojs.Vhs.xhr = (...args) => { - if (args[0].uri.match('.ts')) { - const start = new Date(); - - const cb = args[1]; - args[1] = (request, error, response) => { - const end = new Date(); - const delta = end.getTime() - start.getTime(); - this.playbackMetrics.trackSegmentDownloadTime(delta); - cb(request, error, response); - }; - } - - return oldVjsXhrCallback(...args); - }; - // Add a cachebuster param to playlist URLs. videojs.Vhs.xhr.beforeRequest = (options) => { if (options.uri.match('m3u8')) { @@ -156,37 +102,27 @@ class OwncastPlayer { console.warn(err); } this.vjsPlayer.src(source); - // this.vjsPlayer.play(); + } + + setupPlaybackMetrics() { + this.playbackMetrics = new PlaybackMetrics(this.vjsPlayer, videojs); + } + + setupLatencyCompensator() { + this.latencyCompensator = new LatencyCompensator(this.vjsPlayer); } handleReady() { + console.log('handleReady'); this.vjsPlayer.on('error', this.handleError); this.vjsPlayer.on('playing', this.handlePlaying); - this.vjsPlayer.on('waiting', this.handleWaiting); - this.vjsPlayer.on('canplaythrough', this.handleNoLongerBuffering); this.vjsPlayer.on('volumechange', this.handleVolume); this.vjsPlayer.on('ended', this.handleEnded); - this.vjsPlayer.on('ready', () => { - const tech = this.vjsPlayer.tech({ IWillNotUseThisInPlugins: true }); - tech.on('usage', (e) => { - if (e.name === 'vhs-unknown-waiting') { - this.playbackMetrics.incrementErrorCount(1); - } - - if (e.name === 'vhs-rendition-change-abr') { - // Quality variant has changed - this.playbackMetrics.incrementQualityVariantChanges(); - } - }); - - // Variant changed - const trackElements = this.vjsPlayer.textTracks(); - trackElements.addEventListener('cuechange', function (c) { - console.log(c); - }); - - this.latencyCompensator = new LatencyCompensator(this.vjsPlayer); + this.vjsPlayer.on('loadeddata', () => { + console.log('player loadeddata event'); + this.setupPlaybackMetrics(); + this.setupLatencyCompensator(); }); if (this.appPlayerReadyCallback) { @@ -205,7 +141,6 @@ class OwncastPlayer { } handlePlaying() { - this.log('on Playing'); if (this.appPlayerPlayingCallback) { // start polling this.appPlayerPlayingCallback(); @@ -216,30 +151,6 @@ class OwncastPlayer { } this.hasStartedPlayback = true; - - setInterval(() => { - this.collectPlaybackMetrics(); - }, 5000); - } - - collectPlaybackMetrics() { - const tech = this.vjsPlayer.tech({ IWillNotUseThisInPlugins: true }); - if (!tech || !tech.vhs) { - return; - } - - const bandwidth = tech.vhs.systemBandwidth; - this.playbackMetrics.trackBandwidth(bandwidth); - - try { - const segment = getCurrentlyPlayingSegment(tech); - const segmentTime = segment.dateTimeObject.getTime(); - const now = new Date().getTime(); - const latency = now - segmentTime; - this.playbackMetrics.trackLatency(latency); - } catch (err) { - // console.warn(err); - } } handleEnded() { @@ -256,17 +167,6 @@ class OwncastPlayer { if (this.appPlayerEndedCallback) { this.appPlayerEndedCallback(); } - - this.playbackMetrics.incrementErrorCount(1); - } - - handleWaiting(e) { - this.playbackMetrics.incrementErrorCount(1); - this.playbackMetrics.setIsBuffering(true); - } - - handleNoLongerBuffering() { - this.playbackMetrics.setIsBuffering(false); } log(message) { diff --git a/webroot/js/metrics/playback.js b/webroot/js/metrics/playback.js index 5bb530dc9..da359a3fe 100644 --- a/webroot/js/metrics/playback.js +++ b/webroot/js/metrics/playback.js @@ -2,7 +2,9 @@ import { URL_PLAYBACK_METRICS } from '../utils/constants.js'; const METRICS_SEND_INTERVAL = 10000; class PlaybackMetrics { - constructor() { + constructor(player, videojs) { + this.player = player; + this.supportsDetailedMetrics = false; this.hasPerformedInitialVariantChange = false; this.segmentDownloadTime = []; @@ -12,12 +14,96 @@ class PlaybackMetrics { this.qualityVariantChanges = 0; this.isBuffering = false; this.bufferingDurationTimer = 0; + this.collectPlaybackMetricsTimer = 0; + + this.videoJSReady = this.videoJSReady.bind(this); + this.handlePlaying = this.handlePlaying.bind(this); + this.handleBuffering = this.handleBuffering.bind(this); + this.handleEnded = this.handleEnded.bind(this); + this.handleError = this.handleError.bind(this); + this.collectPlaybackMetrics = this.collectPlaybackMetrics.bind(this); + this.handleNoLongerBuffering = this.handleNoLongerBuffering.bind(this); + + this.player.on('canplaythrough', this.handleNoLongerBuffering); + this.player.on('error', this.handleError); + this.player.on('stalled', this.handleBuffering); + this.player.on('waiting', this.handleBuffering); + this.player.on('playing', this.handlePlaying); + this.player.on('ended', this.handleEnded); + + // Keep a reference of the standard vjs xhr function. + const oldVjsXhrCallback = videojs.xhr; + + // Override the xhr function to track segment download time. + videojs.Vhs.xhr = (...args) => { + if (args[0].uri.match('.ts')) { + const start = new Date(); + + const cb = args[1]; + args[1] = (request, error, response) => { + const end = new Date(); + const delta = end.getTime() - start.getTime(); + this.trackSegmentDownloadTime(delta); + cb(request, error, response); + }; + } + + return oldVjsXhrCallback(...args); + }; + + this.videoJSReady(); setInterval(() => { this.send(); }, METRICS_SEND_INTERVAL); } + videoJSReady() { + const tech = this.player.tech({ IWillNotUseThisInPlugins: true }); + this.supportsDetailedMetrics = !!tech; + + tech.on('usage', (e) => { + if (e.name === 'vhs-unknown-waiting') { + this.setIsBuffering(true); + } + + if (e.name === 'vhs-rendition-change-abr') { + // Quality variant has changed + this.incrementQualityVariantChanges(); + } + }); + + // Variant changed + const trackElements = this.player.textTracks(); + trackElements.addEventListener('cuechange', (c) => { + this.incrementQualityVariantChanges(); + }); + } + + handlePlaying() { + clearInterval(this.collectPlaybackMetricsTimer); + this.collectPlaybackMetricsTimer = setInterval(() => { + this.collectPlaybackMetrics(); + }, 5000); + } + + handleEnded() { + clearInterval(this.collectPlaybackMetricsTimer); + } + + handleBuffering(e) { + this.incrementErrorCount(1); + this.setIsBuffering(true); + } + + handleNoLongerBuffering() { + this.setIsBuffering(false); + } + + handleError() { + this.incrementErrorCount(1); + } + incrementErrorCount(count) { this.errors += count; } @@ -57,34 +143,61 @@ class PlaybackMetrics { this.latencyTracking.push(latency); } - async send() { - if ( - this.segmentDownloadTime.length < 4 || - this.bandwidthTracking.length < 4 - ) { + collectPlaybackMetrics() { + const tech = this.player.tech({ IWillNotUseThisInPlugins: true }); + if (!tech || !tech.vhs) { return; } + const bandwidth = tech.vhs.systemBandwidth; + this.trackBandwidth(bandwidth); + + try { + const segment = getCurrentlyPlayingSegment(tech); + if (!segment || !segment.dateTimeObject) { + return; + } + + const segmentTime = segment.dateTimeObject.getTime(); + const now = new Date().getTime(); + const latency = now - segmentTime; + this.trackLatency(latency); + } catch (err) { + console.warn(err); + } + } + + async send() { const errorCount = this.errors; - 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; + var data; + if (this.supportsDetailedMetrics) { + const average = (arr) => arr.reduce((p, c) => p + c, 0) / arr.length; - const averageBandwidth = average(this.bandwidthTracking) / 1000; - const roundedAverageBandwidth = Math.round(averageBandwidth * 1000) / 1000; + const averageDownloadDuration = average(this.segmentDownloadTime) / 1000; + const roundedAverageDownloadDuration = + Math.round(averageDownloadDuration * 1000) / 1000; - const averageLatency = average(this.latencyTracking) / 1000; - const roundedAverageLatency = Math.round(averageLatency * 1000) / 1000; + const averageBandwidth = average(this.bandwidthTracking) / 1000; + const roundedAverageBandwidth = + Math.round(averageBandwidth * 1000) / 1000; + + const averageLatency = average(this.latencyTracking) / 1000; + const roundedAverageLatency = Math.round(averageLatency * 1000) / 1000; + + data = { + bandwidth: roundedAverageBandwidth, + latency: roundedAverageLatency, + downloadDuration: roundedAverageDownloadDuration, + errors: errorCount + this.isBuffering ? 1 : 0, + qualityVariantChanges: this.qualityVariantChanges, + }; + } else { + data = { + errors: errorCount + this.isBuffering ? 1 : 0, + }; + } - const data = { - bandwidth: roundedAverageBandwidth, - latency: roundedAverageLatency, - downloadDuration: roundedAverageDownloadDuration, - errors: errorCount + this.isBuffering ? 1 : 0, - qualityVariantChanges: this.qualityVariantChanges, - }; this.errors = 0; this.qualityVariantChanges = 0; this.segmentDownloadTime = []; @@ -104,9 +217,38 @@ class PlaybackMetrics { } catch (e) { console.error(e); } - - // console.log(data); } } export default PlaybackMetrics; + +function getCurrentlyPlayingSegment(tech, old_segment = null) { + var target_media = tech.vhs.playlists.media(); + var snapshot_time = tech.currentTime(); + + var segment; + var segment_time; + + // Itinerate 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_time = Math.max( + 0, + snapshot_time - (segment.end - segment.duration) + ); + // Because early segments don't have end property + } else { + segment = target_media.segments[0]; + segment_time = 0; + } + + return segment; +}