playlist-selectors.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import window from 'global/window';
  2. import Config from './config';
  3. import Playlist from './playlist';
  4. import { codecsForPlaylist } from './util/codecs.js';
  5. import logger from './util/logger';
  6. const logFn = logger('PlaylistSelector');
  7. const representationToString = function(representation) {
  8. if (!representation || !representation.playlist) {
  9. return;
  10. }
  11. const playlist = representation.playlist;
  12. return JSON.stringify({
  13. id: playlist.id,
  14. bandwidth: representation.bandwidth,
  15. width: representation.width,
  16. height: representation.height,
  17. codecs: playlist.attributes && playlist.attributes.CODECS || ''
  18. });
  19. };
  20. // Utilities
  21. /**
  22. * Returns the CSS value for the specified property on an element
  23. * using `getComputedStyle`. Firefox has a long-standing issue where
  24. * getComputedStyle() may return null when running in an iframe with
  25. * `display: none`.
  26. *
  27. * @see https://bugzilla.mozilla.org/show_bug.cgi?id=548397
  28. * @param {HTMLElement} el the htmlelement to work on
  29. * @param {string} the proprety to get the style for
  30. */
  31. const safeGetComputedStyle = function(el, property) {
  32. if (!el) {
  33. return '';
  34. }
  35. const result = window.getComputedStyle(el);
  36. if (!result) {
  37. return '';
  38. }
  39. return result[property];
  40. };
  41. /**
  42. * Resuable stable sort function
  43. *
  44. * @param {Playlists} array
  45. * @param {Function} sortFn Different comparators
  46. * @function stableSort
  47. */
  48. const stableSort = function(array, sortFn) {
  49. const newArray = array.slice();
  50. array.sort(function(left, right) {
  51. const cmp = sortFn(left, right);
  52. if (cmp === 0) {
  53. return newArray.indexOf(left) - newArray.indexOf(right);
  54. }
  55. return cmp;
  56. });
  57. };
  58. /**
  59. * A comparator function to sort two playlist object by bandwidth.
  60. *
  61. * @param {Object} left a media playlist object
  62. * @param {Object} right a media playlist object
  63. * @return {number} Greater than zero if the bandwidth attribute of
  64. * left is greater than the corresponding attribute of right. Less
  65. * than zero if the bandwidth of right is greater than left and
  66. * exactly zero if the two are equal.
  67. */
  68. export const comparePlaylistBandwidth = function(left, right) {
  69. let leftBandwidth;
  70. let rightBandwidth;
  71. if (left.attributes.BANDWIDTH) {
  72. leftBandwidth = left.attributes.BANDWIDTH;
  73. }
  74. leftBandwidth = leftBandwidth || window.Number.MAX_VALUE;
  75. if (right.attributes.BANDWIDTH) {
  76. rightBandwidth = right.attributes.BANDWIDTH;
  77. }
  78. rightBandwidth = rightBandwidth || window.Number.MAX_VALUE;
  79. return leftBandwidth - rightBandwidth;
  80. };
  81. /**
  82. * A comparator function to sort two playlist object by resolution (width).
  83. *
  84. * @param {Object} left a media playlist object
  85. * @param {Object} right a media playlist object
  86. * @return {number} Greater than zero if the resolution.width attribute of
  87. * left is greater than the corresponding attribute of right. Less
  88. * than zero if the resolution.width of right is greater than left and
  89. * exactly zero if the two are equal.
  90. */
  91. export const comparePlaylistResolution = function(left, right) {
  92. let leftWidth;
  93. let rightWidth;
  94. if (left.attributes.RESOLUTION &&
  95. left.attributes.RESOLUTION.width) {
  96. leftWidth = left.attributes.RESOLUTION.width;
  97. }
  98. leftWidth = leftWidth || window.Number.MAX_VALUE;
  99. if (right.attributes.RESOLUTION &&
  100. right.attributes.RESOLUTION.width) {
  101. rightWidth = right.attributes.RESOLUTION.width;
  102. }
  103. rightWidth = rightWidth || window.Number.MAX_VALUE;
  104. // NOTE - Fallback to bandwidth sort as appropriate in cases where multiple renditions
  105. // have the same media dimensions/ resolution
  106. if (leftWidth === rightWidth &&
  107. left.attributes.BANDWIDTH &&
  108. right.attributes.BANDWIDTH) {
  109. return left.attributes.BANDWIDTH - right.attributes.BANDWIDTH;
  110. }
  111. return leftWidth - rightWidth;
  112. };
  113. /**
  114. * Chooses the appropriate media playlist based on bandwidth and player size
  115. *
  116. * @param {Object} main
  117. * Object representation of the main manifest
  118. * @param {number} playerBandwidth
  119. * Current calculated bandwidth of the player
  120. * @param {number} playerWidth
  121. * Current width of the player element (should account for the device pixel ratio)
  122. * @param {number} playerHeight
  123. * Current height of the player element (should account for the device pixel ratio)
  124. * @param {boolean} limitRenditionByPlayerDimensions
  125. * True if the player width and height should be used during the selection, false otherwise
  126. * @param {Object} playlistController
  127. * the current playlistController object
  128. * @return {Playlist} the highest bitrate playlist less than the
  129. * currently detected bandwidth, accounting for some amount of
  130. * bandwidth variance
  131. */
  132. export let simpleSelector = function(
  133. main,
  134. playerBandwidth,
  135. playerWidth,
  136. playerHeight,
  137. limitRenditionByPlayerDimensions,
  138. playlistController
  139. ) {
  140. // If we end up getting called before `main` is available, exit early
  141. if (!main) {
  142. return;
  143. }
  144. const options = {
  145. bandwidth: playerBandwidth,
  146. width: playerWidth,
  147. height: playerHeight,
  148. limitRenditionByPlayerDimensions
  149. };
  150. let playlists = main.playlists;
  151. // if playlist is audio only, select between currently active audio group playlists.
  152. if (Playlist.isAudioOnly(main)) {
  153. playlists = playlistController.getAudioTrackPlaylists_();
  154. // add audioOnly to options so that we log audioOnly: true
  155. // at the buttom of this function for debugging.
  156. options.audioOnly = true;
  157. }
  158. // convert the playlists to an intermediary representation to make comparisons easier
  159. let sortedPlaylistReps = playlists.map((playlist) => {
  160. let bandwidth;
  161. const width = playlist.attributes && playlist.attributes.RESOLUTION && playlist.attributes.RESOLUTION.width;
  162. const height = playlist.attributes && playlist.attributes.RESOLUTION && playlist.attributes.RESOLUTION.height;
  163. bandwidth = playlist.attributes && playlist.attributes.BANDWIDTH;
  164. bandwidth = bandwidth || window.Number.MAX_VALUE;
  165. return {
  166. bandwidth,
  167. width,
  168. height,
  169. playlist
  170. };
  171. });
  172. stableSort(sortedPlaylistReps, (left, right) => left.bandwidth - right.bandwidth);
  173. // filter out any playlists that have been excluded due to
  174. // incompatible configurations
  175. sortedPlaylistReps = sortedPlaylistReps.filter((rep) => !Playlist.isIncompatible(rep.playlist));
  176. // filter out any playlists that have been disabled manually through the representations
  177. // api or excluded temporarily due to playback errors.
  178. let enabledPlaylistReps = sortedPlaylistReps.filter((rep) => Playlist.isEnabled(rep.playlist));
  179. if (!enabledPlaylistReps.length) {
  180. // if there are no enabled playlists, then they have all been excluded or disabled
  181. // by the user through the representations api. In this case, ignore exclusion and
  182. // fallback to what the user wants by using playlists the user has not disabled.
  183. enabledPlaylistReps = sortedPlaylistReps.filter((rep) => !Playlist.isDisabled(rep.playlist));
  184. }
  185. // filter out any variant that has greater effective bitrate
  186. // than the current estimated bandwidth
  187. const bandwidthPlaylistReps = enabledPlaylistReps.filter((rep) => rep.bandwidth * Config.BANDWIDTH_VARIANCE < playerBandwidth);
  188. let highestRemainingBandwidthRep =
  189. bandwidthPlaylistReps[bandwidthPlaylistReps.length - 1];
  190. // get all of the renditions with the same (highest) bandwidth
  191. // and then taking the very first element
  192. const bandwidthBestRep = bandwidthPlaylistReps.filter((rep) => rep.bandwidth === highestRemainingBandwidthRep.bandwidth)[0];
  193. // if we're not going to limit renditions by player size, make an early decision.
  194. if (limitRenditionByPlayerDimensions === false) {
  195. const chosenRep = (
  196. bandwidthBestRep ||
  197. enabledPlaylistReps[0] ||
  198. sortedPlaylistReps[0]
  199. );
  200. if (chosenRep && chosenRep.playlist) {
  201. let type = 'sortedPlaylistReps';
  202. if (bandwidthBestRep) {
  203. type = 'bandwidthBestRep';
  204. }
  205. if (enabledPlaylistReps[0]) {
  206. type = 'enabledPlaylistReps';
  207. }
  208. logFn(`choosing ${representationToString(chosenRep)} using ${type} with options`, options);
  209. return chosenRep.playlist;
  210. }
  211. logFn('could not choose a playlist with options', options);
  212. return null;
  213. }
  214. // filter out playlists without resolution information
  215. const haveResolution = bandwidthPlaylistReps.filter((rep) => rep.width && rep.height);
  216. // sort variants by resolution
  217. stableSort(haveResolution, (left, right) => left.width - right.width);
  218. // if we have the exact resolution as the player use it
  219. const resolutionBestRepList = haveResolution.filter((rep) => rep.width === playerWidth && rep.height === playerHeight);
  220. highestRemainingBandwidthRep = resolutionBestRepList[resolutionBestRepList.length - 1];
  221. // ensure that we pick the highest bandwidth variant that have exact resolution
  222. const resolutionBestRep = resolutionBestRepList.filter((rep) => rep.bandwidth === highestRemainingBandwidthRep.bandwidth)[0];
  223. let resolutionPlusOneList;
  224. let resolutionPlusOneSmallest;
  225. let resolutionPlusOneRep;
  226. // find the smallest variant that is larger than the player
  227. // if there is no match of exact resolution
  228. if (!resolutionBestRep) {
  229. resolutionPlusOneList = haveResolution.filter((rep) => rep.width > playerWidth || rep.height > playerHeight);
  230. // find all the variants have the same smallest resolution
  231. resolutionPlusOneSmallest = resolutionPlusOneList.filter((rep) => rep.width === resolutionPlusOneList[0].width &&
  232. rep.height === resolutionPlusOneList[0].height);
  233. // ensure that we also pick the highest bandwidth variant that
  234. // is just-larger-than the video player
  235. highestRemainingBandwidthRep =
  236. resolutionPlusOneSmallest[resolutionPlusOneSmallest.length - 1];
  237. resolutionPlusOneRep = resolutionPlusOneSmallest.filter((rep) => rep.bandwidth === highestRemainingBandwidthRep.bandwidth)[0];
  238. }
  239. let leastPixelDiffRep;
  240. // If this selector proves to be better than others,
  241. // resolutionPlusOneRep and resolutionBestRep and all
  242. // the code involving them should be removed.
  243. if (playlistController.leastPixelDiffSelector) {
  244. // find the variant that is closest to the player's pixel size
  245. const leastPixelDiffList = haveResolution.map((rep) => {
  246. rep.pixelDiff = Math.abs(rep.width - playerWidth) + Math.abs(rep.height - playerHeight);
  247. return rep;
  248. });
  249. // get the highest bandwidth, closest resolution playlist
  250. stableSort(leastPixelDiffList, (left, right) => {
  251. // sort by highest bandwidth if pixelDiff is the same
  252. if (left.pixelDiff === right.pixelDiff) {
  253. return right.bandwidth - left.bandwidth;
  254. }
  255. return left.pixelDiff - right.pixelDiff;
  256. });
  257. leastPixelDiffRep = leastPixelDiffList[0];
  258. }
  259. // fallback chain of variants
  260. const chosenRep = (
  261. leastPixelDiffRep ||
  262. resolutionPlusOneRep ||
  263. resolutionBestRep ||
  264. bandwidthBestRep ||
  265. enabledPlaylistReps[0] ||
  266. sortedPlaylistReps[0]
  267. );
  268. if (chosenRep && chosenRep.playlist) {
  269. let type = 'sortedPlaylistReps';
  270. if (leastPixelDiffRep) {
  271. type = 'leastPixelDiffRep';
  272. } else if (resolutionPlusOneRep) {
  273. type = 'resolutionPlusOneRep';
  274. } else if (resolutionBestRep) {
  275. type = 'resolutionBestRep';
  276. } else if (bandwidthBestRep) {
  277. type = 'bandwidthBestRep';
  278. } else if (enabledPlaylistReps[0]) {
  279. type = 'enabledPlaylistReps';
  280. }
  281. logFn(`choosing ${representationToString(chosenRep)} using ${type} with options`, options);
  282. return chosenRep.playlist;
  283. }
  284. logFn('could not choose a playlist with options', options);
  285. return null;
  286. };
  287. export const TEST_ONLY_SIMPLE_SELECTOR = (newSimpleSelector) => {
  288. const oldSimpleSelector = simpleSelector;
  289. simpleSelector = newSimpleSelector;
  290. return function resetSimpleSelector() {
  291. simpleSelector = oldSimpleSelector;
  292. };
  293. };
  294. // Playlist Selectors
  295. /**
  296. * Chooses the appropriate media playlist based on the most recent
  297. * bandwidth estimate and the player size.
  298. *
  299. * Expects to be called within the context of an instance of VhsHandler
  300. *
  301. * @return {Playlist} the highest bitrate playlist less than the
  302. * currently detected bandwidth, accounting for some amount of
  303. * bandwidth variance
  304. */
  305. export const lastBandwidthSelector = function() {
  306. const pixelRatio = this.useDevicePixelRatio ? window.devicePixelRatio || 1 : 1;
  307. return simpleSelector(
  308. this.playlists.main,
  309. this.systemBandwidth,
  310. parseInt(safeGetComputedStyle(this.tech_.el(), 'width'), 10) * pixelRatio,
  311. parseInt(safeGetComputedStyle(this.tech_.el(), 'height'), 10) * pixelRatio,
  312. this.limitRenditionByPlayerDimensions,
  313. this.playlistController_
  314. );
  315. };
  316. /**
  317. * Chooses the appropriate media playlist based on an
  318. * exponential-weighted moving average of the bandwidth after
  319. * filtering for player size.
  320. *
  321. * Expects to be called within the context of an instance of VhsHandler
  322. *
  323. * @param {number} decay - a number between 0 and 1. Higher values of
  324. * this parameter will cause previous bandwidth estimates to lose
  325. * significance more quickly.
  326. * @return {Function} a function which can be invoked to create a new
  327. * playlist selector function.
  328. * @see https://en.wikipedia.org/wiki/Moving_average#Exponential_moving_average
  329. */
  330. export const movingAverageBandwidthSelector = function(decay) {
  331. let average = -1;
  332. let lastSystemBandwidth = -1;
  333. if (decay < 0 || decay > 1) {
  334. throw new Error('Moving average bandwidth decay must be between 0 and 1.');
  335. }
  336. return function() {
  337. const pixelRatio = this.useDevicePixelRatio ? window.devicePixelRatio || 1 : 1;
  338. if (average < 0) {
  339. average = this.systemBandwidth;
  340. lastSystemBandwidth = this.systemBandwidth;
  341. }
  342. // stop the average value from decaying for every 250ms
  343. // when the systemBandwidth is constant
  344. // and
  345. // stop average from setting to a very low value when the
  346. // systemBandwidth becomes 0 in case of chunk cancellation
  347. if (this.systemBandwidth > 0 && this.systemBandwidth !== lastSystemBandwidth) {
  348. average = decay * this.systemBandwidth + (1 - decay) * average;
  349. lastSystemBandwidth = this.systemBandwidth;
  350. }
  351. return simpleSelector(
  352. this.playlists.main,
  353. average,
  354. parseInt(safeGetComputedStyle(this.tech_.el(), 'width'), 10) * pixelRatio,
  355. parseInt(safeGetComputedStyle(this.tech_.el(), 'height'), 10) * pixelRatio,
  356. this.limitRenditionByPlayerDimensions,
  357. this.playlistController_
  358. );
  359. };
  360. };
  361. /**
  362. * Chooses the appropriate media playlist based on the potential to rebuffer
  363. *
  364. * @param {Object} settings
  365. * Object of information required to use this selector
  366. * @param {Object} settings.main
  367. * Object representation of the main manifest
  368. * @param {number} settings.currentTime
  369. * The current time of the player
  370. * @param {number} settings.bandwidth
  371. * Current measured bandwidth
  372. * @param {number} settings.duration
  373. * Duration of the media
  374. * @param {number} settings.segmentDuration
  375. * Segment duration to be used in round trip time calculations
  376. * @param {number} settings.timeUntilRebuffer
  377. * Time left in seconds until the player has to rebuffer
  378. * @param {number} settings.currentTimeline
  379. * The current timeline segments are being loaded from
  380. * @param {SyncController} settings.syncController
  381. * SyncController for determining if we have a sync point for a given playlist
  382. * @return {Object|null}
  383. * {Object} return.playlist
  384. * The highest bandwidth playlist with the least amount of rebuffering
  385. * {Number} return.rebufferingImpact
  386. * The amount of time in seconds switching to this playlist will rebuffer. A
  387. * negative value means that switching will cause zero rebuffering.
  388. */
  389. export const minRebufferMaxBandwidthSelector = function(settings) {
  390. const {
  391. main,
  392. currentTime,
  393. bandwidth,
  394. duration,
  395. segmentDuration,
  396. timeUntilRebuffer,
  397. currentTimeline,
  398. syncController
  399. } = settings;
  400. // filter out any playlists that have been excluded due to
  401. // incompatible configurations
  402. const compatiblePlaylists = main.playlists.filter(playlist => !Playlist.isIncompatible(playlist));
  403. // filter out any playlists that have been disabled manually through the representations
  404. // api or excluded temporarily due to playback errors.
  405. let enabledPlaylists = compatiblePlaylists.filter(Playlist.isEnabled);
  406. if (!enabledPlaylists.length) {
  407. // if there are no enabled playlists, then they have all been excluded or disabled
  408. // by the user through the representations api. In this case, ignore exclusion and
  409. // fallback to what the user wants by using playlists the user has not disabled.
  410. enabledPlaylists = compatiblePlaylists.filter(playlist => !Playlist.isDisabled(playlist));
  411. }
  412. const bandwidthPlaylists =
  413. enabledPlaylists.filter(Playlist.hasAttribute.bind(null, 'BANDWIDTH'));
  414. const rebufferingEstimates = bandwidthPlaylists.map((playlist) => {
  415. const syncPoint = syncController.getSyncPoint(
  416. playlist,
  417. duration,
  418. currentTimeline,
  419. currentTime
  420. );
  421. // If there is no sync point for this playlist, switching to it will require a
  422. // sync request first. This will double the request time
  423. const numRequests = syncPoint ? 1 : 2;
  424. const requestTimeEstimate = Playlist.estimateSegmentRequestTime(
  425. segmentDuration,
  426. bandwidth,
  427. playlist
  428. );
  429. const rebufferingImpact = (requestTimeEstimate * numRequests) - timeUntilRebuffer;
  430. return {
  431. playlist,
  432. rebufferingImpact
  433. };
  434. });
  435. const noRebufferingPlaylists = rebufferingEstimates.filter((estimate) => estimate.rebufferingImpact <= 0);
  436. // Sort by bandwidth DESC
  437. stableSort(
  438. noRebufferingPlaylists,
  439. (a, b) => comparePlaylistBandwidth(b.playlist, a.playlist)
  440. );
  441. if (noRebufferingPlaylists.length) {
  442. return noRebufferingPlaylists[0];
  443. }
  444. stableSort(rebufferingEstimates, (a, b) => a.rebufferingImpact - b.rebufferingImpact);
  445. return rebufferingEstimates[0] || null;
  446. };
  447. /**
  448. * Chooses the appropriate media playlist, which in this case is the lowest bitrate
  449. * one with video. If no renditions with video exist, return the lowest audio rendition.
  450. *
  451. * Expects to be called within the context of an instance of VhsHandler
  452. *
  453. * @return {Object|null}
  454. * {Object} return.playlist
  455. * The lowest bitrate playlist that contains a video codec. If no such rendition
  456. * exists pick the lowest audio rendition.
  457. */
  458. export const lowestBitrateCompatibleVariantSelector = function() {
  459. // filter out any playlists that have been excluded due to
  460. // incompatible configurations or playback errors
  461. const playlists = this.playlists.main.playlists.filter(Playlist.isEnabled);
  462. // Sort ascending by bitrate
  463. stableSort(
  464. playlists,
  465. (a, b) => comparePlaylistBandwidth(a, b)
  466. );
  467. // Parse and assume that playlists with no video codec have no video
  468. // (this is not necessarily true, although it is generally true).
  469. //
  470. // If an entire manifest has no valid videos everything will get filtered
  471. // out.
  472. const playlistsWithVideo = playlists.filter(playlist => !!codecsForPlaylist(this.playlists.main, playlist).video);
  473. return playlistsWithVideo[0] || null;
  474. };