UNPKG

shaka-player

Version:
1,237 lines (1,140 loc) 38 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ goog.provide('shaka.util.CmcdManager'); goog.require('goog.asserts'); goog.require('cml.cmcd.CMCD_EVENT_KEYS'); goog.require('cml.cmcd.CMCD_HEADERS'); goog.require('cml.cmcd.CMCD_QUERY'); goog.require('cml.cmcd.CMCD_REQUEST_KEYS'); goog.require('cml.cmcd.CMCD_REQUEST_MODE'); goog.require('cml.cmcd.CMCD_RESPONSE_KEYS'); goog.require('cml.cmcd.CMCD_V1'); goog.require('cml.cmcd.CMCD_V1_KEYS'); goog.require('cml.cmcd.CMCD_V2'); goog.require('cml.cmcd.CmcdEventType'); goog.require('cml.cmcd.CmcdObjectType'); goog.require('cml.cmcd.CmcdPlayerState'); goog.require('cml.cmcd.CmcdReporter'); goog.require('cml.cmcd.CmcdStreamType'); goog.require('cml.cmcd.CmcdStreamingFormat'); goog.require('cml.cmcd.appendCmcdQuery'); goog.require('shaka.log'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.ArrayUtils'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.StringUtils'); goog.requireType('cml.cmcd.Cmcd'); goog.requireType('cml.cmcd.CmcdEncodeOptions'); goog.requireType('cml.cmcd.CmcdReporterConfig'); goog.requireType('shaka.media.SegmentReference'); goog.requireType('shaka.Player'); /** * @summary * Thin shaka adapter around `cml.cmcd.CmcdReporter`. The vendored * reporter owns CMCD state, encoding, key filtering, sequence numbers, * and event-mode dispatch. The adapter translates shaka's player / * `<video>` events into reporter calls, and shaka's request/response * shapes into CML's HttpRequest/HttpResponse shapes. * * Adapter responsibilities: * 1. Lifecycle: construct/start/stop CmcdReporter from configure() / reset() * 2. Player wiring: <video> + Player events → reporter.update. * State-change events (PLAY_STATE/BITRATE_CHANGE/BACKGROUNDED_MODE) * auto-fire from update() since v2.4.0; adapter only invokes * recordEvent() for non-state-change events (MUTE/UNMUTE/ERROR/etc.). * 3. Request mode: applyRequestData → reporter.createRequestReport * 4. Response mode: applyResponseData → reporter.recordResponseReceived * 5. Event mode: requester callback → NetworkingEngine.request * 6. Config translation: shaka.extern.CmcdConfiguration → CmcdReporterConfig * 7. Public-API back-compat: re-exports for StreamingFormat / EventType / * PlayerState */ shaka.util.CmcdManager = class { /** * @param {shaka.Player} player * @param {shaka.extern.CmcdConfiguration} config */ constructor(player, config) { /** @private {?shaka.Player} */ this.player_ = player; /** @private {?shaka.extern.CmcdConfiguration} */ this.config_ = config; /** @private {HTMLMediaElement} */ this.video_ = null; /** @private {?cml.cmcd.CmcdReporter} */ this.reporter_ = null; /** @private {?string} */ this.lastPlayerState_ = null; /** * Streaming format set once at manifest-load time. CTA-5004 / CTA-5004-B * define `sf` as a stable manifest-type indicator (`'d'`/`'h'`/`'s'`/ * `'o'`); the value does not reflect the low-latency state. * * @private {(cml.cmcd.CmcdStreamingFormat|undefined)} */ this.sf_ = undefined; /** * Per-request `ts` values for TTFB/TTLB derivation in * `applyResponseData`. CmcdReporter cannot observe request-send time; * the adapter measures it. * * @private {!Map<!shaka.extern.Request, number>} */ this.requestTimestampMap_ = new Map(); /** @private {shaka.util.EventManager} */ this.eventManager_ = new shaka.util.EventManager(); /** @private {number} */ this.startTimeOfLoad_ = 0; } /** * Set the media element and start the reporter (if enabled). * * @param {HTMLMediaElement} mediaElement The video element */ setMediaElement(mediaElement) { this.video_ = mediaElement; this.maybeStartReporter_(); if (this.reporter_) { this.setupEventListeners_(); } } /** * Update the manager's configuration. If `enabled` flips or any * material field changes, the reporter is torn down and recreated. * * @param {shaka.extern.CmcdConfiguration} config */ configure(config) { const oldConfig = this.config_; this.config_ = config; if (this.reporter_) { const enabledOff = oldConfig && oldConfig.enabled && !config.enabled; const materialChange = oldConfig && config.enabled && ( oldConfig.sessionId !== config.sessionId || oldConfig.contentId !== config.contentId || oldConfig.version !== config.version || oldConfig.useHeaders !== config.useHeaders || oldConfig.eventTargets !== config.eventTargets); if (enabledOff || materialChange) { this.reporter_.stop(true); this.reporter_ = null; this.lastPlayerState_ = null; } } if (!this.reporter_ && this.video_) { this.maybeStartReporter_(); if (this.reporter_) { this.setupEventListeners_(); } } } /** * Reset the manager. Stops the reporter and clears session-scoped * state. The video element reference is preserved — shaka's lifecycle * keeps it attached across `unload()`/`load()` cycles, and only * `detach()` releases it. */ reset() { if (this.reporter_) { this.reporter_.stop(true); this.reporter_ = null; } this.lastPlayerState_ = null; this.sf_ = undefined; this.requestTimestampMap_.clear(); this.startTimeOfLoad_ = 0; this.eventManager_.removeAll(); } /** * Forwarded from Player buffering observer; translates to a * REBUFFERING / PLAYING player-state transition. * * @param {boolean} buffering */ setBuffering(buffering) { this.setPlayerState_(buffering ? cml.cmcd.CmcdPlayerState.REBUFFERING : cml.cmcd.CmcdPlayerState.PLAYING); } /** * No-op shim retained for Player call-site back-compat. Phase 1 * dropped the non-spec `'ld'`/`'lh'` StreamingFormat values; CTA-5004 * does not define LL-specific values, so low-latency content emits * `sf=d` (DASH) or `sf=h` (HLS). * * @param {boolean} lowLatency */ setLowLatency(lowLatency) {} /** * Set start time of load; trigger autoplay-driven start if applicable. * * @param {number} startTimeOfLoad */ setStartTimeOfLoad(startTimeOfLoad) { if (!this.reporter_) { return; } this.startTimeOfLoad_ = startTimeOfLoad; if (this.video_ && this.video_.autoplay) { const playResult = this.video_.play(); if (playResult) { playResult.catch(() => { this.startTimeOfLoad_ = 0; }); } } } /** * Apply CMCD data to a request via the reporter. * * @param {!shaka.net.NetworkingEngine.RequestType} type * @param {!shaka.extern.Request} request * @param {shaka.extern.RequestContext=} context */ applyRequestData(type, request, context = {}) { if (!this.reporter_) { return; } const RequestType = shaka.net.NetworkingEngine.RequestType; const ObjectType = cml.cmcd.CmcdObjectType; if (request.method === 'HEAD') { this.applyToRequest_(request, {}); return; } switch (type) { case RequestType.MANIFEST: { if (context.type) { const sf = this.getStreamFormat_(context.type); if (sf && sf !== this.sf_) { this.sf_ = sf; this.reporter_.update(/** @type {!cml.cmcd.Cmcd} */ ( {sf: sf, st: this.getStreamType_()})); } } this.applyToRequest_(request, /** @type {!cml.cmcd.Cmcd} */ ({ ot: ObjectType.MANIFEST, su: this.lastPlayerState_ === null, })); break; } case RequestType.SEGMENT: { const data = this.getDataForSegment_(context, request.uris[0]); // Track request initiation time for TTFB/TTLB derivation when // the response comes back. if (this.requestTimestampMap_.has(request)) { this.requestTimestampMap_.delete(request); } this.requestTimestampMap_.set(request, Date.now()); this.applyToRequest_(request, /** @type {!cml.cmcd.Cmcd} */ (data)); break; } case RequestType.LICENSE: case RequestType.SERVER_CERTIFICATE: case RequestType.KEY: this.applyToRequest_(request, /** @type {!cml.cmcd.Cmcd} */ ( {ot: ObjectType.KEY})); break; case RequestType.TIMING: this.applyToRequest_(request, /** @type {!cml.cmcd.Cmcd} */ ( {ot: ObjectType.OTHER})); break; } } /** * Apply CMCD data to a response. * * @param {!shaka.net.NetworkingEngine.RequestType} type * @param {!shaka.extern.Response} response * @param {shaka.extern.RequestContext=} context */ applyResponseData(type, response, context = {}) { if (!this.reporter_) { return; } const RequestType = shaka.net.NetworkingEngine.RequestType; if (type !== RequestType.SEGMENT) { return; } // Always clean up the per-request timestamp entry to prevent unbounded // map growth when response-mode reporting is disabled (the default when // eventTargets is empty). Mirrors the upstream fix in shaka-project#9193. const startTs = this.requestTimestampMap_.get(response.originalRequest); this.requestTimestampMap_.delete(response.originalRequest); if (!this.responseModeEnabled_()) { return; } try { const url = this.removeCmcdQueryFromUri_( response.originalUri || response.uri); /** @type {!cml.cmcd.Cmcd} */ const data = /** @type {!cml.cmcd.Cmcd} */ ({url: url}); if (response.status != null) { data.rc = response.status; } if (response.originalRequest && response.originalRequest.timeToFirstByte != null) { data.ttfb = response.originalRequest.timeToFirstByte; } if (response.timeMs != null) { data.ttlb = response.timeMs; } if (startTs != null) { data.ts = startTs; } if (response.headers && response.headers['CMSD-Static']) { data.cmsds = btoa(response.headers['CMSD-Static']); } if (response.headers && response.headers['CMSD-Dynamic']) { data.cmsdd = btoa(response.headers['CMSD-Dynamic']); } // Synthesize a CML-shaped HttpResponse: CmcdReporter reads // `request.url` and `status`. We pass `data` as the override so // CML's auto-derivation from `resourceTiming` is bypassed // (shaka measures these via `requestTimestampMap_`). this.reporter_.recordResponseReceived({ request: {url: url}, status: response.status || 0, }, data); } catch (error) { shaka.log.warnOnce( 'CMCD_RESPONSE_ERROR', 'Could not apply CMCD data to response.', error); } } /** * Apply CMCD data to a sidecar text request. * * @param {!shaka.extern.Request} request */ applyTextData(request) { if (!this.reporter_) { return; } this.applyToRequest_(request, /** @type {!cml.cmcd.Cmcd} */ ({ ot: cml.cmcd.CmcdObjectType.CAPTION, su: true, })); } /** * Apply CMCD data to streams loaded via `<video src=...>`. Forces * query-mode encoding regardless of configured `useHeaders`, since * direct media loads cannot carry custom request headers. * * @param {string} uri * @param {string} mimeType * @return {string} */ appendSrcData(uri, mimeType) { if (!this.reporter_) { return uri; } try { const data = this.buildExternalUriData_(); data.ot = this.getObjectTypeFromMimeType_(mimeType); data.su = true; return cml.cmcd.appendCmcdQuery( uri, data, this.encodeOptionsForUri_(uri)); } catch (error) { shaka.log.warnOnce('CMCD_SRC_ERROR', 'Could not generate src CMCD data.', error); return uri; } } /** * Apply CMCD data to a sidecar text track URI. * * @param {string} uri * @return {string} */ appendTextTrackData(uri) { if (!this.reporter_) { return uri; } try { const data = this.buildExternalUriData_(); data.ot = cml.cmcd.CmcdObjectType.CAPTION; data.su = true; return cml.cmcd.appendCmcdQuery( uri, data, this.encodeOptionsForUri_(uri)); } catch (error) { shaka.log.warnOnce('CMCD_TEXT_TRACK_ERROR', 'Could not generate text track CMCD data.', error); return uri; } } /** * Construct the reporter if `enabled` is set and a video element is * available. No-ops if either precondition fails. * * @private */ maybeStartReporter_() { if (this.reporter_ || !this.config_ || !this.config_.enabled || !this.video_) { return; } const reporterConfig = this.toReporterConfig_(this.config_); this.reporter_ = new cml.cmcd.CmcdReporter( reporterConfig, this.makeRequester_()); this.reporter_.start(); } /** * Translate `shaka.extern.CmcdConfiguration` into * `cml.cmcd.CmcdReporterConfig`. Field renames: * - `useHeaders` → `transmissionMode` enum * - `version: 1|2` → `CMCD_V1` / `CMCD_V2` constants * - top-level `targets` → `eventTargets` (Phase 3 experimental rename) * - per-target `timeInterval` → `interval` * - per-target `includeKeys` → `enabledKeys` * * Empty `includeKeys` is expanded to all valid keys for the version, * since CML's reporter early-returns when `enabledKeys` is empty. * * @param {!shaka.extern.CmcdConfiguration} cfg * @return {!cml.cmcd.CmcdReporterConfig} * @private */ toReporterConfig_(cfg) { if (!cfg.sessionId) { cfg.sessionId = window.crypto.randomUUID(); } const includeKeys = (cfg.includeKeys && cfg.includeKeys.length) ? cfg.includeKeys : this.allKeysForVersion_(cfg.version); /** @type {!cml.cmcd.CmcdReporterConfig} */ const reporterConfig = /** @type {!cml.cmcd.CmcdReporterConfig} */ ({ sid: cfg.sessionId, cid: cfg.contentId, transmissionMode: cfg.useHeaders ? cml.cmcd.CMCD_HEADERS : cml.cmcd.CMCD_QUERY, enabledKeys: includeKeys, version: cfg.version === 2 ? cml.cmcd.CMCD_V2 : cml.cmcd.CMCD_V1, }); const targets = cfg.eventTargets; if (targets && targets.length) { reporterConfig.eventTargets = targets .filter((t) => t.enabled !== false && t.url && t.events && t.events.length) .map((t) => { const tIncludeKeys = (t.includeKeys && t.includeKeys.length) ? t.includeKeys : this.allKeysForVersion_(t.version || cfg.version); return { url: t.url, events: t.events, interval: t.interval, batchSize: t.batchSize, enabledKeys: tIncludeKeys, version: cml.cmcd.CMCD_V2, }; }); } return reporterConfig; } /** * @param {(number|undefined)} version * @return {!Array<string>} * @private */ allKeysForVersion_(version) { if (version === 2 || version === cml.cmcd.CMCD_V2) { return Array.from(new Set([].concat( cml.cmcd.CMCD_REQUEST_KEYS, cml.cmcd.CMCD_RESPONSE_KEYS, cml.cmcd.CMCD_EVENT_KEYS))); } return [].concat(cml.cmcd.CMCD_V1_KEYS); } /** * Build the `requester` callback CmcdReporter uses for event-mode * dispatch. Routes through NetworkingEngine so the request inherits * auth / retry / filters. * * @return {function(!Object): !Promise<{status: number}>} * @private */ makeRequester_() { return async (cmcdReq) => { const RequestType = shaka.net.NetworkingEngine.RequestType; const retryParams = shaka.net.NetworkingEngine.defaultRetryParameters(); const url = /** @type {string} */ (cmcdReq['url']); const shakaReq = shaka.net.NetworkingEngine.makeRequest([url], retryParams); shakaReq.method = /** @type {string} */ (cmcdReq['method']) || 'POST'; shakaReq.headers = /** @type {!Object<string, string>} */ ( cmcdReq['headers']) || {}; shakaReq.body = shaka.util.StringUtils.toUTF8( /** @type {string} */ (cmcdReq['body'])); try { const networkingEngine = this.player_.getNetworkingEngine(); goog.asserts.assert(networkingEngine, 'Must have net engine'); await networkingEngine.request( RequestType.CMCD, shakaReq).promise; return {status: 200}; } catch (err) { return { status: /** @type {number} */ ( (err && err['status']) ? err['status'] : 0), }; } }; } /** * Player-state deduplication: skip the update + recordEvent pair if * the new state is identical to the last-emitted one. Without this, * `<video>` events can fire repeatedly on stable states (e.g., * `playing` after every minor stall) and spam the reporter. * * @param {!cml.cmcd.CmcdPlayerState} state * @private */ setPlayerState_(state) { if (!this.reporter_ || this.lastPlayerState_ === state) { return; } this.lastPlayerState_ = state; // CmcdReporter.update() auto-fires PLAY_STATE since v2.4.0 // when sta changes vs. lastEmitted. this.reporter_.update(/** @type {!cml.cmcd.Cmcd} */ ({sta: state})); } /** * Push a CMCD-applied request through `createRequestReport`. Mutates * `request` in place to preserve shaka's existing applyRequestData * contract. * * @param {!shaka.extern.Request} request * @param {!cml.cmcd.Cmcd} data * @private */ applyToRequest_(request, data) { try { /** @type {!Object<string, *>} */ const cmlReq = { url: request.uris[0], headers: Object.assign({}, request.headers || {}), customData: {}, }; const report = /** @type {!Object<string, *>} */ ( this.reporter_.createRequestReport(cmlReq, data)); // Headers mode: copy CMCD-* headers back onto the request. const reportHeaders = /** @type {!Object<string, string>} */ ( report['headers'] || {}); const inputHeaderCount = Object.keys(cmlReq.headers).length; const newHeaderCount = Object.keys(reportHeaders).length; if (newHeaderCount > inputHeaderCount) { if (!request.headers) { request.headers = {}; } for (const key of Object.keys(reportHeaders)) { if (!(key in cmlReq.headers)) { request.headers[key] = reportHeaders[key]; } } } // Query mode: report.url has CMCD appended. Apply to all URIs in // the retry list (alternates carry identical CMCD). const reportUrl = /** @type {string} */ (report['url']); if (reportUrl && reportUrl !== cmlReq.url) { if (request.uris.length === 1) { request.uris = [reportUrl]; } else { let cmcdParam = null; try { const u = new URL(reportUrl); cmcdParam = u.searchParams.get('CMCD'); } catch (e) { // First URI not parsable; leave list untouched. } if (cmcdParam != null) { const param = cmcdParam; request.uris = request.uris.map((uri) => { if (uri.includes('offline:')) { return uri; } try { const uu = new URL(uri); uu.searchParams.set('CMCD', param); return uu.toString(); } catch (e) { return uri; } }); } } } } catch (error) { shaka.log.warnOnce('CMCD_REQUEST_ERROR', 'Could not apply CMCD data to request.', error); } } /** * Whether any configured `eventTarget` subscribes to the `'rr'` * (response-received) event. Saves recording response-received calls * into the reporter when no target consumes them. * * @return {boolean} * @private */ responseModeEnabled_() { const targets = this.config_.eventTargets; if (!targets) { return false; } return targets.some((target) => target.enabled !== false && target.events && target.events.includes(cml.cmcd.CmcdEventType.RESPONSE_RECEIVED)); } /** * Persistent CMCD baseline used by `appendSrcData` / * `appendTextTrackData`. These bypass the reporter (browsers can't * observe headers for direct media loads, so we always force query * mode regardless of configured `useHeaders`); `sn` is omitted since * the reporter's per-target counters don't apply. * * @return {!cml.cmcd.Cmcd} * @private */ buildExternalUriData_() { return /** @type {!cml.cmcd.Cmcd} */ ({ v: this.config_.version, sid: this.config_.sessionId, cid: this.config_.contentId, sf: this.sf_, mtp: this.player_.getBandwidthEstimate() / 1000, }); } /** * Encode options for direct `cml.cmcd.appendCmcdQuery` calls * (`appendSrcData`, `appendTextTrackData`). `baseUrl` lets CML * relativize `nor` URLs root-relative; `version` filters keys per * spec; `reportingMode = REQUEST` pins the request-mode key filter. * * @param {string} uri * @return {!cml.cmcd.CmcdEncodeOptions} * @private */ encodeOptionsForUri_(uri) { /** @type {!cml.cmcd.CmcdEncodeOptions} */ const options = /** @type {!cml.cmcd.CmcdEncodeOptions} */ ({}); if (uri && !uri.includes('offline:')) { try { options.baseUrl = new URL(uri).origin; } catch (e) { // Unparsable URI; CML's `nor` formatter passes values through // unchanged when baseUrl is absent. } } if (this.config_.version != null) { options.version = this.config_.version; } options.reportingMode = cml.cmcd.CMCD_REQUEST_MODE; return options; } /** * Wire `<video>` and Player events to reporter calls per spec * § "Player state ↔ reporter state mapping". * * @private */ setupEventListeners_() { const PlayerState = cml.cmcd.CmcdPlayerState; const EventType = cml.cmcd.CmcdEventType; this.eventManager_.listen(this.video_, 'pause', () => { this.setPlayerState_(PlayerState.PAUSED); }); this.eventManager_.listen(this.video_, 'playing', () => { this.setPlayerState_(PlayerState.PLAYING); // Compute and emit Measured Start Delay (msd) on the first 'playing' // event after setStartTimeOfLoad() has been called. CmcdReporter // sends msd once per target on the first request; zeroing // startTimeOfLoad_ here prevents re-emission on subsequent 'playing' // events within the same session. if (this.reporter_ && this.startTimeOfLoad_) { this.reporter_.update(/** @type {!cml.cmcd.Cmcd} */ ( {msd: Date.now() - this.startTimeOfLoad_})); this.startTimeOfLoad_ = 0; } }); this.eventManager_.listen(this.video_, 'seeking', () => { this.setPlayerState_(PlayerState.SEEKING); }); this.eventManager_.listen(this.video_, 'ended', () => { this.setPlayerState_(PlayerState.ENDED); }); this.eventManager_.listen(this.video_, 'volumechange', () => { if (!this.reporter_ || !this.video_) { return; } const muted = this.video_.muted; // Mute state isn't a persistent CMCD wire key (CTA-5004-B uses // m/um events, not a `muted` data field). Emit the event only. this.reporter_.recordEvent(muted ? EventType.MUTE : EventType.UNMUTE); }); this.eventManager_.listen(this.player_, 'buffering', (e) => { const buffering = /** @type {!Object} */ (e)['buffering']; this.setPlayerState_(buffering ? PlayerState.REBUFFERING : PlayerState.PLAYING); }); this.eventManager_.listen(this.player_, 'adaptation', () => { if (!this.reporter_ || !this.player_) { return; } const variants = this.player_.getVariantTracks(); const active = variants.find((t) => t.active); if (active && active.bandwidth) { // CmcdReporter.update() auto-fires BITRATE_CHANGE since v2.4.0 // when br changes vs. lastEmitted. this.reporter_.update(/** @type {!cml.cmcd.Cmcd} */ ( {br: [active.bandwidth / 1000]})); } }); this.eventManager_.listen(this.player_, 'error', () => { this.setPlayerState_(PlayerState.FATAL_ERROR); if (this.reporter_) { this.reporter_.recordEvent(EventType.ERROR); } }); this.eventManager_.listen(document, 'fullscreenchange', () => { if (!this.reporter_) { return; } const isFullscreen = !!document.fullscreenElement; this.reporter_.recordEvent(isFullscreen ? EventType.PLAYER_EXPAND : EventType.PLAYER_COLLAPSE); }); const video = /** @type {HTMLVideoElement} */ (this.video_); if (video.webkitPresentationMode || video.webkitSupportsFullscreen) { this.eventManager_.listen(video, 'webkitpresentationmodechanged', () => { if (!this.reporter_) { return; } if (video.webkitPresentationMode) { this.reporter_.recordEvent( video.webkitPresentationMode !== 'inline' ? EventType.PLAYER_EXPAND : EventType.PLAYER_COLLAPSE); } else if (video.webkitSupportsFullscreen) { this.reporter_.recordEvent( video.webkitDisplayingFullscreen ? EventType.PLAYER_EXPAND : EventType.PLAYER_COLLAPSE); } }); } this.eventManager_.listen(this.video_, 'enterpictureinpicture', () => { if (this.reporter_) { this.reporter_.recordEvent(EventType.PLAYER_EXPAND); } }); this.eventManager_.listen(this.video_, 'leavepictureinpicture', () => { if (this.reporter_) { this.reporter_.recordEvent(EventType.PLAYER_COLLAPSE); } }); if ('documentPictureInPicture' in window) { this.eventManager_.listen(window.documentPictureInPicture, 'enter', (e) => { if (!this.reporter_) { return; } this.reporter_.recordEvent(EventType.PLAYER_EXPAND); const event = /** @type {DocumentPictureInPictureEvent} */ (e); const pipWindow = event.window; this.eventManager_.listenOnce(pipWindow, 'pagehide', () => { if (this.reporter_) { this.reporter_.recordEvent(EventType.PLAYER_COLLAPSE); } }); }); } this.eventManager_.listen(document, 'visibilitychange', () => { if (!this.reporter_) { return; } const backgrounded = document.hidden; // CmcdReporter.update() auto-fires BACKGROUNDED_MODE since v2.4.0 // when bg changes vs. lastEmitted. this.reporter_.update(/** @type {!cml.cmcd.Cmcd} */ ( {bg: backgrounded})); }); this.eventManager_.listen(this.player_, 'complete', () => { this.setPlayerState_(PlayerState.ENDED); }); } /** * Strip the CMCD query parameter from a URL. * * @param {string} uri * @return {string} * @private */ removeCmcdQueryFromUri_(uri) { if (!uri.includes('CMCD=')) { return uri; } try { const url = new URL(uri); url.searchParams.delete('CMCD'); return url.toString(); } catch (error) { shaka.log.error('Failed to parse URI for CMCD removal:', uri, error); return uri; } } /** * Map a shaka request context to a CMCD object type. * * @param {shaka.extern.RequestContext} context * @return {(cml.cmcd.CmcdObjectType|undefined)} * @private */ getObjectType_(context) { if (context.type === shaka.net.NetworkingEngine.AdvancedRequestType.INIT_SEGMENT) { return cml.cmcd.CmcdObjectType.INIT; } const stream = context.stream; if (!stream) { return undefined; } const type = stream.type; if (type == 'video') { if (stream.codecs && stream.codecs.includes(',')) { return cml.cmcd.CmcdObjectType.MUXED; } return cml.cmcd.CmcdObjectType.VIDEO; } if (type == 'audio') { return cml.cmcd.CmcdObjectType.AUDIO; } if (type == 'text') { if (stream.mimeType === 'application/mp4') { return cml.cmcd.CmcdObjectType.TIMED_TEXT; } return cml.cmcd.CmcdObjectType.CAPTION; } return undefined; } /** * Map a mimeType to a CMCD object type. Used by `appendSrcData`. * * @param {string} mimeType * @return {(cml.cmcd.CmcdObjectType|undefined)} * @private */ getObjectTypeFromMimeType_(mimeType) { switch (mimeType.toLowerCase()) { case 'audio/mp4': case 'audio/webm': case 'audio/ogg': case 'audio/mpeg': case 'audio/aac': case 'audio/flac': case 'audio/wav': return cml.cmcd.CmcdObjectType.AUDIO; case 'video/webm': case 'video/mp4': case 'video/mpeg': case 'video/mp2t': return cml.cmcd.CmcdObjectType.MUXED; case 'application/x-mpegurl': case 'application/vnd.apple.mpegurl': case 'audio/x-mpegurl': case 'application/dash+xml': case 'video/vnd.mpeg.dash.mpd': case 'application/vnd.ms-sstr+xml': return cml.cmcd.CmcdObjectType.MANIFEST; default: return undefined; } } /** * Buffer length in milliseconds for a media type. * * @param {string} type * @return {number} * @private */ getBufferLength_(type) { const ranges = this.player_.getBufferedInfo()[type]; if (!ranges.length) { return NaN; } const start = this.getCurrentTime_(); const range = ranges.find((r) => r.start <= start && r.end >= start); if (!range) { return NaN; } return (range.end - start) * 1000; } /** * Remaining buffer length in milliseconds. * * @param {string} type * @return {number} * @private */ getRemainingBufferLength_(type) { const ranges = this.player_.getBufferedInfo()[type]; if (!ranges.length) { return 0; } const start = this.getCurrentTime_(); const range = ranges.find((r) => r.start <= start && r.end >= start); if (!range) { return 0; } return (range.end - start) * 1000; } /** * Computed `rtp` (requested maximum throughput) for a segment. * * @param {shaka.extern.Stream} stream * @param {shaka.media.SegmentReference} segment * @return {number} * @private */ calculateRtp_(stream, segment) { const playbackRate = this.player_.getPlaybackRate() || 1; const currentBufferLevel = this.getRemainingBufferLength_(stream.type) || 500; const bandwidth = stream.bandwidth; if (!bandwidth) { return NaN; } const segmentDuration = segment.endTime - segment.startTime; const segmentSize = bandwidth * segmentDuration / 1000; const timeToLoad = (currentBufferLevel / playbackRate) / 1000; const minBandwidth = segmentSize / timeToLoad; return minBandwidth * this.config_.rtpSafetyFactor; } /** * Resolve `sf` from the manifest-parser advanced-request type. * * @param {shaka.net.NetworkingEngine.AdvancedRequestType} type * @return {(cml.cmcd.CmcdStreamingFormat|undefined)} * @private */ getStreamFormat_(type) { const AdvancedRequestType = shaka.net.NetworkingEngine.AdvancedRequestType; switch (type) { case AdvancedRequestType.MPD: return cml.cmcd.CmcdStreamingFormat.DASH; case AdvancedRequestType.MASTER_PLAYLIST: case AdvancedRequestType.MEDIA_PLAYLIST: return cml.cmcd.CmcdStreamingFormat.HLS; } return undefined; } /** * @return {cml.cmcd.CmcdStreamType} * @private */ getStreamType_() { return this.player_.isLive() ? cml.cmcd.CmcdStreamType.LIVE : cml.cmcd.CmcdStreamType.VOD; } /** * Top-bandwidth value across variants for a given object type. * * @param {(cml.cmcd.CmcdObjectType|undefined)} type * @return {number} * @private */ getTopBandwidth_(type) { const variants = this.player_.getVariantTracks(); if (!variants.length) { return NaN; } let top = variants[0]; for (const variant of variants) { if (variant.type === 'variant' && variant.bandwidth > top.bandwidth) { top = variant; } } const ObjectType = cml.cmcd.CmcdObjectType; switch (type) { case ObjectType.VIDEO: return top.videoBandwidth || NaN; case ObjectType.AUDIO: return top.audioBandwidth || NaN; default: return top.bandwidth; } } /** * Build the per-segment CMCD payload from shaka's request context. * * @param {shaka.extern.RequestContext} context * @param {?string} requestUri * @return {!Object<string, *>} * @private */ getDataForSegment_(context, requestUri) { const segment = context.segment; let duration = 0; if (segment) { duration = segment.endTime - segment.startTime; } /** @type {!Object<string, *>} */ const data = { d: duration * 1000, st: this.getStreamType_(), }; data['ot'] = this.getObjectType_(context); const ObjectType = cml.cmcd.CmcdObjectType; const isMedia = data['ot'] === ObjectType.VIDEO || data['ot'] === ObjectType.AUDIO || data['ot'] === ObjectType.MUXED || data['ot'] === ObjectType.TIMED_TEXT; const stream = context.stream; if (stream) { const playbackRate = this.player_.getPlaybackRate(); if (isMedia) { const bl = this.getBufferLength_(stream.type); if (Number.isFinite(bl)) { data['bl'] = bl; } if (data['ot'] !== ObjectType.TIMED_TEXT) { const remainingBufferLength = this.getRemainingBufferLength_(stream.type); if (playbackRate) { data['dl'] = remainingBufferLength / Math.abs(playbackRate); } else { data['dl'] = remainingBufferLength; } } } if (stream.bandwidth) { data['br'] = stream.bandwidth / 1000; } if (stream.segmentIndex && segment) { const reverse = playbackRate < 0; const iterator = stream.segmentIndex.getIteratorForTime( segment.endTime, /* allowNonIndependent= */ true, reverse); if (iterator) { const nextSegment = iterator.next().value; if (nextSegment && nextSegment != segment) { if (requestUri && !shaka.util.ArrayUtils.equal( segment.getUris(), nextSegment.getUris())) { data['nor'] = nextSegment.getUris()[0]; } if ((nextSegment.startByte || nextSegment.endByte) && (segment.startByte != nextSegment.startByte || segment.endByte != nextSegment.endByte)) { let range = nextSegment.startByte + '-'; if (nextSegment.endByte) { range += nextSegment.endByte; } data['nrr'] = range; } } } const rtp = this.calculateRtp_(stream, segment); if (Number.isFinite(rtp)) { data['rtp'] = rtp; } } } if (isMedia && data['ot'] !== ObjectType.TIMED_TEXT) { const tb = this.getTopBandwidth_( /** @type {(cml.cmcd.CmcdObjectType|undefined)} */ ( data['ot'])) / 1000; if (Number.isFinite(tb)) { data['tb'] = tb; } } if (this.player_.isLive()) { const liveLatency = this.player_.getLiveLatency(); if (liveLatency) { data['ltc'] = liveLatency; } } // Set the startup flag: true when the player has not yet transitioned // to a playing state (lastPlayerState_ is null = initial buffering) or // is actively rebuffering. Mirrors pre-refactor: `data.su = buffering_`. data['su'] = this.lastPlayerState_ === null || this.lastPlayerState_ === cml.cmcd.CmcdPlayerState.REBUFFERING; return data; } /** * @return {number} * @private */ getCurrentTime_() { return this.video_ ? this.video_.currentTime : 0; } }; /** * Public re-export of CMCD streaming-format values. The literal object * form is required for Closure tooling: clutz (TypeScript-defs gen) * and `generateExterns.js` reject `@export`ed `@enum`s whose RHS is a * MemberExpression / alias. The 4 values match * `cml.cmcd.CmcdStreamingFormat` exactly; a unit test asserts value * identity. * * @enum {string} * @export */ shaka.util.CmcdManager.StreamingFormat = { DASH: 'd', HLS: 'h', SMOOTH: 's', OTHER: 'o', }; /** * Public re-export of CMCD v2 event types. Literal form per the * Closure-tooling constraint above. Values match `cml.cmcd.CmcdEventType` * exactly; unit-test asserts identity. * * @enum {string} * @export */ shaka.util.CmcdManager.EventType = { BITRATE_CHANGE: 'bc', PLAY_STATE: 'ps', PLAYBACK_RATE: 'pr', ERROR: 'e', TIME_INTERVAL: 't', CONTENT_ID: 'c', BACKGROUNDED_MODE: 'b', MUTE: 'm', UNMUTE: 'um', PLAYER_EXPAND: 'pe', PLAYER_COLLAPSE: 'pc', RESPONSE_RECEIVED: 'rr', AD_START: 'as', AD_END: 'ae', AD_BREAK_START: 'abs', AD_BREAK_END: 'abe', SKIP: 'sk', CUSTOM_EVENT: 'ce', }; /** * Public re-export of CMCD v2 player states. Literal form per the * Closure-tooling constraint above. Values match `cml.cmcd.CmcdPlayerState` * exactly; unit-test asserts identity. * * @enum {string} * @export */ shaka.util.CmcdManager.PlayerState = { STARTING: 's', PLAYING: 'p', SEEKING: 'k', REBUFFERING: 'r', PAUSED: 'a', WAITING: 'w', ENDED: 'e', FATAL_ERROR: 'f', QUIT: 'q', PRELOADING: 'd', };