diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 470a25b6e0..74a94b57a8 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -41,9 +41,11 @@ jobs: include: # Run Linux browsers with xvfb, so they're in a headless X session. # Additionally, generate a code coverage report from Linux Chrome. + # It should be the uncompiled build, or else we won't execute any + # coverage instrumentation on full-stack player integration tests. - os: ubuntu-latest browser: Chrome - extra_flags: "--use-xvfb --html-coverage-report" + extra_flags: "--use-xvfb --html-coverage-report --uncompiled" - os: ubuntu-latest browser: Firefox extra_flags: "--use-xvfb" @@ -57,7 +59,7 @@ jobs: - os: macos-latest browser: Safari - os: macos-latest - browser: Safari-14 + browser: Safari-old - os: windows-latest browser: Chrome @@ -87,21 +89,22 @@ jobs: with: ref: ${{ github.event.inputs.ref || github.ref }} - # Safari 14 can be installed, but not to the root, and it can't replace - # the standard version, at least not on GitHub's VMs. If you try to - # install directly to the root with sudo, it will appear to succeed, but - # will have no effect. If you try to script it explicitly with rm -rf - # and cp, this will fail. Safari may be on a read-only filesystem. - - name: Install Safari 14 to home directory - if: matrix.os == 'macos-latest' && matrix.browser == 'Safari-14' + # Older versions of Safari can be installed, but not to the root, and it + # can't replace the standard version, at least not on GitHub's VMs. If + # you try to install directly to the root with sudo, it will appear to + # succeed, but will have no effect. If you try to script it explicitly + # with rm -rf and cp, this will fail. Safari may be on a read-only + # filesystem. + - name: Install old Safari to home directory + if: matrix.os == 'macos-latest' && matrix.browser == 'Safari-old' run: | - # Download Safari 14 - # See also https://www.macupdate.com/app/mac/15675/apple-safari/old-versions - curl -Lv https://www.macupdate.com/action/download/62946 > Safari14.0CatalinaAuto.pkg.zip + # Download Safari 15 + # This URL discovered through the seed files listed at + # https://github.com/zhangyoufu/swscan.apple.com/blob/master/url.txt + curl -Lv http://swcdn.apple.com/content/downloads/42/33/012-57329-A_41P2VU6UHN/5fw5vna27fdw4mqfak5adj3pjpxvo9hgh7/Safari15.6.1CatalinaAuto.pkg > Safari.pkg - # Install Safari 14 to homedir specifically. - unzip Safari14.0CatalinaAuto.pkg.zip - installer -pkg Safari14.0CatalinaAuto.pkg -target CurrentUserHomeDirectory + # Install older Safari to homedir specifically. + installer -pkg Safari.pkg -target CurrentUserHomeDirectory # Install a launcher that can execute a shell script to launch this npm install karma-script-launcher --save-dev @@ -114,7 +117,7 @@ jobs: run: | browser=${{ matrix.browser }} - if [[ "$browser" == "Safari-14" ]]; then + if [[ "$browser" == "Safari-old" ]]; then # Replace the browser name with a script that can launch this # browser from the command line. browser="$PWD/.github/workflows/safari-homedir-launcher.sh" @@ -165,6 +168,18 @@ jobs: # an environment variable set, the file should definitely be there. if-no-files-found: error + # Upload new screenshots and diffs on failure; ignore if missing + - name: Upload screenshots + uses: actions/upload-artifact@v3 + if: ${{ failure() }} + with: + name: screenshots-${{ matrix.browser }} + path: | + test/test/assets/screenshots/*/*.png-new + test/test/assets/screenshots/*/*.png-diff + if-no-files-found: ignore + retention-days: 5 + build_in_docker: # Don't waste time doing a full matrix of test runs when there was an # obvious linter error. diff --git a/.github/workflows/selenium-lab-tests.yaml b/.github/workflows/selenium-lab-tests.yaml index db8d157f53..6852b6813f 100644 --- a/.github/workflows/selenium-lab-tests.yaml +++ b/.github/workflows/selenium-lab-tests.yaml @@ -180,6 +180,15 @@ jobs: # the container. - name: Test Player run: | + # Generate a coverage report from uncompiled code on ChromeLinux. + # It should be the uncompiled build, or else we won't execute any + # coverage instrumentation on full-stack player integration tests. + if [[ "${{ matrix.browser }}" == "ChromeLinux" ]]; then + extra_flags="--html-coverage-report --uncompiled" + else + extra_flags="" + fi + python3 build/test.py \ --no-build \ --reporters spec --spec-hide-passed \ @@ -189,7 +198,7 @@ jobs: --grid-config build/shaka-lab.yaml \ --grid-address selenium-grid.lab:4444 \ --browsers ${{ matrix.browser }} \ - --html-coverage-report + $extra_flags - name: Find coverage report (ChromeLinux only) id: coverage diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js index b39d74e68b..6f57cc69e9 100644 --- a/demo/common/message_ids.js +++ b/demo/common/message_ids.js @@ -226,6 +226,7 @@ shakaDemo.MessageIds = { MAX_HEIGHT: 'DEMO_MAX_HEIGHT', MAX_PIXELS: 'DEMO_MAX_PIXELS', MAX_WIDTH: 'DEMO_MAX_WIDTH', + MEDIA_SOURCE_SECTION_HEADER: 'DEMO_MEDIA_SOURCE_SECTION_HEADER', MIN_BANDWIDTH: 'DEMO_MIN_BANDWIDTH', MIN_BYTES: 'DEMO_MIN_BYTES', MIN_FRAMERATE: 'DEMO_MIN_FRAMERATE', @@ -244,6 +245,7 @@ shakaDemo.MessageIds = { PREFER_NATIVE_HLS: 'DEMO_PREFER_NATIVE_HLS', REBUFFERING_GOAL: 'DEMO_REBUFFERING_GOAL', RESTRICT_TO_ELEMENT_SIZE: 'DEMO_RESTRICT_TO_ELEMENT_SIZE', + RESTRICT_TO_SCREEN_SIZE: 'DEMO_RESTRICT_TO_SCREEN_SIZE', RESTRICTIONS_SECTION_HEADER: 'DEMO_RESTRICTIONS_SECTION_HEADER', SAFE_SEEK_OFFSET: 'DEMO_SAFE_SEEK_OFFSET', SAFE_SKIP_DISTANCE: 'DEMO_SAFE_SKIP_DISTANCE', @@ -251,6 +253,7 @@ shakaDemo.MessageIds = { SESSION_ID: 'DEMO_SESSION_ID', SHAKA_CONTROLS: 'DEMO_SHAKA_CONTROLS', SLOW_HALF_LIFE: 'DEMO_SLOW_HALF_LIFE', + SOURCE_BUFFER_EXTRA_FEATURES: 'DEMO_SOURCE_BUFFER_EXTRA_FEATURES', STALL_DETECTOR_ENABLED: 'DEMO_STALL_DETECTOR_ENABLED', STALL_THRESHOLD: 'DEMO_STALL_THRESHOLD', STALL_TIMEOUT: 'DEMO_STALL_TIMEOUT', diff --git a/demo/config.js b/demo/config.js index a0ca8b24be..d6e424346f 100644 --- a/demo/config.js +++ b/demo/config.js @@ -92,6 +92,7 @@ shakaDemo.Config = class { this.addOfflineSection_(); this.addDrmSection_(); this.addStreamingSection_(); + this.addMediaSourceSection_(); this.addManifestSection_(); this.addRetrictionsSection_('', shakaDemo.MessageIds.RESTRICTIONS_SECTION_HEADER); @@ -275,6 +276,8 @@ shakaDemo.Config = class { /* canBeDecimal= */ true) .addBoolInput_(MessageIds.RESTRICT_TO_ELEMENT_SIZE, 'abr.restrictToElementSize') + .addBoolInput_(MessageIds.RESTRICT_TO_SCREEN_SIZE, + 'abr.restrictToScreenSize') .addBoolInput_(MessageIds.IGNORE_DEVICE_PIXEL_RATIO, 'abr.ignoreDevicePixelRatio'); this.addRetrictionsSection_('abr', @@ -438,6 +441,15 @@ shakaDemo.Config = class { MessageIds.STREAMING_RETRY_SECTION_HEADER); } + /** @private */ + addMediaSourceSection_() { + const MessageIds = shakaDemo.MessageIds; + const docLink = this.resolveExternLink_('.MediaSourceConfiguration'); + this.addSection_(MessageIds.MEDIA_SOURCE_SECTION_HEADER, docLink) + .addTextInput_(MessageIds.SOURCE_BUFFER_EXTRA_FEATURES, + 'mediaSource.sourceBufferExtraFeatures'); + } + /** @private */ addLanguageSection_() { const MessageIds = shakaDemo.MessageIds; diff --git a/demo/locales/en.json b/demo/locales/en.json index 63178a01d7..321434d534 100644 --- a/demo/locales/en.json +++ b/demo/locales/en.json @@ -148,6 +148,7 @@ "DEMO_MAX_HEIGHT": "Max Height", "DEMO_MAX_PIXELS": "Max Pixels", "DEMO_MAX_WIDTH": "Max Width", + "DEMO_MEDIA_SOURCE_SECTION_HEADER": "Media source", "DEMO_METACDN": "MetaCDN", "DEMO_MICROSOFT": "Microsoft", "DEMO_MIME_TYPE": "MIME Type", @@ -188,6 +189,7 @@ "DEMO_REBUFFERING_GOAL": "Rebuffering Goal", "DEMO_REPORT_BUG": "REPORT BUG", "DEMO_RESTRICT_TO_ELEMENT_SIZE": "Restrict to element size", + "DEMO_RESTRICT_TO_SCREEN_SIZE": "Restrict to screen size", "DEMO_RESTRICTIONS_SECTION_HEADER": "Restrictions", "DEMO_SAFE_SEEK_OFFSET": "Safe Seek Offset", "DEMO_SAFE_SKIP_DISTANCE": "Safe Skip Distance", @@ -198,6 +200,7 @@ "DEMO_SHAKA_CONTROLS": "Shaka Controls", "DEMO_SLOW_HALF_LIFE": "Slow half life", "DEMO_SOURCE": "Source on GitHub", + "DEMO_SOURCE_BUFFER_EXTRA_FEATURES": "Source buffer extra features", "DEMO_SOURCE_SEARCH": "Source", "DEMO_STALL_DETECTOR_ENABLED": "Stall Detector Enabled", "DEMO_STALL_THRESHOLD": "Stall Threshold", diff --git a/demo/locales/source.json b/demo/locales/source.json index e66939c4f6..62219ead8e 100644 --- a/demo/locales/source.json +++ b/demo/locales/source.json @@ -595,6 +595,10 @@ "description": "The name of a configuration value.", "message": "Max Width" }, + "DEMO_MEDIA_SOURCE_SECTION_HEADER": { + "description": "The header for a section of configuration values.", + "message": "Media source" + }, "DEMO_METACDN": { "description": "Text that describes an asset that comes from the MetaCDN asset library.", "message": "[PROPER_NAME:MetaCDN]" @@ -755,6 +759,10 @@ "description": "The name of a configuration value.", "message": "Restrict to element size" }, + "DEMO_RESTRICT_TO_SCREEN_SIZE": { + "description": "The name of a configuration value.", + "message": "Restrict to screen size" + }, "DEMO_RESTRICTIONS_SECTION_HEADER": { "description": "The header for a section of configuration values.", "message": "Restrictions" @@ -795,6 +803,10 @@ "description": "A link in the footer, to the Shaka Player source on GitHub.", "message": "Source on [PROPER_NAME:GitHub]" }, + "DEMO_SOURCE_BUFFER_EXTRA_FEATURES": { + "description": "The name of a configuration value.", + "message": "Source buffer extra features" + }, "DEMO_SOURCE_SEARCH": { "description": "A header on a search field that filters by the source of the asset.", "message": "Source" diff --git a/externs/ima.js b/externs/ima.js index 24511e2308..456f5e7628 100644 --- a/externs/ima.js +++ b/externs/ima.js @@ -170,6 +170,9 @@ google.ima.Ad = class { /** @return {string} */ getTitle() {} + + /** @return {string} */ + getDescription() {} }; @@ -395,6 +398,9 @@ google.ima.dai.api.Ad = class { /** @return {string} */ getTitle() {} + + /** @return {string} */ + getDescription() {} }; diff --git a/externs/shaka/ads.js b/externs/shaka/ads.js index 4259a3ed42..af83b7c046 100644 --- a/externs/shaka/ads.js +++ b/externs/shaka/ads.js @@ -232,4 +232,9 @@ shaka.extern.IAd = class { * @return {string} */ getTitle() {} + + /** + * @return {string} + */ + getDescription() {} }; diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 66c7ba083d..a8efdad7f1 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -1017,6 +1017,23 @@ shaka.extern.ManifestConfiguration; shaka.extern.StreamingConfiguration; +/** + * @typedef {{ + * sourceBufferExtraFeatures: string + * }} + * + * @description + * Media source configuration. + * + * @property {string} sourceBufferExtraFeatures + * Some platforms may need to pass features when initializing the + * sourceBuffer. + * This string is ultimately appended to MIME types in addSourceBuffer(). + * @exportDoc + */ +shaka.extern.MediaSourceConfiguration; + + /** * @typedef {{ * enabled: boolean, @@ -1028,6 +1045,7 @@ shaka.extern.StreamingConfiguration; * bandwidthDowngradeTarget: number, * advanced: shaka.extern.AdvancedAbrConfiguration, * restrictToElementSize: boolean, + * restrictToScreenSize: boolean, * ignoreDevicePixelRatio: boolean * }} * @@ -1063,9 +1081,12 @@ shaka.extern.StreamingConfiguration; * Note: The use of ResizeObserver is required for it to work properly. If * true without ResizeObserver, it behaves as false. * Defaults false. + * @property {boolean} restrictToScreenSize + * If true, restrict the quality to screen size. + * Defaults false. * @property {boolean} ignoreDevicePixelRatio * If true,device pixel ratio is ignored when restricting the quality to - * media element size. + * media element size or screen size. * Defaults false. * @exportDoc */ @@ -1220,6 +1241,7 @@ shaka.extern.OfflineConfiguration; * drm: shaka.extern.DrmConfiguration, * manifest: shaka.extern.ManifestConfiguration, * streaming: shaka.extern.StreamingConfiguration, + * mediaSource: shaka.extern.MediaSourceConfiguration, * abrFactory: shaka.extern.AbrManager.Factory, * abr: shaka.extern.AbrConfiguration, * cmcd: shaka.extern.CmcdConfiguration, @@ -1248,6 +1270,8 @@ shaka.extern.OfflineConfiguration; * Manifest configuration and settings. * @property {shaka.extern.StreamingConfiguration} streaming * Streaming configuration and settings. + * @property {shaka.extern.MediaSourceConfiguration} mediaSource + * Media source configuration and settings. * @property {shaka.extern.AbrManager.Factory} abrFactory * A factory to construct an abr manager. * @property {shaka.extern.AbrConfiguration} abr diff --git a/lib/abr/simple_abr_manager.js b/lib/abr/simple_abr_manager.js index d799dbf06b..19bec41518 100644 --- a/lib/abr/simple_abr_manager.js +++ b/lib/abr/simple_abr_manager.js @@ -148,6 +148,13 @@ shaka.abr.SimpleAbrManager = class { let maxHeight = Infinity; let maxWidth = Infinity; + if (this.config_.restrictToScreenSize) { + const devicePixelRatio = + this.config_.ignoreDevicePixelRatio ? 1 : window.devicePixelRatio; + maxHeight = window.screen.height * devicePixelRatio; + maxWidth = window.screen.width * devicePixelRatio; + } + if (this.resizeObserver_ && this.config_.restrictToElementSize) { const devicePixelRatio = this.config_.ignoreDevicePixelRatio ? 1 : window.devicePixelRatio; diff --git a/lib/ads/client_side_ad.js b/lib/ads/client_side_ad.js index f768648dc2..2b54da55a5 100644 --- a/lib/ads/client_side_ad.js +++ b/lib/ads/client_side_ad.js @@ -244,6 +244,14 @@ shaka.ads.ClientSideAd = class { return this.ad_.getTitle(); } + /** + * @override + * @export + */ + getDescription() { + return this.ad_.getDescription(); + } + /** * @override * @export diff --git a/lib/ads/server_side_ad.js b/lib/ads/server_side_ad.js index a078585ad9..12aa2db89a 100644 --- a/lib/ads/server_side_ad.js +++ b/lib/ads/server_side_ad.js @@ -213,6 +213,14 @@ shaka.ads.ServerSideAd = class { return this.ad_.getTitle(); } + /** + * @override + * @export + */ + getDescription() { + return this.ad_.getDescription(); + } + /** * @override * @export diff --git a/lib/cast/cast_utils.js b/lib/cast/cast_utils.js index 74e3eb6cee..3eb3e47fc0 100644 --- a/lib/cast/cast_utils.js +++ b/lib/cast/cast_utils.js @@ -374,6 +374,7 @@ shaka.cast.CastUtils.PlayerInitAfterLoadState = [ shaka.cast.CastUtils.PlayerVoidMethods = [ 'addChaptersTrack', 'addTextTrackAsync', + 'addThumbnailsTrack', 'cancelTrickPlay', 'configure', 'getChapters', diff --git a/lib/cea/cea608_memory.js b/lib/cea/cea608_memory.js index a30d8e8073..751141a39f 100644 --- a/lib/cea/cea608_memory.js +++ b/lib/cea/cea608_memory.js @@ -184,6 +184,10 @@ shaka.cea.Cea608Memory = class { * @param {number} count Count of rows to move. */ moveRows(dst, src, count) { + if (src < 0 || dst < 0) { + return; + } + if (dst >= src) { for (let i = count-1; i >= 0; i--) { this.rows_[dst + i] = this.rows_[src + i].map((e) => e); diff --git a/lib/media/media_source_engine.js b/lib/media/media_source_engine.js index 82799d9fe6..9e2373585f 100644 --- a/lib/media/media_source_engine.js +++ b/lib/media/media_source_engine.js @@ -57,6 +57,9 @@ shaka.media.MediaSourceEngine = class { /** @private {HTMLMediaElement} */ this.video_ = video; + /** @private {?shaka.extern.MediaSourceConfiguration} */ + this.config_ = null; + /** @private {shaka.extern.TextDisplayer} */ this.textDisplayer_ = textDisplayer; @@ -298,6 +301,7 @@ shaka.media.MediaSourceEngine = class { this.video_ = null; } + this.config_ = null; this.mediaSource_ = null; this.textEngine_ = null; this.textDisplayer_ = null; @@ -367,8 +371,8 @@ shaka.media.MediaSourceEngine = class { mimeType = shaka.media.Transmuxer.convertTsCodecs(contentType, mimeType); } - - const sourceBuffer = this.mediaSource_.addSourceBuffer(mimeType); + const type = mimeType + this.config_.sourceBufferExtraFeatures; + const sourceBuffer = this.mediaSource_.addSourceBuffer(type); this.eventManager_.listen( sourceBuffer, 'error', @@ -384,6 +388,16 @@ shaka.media.MediaSourceEngine = class { } } + /** + * Called by the Player to provide an updated configuration any time it + * changes. Must be called at least once before init(). + * + * @param {shaka.extern.MediaSourceConfiguration} config + */ + configure(config) { + this.config_ = config; + } + /** * Reinitialize the TextEngine for a new text type. * @param {string} mimeType diff --git a/lib/player.js b/lib/player.js index b376897817..62c0d4725f 100644 --- a/lib/player.js +++ b/lib/player.js @@ -27,6 +27,7 @@ goog.require('shaka.media.QualityObserver'); goog.require('shaka.media.RegionObserver'); goog.require('shaka.media.RegionTimeline'); goog.require('shaka.media.SegmentIndex'); +goog.require('shaka.media.SegmentReference'); goog.require('shaka.media.SrcEqualsPlayhead'); goog.require('shaka.media.StreamingEngine'); goog.require('shaka.media.TimeRangesUtils'); @@ -1696,6 +1697,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.processTimedMetadataMediaSrc_(metadata, offset, endTime); }, this.lcevcDil_); + mediaSourceEngine.configure(this.config_.mediaSource); const {segmentRelativeVttTiming} = this.config_.manifest; mediaSourceEngine.setSegmentRelativeVttTiming(segmentRelativeVttTiming); @@ -3187,6 +3189,7 @@ shaka.Player = class extends shaka.util.FakeEventTarget { } if (this.mediaSourceEngine_) { + this.mediaSourceEngine_.configure(this.config_.mediaSource); const {segmentRelativeVttTiming} = this.config_.manifest; this.mediaSourceEngine_.setSegmentRelativeVttTiming( segmentRelativeVttTiming); @@ -4728,6 +4731,138 @@ shaka.Player = class extends shaka.util.FakeEventTarget { return shaka.util.StreamUtils.textStreamToTrack(stream); } + /** + * Adds the given thumbnails track to the loaded manifest. + * load() must resolve before calling. The presentation must + * have a duration. + * + * This returns the created track, which can immediately be used by the + * application. + * + * @param {string} uri + * @param {string=} mimeType + * @return {!Promise.} + * @export + */ + async addThumbnailsTrack(uri, mimeType) { + if (this.loadMode_ != shaka.Player.LoadMode.MEDIA_SOURCE && + this.loadMode_ != shaka.Player.LoadMode.SRC_EQUALS) { + shaka.log.error( + 'Must call load() and wait for it to resolve before adding image ' + + 'tracks.'); + throw new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.PLAYER, + shaka.util.Error.Code.CONTENT_NOT_LOADED); + } + + if (this.loadMode_ == shaka.Player.LoadMode.SRC_EQUALS) { + shaka.log.error('Cannot add this thumbnail track when loaded with src='); + throw new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.TEXT, + shaka.util.Error.Code.CANNOT_ADD_EXTERNAL_THUMBNAILS_TO_SRC_EQUALS); + } + + if (!mimeType) { + mimeType = await this.getTextMimetype_(uri); + } + + if (mimeType != 'text/vtt') { + throw new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.TEXT, + shaka.util.Error.Code.UNSUPPORTED_EXTERNAL_THUMBNAILS_URI, + uri); + } + + const ContentType = shaka.util.ManifestParserUtils.ContentType; + + const duration = this.manifest_.presentationTimeline.getDuration(); + if (duration == Infinity) { + throw new shaka.util.Error( + shaka.util.Error.Severity.RECOVERABLE, + shaka.util.Error.Category.MANIFEST, + shaka.util.Error.Code.CANNOT_ADD_EXTERNAL_THUMBNAILS_TO_LIVE_STREAM); + } + + goog.asserts.assert( + this.networkingEngine_, 'Need networking engine.'); + const buffer = await this.getTextData_(uri, + this.networkingEngine_, + this.config_.streaming.retryParameters); + + const factory = shaka.text.TextEngine.findParser(mimeType); + if (!factory) { + throw new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.TEXT, + shaka.util.Error.Code.MISSING_TEXT_PLUGIN, + mimeType); + } + const TextParser = factory(); + const time = { + periodStart: 0, + segmentStart: 0, + segmentEnd: duration, + vttOffset: 0, + }; + const data = shaka.util.BufferUtils.toUint8(buffer); + const cues = TextParser.parseMedia(data, time); + + const references = []; + for (const cue of cues) { + const imageUri = shaka.util.ManifestParserUtils.resolveUris( + [uri], [cue.payload])[0]; + if (imageUri.includes('#xywh')) { + shaka.log.alwaysWarn('Unsupported image uri', imageUri); + continue; + } + references.push(new shaka.media.SegmentReference( + cue.startTime, + cue.endTime, + () => [imageUri], + /* startByte= */ 0, + /* endByte= */ null, + /* initSegmentReference= */ null, + /* timestampOffset= */ 0, + /* appendWindowStart= */ 0, + /* appendWindowEnd= */ Infinity, + )); + } + + /** @type {shaka.extern.Stream} */ + const stream = { + id: this.nextExternalStreamId_++, + originalId: null, + createSegmentIndex: () => Promise.resolve(), + segmentIndex: new shaka.media.SegmentIndex(references), + mimeType: mimeType || '', + codecs: '', + kind: '', + encrypted: false, + drmInfos: [], + keyIds: new Set(), + language: 'und', + label: null, + type: ContentType.IMAGE, + primary: false, + trickModeVideo: null, + emsgSchemeIdUris: null, + roles: [], + forced: false, + channelsCount: null, + audioSamplingRate: null, + spatialAudio: false, + closedCaptions: null, + tilesLayout: '1x1', + }; + + this.manifest_.imageStreams.push(stream); + this.onTracksChanged_(); + return shaka.util.StreamUtils.imageStreamToTrack(stream); + } + /** * Adds the given chapters track to the loaded manifest. load() * must resolve before calling. The presentation must have a duration. diff --git a/lib/text/ttml_text_parser.js b/lib/text/ttml_text_parser.js index de34a05241..bf26ec4509 100644 --- a/lib/text/ttml_text_parser.js +++ b/lib/text/ttml_text_parser.js @@ -164,6 +164,12 @@ shaka.text.TtmlTextParser = class { cellResolutionInfo, /* parentCueElement= */ null, /* isContent= */ false); if (cue) { + // According to the TTML spec, backgrounds default to transparent. + // So default the background of the top-level element to transparent. + // Nested elements may override that background color already. + if (!cue.backgroundColor) { + cue.backgroundColor = 'transparent'; + } cues.push(cue); } } diff --git a/lib/util/error.js b/lib/util/error.js index 45f7f4fd18..9cf5305cc7 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -358,6 +358,18 @@ shaka.util.Error.Code = { */ 'CHAPTERS_TRACK_FAILED': 2015, + /** + * External thumbnails tracks cannot be added in src= because native platform + * doesn't support it. + */ + 'CANNOT_ADD_EXTERNAL_THUMBNAILS_TO_SRC_EQUALS': 2016, + + /** + * Only external urls of WebVTT type are supported. + *
error.data[0] is the uri. + */ + 'UNSUPPORTED_EXTERNAL_THUMBNAILS_URI': 2017, + /** * Some component tried to read past the end of a buffer. The segment index, * init segment, or PSSH may be malformed. @@ -707,6 +719,11 @@ shaka.util.Error.Code = { */ 'HLS_AES_128_INVALID_KEY_LENGTH': 4044, + /** + * External thumbnails tracks cannot be added to live streams. + */ + 'CANNOT_ADD_EXTERNAL_THUMBNAILS_TO_LIVE_STREAM': 4045, + // RETIRED: 'INCONSISTENT_BUFFER_STATE': 5000, // RETIRED: 'INVALID_SEGMENT_INDEX': 5001, diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index e016445909..e6246ff1c2 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -247,6 +247,7 @@ shaka.util.PlayerConfiguration = class { slowHalfLife: 5, }, restrictToElementSize: false, + restrictToScreenSize: false, ignoreDevicePixelRatio: false, }; @@ -263,6 +264,10 @@ shaka.util.PlayerConfiguration = class { drawLogo: false, }; + const mediaSource = { + sourceBufferExtraFeatures: '', + }; + const AutoShowText = shaka.config.AutoShowText; /** @type {shaka.extern.PlayerConfiguration} */ @@ -270,6 +275,7 @@ shaka.util.PlayerConfiguration = class { drm: drm, manifest: manifest, streaming: streaming, + mediaSource: mediaSource, offline: offline, abrFactory: () => new shaka.abr.SimpleAbrManager(), abr: abr, diff --git a/test/cast/cast_utils_unit.js b/test/cast/cast_utils_unit.js index 93e08c7654..fa78068622 100644 --- a/test/cast/cast_utils_unit.js +++ b/test/cast/cast_utils_unit.js @@ -207,6 +207,9 @@ describe('CastUtils', () => { video, new shaka.test.FakeClosedCaptionParser(), new shaka.test.FakeTextDisplayer()); + const config = + shaka.util.PlayerConfiguration.createDefault().mediaSource; + mediaSourceEngine.configure(config); const ContentType = shaka.util.ManifestParserUtils.ContentType; const initObject = new Map(); diff --git a/test/cea/cea608_memory_unit.js b/test/cea/cea608_memory_unit.js index 4105b0bd41..b7858c414c 100644 --- a/test/cea/cea608_memory_unit.js +++ b/test/cea/cea608_memory_unit.js @@ -295,5 +295,93 @@ describe('Cea608Memory', () => { const caption = memory.forceEmit(startTime, endTime); expect(caption).toEqual(expectedCaption); }); + + it('does not move rows if source row index is negative', () => { + const startTime = 1; + const endTime = 2; + const text = 'test'; + + // Add the text to the buffer, each character on separate rows. + // At this point, the memory looks like: + // [1]: t + // [2]: e + // [3]: s + // [4]: t + for (const c of text) { + memory.addChar(CharSet.BASIC_NORTH_AMERICAN, + c.charCodeAt(0)); + memory.setRow(memory.getRow() + 1); // increment row + } + + const srcRowIdx = -1; + const dstRowIdx = 2; + const rowsToMove = 3; + memory.moveRows(dstRowIdx, srcRowIdx, rowsToMove); + + // Expected text is 't\ne\ns\nt' + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, 't'), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, 'e'), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, 's'), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, 't'), + ]; + + const expectedCaption = { + stream, + cue: topLevelCue, + }; + + // Force out the new memory. + const caption = memory.forceEmit(startTime, endTime); + expect(caption).toEqual(expectedCaption); + }); + + it('does not move rows if destination row index is negative', () => { + const startTime = 1; + const endTime = 2; + const text = 'test'; + + // Add the text to the buffer, each character on separate rows. + // At this point, the memory looks like: + // [1]: t + // [2]: e + // [3]: s + // [4]: t + for (const c of text) { + memory.addChar(CharSet.BASIC_NORTH_AMERICAN, + c.charCodeAt(0)); + memory.setRow(memory.getRow() + 1); // increment row + } + + const srcRowIdx = 1; + const dstRowIdx = -2; + const rowsToMove = 3; + memory.moveRows(dstRowIdx, srcRowIdx, rowsToMove); + + // Expected text is 't\ne\ns\nt' + const topLevelCue = new shaka.text.Cue(startTime, endTime, ''); + topLevelCue.nestedCues = [ + CeaUtils.createDefaultCue(startTime, endTime, 't'), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, 'e'), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, 's'), + CeaUtils.createLineBreakCue(startTime, endTime), + CeaUtils.createDefaultCue(startTime, endTime, 't'), + ]; + + const expectedCaption = { + stream, + cue: topLevelCue, + }; + + // Force out the new memory. + const caption = memory.forceEmit(startTime, endTime); + expect(caption).toEqual(expectedCaption); + }); }); }); diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index a0c6612d68..2d6ccca6d4 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -118,6 +118,8 @@ describe('HlsParser', () => { .setResponseText('test:/audio2', media) .setResponseText('test:/video', media) .setResponseText('test:/video2', media) + .setResponseText('test:/text', media) + .setResponseText('test:/text2', media) .setResponseText('test:/main.vtt', vttText) .setResponseValue('test:/init.mp4', initSegmentData) .setResponseValue('test:/init2.mp4', initSegmentData) @@ -935,11 +937,14 @@ describe('HlsParser', () => { '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1,mp4a",', 'RESOLUTION=960x540,FRAME-RATE=60,AUDIO="aud1"\n', 'video\n', + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="en",', - 'URI="audio"\n', + 'NAME="English",URI="audio"\n', + '#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="en",', 'CHARACTERISTICS="public.accessibility.describes-video,', - 'public.accessibility.describes-music-and-sound",URI="audio2"\n', + 'public.accessibility.describes-music-and-sound",', + 'NAME="English (describes-video)",URI="audio2"\n', ].join(''); const media = [ @@ -977,6 +982,58 @@ describe('HlsParser', () => { await testHlsParser(master, media, manifest); }); + it('parses characteristics from text tags', async () => { + const master = [ + '#EXTM3U\n', + '#EXT-X-STREAM-INF:BANDWIDTH=200,CODECS="avc1",', + 'RESOLUTION=960x540,FRAME-RATE=60,SUBTITLES="sub1"\n', + 'video\n', + + '#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub1",LANGUAGE="en",', + 'NAME="English (caption)",DEFAULT=YES,AUTOSELECT=YES,', + 'URI="text"\n', + + '#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub1",LANGUAGE="en",', + 'NAME="English (caption)",DEFAULT=YES,AUTOSELECT=YES,', + 'CHARACTERISTICS="public.accessibility.describes-spoken-dialog,', + 'public.accessibility.describes-music-and-sound",', + 'URI="text2"\n', + ].join(''); + + const media = [ + '#EXTM3U\n', + '#EXT-X-PLAYLIST-TYPE:VOD\n', + '#EXT-X-MAP:URI="init.mp4",BYTERANGE="616@0"\n', + '#EXTINF:5,\n', + '#EXT-X-BYTERANGE:121090@616\n', + 'main.mp4', + ].join(''); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO); + }); + manifest.addPartialTextStream((stream) => { + stream.language = 'en'; + stream.kind = TextStreamKind.SUBTITLE; + stream.mime('application/mp4', ''); + }); + manifest.addPartialTextStream((stream) => { + stream.language = 'en'; + stream.kind = TextStreamKind.SUBTITLE; + stream.mime('application/mp4', ''); + stream.roles = [ + 'public.accessibility.describes-spoken-dialog', + 'public.accessibility.describes-music-and-sound', + ]; + }); + manifest.sequenceMode = true; + }); + + await testHlsParser(master, media, manifest); + }); + it('gets mime type from header request', async () => { const master = [ '#EXTM3U\n', diff --git a/test/media/drm_engine_integration.js b/test/media/drm_engine_integration.js index 8b3f587af9..25ea1524ae 100644 --- a/test/media/drm_engine_integration.js +++ b/test/media/drm_engine_integration.js @@ -128,6 +128,9 @@ describe('DrmEngine', () => { video, new shaka.test.FakeClosedCaptionParser(), new shaka.test.FakeTextDisplayer()); + const mediaSourceConfig = + shaka.util.PlayerConfiguration.createDefault().mediaSource; + mediaSourceEngine.configure(mediaSourceConfig); const expectedObject = new Map(); expectedObject.set(ContentType.AUDIO, audioStream); diff --git a/test/media/media_source_engine_integration.js b/test/media/media_source_engine_integration.js index ebbd49a370..b950129967 100644 --- a/test/media/media_source_engine_integration.js +++ b/test/media/media_source_engine_integration.js @@ -41,6 +41,8 @@ describe('MediaSourceEngine', () => { video, new shaka.media.ClosedCaptionParser(), textDisplayer); + const config = shaka.util.PlayerConfiguration.createDefault().mediaSource; + mediaSourceEngine.configure(config); mediaSource = /** @type {?} */(mediaSourceEngine)['mediaSource_']; expect(video.src).toBeTruthy(); diff --git a/test/media/media_source_engine_unit.js b/test/media/media_source_engine_unit.js index 2ef9e190db..8a805d5e7e 100644 --- a/test/media/media_source_engine_unit.js +++ b/test/media/media_source_engine_unit.js @@ -150,6 +150,8 @@ describe('MediaSourceEngine', () => { video, mockClosedCaptionParser, mockTextDisplayer); + const config = shaka.util.PlayerConfiguration.createDefault().mediaSource; + mediaSourceEngine.configure(config); }); afterEach(() => { diff --git a/test/media/streaming_engine_integration.js b/test/media/streaming_engine_integration.js index 36af6b43d6..517b68ecba 100644 --- a/test/media/streaming_engine_integration.js +++ b/test/media/streaming_engine_integration.js @@ -68,6 +68,9 @@ describe('StreamingEngine', () => { video, new shaka.test.FakeClosedCaptionParser(), new shaka.test.FakeTextDisplayer()); + const mediaSourceConfig = + shaka.util.PlayerConfiguration.createDefault().mediaSource; + mediaSourceEngine.configure(mediaSourceConfig); waiter.setMediaSourceEngine(mediaSourceEngine); }); @@ -90,17 +93,17 @@ describe('StreamingEngine', () => { segmentAvailability = { start: 0, - end: 60, + end: 40, }; timeline = shaka.test.StreamingEngineUtil.createFakePresentationTimeline( segmentAvailability, - /* presentationDuration= */ 60, + /* presentationDuration= */ 40, /* maxSegmentDuration= */ metadata.video.segmentDuration, /* isLive= */ false); setupNetworkingEngine( - /* presentationDuration= */ 60, + /* presentationDuration= */ 40, { audio: metadata.audio.segmentDuration, video: metadata.video.segmentDuration, @@ -108,8 +111,8 @@ describe('StreamingEngine', () => { setupManifest( /* firstPeriodStartTime= */ 0, - /* secondPeriodStartTime= */ 30, - /* presentationDuration= */ 60); + /* secondPeriodStartTime= */ 20, + /* presentationDuration= */ 40); setupPlayhead(); @@ -263,10 +266,11 @@ describe('StreamingEngine', () => { streamingEngine.switchVariant(variant); await streamingEngine.start(); video.play(); - // The overall test timeout is 120 seconds, and the content is 60 + // The overall test timeout is 120 seconds, and the content is 40 // seconds. It should be possible to complete this test in 100 seconds, // and if not, we want the error thrown to be within the overall test's - // timeout window. + // timeout window. Note that we have seen some devices fail to play at + // full speed for reasons beyond our control, so we plan for >= 0.5x. await waiter.timeoutAfter(100).waitForEnd(video); }); diff --git a/test/offline/offline_integration.js b/test/offline/offline_integration.js index a4771c93a2..db935b0ffa 100644 --- a/test/offline/offline_integration.js +++ b/test/offline/offline_integration.js @@ -68,7 +68,7 @@ filterDescribe('Offline', supportsStorage, () => { await player.load(contentUri); video.play(); - await playTo(/* end= */ 3, /* timeout= */ 10); + await playTo(/* end= */ 3, /* timeout= */ 20); await player.unload(); await storage.remove(contentUri); }); @@ -104,7 +104,7 @@ filterDescribe('Offline', supportsStorage, () => { await player.load(contentUri); video.play(); - await playTo(/* end= */ 3, /* timeout= */ 10); + await playTo(/* end= */ 3, /* timeout= */ 20); await player.unload(); await storage.remove(contentUri); }); @@ -147,7 +147,7 @@ filterDescribe('Offline', supportsStorage, () => { await player.load(contentUri); video.play(); - await playTo(/* end= */ 3, /* timeout= */ 10); + await playTo(/* end= */ 3, /* timeout= */ 20); await player.unload(); await storage.remove(contentUri); }); diff --git a/test/player_integration.js b/test/player_integration.js index 4093b2f906..7109fff5db 100644 --- a/test/player_integration.js +++ b/test/player_integration.js @@ -262,6 +262,49 @@ describe('Player', () => { expect(cues.length).toBeGreaterThan(0); }); + it('skip appended thumbnails for external thumbnails with sprites', + async () => { + await player.load('test:sintel_no_text_compiled'); + const locationUri = new goog.Uri(location.href); + const partialUri = + new goog.Uri('/base/test/test/assets/thumbnails-sprites.vtt'); + const absoluteUri = locationUri.resolve(partialUri); + const newTrack = + await player.addThumbnailsTrack(absoluteUri.toString()); + + expect(player.getImageTracks()).toEqual([newTrack]); + + const thumbnail1 = await player.getThumbnails(newTrack.id, 0); + expect(thumbnail1).toBe(null); + const thumbnail2 = await player.getThumbnails(newTrack.id, 10); + expect(thumbnail2).toBe(null); + const thumbnail3 = await player.getThumbnails(newTrack.id, 40); + expect(thumbnail3).toBe(null); + }); + + it('appends thumbnails for external thumbnails without sprites', + async () => { + await player.load('test:sintel_no_text_compiled'); + const locationUri = new goog.Uri(location.href); + const partialUri = + new goog.Uri('/base/test/test/assets/thumbnails.vtt'); + const absoluteUri = locationUri.resolve(partialUri); + const newTrack = + await player.addThumbnailsTrack(absoluteUri.toString()); + + expect(player.getImageTracks()).toEqual([newTrack]); + + const thumbnail1 = await player.getThumbnails(newTrack.id, 0); + expect(thumbnail1.startTime).toBe(0); + expect(thumbnail1.duration).toBe(5); + const thumbnail2 = await player.getThumbnails(newTrack.id, 10); + expect(thumbnail2.startTime).toBe(5); + expect(thumbnail2.duration).toBe(25); + const thumbnail3 = await player.getThumbnails(newTrack.id, 40); + expect(thumbnail3.startTime).toBe(30); + expect(thumbnail3.duration).toBe(30); + }); + // https://github.com/shaka-project/shaka-player/issues/2553 it('does not change the selected track', async () => { player.configure('streaming.alwaysStreamText', false); @@ -506,7 +549,7 @@ describe('Player', () => { // Seek the video, and see if it can continue playing from that point. video.currentTime = 20; // Expect that we can then reach the end of the video. - await waiter.timeoutAfter(20).waitForEnd(video); + await waiter.timeoutAfter(40).waitForEnd(video); }); // Regression test for #2326. @@ -857,7 +900,7 @@ describe('Player', () => { /** @type {shaka.test.Waiter} */ const waiter = new shaka.test.Waiter(eventManager) .setPlayer(player) - .timeoutAfter(20) + .timeoutAfter(40) .failOnTimeout(true); await waiter.waitForEnd(video); diff --git a/test/player_unit.js b/test/player_unit.js index 91380327ec..ba48ff5f5a 100644 --- a/test/player_unit.js +++ b/test/player_unit.js @@ -123,6 +123,7 @@ describe('Player', () => { streamingEngine = new shaka.test.FakeStreamingEngine(); mediaSourceEngine = { init: jasmine.createSpy('init').and.returnValue(Promise.resolve()), + configure: jasmine.createSpy('configure'), open: jasmine.createSpy('open').and.returnValue(Promise.resolve()), destroy: jasmine.createSpy('destroy').and.returnValue(Promise.resolve()), diff --git a/test/test/assets/screenshots/safari-Mac/native-end-time-edge-case.png b/test/test/assets/screenshots/safari-Mac/native-end-time-edge-case.png index 966ddb9255..173924f94f 100644 Binary files a/test/test/assets/screenshots/safari-Mac/native-end-time-edge-case.png and b/test/test/assets/screenshots/safari-Mac/native-end-time-edge-case.png differ diff --git a/test/test/assets/thumbnails-sprites.vtt b/test/test/assets/thumbnails-sprites.vtt new file mode 100644 index 0000000000..42d2cf0fb5 --- /dev/null +++ b/test/test/assets/thumbnails-sprites.vtt @@ -0,0 +1,10 @@ +WEBVTT + +00:00.000 --> 00:05.000 +image1.jpg#xywh=0,0,160,90 + +00:05.000 --> 00:30.000 +image2.jpg#xywh=0,0,160,90 + +00:30.000 --> 01:00.000 +image3.jpg#xywh=0,0,160,90 \ No newline at end of file diff --git a/test/test/assets/thumbnails.vtt b/test/test/assets/thumbnails.vtt new file mode 100644 index 0000000000..b55896bf7c --- /dev/null +++ b/test/test/assets/thumbnails.vtt @@ -0,0 +1,10 @@ +WEBVTT + +00:00.000 --> 00:05.000 +image1.jpg + +00:05.000 --> 00:30.000 +image2.jpg + +00:30.000 --> 01:00.000 +image3.jpg \ No newline at end of file diff --git a/test/test/util/fake_ad.js b/test/test/util/fake_ad.js index ef67c45925..dbd9fb774d 100644 --- a/test/test/util/fake_ad.js +++ b/test/test/util/fake_ad.js @@ -37,6 +37,9 @@ shaka.test.FakeAd = class { /** @private {string} */ this.title_ = 'Test Title'; + + /** @private {string} */ + this.description_ = 'Test Description'; } /** @@ -201,6 +204,14 @@ shaka.test.FakeAd = class { return this.title_; } + /** + * @override + * @export + */ + getDescription() { + return this.description_; + } + /** * @override * @export diff --git a/test/test/util/fake_media_source_engine.js b/test/test/util/fake_media_source_engine.js index 516f42d820..40224f0943 100644 --- a/test/test/util/fake_media_source_engine.js +++ b/test/test/util/fake_media_source_engine.js @@ -54,6 +54,9 @@ shaka.test.FakeMediaSourceEngine = class { /** @type {!jasmine.Spy} */ this.open = jasmine.createSpy('open').and.returnValue(Promise.resolve()); + /** @type {!jasmine.Spy} */ + this.configure = jasmine.createSpy('configure').and.stub(); + /** @type {!jasmine.Spy} */ this.reinitText = jasmine.createSpy('reinitText').and.stub(); diff --git a/test/test/util/ttml_utils.js b/test/test/util/ttml_utils.js index bb7ed09482..a8417bade4 100644 --- a/test/test/util/ttml_utils.js +++ b/test/test/util/ttml_utils.js @@ -51,8 +51,6 @@ shaka.test.TtmlUtils = class { region, nestedCues: jasmine.any(Object), payload: '', - startTime: 0, - endTime: Infinity, isContainer: true, }); Object.assign(containerCue, properties); diff --git a/test/text/ttml_text_parser_unit.js b/test/text/ttml_text_parser_unit.js index 32f4e3353a..a0b09bb0fb 100644 --- a/test/text/ttml_text_parser_unit.js +++ b/test/text/ttml_text_parser_unit.js @@ -1535,6 +1535,52 @@ describe('TtmlTextParser', () => { {startTime: 1, endTime: 2}); }); + // Regression test for #4468 + it('defaults the body background to transparent', () => { + verifyHelper( + // One cue, don't care about the details + [{}], + '' + + '' + + '' + + '