diff --git a/demo/common/asset.js b/demo/common/asset.js index 7d39f25be3..8205db7918 100644 --- a/demo/common/asset.js +++ b/demo/common/asset.js @@ -77,6 +77,8 @@ const ShakaDemoAssetInfo = class { this.imaContentSrcId = null; /** @type {?string} */ this.mimeType = null; + /** @type {?string} */ + this.mediaPlaylistFullMimeType = null; // Offline storage values. @@ -161,6 +163,15 @@ const ShakaDemoAssetInfo = class { return this.drm.length == 1 && this.drm[0] == shakaAssets.KeySystem.CLEAR; } + /** + * @param {string} mediaPlaylistFullMimeType + * @return {!ShakaDemoAssetInfo} + */ + setMediaPlaylistFullMimeType(mediaPlaylistFullMimeType) { + this.mediaPlaylistFullMimeType = mediaPlaylistFullMimeType; + return this; + } + /** * @param {!Object} extraConfig * @return {!ShakaDemoAssetInfo} @@ -368,7 +379,7 @@ const ShakaDemoAssetInfo = class { */ getConfiguration() { const config = /** @type {shaka.extern.PlayerConfiguration} */( - {drm: {advanced: {}}, manifest: {dash: {}}}); + {drm: {advanced: {}}, manifest: {dash: {}, hls: {}}}); if (this.extraConfig) { for (const key in this.extraConfig) { @@ -376,6 +387,11 @@ const ShakaDemoAssetInfo = class { } } + if (this.mediaPlaylistFullMimeType) { + config.manifest.hls.mediaPlaylistFullMimeType = + this.mediaPlaylistFullMimeType; + } + if (this.licenseServers.size) { config.drm.servers = config.drm.servers || {}; this.licenseServers.forEach((value, key) => { diff --git a/demo/common/assets.js b/demo/common/assets.js index f735306006..4efefb8c32 100644 --- a/demo/common/assets.js +++ b/demo/common/assets.js @@ -294,6 +294,15 @@ shakaAssets.testAssets = [ .addFeature(shakaAssets.Feature.SUBTITLES) .addFeature(shakaAssets.Feature.SURROUND) .addFeature(shakaAssets.Feature.OFFLINE), + new ShakaDemoAssetInfo( + /* name= */ 'Angel One (HLS, MP4, video media playlist only)', + /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/angel_one.png', + /* manifestUri= */ 'https://storage.googleapis.com/shaka-demo-assets/angel-one-hls/playlist_v-0480p-1000k-libx264.mp4.m3u8', + /* source= */ shakaAssets.Source.SHAKA) + .addFeature(shakaAssets.Feature.HLS) + .addFeature(shakaAssets.Feature.MP4) + .addFeature(shakaAssets.Feature.OFFLINE) + .setMediaPlaylistFullMimeType('video/mp4; codecs="avc1.4d401f"'), new ShakaDemoAssetInfo( /* name= */ 'Angel One (HLS, MP4, multilingual, Widevine)', /* iconUri= */ 'https://storage.googleapis.com/shaka-asset-icons/angel_one.png', diff --git a/demo/common/message_ids.js b/demo/common/message_ids.js index 159eddcca1..f246ba68db 100644 --- a/demo/common/message_ids.js +++ b/demo/common/message_ids.js @@ -107,6 +107,7 @@ shakaDemo.MessageIds = { DRM_SYSTEM: 'DEMO_DRM_SYSTEM', DRM_TAB: 'DEMO_DRM_TAB', EDIT_CUSTOM: 'DEMO_EDIT_CUSTOM', + FULL_MIME_TYPE: 'DEMO_FULL_MIME_TYPE', HEADERS_TAB: 'DEMO_HEADERS_TAB', ICON_URL: 'DEMO_ICON_URL', LICENSE_CERTIFICATE_URL: 'DEMO_LICENSE_CERTIFICATE_URL', @@ -117,6 +118,7 @@ shakaDemo.MessageIds = { MAIN_TAB: 'DEMO_MAIN_TAB', MANIFEST_URL: 'DEMO_MANIFEST_URL', MANIFEST_URL_ERROR: 'DEMO_MANIFEST_URL_ERROR', + MEDIA_PLAYLIST_TAB: 'DEMO_MEDIA_PLAYLIST_TAB', NAME: 'DEMO_NAME', NAME_ERROR: 'DEMO_NAME_ERROR', EXTRA_SHAKA_PLAYER_CONFIG: 'DEMO_EXTRA_SHAKA_PLAYER_CONFIG', diff --git a/demo/custom.js b/demo/custom.js index 7ce29cb795..c87482f96a 100644 --- a/demo/custom.js +++ b/demo/custom.js @@ -245,6 +245,38 @@ shakaDemo.Custom = class { } + /** + * @param {!ShakaDemoAssetInfo} assetInProgress + * @param {!Array.} inputsToCheck + * @return {!Element} div + * @private + */ + makeAssetDialogContentsMediaPlaylist_(assetInProgress, inputsToCheck) { + const mediaPlaylistDiv = document.createElement('div'); + const containerStyle = shakaDemo.InputContainer.Style.FLEX; + const container = new shakaDemo.InputContainer( + mediaPlaylistDiv, /* headerText= */ null, containerStyle, + /* docLink= */ null); + container.getClassList().add('wide-input'); + container.setDefaultRowClass('wide-input'); + + const fullMimeTypeSetup = (input, container) => { + if (assetInProgress.mediaPlaylistFullMimeType) { + input.value = assetInProgress.mediaPlaylistFullMimeType; + } + }; + const fullMimeTypeOnChange = (input) => { + assetInProgress.setMediaPlaylistFullMimeType(input.value); + }; + const fullMimeTypeName = shakaDemoMain.getLocalizedString( + shakaDemo.MessageIds.FULL_MIME_TYPE); + this.makeField_( + container, fullMimeTypeName, fullMimeTypeSetup, fullMimeTypeOnChange); + + return mediaPlaylistDiv; + } + + /** * @param {!ShakaDemoAssetInfo} assetInProgress * @param {!Array.} inputsToCheck @@ -674,6 +706,8 @@ shakaDemo.Custom = class { assetInProgress, inputsToCheck); const adsDiv = this.makeAssetDialogContentsAds_( assetInProgress, inputsToCheck); + const mediaPlaylistDiv = this.makeAssetDialogContentsMediaPlaylist_( + assetInProgress, inputsToCheck); const extraConfigDiv = this.makeAssetDialogContentsExtra_( assetInProgress, inputsToCheck); const finishDiv = this.makeAssetDialogContentsFinish_( @@ -713,6 +747,9 @@ shakaDemo.Custom = class { shakaDemo.MessageIds.HEADERS_TAB, headersDiv, /* startOn= */ false); addTabButton( shakaDemo.MessageIds.ADS_TAB, adsDiv, /* startOn= */ false); + addTabButton( + shakaDemo.MessageIds.MEDIA_PLAYLIST_TAB, mediaPlaylistDiv, + /* startOn= */ false); addTabButton( shakaDemo.MessageIds.EXTRA_TAB, extraConfigDiv, /* startOn= */ false); @@ -722,6 +759,7 @@ shakaDemo.Custom = class { this.dialog_.appendChild(drmDiv); this.dialog_.appendChild(headersDiv); this.dialog_.appendChild(adsDiv); + this.dialog_.appendChild(mediaPlaylistDiv); this.dialog_.appendChild(extraConfigDiv); this.dialog_.appendChild(finishDiv); this.dialog_.appendChild(iconDiv); diff --git a/demo/locales/en.json b/demo/locales/en.json index b69622cd85..f01147f44d 100644 --- a/demo/locales/en.json +++ b/demo/locales/en.json @@ -138,6 +138,7 @@ "DEMO_MAX_PIXELS": "Max Pixels", "DEMO_MAX_SMALL_GAP_SIZE": "Maximum Small Gap Size", "DEMO_MAX_WIDTH": "Max Width", + "DEMO_MEDIA_PLAYLIST_TAB": "HLS Media Playlist", "DEMO_METACDN": "MetaCDN", "DEMO_MICROSOFT": "Microsoft", "DEMO_MIME_TYPE": "MIME Type", @@ -166,6 +167,7 @@ "DEMO_OFFLINE_SECTION_HEADER": "Offline", "DEMO_FAILURE_MISC": "Shaka Player failed to load! If you are using an ad blocker, try switching to compiled mode at the bottom of the page.", "DEMO_FAILURE_NO_BROWSER_SUPPORT": "Your browser is not supported!", + "DEMO_FULL_MIME_TYPE": "Full Mime Type", "DEMO_PLAY": "Play", "DEMO_PLAYREADY": "PlayReady DRM", "DEMO_PREFER_FORCED_SUBS": "Prefer Forced Subs", diff --git a/demo/locales/source.json b/demo/locales/source.json index aa2920a5fb..2d00c9ff98 100644 --- a/demo/locales/source.json +++ b/demo/locales/source.json @@ -555,6 +555,10 @@ "description": "The name of a configuration value.", "message": "Max Width" }, + "DEMO_MEDIA_PLAYLIST_TAB": { + "description": "The header for a tab within the custom asset creation dialog.", + "message": "HLS Media Playlist" + }, "DEMO_METACDN": { "description": "Text that describes an asset that comes from the MetaCDN asset library.", "message": "[PROPER_NAME:MetaCDN]" @@ -667,6 +671,10 @@ "description": "An error displayed if Shaka Player is unable to load due to lack of browser support.", "message": "Your browser is not supported!" }, + "DEMO_FULL_MIME_TYPE": { + "description": "The label on a field that allows users to provide a full mime type for a custom asset.", + "message": "Full Mime Type" + }, "DEMO_PLAY": { "description": "A button to play the attached asset.", "message": "Play" diff --git a/externs/shaka/player.js b/externs/shaka/player.js index 87abd72a26..90870f4ec4 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -748,7 +748,8 @@ shaka.extern.DashManifestConfiguration; * ignoreImageStreamFailures: boolean, * defaultAudioCodec: string, * defaultVideoCodec: string, - * ignoreManifestProgramDateTime: boolean + * ignoreManifestProgramDateTime: boolean, + * mediaPlaylistFullMimeType: string * }} * * @property {boolean} ignoreTextStreamFailures @@ -768,6 +769,13 @@ shaka.extern.DashManifestConfiguration; * EXT-X-PROGRAM-DATE-TIME tags in the manifest. * Meant for tags that are incorrect or malformed. * Defaults to false. + * @property {string} mediaPlaylistFullMimeType + * A string containing a full mime type, including both the basic mime type + * and also the codecs. Used when the HLS parser parses a media playlist + * directly. + * You can use the shaka.util.MimeUtils.getFullType() utility to + * format this value. + * Defaults to 'video/mp4; codecs="avc1.42E01E"'. * @exportDoc */ shaka.extern.HlsManifestConfiguration; diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 5adf6a5976..f6e832f494 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -216,7 +216,7 @@ shaka.hls.HlsParser = class { this.masterPlaylistUri_ = response.uri; goog.asserts.assert(response.data, 'Response data should be non-null!'); - await this.parseManifest_(response.data); + await this.parseManifest_(response.data, uri); // Start the update timer if we want updates. const delay = this.updatePlaylistDelay_; @@ -401,10 +401,11 @@ shaka.hls.HlsParser = class { * Parses the manifest. * * @param {BufferSource} data + * @param {string} uri * @return {!Promise} * @private */ - async parseManifest_(data) { + async parseManifest_(data, uri) { const Utils = shaka.hls.Utils; goog.asserts.assert(this.masterPlaylistUri_, @@ -413,63 +414,107 @@ shaka.hls.HlsParser = class { const playlist = this.manifestTextParser_.parsePlaylist( data, this.masterPlaylistUri_); - // We don't support directly providing a Media Playlist. - // See the error code for details. - if (playlist.type != shaka.hls.PlaylistType.MASTER) { - throw new shaka.util.Error( - shaka.util.Error.Severity.CRITICAL, - shaka.util.Error.Category.MANIFEST, - shaka.util.Error.Code.HLS_MASTER_PLAYLIST_NOT_PROVIDED); - } - /** @type {!Array.} */ const variablesTags = Utils.filterTagsByName(playlist.tags, 'EXT-X-DEFINE'); this.parseMasterVariables_(variablesTags); - /** @type {!Array.} */ - const mediaTags = Utils.filterTagsByName(playlist.tags, 'EXT-X-MEDIA'); - /** @type {!Array.} */ - const variantTags = Utils.filterTagsByName( - playlist.tags, 'EXT-X-STREAM-INF'); - /** @type {!Array.} */ - const imageTags = Utils.filterTagsByName( - playlist.tags, 'EXT-X-IMAGE-STREAM-INF'); - - this.parseCodecs_(variantTags); - - /** @type {!Array.} */ - const sesionDataTags = - Utils.filterTagsByName(playlist.tags, 'EXT-X-SESSION-DATA'); - for (const tag of sesionDataTags) { - const id = tag.getAttributeValue('DATA-ID'); - const uri = tag.getAttributeValue('URI'); - const language = tag.getAttributeValue('LANGUAGE'); - const value = tag.getAttributeValue('VALUE'); - const data = (new Map()).set('id', id); - if (uri) { - data.set('uri', - shaka.hls.Utils.constructAbsoluteUri(this.masterPlaylistUri_, uri)); - } - if (language) { - data.set('language', language); - } - if (value) { - data.set('value', value); - } - const event = new shaka.util.FakeEvent('sessiondata', data); - if (this.playerInterface_) { - this.playerInterface_.onEvent(event); + /** @type {!Array.} */ + let variants = []; + /** @type {!Array.} */ + let textStreams = []; + /** @type {!Array.} */ + let imageStreams = []; + + // Parsing a media playlist results in a single-variant stream. + if (playlist.type == shaka.hls.PlaylistType.MEDIA) { + // Get necessary info for this stream, from the config. These are things + // we would normally be finding from the master playlist (e.g. from values + // on EXT-X-MEDIA tags). + const fullMimeType = this.config_.hls.mediaPlaylistFullMimeType; + const mimeType = shaka.util.MimeUtils.getBasicType(fullMimeType); + const type = mimeType.split('/')[0]; + const codecs = shaka.util.MimeUtils.getCodecs(fullMimeType); + + // Some values we cannot figure out, and aren't important enough to ask + // the user to provide through config values. A lot of these are only + // relevant to ABR, which isn't necessary if there's only one variant. + // So these unknowns should be set to false or null, largely. + const language = ''; + const channelsCount = null; + const spatialAudio = false; + const characteristics = null; + const closedCaptions = new Map(); + const bandwidth = 0; + const forced = false; // Only relevant for text. + const primary = true; // This is the only stream! + const name = 'Media Playlist'; + + // Make the stream info, with those values. + const streamInfo = await this.convertParsedPlaylistIntoStreamInfo_( + playlist, uri, uri, codecs, type, language, primary, name, + channelsCount, closedCaptions, characteristics, forced, spatialAudio, + bandwidth, mimeType); + goog.asserts.assert(streamInfo != null, 'StreamInfo is null!'); + this.uriToStreamInfosMap_.set(uri, streamInfo); + + // Wrap the stream from that stream info with a variant. + variants.push({ + id: 0, + language: 'und', + primary: true, + audio: type == 'audio' ? streamInfo.stream : null, + video: type == 'video' ? streamInfo.stream : null, + bandwidth: 0, + allowedByApplication: true, + allowedByKeySystem: true, + decodingInfos: [], + }); + } else { + /** @type {!Array.} */ + const mediaTags = Utils.filterTagsByName(playlist.tags, 'EXT-X-MEDIA'); + /** @type {!Array.} */ + const variantTags = Utils.filterTagsByName( + playlist.tags, 'EXT-X-STREAM-INF'); + /** @type {!Array.} */ + const imageTags = Utils.filterTagsByName( + playlist.tags, 'EXT-X-IMAGE-STREAM-INF'); + + this.parseCodecs_(variantTags); + + /** @type {!Array.} */ + const sesionDataTags = + Utils.filterTagsByName(playlist.tags, 'EXT-X-SESSION-DATA'); + for (const tag of sesionDataTags) { + const id = tag.getAttributeValue('DATA-ID'); + const uri = tag.getAttributeValue('URI'); + const language = tag.getAttributeValue('LANGUAGE'); + const value = tag.getAttributeValue('VALUE'); + const data = (new Map()).set('id', id); + if (uri) { + data.set('uri', shaka.hls.Utils.constructAbsoluteUri( + this.masterPlaylistUri_, uri)); + } + if (language) { + data.set('language', language); + } + if (value) { + data.set('value', value); + } + const event = new shaka.util.FakeEvent('sessiondata', data); + if (this.playerInterface_) { + this.playerInterface_.onEvent(event); + } } - } - // Parse audio and video media tags first, so that we can extract segment - // start time from audio/video streams and reuse for text streams. - await this.createStreamInfosFromMediaTags_(mediaTags); - this.parseClosedCaptions_(mediaTags); - const variants = await this.createVariantsForTags_(variantTags); - const textStreams = await this.parseTexts_(mediaTags); - const imageStreams = await this.parseImages_(imageTags); + // Parse audio and video media tags first, so that we can extract segment + // start time from audio/video streams and reuse for text streams. + await this.createStreamInfosFromMediaTags_(mediaTags); + this.parseClosedCaptions_(mediaTags); + variants = await this.createVariantsForTags_(variantTags); + textStreams = await this.parseTexts_(mediaTags); + imageStreams = await this.parseImages_(imageTags); + } // Make sure that the parser has not been destroyed. if (!this.playerInterface_) { @@ -1413,6 +1458,35 @@ shaka.hls.HlsParser = class { const playlist = this.manifestTextParser_.parsePlaylist( response.data, absoluteMediaPlaylistUri); + return this.convertParsedPlaylistIntoStreamInfo_(playlist, + verbatimMediaPlaylistUri, absoluteMediaPlaylistUri, codecs, type, + language, primary, name, channelsCount, closedCaptions, characteristics, + forced, spatialAudio, bandwidth); + } + + /** + * @param {!shaka.hls.Playlist} playlist + * @param {string} verbatimMediaPlaylistUri + * @param {string} absoluteMediaPlaylistUri + * @param {string} codecs + * @param {string} type + * @param {string} language + * @param {boolean} primary + * @param {?string} name + * @param {?number} channelsCount + * @param {Map.} closedCaptions + * @param {?string} characteristics + * @param {boolean} forced + * @param {boolean} spatialAudio + * @param {(number|undefined)} bandwidth + * @param {(string|undefined)} mimeType + * @return {!Promise.} + * @private + */ + async convertParsedPlaylistIntoStreamInfo_(playlist, verbatimMediaPlaylistUri, + absoluteMediaPlaylistUri, codecs, type, language, primary, name, + channelsCount, closedCaptions, characteristics, forced, spatialAudio, + bandwidth = undefined, mimeType = undefined) { if (playlist.type != shaka.hls.PlaylistType.MEDIA) { // EXT-X-MEDIA and EXT-X-IMAGE-STREAM-INF tags should point to media // playlists. @@ -1433,9 +1507,10 @@ shaka.hls.HlsParser = class { this.determinePresentationType_(playlist); - /** @type {string} */ - const mimeType = await this.guessMimeType_(type, codecs, playlist, - mediaVariables); + if (!mimeType) { + mimeType = await this.guessMimeType_(type, codecs, playlist, + mediaVariables); + } /** @type {!Array.} */ const drmTags = []; diff --git a/lib/util/error.js b/lib/util/error.js index 99847cbacc..4830701d07 100644 --- a/lib/util/error.js +++ b/lib/util/error.js @@ -581,13 +581,7 @@ shaka.util.Error.Code = { // RETIRED: 'HLS_COULD_NOT_GUESS_MIME_TYPE': 4021, - /** - * No Master Playlist has been provided. Master playlist provides - * vital information about the streams (like codecs) that is - * required for MediaSource. We don't support directly providing - * a Media Playlist. - */ - 'HLS_MASTER_PLAYLIST_NOT_PROVIDED': 4022, + // RETIRED: 'HLS_MASTER_PLAYLIST_NOT_PROVIDED': 4022, /** * One of the required attributes was not provided, so the diff --git a/lib/util/mime_utils.js b/lib/util/mime_utils.js index a9d3523ff5..8183e50448 100644 --- a/lib/util/mime_utils.js +++ b/lib/util/mime_utils.js @@ -11,6 +11,7 @@ goog.require('shaka.media.Transmuxer'); /** * @summary A set of utility functions for dealing with MIME types. + * @export */ shaka.util.MimeUtils = class { /** @@ -20,6 +21,7 @@ shaka.util.MimeUtils = class { * @param {string} mimeType * @param {string=} codecs * @return {string} + * @export */ static getFullType(mimeType, codecs) { let fullMimeType = mimeType; diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index fe4f5d27e9..93364f460d 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -122,6 +122,7 @@ shaka.util.PlayerConfiguration = class { defaultAudioCodec: 'mp4a.40.2', defaultVideoCodec: 'avc1.42E01E', ignoreManifestProgramDateTime: false, + mediaPlaylistFullMimeType: 'video/mp4; codecs="avc1.42E01E"', }, }; diff --git a/test/demo/demo_unit.js b/test/demo/demo_unit.js index b493f7f151..bd1afa4806 100644 --- a/test/demo/demo_unit.js +++ b/test/demo/demo_unit.js @@ -98,7 +98,8 @@ describe('Demo', () => { .add('preferredVariantRole') .add('playRangeStart') .add('playRangeEnd') - .add('manifest.dash.keySystemsByURI'); + .add('manifest.dash.keySystemsByURI') + .add('manifest.hls.mediaPlaylistFullMimeType'); /** * @param {!Object} section diff --git a/test/hls/hls_parser_unit.js b/test/hls/hls_parser_unit.js index 0b92a1ec63..691da31db8 100644 --- a/test/hls/hls_parser_unit.js +++ b/test/hls/hls_parser_unit.js @@ -3354,4 +3354,54 @@ describe('HlsParser', () => { jasmine.objectContaining(eventValue3)); }); }); + + it('parses media playlists directly', async () => { + 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.sequenceMode = true; + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.VIDEO, (stream) => { + stream.mime('video/mp4', 'avc1.42E01E'); + }); + }); + }); + + await testHlsParser(media, '', manifest); + }); + + it('honors hls.mediaPlaylistFullMimeType', async () => { + 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 config = shaka.util.PlayerConfiguration.createDefault().manifest; + config.hls.mediaPlaylistFullMimeType = 'audio/webm; codecs="vorbis"'; + parser.configure(config); + + const manifest = shaka.test.ManifestGenerator.generate((manifest) => { + manifest.sequenceMode = true; + manifest.anyTimeline(); + manifest.addPartialVariant((variant) => { + variant.addPartialStream(ContentType.AUDIO, (stream) => { + stream.mime('audio/webm', 'vorbis'); + }); + }); + }); + + await testHlsParser(media, '', manifest); + }); });