shaka-player 4.2.0 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -180,10 +180,6 @@ shaka.hls.HlsParser = class {
180
180
  */
181
181
  this.groupIdToClosedCaptionsMap_ = new Map();
182
182
 
183
- /** True if some of the variants in the playlist is encrypted with AES-128.
184
- * @private {boolean} */
185
- this.aesEncrypted_ = false;
186
-
187
183
  /** @private {Map.<string, string>} */
188
184
  this.groupIdToCodecsMap_ = new Map();
189
185
 
@@ -576,7 +572,6 @@ shaka.hls.HlsParser = class {
576
572
  playlist, uri, uri, codecs, type, language, primary, name,
577
573
  channelsCount, closedCaptions, characteristics, forced, spatialAudio,
578
574
  mimeType);
579
- goog.asserts.assert(streamInfo != null, 'StreamInfo is null!');
580
575
  this.uriToStreamInfosMap_.set(uri, streamInfo);
581
576
 
582
577
  // Wrap the stream from that stream info with a variant.
@@ -654,17 +649,6 @@ shaka.hls.HlsParser = class {
654
649
  this.syncStreamsWithProgramDateTime_();
655
650
  }
656
651
 
657
- if (this.aesEncrypted_ && variants.length == 0) {
658
- // We do not support AES-128 encryption with HLS yet. Variants is null
659
- // when the playlist is encrypted with AES-128.
660
- shaka.log.info('No stream is created, because we don\'t support AES-128',
661
- 'encryption yet');
662
- throw new shaka.util.Error(
663
- shaka.util.Error.Severity.CRITICAL,
664
- shaka.util.Error.Category.MANIFEST,
665
- shaka.util.Error.Code.HLS_AES_128_ENCRYPTION_NOT_SUPPORTED);
666
- }
667
-
668
652
  // Find the min and max timestamp of the earliest segment in all streams.
669
653
  // Find the minimum duration of all streams as well.
670
654
  let minDuration = Infinity;
@@ -863,8 +847,6 @@ shaka.hls.HlsParser = class {
863
847
  }
864
848
  try {
865
849
  const streamInfo = await this.createStreamInfoFromMediaTag_(tag);
866
- goog.asserts.assert(
867
- streamInfo, 'Should always have a streamInfo for text');
868
850
  return streamInfo.stream;
869
851
  } catch (e) {
870
852
  if (this.config_.hls.ignoreTextStreamFailures) {
@@ -907,8 +889,6 @@ shaka.hls.HlsParser = class {
907
889
  }
908
890
  try {
909
891
  const streamInfo = await this.createStreamInfoFromImageTag_(tag);
910
- goog.asserts.assert(
911
- streamInfo, 'Should always have a streamInfo for image');
912
892
  return streamInfo.stream;
913
893
  } catch (e) {
914
894
  if (this.config_.hls.ignoreImageStreamFailures) {
@@ -960,22 +940,17 @@ shaka.hls.HlsParser = class {
960
940
  const streamInfos = await this.createStreamInfosForVariantTag_(tag,
961
941
  resolution, frameRate);
962
942
 
963
- if (streamInfos) {
964
- goog.asserts.assert(streamInfos.audio.length ||
965
- streamInfos.video.length, 'We should have created a stream!');
966
-
967
- return this.createVariants_(
968
- streamInfos.audio,
969
- streamInfos.video,
970
- bandwidth,
971
- width,
972
- height,
973
- frameRate,
974
- videoRange);
975
- }
976
- // We do not support AES-128 encryption with HLS yet. If the streamInfos
977
- // is null because of AES-128 encryption, do not create variants for that.
978
- return [];
943
+ goog.asserts.assert(streamInfos.audio.length ||
944
+ streamInfos.video.length, 'We should have created a stream!');
945
+
946
+ return this.createVariants_(
947
+ streamInfos.audio,
948
+ streamInfos.video,
949
+ bandwidth,
950
+ width,
951
+ height,
952
+ frameRate,
953
+ videoRange);
979
954
  });
980
955
 
981
956
  const allVariants = await Promise.all(variantsPromises);
@@ -992,7 +967,7 @@ shaka.hls.HlsParser = class {
992
967
  * @param {!shaka.hls.Tag} tag
993
968
  * @param {?string} resolution
994
969
  * @param {?string} frameRate
995
- * @return {!Promise.<?shaka.hls.HlsParser.StreamInfos>}
970
+ * @return {!Promise.<!shaka.hls.HlsParser.StreamInfos>}
996
971
  * @private
997
972
  */
998
973
  async createStreamInfosForVariantTag_(tag, resolution, frameRate) {
@@ -1062,17 +1037,10 @@ shaka.hls.HlsParser = class {
1062
1037
  type = ContentType.VIDEO;
1063
1038
  }
1064
1039
 
1065
- let streamInfo;
1066
1040
  if (!ignoreStream) {
1067
- streamInfo =
1041
+ const streamInfo =
1068
1042
  await this.createStreamInfoFromVariantTag_(tag, allCodecs, type);
1069
- }
1070
- if (streamInfo) {
1071
1043
  res[streamInfo.stream.type] = [streamInfo];
1072
- } else if (streamInfo === null) {
1073
- // Triple-equals for undefined.
1074
- shaka.log.debug('streamInfo is null');
1075
- return null;
1076
1044
  }
1077
1045
  this.filterLegacyCodecs_(res);
1078
1046
  return res;
@@ -1364,7 +1332,7 @@ shaka.hls.HlsParser = class {
1364
1332
  * Parse EXT-X-MEDIA media tag into a Stream object.
1365
1333
  *
1366
1334
  * @param {shaka.hls.Tag} tag
1367
- * @return {!Promise.<?shaka.hls.HlsParser.StreamInfo>}
1335
+ * @return {!Promise.<!shaka.hls.HlsParser.StreamInfo>}
1368
1336
  * @private
1369
1337
  */
1370
1338
  async createStreamInfoFromMediaTag_(tag) {
@@ -1418,9 +1386,6 @@ shaka.hls.HlsParser = class {
1418
1386
  } else {
1419
1387
  this.groupIdToStreamInfosMap_.set(groupId, [streamInfo]);
1420
1388
  }
1421
- if (streamInfo == null) {
1422
- return null;
1423
- }
1424
1389
 
1425
1390
  // TODO: This check is necessary because of the possibility of multiple
1426
1391
  // calls to createStreamInfoFromMediaTag_ before either has resolved.
@@ -1435,7 +1400,7 @@ shaka.hls.HlsParser = class {
1435
1400
  * Parse EXT-X-MEDIA media tag into a Stream object.
1436
1401
  *
1437
1402
  * @param {shaka.hls.Tag} tag
1438
- * @return {!Promise.<?shaka.hls.HlsParser.StreamInfo>}
1403
+ * @return {!Promise.<!shaka.hls.HlsParser.StreamInfo>}
1439
1404
  * @private
1440
1405
  */
1441
1406
  async createStreamInfoFromImageTag_(tag) {
@@ -1463,9 +1428,6 @@ shaka.hls.HlsParser = class {
1463
1428
  verbatimImagePlaylistUri, codecs, type, language, /* primary= */ false,
1464
1429
  name, /* channelsCount= */ null, /* closedCaptions= */ null,
1465
1430
  characteristics, /* forced= */ false, /* spatialAudio= */ false);
1466
- if (streamInfo == null) {
1467
- return null;
1468
- }
1469
1431
 
1470
1432
  // TODO: This check is necessary because of the possibility of multiple
1471
1433
  // calls to createStreamInfoFromImageTag_ before either has resolved.
@@ -1506,7 +1468,7 @@ shaka.hls.HlsParser = class {
1506
1468
  * @param {!shaka.hls.Tag} tag
1507
1469
  * @param {!Array.<string>} allCodecs
1508
1470
  * @param {string} type
1509
- * @return {!Promise.<?shaka.hls.HlsParser.StreamInfo>}
1471
+ * @return {!Promise.<!shaka.hls.HlsParser.StreamInfo>}
1510
1472
  * @private
1511
1473
  */
1512
1474
  async createStreamInfoFromVariantTag_(tag, allCodecs, type) {
@@ -1526,9 +1488,6 @@ shaka.hls.HlsParser = class {
1526
1488
  /* name= */ null, /* channelcount= */ null, closedCaptions,
1527
1489
  /* characteristics= */ null, /* forced= */ false,
1528
1490
  /* spatialAudio= */ false);
1529
- if (streamInfo == null) {
1530
- return null;
1531
- }
1532
1491
  // TODO: This check is necessary because of the possibility of multiple
1533
1492
  // calls to createStreamInfoFromVariantTag_ before either has resolved.
1534
1493
  if (this.uriToStreamInfosMap_.has(verbatimMediaPlaylistUri)) {
@@ -1552,7 +1511,7 @@ shaka.hls.HlsParser = class {
1552
1511
  * @param {?string} characteristics
1553
1512
  * @param {boolean} forced
1554
1513
  * @param {boolean} spatialAudio
1555
- * @return {!Promise.<?shaka.hls.HlsParser.StreamInfo>}
1514
+ * @return {!Promise.<!shaka.hls.HlsParser.StreamInfo>}
1556
1515
  * @private
1557
1516
  */
1558
1517
  async createStreamInfo_(verbatimMediaPlaylistUri, codecs, type, language,
@@ -1592,7 +1551,7 @@ shaka.hls.HlsParser = class {
1592
1551
  * @param {boolean} forced
1593
1552
  * @param {boolean} spatialAudio
1594
1553
  * @param {(string|undefined)} mimeType
1595
- * @return {!Promise.<?shaka.hls.HlsParser.StreamInfo>}
1554
+ * @return {!Promise.<!shaka.hls.HlsParser.StreamInfo>}
1596
1555
  * @private
1597
1556
  */
1598
1557
  async convertParsedPlaylistIntoStreamInfo_(playlist, verbatimMediaPlaylistUri,
@@ -1635,6 +1594,8 @@ shaka.hls.HlsParser = class {
1635
1594
  }
1636
1595
 
1637
1596
  let encrypted = false;
1597
+ let aesEncrypted = false;
1598
+
1638
1599
  /** @type {!Array.<shaka.extern.DrmInfo>}*/
1639
1600
  const drmInfos = [];
1640
1601
  const keyIds = new Set();
@@ -1647,7 +1608,7 @@ shaka.hls.HlsParser = class {
1647
1608
 
1648
1609
  if (method == 'AES-128') {
1649
1610
  // These keys are handled separately.
1650
- this.aesEncrypted_ = true;
1611
+ aesEncrypted = true;
1651
1612
  } else {
1652
1613
  const keyFormat = drmTag.getRequiredAttrValue('KEYFORMAT');
1653
1614
  const drmParser =
@@ -1668,7 +1629,7 @@ shaka.hls.HlsParser = class {
1668
1629
  }
1669
1630
  }
1670
1631
 
1671
- if (encrypted && !drmInfos.length && !this.aesEncrypted_) {
1632
+ if (encrypted && !drmInfos.length && !aesEncrypted) {
1672
1633
  throw new shaka.util.Error(
1673
1634
  shaka.util.Error.Severity.CRITICAL,
1674
1635
  shaka.util.Error.Category.MANIFEST,
@@ -1683,19 +1644,8 @@ shaka.hls.HlsParser = class {
1683
1644
  /** @type {!Map.<number, number>} */
1684
1645
  const mediaSequenceToStartTime = new Map();
1685
1646
 
1686
- let segments;
1687
- try {
1688
- segments = this.createSegments_(verbatimMediaPlaylistUri, playlist, type,
1689
- mimeType, mediaSequenceToStartTime, mediaVariables, codecs);
1690
- } catch (error) {
1691
- if (error.code == shaka.util.Error.Code.HLS_INTERNAL_SKIP_STREAM) {
1692
- shaka.log.alwaysWarn('Skipping unsupported HLS stream',
1693
- mimeType, verbatimMediaPlaylistUri);
1694
- return null;
1695
- }
1696
-
1697
- throw error;
1698
- }
1647
+ const segments = this.createSegments_(verbatimMediaPlaylistUri, playlist,
1648
+ type, mimeType, mediaSequenceToStartTime, mediaVariables, codecs);
1699
1649
 
1700
1650
  const lastEndTime = segments[segments.length - 1].endTime;
1701
1651
  /** @type {!shaka.media.SegmentIndex} */
package/lib/player.js CHANGED
@@ -4459,6 +4459,12 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
4459
4459
  this.stats_.setBandwidthEstimate(estimate);
4460
4460
  }
4461
4461
 
4462
+ if (this.loadMode_ == shaka.Player.LoadMode.SRC_EQUALS) {
4463
+ this.stats_.setResolution(
4464
+ /* width= */ element.videoWidth || NaN,
4465
+ /* height= */ element.videoHeight || NaN);
4466
+ }
4467
+
4462
4468
  return this.stats_.getBlob();
4463
4469
  }
4464
4470
 
@@ -5625,6 +5631,12 @@ shaka.Player = class extends shaka.util.FakeEventTarget {
5625
5631
  return false;
5626
5632
  }
5627
5633
 
5634
+ if (!navigator.onLine) {
5635
+ // Don't restrict variants if we're completely offline, or else we end up
5636
+ // rapidly restricting all of them.
5637
+ return false;
5638
+ }
5639
+
5628
5640
  let maxDisabledTime = this.config_.streaming.maxDisabledTime;
5629
5641
  if (maxDisabledTime == 0) {
5630
5642
  if (error.code == shaka.util.Error.Code.SEGMENT_MISSING) {
@@ -6558,7 +6570,7 @@ shaka.Player.TYPICAL_BUFFERING_THRESHOLD_ = 0.5;
6558
6570
  * @define {string} A version number taken from git at compile time.
6559
6571
  * @export
6560
6572
  */
6561
- shaka.Player.version = 'v4.2.0-uncompiled';
6573
+ shaka.Player.version = 'v4.2.1-uncompiled';
6562
6574
 
6563
6575
  // Initialize the deprecation system using the version string we just set
6564
6576
  // on the player.
@@ -493,6 +493,14 @@ shaka.text.UITextDisplayer = class {
493
493
  style.backgroundRepeat = 'no-repeat';
494
494
  style.backgroundSize = 'contain';
495
495
  style.backgroundPosition = 'center';
496
+
497
+ // Quoting https://www.w3.org/TR/ttml-imsc1.2/:
498
+ // "The width and height (in pixels) of the image resource referenced by
499
+ // smpte:backgroundImage SHALL be equal to the width and height expressed
500
+ // by the tts:extent attribute of the region in which the div element is
501
+ // presented".
502
+ style.width = '100%';
503
+ style.height = '100%';
496
504
  } else {
497
505
  // If we have both text and nested cues, then style everything; otherwise
498
506
  // place the text in its own <span> so the background doesn't fill the
package/lib/util/error.js CHANGED
@@ -656,16 +656,8 @@ shaka.util.Error.Code = {
656
656
  */
657
657
  'CANNOT_ADD_EXTERNAL_TEXT_TO_LIVE_STREAM': 4033,
658
658
 
659
- /**
660
- * We do not support AES-128 encryption with HLS yet.
661
- */
662
- 'HLS_AES_128_ENCRYPTION_NOT_SUPPORTED': 4034,
663
-
664
- /**
665
- * An internal error code that should never be seen by applications, thrown
666
- * to force the HLS parser to skip an unsupported stream.
667
- */
668
- 'HLS_INTERNAL_SKIP_STREAM': 4035,
659
+ // RETIRED: 'HLS_AES_128_ENCRYPTION_NOT_SUPPORTED': 4034,
660
+ // RETIRED: 'HLS_INTERNAL_SKIP_STREAM': 4035,
669
661
 
670
662
  /** The Manifest contained no Variants. */
671
663
  'NO_VARIANTS': 4036,
@@ -314,6 +314,7 @@ shaka.util.PlayerConfiguration = class {
314
314
  */
315
315
  static mergeConfigObjects(destination, updates, template) {
316
316
  const overrides = {
317
+ '.drm.keySystemsMapping': '',
317
318
  '.drm.servers': '',
318
319
  '.drm.clearKeys': '',
319
320
  '.drm.advanced': {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shaka-player",
3
3
  "description": "DASH/EME video player library",
4
- "version": "4.2.0",
4
+ "version": "4.2.1",
5
5
  "homepage": "https://github.com/shaka-project/shaka-player",
6
6
  "author": "Google",
7
7
  "maintainers": [
@@ -2537,9 +2537,60 @@ describe('HlsParser', () => {
2537
2537
 
2538
2538
  const actual = await parser.start('test:/master', playerInterface);
2539
2539
  expect(actual).toEqual(manifest);
2540
- return actual;
2541
2540
  });
2542
2541
 
2542
+ it('fails on AES-128 if WebCrypto APIs are not available', async () => {
2543
+ const master = [
2544
+ '#EXTM3U\n',
2545
+ '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",',
2546
+ 'RESOLUTION=960x540,FRAME-RATE=60,AUDIO="aud1"\n',
2547
+ 'video\n',
2548
+ '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="eng",',
2549
+ 'URI="audio"\n',
2550
+ ].join('');
2551
+
2552
+ const mediaWithMp4AesEncryption = [
2553
+ '#EXTM3U\n',
2554
+ '#EXT-X-PLAYLIST-TYPE:VOD\n',
2555
+ '#EXT-X-KEY:METHOD=AES-128,',
2556
+ 'URI="800k.key"\n',
2557
+ '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n',
2558
+ '#EXTINF:5,\n',
2559
+ '#EXT-X-BYTERANGE:121090@616\n',
2560
+ 'main.mp4',
2561
+ ].join('');
2562
+
2563
+ fakeNetEngine
2564
+ .setResponseText('test:/master', master)
2565
+ .setResponseText('test:/audio', mediaWithMp4AesEncryption)
2566
+ .setResponseText('test:/video', mediaWithMp4AesEncryption)
2567
+ .setResponseValue('test:/init.mp4', initSegmentData)
2568
+ .setResponseValue('test:/main.mp4', segmentData)
2569
+ .setResponseValue('test:/800k.key', aes128Key);
2570
+
2571
+ let originalWebCrypto = null;
2572
+ try {
2573
+ originalWebCrypto = window.crypto;
2574
+ Object.defineProperty(window, 'crypto', {
2575
+ configurable: true,
2576
+ value: null,
2577
+ });
2578
+
2579
+ const expectedError = shaka.test.Util.jasmineError(new shaka.util.Error(
2580
+ shaka.util.Error.Severity.CRITICAL,
2581
+ shaka.util.Error.Category.MANIFEST,
2582
+ shaka.util.Error.Code.NO_WEB_CRYPTO_API));
2583
+ await expectAsync(parser.start('test:/master', playerInterface))
2584
+ .toBeRejectedWith(expectedError);
2585
+ } finally {
2586
+ Object.defineProperty(window, 'crypto', {
2587
+ configurable: true,
2588
+ value: originalWebCrypto,
2589
+ });
2590
+ }
2591
+ });
2592
+
2593
+
2543
2594
  it('constructs DrmInfo for Widevine', async () => {
2544
2595
  const master = [
2545
2596
  '#EXTM3U\n',
@@ -677,6 +677,35 @@ describe('DrmEngine', () => {
677
677
  .toBeRejectedWith(expected);
678
678
  });
679
679
 
680
+ it('uses key system IDs from keySystemsMapping config', async () => {
681
+ manifest = shaka.test.ManifestGenerator.generate((manifest) => {
682
+ manifest.addVariant(0, (variant) => {
683
+ variant.addVideo(1, (stream) => {
684
+ stream.encrypted = true;
685
+ stream.addDrmInfo('drm.abc');
686
+ });
687
+ });
688
+ });
689
+
690
+ setDecodingInfoSpy([]);
691
+
692
+ config.keySystemsMapping['drm.abc'] = 'drm.def';
693
+ drmEngine.configure(config);
694
+
695
+ const variants = manifest.variants;
696
+ await expectAsync(
697
+ drmEngine.initForPlayback(variants, manifest.offlineSessionIds))
698
+ .toBeRejected();
699
+
700
+ expect(drmEngine.initialized()).toBe(false);
701
+ expect(decodingInfoSpy).toHaveBeenCalledTimes(1);
702
+ expect(decodingInfoSpy).toHaveBeenCalledWith(containing({
703
+ keySystemConfiguration: containing({
704
+ keySystem: 'drm.def',
705
+ }),
706
+ }));
707
+ });
708
+
680
709
  it('maps TS MIME types through the transmuxer', async () => {
681
710
  const originalIsSupported = shaka.media.Transmuxer.isSupported;
682
711
 
@@ -305,6 +305,8 @@ describe('Player Src Equals', () => {
305
305
  expect(stats.loadLatency).toBeGreaterThan(0);
306
306
  expect(stats.manifestTimeSeconds).toBeNaN(); // There's no manifest.
307
307
  expect(stats.drmTimeSeconds).toBeNaN(); // There's no DRM.
308
+ expect(stats.height).toBe(110);
309
+ expect(stats.width).toBe(256);
308
310
  });
309
311
 
310
312
  it('plays with external text tracks', async () => {
@@ -422,7 +422,6 @@ describe('Player', () => {
422
422
  dispatchEventSpy.calls.reset();
423
423
  player.configure({streaming: {maxDisabledTime}});
424
424
  player.setMaxHardwareResolution(123, 456);
425
- onErrorCallback(httpError);
426
425
  });
427
426
 
428
427
  afterEach(() => {
@@ -434,14 +433,18 @@ describe('Player', () => {
434
433
  });
435
434
 
436
435
  it('handles HTTP_ERROR', () => {
436
+ onErrorCallback(httpError);
437
437
  expect(httpError.handled).toBeTruthy();
438
438
  });
439
439
 
440
440
  it('does not dispatch any error', () => {
441
+ onErrorCallback(httpError);
441
442
  expect(dispatchEventSpy).not.toHaveBeenCalled();
442
443
  });
443
444
 
444
445
  it('disables the current variant and applies restrictions', () => {
446
+ onErrorCallback(httpError);
447
+
445
448
  const foundDisabledVariant =
446
449
  manifest.variants.some(({disabledUntilTime}) =>
447
450
  disabledUntilTime == currentTime + maxDisabledTime);
@@ -453,11 +456,46 @@ describe('Player', () => {
453
456
  });
454
457
 
455
458
  it('switches the variant', () => {
459
+ onErrorCallback(httpError);
460
+
456
461
  expect(chooseVariantSpy).toHaveBeenCalled();
457
462
  expect(getBufferedInfoSpy).toHaveBeenCalled();
458
463
  expect(switchVariantSpy)
459
464
  .toHaveBeenCalledWith(chosenVariant, false, true, 14);
460
465
  });
466
+
467
+ describe('but browser is truly offline', () => {
468
+ /** @type {!Object} */
469
+ let navigatorOnLineDescriptor;
470
+
471
+ // eslint-disable-next-line no-restricted-syntax
472
+ const navigatorPrototype = Navigator.prototype;
473
+
474
+ beforeAll(() => {
475
+ navigatorOnLineDescriptor =
476
+ /** @type {!Object} */(Object.getOwnPropertyDescriptor(
477
+ navigatorPrototype, 'onLine'));
478
+ });
479
+
480
+ beforeEach(() => {
481
+ // Redefine the property, replacing only the getter.
482
+ Object.defineProperty(navigatorPrototype, 'onLine',
483
+ Object.assign(navigatorOnLineDescriptor, {
484
+ get: () => false,
485
+ }));
486
+ });
487
+
488
+ afterEach(() => {
489
+ // Restore the original property definition.
490
+ Object.defineProperty(
491
+ navigatorPrototype, 'onLine', navigatorOnLineDescriptor);
492
+ });
493
+
494
+ it('does not handle HTTP_ERROR', () => {
495
+ onErrorCallback(httpError);
496
+ expect(httpError.handled).toBe(false);
497
+ });
498
+ });
461
499
  });
462
500
  });
463
501
  });
Binary file
@@ -71,6 +71,10 @@
71
71
  .ad-text-shadow();
72
72
  }
73
73
 
74
+ .shaka-ad-controls .shaka-ad-position:not(.shaka-hidden) + .shaka-ad-counter {
75
+ margin-left: 0.75em;
76
+ }
77
+
74
78
  .shaka-skip-ad-container {
75
79
  /* Skip button is positioned at the very right edge of the
76
80
  * video container unlike the rest of the bottom controls. */