diff --git a/build/all.py b/build/all.py index 99a4f058a6..7efc553915 100755 --- a/build/all.py +++ b/build/all.py @@ -231,6 +231,16 @@ def main(args): for mode in modes: tasks = [] + worker_args = [ + '--worker', '--name', 'transmuxer-worker', + '+@transmuxer-worker', '--langout', 'ECMASCRIPT5', '--mode', mode, + ] + if parsed_args.force: + worker_args += ['--force'] + tasks.append(( + [sys.executable, os.path.join(base, 'build', 'build.py')] + worker_args, + os.environ.copy())) + for lang_out, suffix in language_variants: for build_args in builds: args = list(build_args) @@ -242,6 +252,7 @@ def main(args): # Add language and mode flags. args += ['--langout', lang_out] args += ['--mode', mode] + args += ['--skip-worker'] # Prepare environment and command for a separate process. env = os.environ.copy() diff --git a/build/build.py b/build/build.py index 7d3ccd1a64..00f718c9f8 100755 --- a/build/build.py +++ b/build/build.py @@ -192,6 +192,13 @@ def has_cast(self): return True return False + def has_transmuxer_proxy(self): + """Returns True if the transmuxer proxy is in the build.""" + for path in self.include: + if path.endswith('transmuxer_proxy.js'): + return True + return False + def generate_localizations(self, locales, force): localizations = compiler.GenerateLocalizations(locales) localizations.generate(force) @@ -265,7 +272,56 @@ def parse_build(self, lines, root): return True - def build_library(self, name, langout, locales, force, is_debug, skip_ts): + def build_worker_bundle(self, langout, force, is_debug): + """Compiles the transmuxer worker bundle. + + Returns True on success; False on failure. + """ + worker_build = Build() + if not worker_build.parse_build(['+@transmuxer-worker'], os.getcwd()): + return False + worker_build.add_closure() + if not worker_build.add_core(): + return False + + build_name = 'shaka-player.transmuxer-worker' + if is_debug: + build_name += '.debug' + closure = compiler.ClosureCompiler(worker_build.include, build_name) + closure.add_wrapper = False + closure.add_source_map = False + + closure_opts = common_closure_opts + common_closure_defines + closure_opts += ['--language_out', langout] + if is_debug: + closure_opts += debug_closure_opts + debug_closure_defines + else: + closure_opts += release_closure_opts + release_closure_defines + + closure_opts += [ + '--dependency_mode=PRUNE', + '--entry_point=goog:shaka.transmuxer.TransmuxerWorker', + # Each transmuxer plugin registers itself at load time (side effect), + # so we must list them as entry points too. + '--entry_point=goog:shaka.transmuxer.AacTransmuxer', + '--entry_point=goog:shaka.transmuxer.Ac3Transmuxer', + '--entry_point=goog:shaka.transmuxer.Ec3Transmuxer', + '--entry_point=goog:shaka.transmuxer.Mp3Transmuxer', + '--entry_point=goog:shaka.transmuxer.MpegTsTransmuxer', + '--entry_point=goog:shaka.transmuxer.TsTransmuxer', + ] + + # Suppress type errors caused by dependency pruning; the main build + # already validates all types. + closure_opts += [ + '--jscomp_off=checkTypes', + '--jscomp_off=unknownDefines', + ] + + return closure.compile(closure_opts, force) + + def build_library(self, name, langout, locales, force, is_debug, skip_ts, + build_worker): """Builds Shaka Player using the files in |self.include|. Args: @@ -275,6 +331,7 @@ def build_library(self, name, langout, locales, force, is_debug, skip_ts): force: True to rebuild, False to ignore if no changes are detected. is_debug: True to compile for debugging, false for release. skip_ts: True to skip generation of TypeScript definitions. + build_worker: True to build the standalone transmuxer worker if needed. Returns: True on success; False on failure. @@ -289,6 +346,11 @@ def build_library(self, name, langout, locales, force, is_debug, skip_ts): if not self.has_cast(): self.include.add(os.path.abspath('conditional/dummy_cast_proxy.js')) + if build_worker and self.has_transmuxer_proxy(): + logging.info('Compiling transmuxer worker bundle...') + if not self.build_worker_bundle(langout, force, is_debug): + return False + if is_debug: name += '.debug' @@ -385,6 +447,16 @@ def main(args): help='Skips generation of TypeScript definition files (.d.ts).', action='store_true') + parser.add_argument( + '--worker', + help='Build only the standalone transmuxer worker script.', + action='store_true') + + parser.add_argument( + '--skip-worker', + help='Do not build the standalone transmuxer worker alongside the library.', + action='store_true') + parsed_args, commands = parser.parse_known_args(args) # Make the dist/ folder, ignore errors. @@ -501,8 +573,11 @@ def locked_file_rw(path, create=False): is_debug = parsed_args.mode == 'debug' skip_ts = parsed_args.skip_ts - if not custom_build.build_library(name, langout, locales, force, is_debug, - skip_ts): + if parsed_args.worker: + if not custom_build.build_worker_bundle(langout, force, is_debug): + return 1 + elif not custom_build.build_library(name, langout, locales, force, is_debug, + skip_ts, not parsed_args.skip_worker): return 1 # Persist (merge) the updated state under lock so we don't clobber parallel updates. diff --git a/build/check.py b/build/check.py index 0d93a33425..9634f12aa4 100755 --- a/build/check.py +++ b/build/check.py @@ -17,7 +17,7 @@ """This is used to validate that the library is correct. This checks: - * All files in lib/ appear when compiling +@complete + * All files in lib/ appear in +@complete or another standalone build type * Runs a compiler pass over the test code to check for type errors * Run the linter to check for style violations. """ @@ -48,7 +48,11 @@ def complete_build_files(): # Normally we don't need to include @core, but because we look at the build # object directly, we need to include it here. When using main(), it will # call addCore which will ensure core is included. - if not complete.parse_build(['+@complete', '+@core'], os.getcwd()): + # + # Standalone build types are included here so their entry points are covered + # by the "all files are in a build" invariant without bloating +@complete. + if not complete.parse_build( + ['+@complete', '+@core', '+@transmuxer-worker'], os.getcwd()): logging.error('Error parsing complete build') return False return complete.include @@ -120,7 +124,7 @@ def check_html_lint(args): @_Check('complete') def check_complete(_): - """Checks whether the 'complete' build references every file. + """Checks whether the build type definitions reference every file. This is used by the build script to ensure that every file is included in at least one build type. diff --git a/build/compiler.py b/build/compiler.py index 4182152a0e..4652215d5f 100644 --- a/build/compiler.py +++ b/build/compiler.py @@ -329,7 +329,7 @@ def __init__(self, main_source_file, all_source_files, output): def compile(self, force=False): """Compiles the main less file in |self.main_source_file| into the - |self.output| css file. + |self.output| css file and applies PostCSS Autoprefixer. Args: force: Generate the output even if the inputs have not changed. @@ -342,8 +342,6 @@ def compile(self, force=False): lessc = shakaBuildHelpers.get_node_binary('less', 'lessc') less_options = [ - # Enable the "clean-CSS" plugin to minify the output and strip out comments. - '--clean-css', # Output a source map of the original CSS/less files. '--source-map=' + self.output + '.map', ] @@ -354,6 +352,24 @@ def compile(self, force=False): logging.error('CSS compilation failed') return False + # We look for the PostCSS binary within node_modules/postcss-cli + postcss = shakaBuildHelpers.get_node_binary('postcss-cli', 'postcss') + postcss_options = [ + self.output, + '-o', self.output, + '--use', 'autoprefixer', + '--use', 'cssnano', + '--map', + ] + + # We define the specific browsers using the environment variable. These are + # the minimum browsers that the UI supports. + os.environ['BROWSERSLIST'] = 'chrome 38, safari 8, firefox 42' + + if shakaBuildHelpers.execute_get_code(postcss + postcss_options) != 0: + logging.error('PostCSS / Autoprefixer processing failed') + return False + # We need to prepend the license header to the compiled CSS. with open(_get_source_path('build/license-header'), 'rb') as f: license_header = f.read() diff --git a/build/conformance.textproto b/build/conformance.textproto index e446c07021..127a9350e8 100644 --- a/build/conformance.textproto +++ b/build/conformance.textproto @@ -236,6 +236,7 @@ requirement: { whitelist_regexp: "demo/" whitelist_regexp: "test/" whitelist_regexp: "lib/util/timer.js" + whitelist_regexp: "third_party/cml-cmcd/cmcd_reporter.js" } requirement: { type: BANNED_NAME @@ -248,6 +249,7 @@ requirement: { whitelist_regexp: "demo/" whitelist_regexp: "test/" whitelist_regexp: "lib/util/timer.js" + whitelist_regexp: "third_party/cml-cmcd/cmcd_reporter.js" } # Disallow setTimeout. @@ -313,6 +315,7 @@ requirement: { "instead." whitelist_regexp: "lib/net/http_fetch_plugin.js" whitelist_regexp: "test/test/util/util.js" + whitelist_regexp: "third_party/cml-cmcd/cml_utils.js" } # Disallow the general use of AbortController, which is not available on all diff --git a/build/types/core b/build/types/core index 23a728059c..9ad536e0cf 100644 --- a/build/types/core +++ b/build/types/core @@ -79,6 +79,7 @@ +../../lib/text/web_vtt_generator.js +../../lib/transmuxer/transmuxer_engine.js ++../../lib/transmuxer/transmuxer_proxy.js +../../lib/transmuxer/transmuxer_utils.js +../../lib/util/abortable_operation.js @@ -136,6 +137,81 @@ +../../third_party/closure-uri/uri.js +../../third_party/closure-uri/utils.js ++../../third_party/cml-cmcd/append_cmcd_headers.js ++../../third_party/cml-cmcd/append_cmcd_query.js ++../../third_party/cml-cmcd/cmcd.js ++../../third_party/cml-cmcd/cmcd_custom_key.js ++../../third_party/cml-cmcd/cmcd_custom_value.js ++../../third_party/cml-cmcd/cmcd_data.js ++../../third_party/cml-cmcd/cmcd_default_time_interval.js ++../../third_party/cml-cmcd/cmcd_encode_options.js ++../../third_party/cml-cmcd/cmcd_event.js ++../../third_party/cml-cmcd/cmcd_event_keys.js ++../../third_party/cml-cmcd/cmcd_event_report_config.js ++../../third_party/cml-cmcd/cmcd_event_type.js ++../../third_party/cml-cmcd/cmcd_formatter.js ++../../third_party/cml-cmcd/cmcd_formatter_map.js ++../../third_party/cml-cmcd/cmcd_formatter_map_const.js ++../../third_party/cml-cmcd/cmcd_formatter_options.js ++../../third_party/cml-cmcd/cmcd_header_field.js ++../../third_party/cml-cmcd/cmcd_header_key.js ++../../third_party/cml-cmcd/cmcd_header_map.js ++../../third_party/cml-cmcd/cmcd_header_map_const.js ++../../third_party/cml-cmcd/cmcd_header_value.js ++../../third_party/cml-cmcd/cmcd_inner_list_keys.js ++../../third_party/cml-cmcd/cmcd_key.js ++../../third_party/cml-cmcd/cmcd_key_types.js ++../../third_party/cml-cmcd/cmcd_keys.js ++../../third_party/cml-cmcd/cmcd_mime_type.js ++../../third_party/cml-cmcd/cmcd_object_type.js ++../../third_party/cml-cmcd/cmcd_object_type_list.js ++../../third_party/cml-cmcd/cmcd_param.js ++../../third_party/cml-cmcd/cmcd_player_state.js ++../../third_party/cml-cmcd/cmcd_report_config.js ++../../third_party/cml-cmcd/cmcd_reporter.js ++../../third_party/cml-cmcd/cmcd_reporter_config.js ++../../third_party/cml-cmcd/cmcd_reporting_mode.js ++../../third_party/cml-cmcd/cmcd_request.js ++../../third_party/cml-cmcd/cmcd_request_key.js ++../../third_party/cml-cmcd/cmcd_request_keys.js ++../../third_party/cml-cmcd/cmcd_request_report.js ++../../third_party/cml-cmcd/cmcd_request_report_config.js ++../../third_party/cml-cmcd/cmcd_response.js ++../../third_party/cml-cmcd/cmcd_response_keys.js ++../../third_party/cml-cmcd/cmcd_state_event_fields.js ++../../third_party/cml-cmcd/cmcd_stream_type.js ++../../third_party/cml-cmcd/cmcd_streaming_format.js ++../../third_party/cml-cmcd/cmcd_string_length_limits.js ++../../third_party/cml-cmcd/cmcd_token_values.js ++../../third_party/cml-cmcd/cmcd_transmission_mode.js ++../../third_party/cml-cmcd/cmcd_v1.js ++../../third_party/cml-cmcd/cmcd_v1_const.js ++../../third_party/cml-cmcd/cmcd_v1_keys.js ++../../third_party/cml-cmcd/cmcd_v2_const.js ++../../third_party/cml-cmcd/cmcd_value.js ++../../third_party/cml-cmcd/cmcd_version.js ++../../third_party/cml-cmcd/cml_sfv.js ++../../third_party/cml-cmcd/cml_utils.js ++../../third_party/cml-cmcd/encode_cmcd.js ++../../third_party/cml-cmcd/encode_prepared_cmcd.js ++../../third_party/cml-cmcd/ensure_headers.js ++../../third_party/cml-cmcd/group_cmcd_headers.js ++../../third_party/cml-cmcd/is_cmcd_custom_key.js ++../../third_party/cml-cmcd/is_cmcd_event_key.js ++../../third_party/cml-cmcd/is_cmcd_request_key.js ++../../third_party/cml-cmcd/is_cmcd_response_received_key.js ++../../third_party/cml-cmcd/is_cmcd_v1_key.js ++../../third_party/cml-cmcd/is_token_field.js ++../../third_party/cml-cmcd/is_valid.js ++../../third_party/cml-cmcd/prepare_cmcd_data.js ++../../third_party/cml-cmcd/resolve_version.js ++../../third_party/cml-cmcd/to_cmcd_headers.js ++../../third_party/cml-cmcd/to_cmcd_query.js ++../../third_party/cml-cmcd/to_cmcd_url.js ++../../third_party/cml-cmcd/to_cmcd_value.js ++../../third_party/cml-cmcd/to_prepared_cmcd_headers.js ++../../third_party/cml-cmcd/up_convert_to_v2.js + +../../third_party/language-mapping-list/language-mapping-list.js +@lcevc diff --git a/build/types/transmuxer-worker b/build/types/transmuxer-worker new file mode 100644 index 0000000000..a1e2a7202e --- /dev/null +++ b/build/types/transmuxer-worker @@ -0,0 +1,10 @@ +# Transmuxer Worker bundle. +# This build type produces a standalone script to be loaded in a Web Worker. +# It includes all transmuxer plugins and the worker entry point. + ++../../lib/device/apple_browser.js ++../../lib/device/default_browser.js ++@devices ++../../lib/transmuxer/loc_transmuxer.js ++../../lib/transmuxer/transmuxer_worker.js ++@transmuxer diff --git a/demo/TengwarTelcontar.woff2 b/demo/TengwarTelcontar.woff2 new file mode 100644 index 0000000000..62404f98ea Binary files /dev/null and b/demo/TengwarTelcontar.woff2 differ diff --git a/demo/asset_card.js b/demo/asset_card.js index c7b052e59b..653cbf6edb 100644 --- a/demo/asset_card.js +++ b/demo/asset_card.js @@ -248,6 +248,38 @@ shakaDemo.AssetCard = class { this.remakeButtonsFn_(this); } + /** + * Adds a Play button for M3U playlist assets and shows the remaining + * base buttons ("Add to queue", "Start Preload") as disabled, since + * those operations are not applicable to playlist-type assets: + * - "Add to queue" would add the raw playlist URL, not its entries. + * - "Start Preload" has no meaning before the playlist is fetched. + */ + addBaseButtonsPlaylist() { + let disablePlay = false; + + // Play loads the playlist via QueueManager.loadFromM3uPlaylist() + // and starts the first channel automatically. + this.addButton('Play', async () => { + if (disablePlay) { + return; + } + disablePlay = true; + await shakaDemoMain.loadAsset(this.asset_); + this.remakeButtons(); + }); + + // Disabled: adding a raw playlist URL to an existing queue is not + // supported; the user should play the playlist directly instead. + const addToQueueButton = this.addButton('Add to queue', () => {}); + addToQueueButton.setAttribute('disabled', ''); + + // Disabled: preloading a playlist URL is not meaningful before the + // playlist entries have been resolved. + const preloadButton = this.addButton('Start Preload', () => {}); + preloadButton.setAttribute('disabled', ''); + } + /** Adds basic buttons to the card ("play" and "preload"). */ addBaseButtons() { let disableButtons = false; diff --git a/demo/common/asset.js b/demo/common/asset.js index ffb9b618bb..678a5899ed 100644 --- a/demo/common/asset.js +++ b/demo/common/asset.js @@ -99,6 +99,14 @@ const ShakaDemoAssetInfo = class { this.useIMA = true; /** @type {?string} */ this.mimeType = null; + /** + * When true, |manifestUri| points to an M3U/M3U8 playlist file + * rather than a single-stream manifest. The demo will call + * QueueManager.loadFromM3uPlaylist() instead of the normal load path, + * and will play the first item in the playlist automatically. + * @type {boolean} + */ + this.isPlaylist = false; // Preload values. diff --git a/demo/config.js b/demo/config.js index d4ffd73dc1..3b8e9a6eed 100644 --- a/demo/config.js +++ b/demo/config.js @@ -106,6 +106,7 @@ shakaDemo.Config = class { this.addUISection_(); this.addUISeekBarColorsSection_(); this.addUIVolumeBarColorsSection_(); + this.addUIPlaybackRateBarColorsSection_(); this.addUIQualityMarksSection_(); this.addUIMediaSessionSection_(); this.addUIDocumentPiPSection_(); @@ -444,10 +445,35 @@ shakaDemo.Config = class { .addBoolInput_('Enabled', 'cmcd.enabled') .addTextInput_('Session ID', 'cmcd.sessionId') .addTextInput_('Content ID', 'cmcd.contentId') - .addTextInput_('Version', 'cmcd.version') + .addNumberInput_('Version', 'cmcd.version', + /* canBeDecimal= */ false) .addNumberInput_('RTP safety Factor', 'cmcd.rtpSafetyFactor', /* canBeDecimal= */ true) .addBoolInput_('Use Headers', 'cmcd.useHeaders'); + + // CMCD v2 event-mode targets. JSON because the typedef is an + // array of objects with several fields each; a per-field UI would + // bloat the demo significantly. + const eventTargetsTooltip = + 'JSON array of event-mode CmcdTarget objects, e.g. ' + + '[{"enabled":true,"url":"https://collector/cmcd",' + + '"events":["ps","rr"],"interval":30,"includeKeys":[]}]'; + const onTargetsChange = (input) => { + try { + const parsed = input.value.trim() ? JSON.parse(input.value) : []; + shakaDemoMain.configure('cmcd.eventTargets', parsed); + shakaDemoMain.remakeHash(); + input.setCustomValidity(''); + } catch (e) { + input.setCustomValidity('Invalid JSON'); + } + }; + this.addCustomTextInput_( + 'Event Targets (JSON)', onTargetsChange, eventTargetsTooltip); + const current = /** @type {Array<*>} */ ( + shakaDemoMain.getCurrentConfigValue('cmcd.eventTargets')); + this.latestInput_.input().value = + (current && current.length) ? JSON.stringify(current) : ''; } /** @private */ @@ -806,6 +832,18 @@ shakaDemo.Config = class { 'mediaSource.useSourceElements') .addBoolInput_('Expect updateEnd when duration is truncated', 'mediaSource.durationReductionEmitsUpdateEnd'); + + const transmuxWorkerToggleOnChange = (input) => { + const url = input.checked ? + shakaDemoMain.getTransmuxerWorkerUrl() : ''; + shakaDemoMain.configure('mediaSource.transmuxWorkerUrl', url); + shakaDemoMain.remakeHash(); + }; + this.addCustomBoolInput_( + 'Use a worker for transmuxing', transmuxWorkerToggleOnChange); + if (shakaDemoMain.getCurrentConfigValue('mediaSource.transmuxWorkerUrl')) { + this.latestInput_.input().checked = true; + } } /** @@ -1189,6 +1227,12 @@ shakaDemo.Config = class { .addUIArrayStringInput_('Statistics List', 'statisticsList') .addUIArrayStringInput_('Ad Statistics List', 'adStatisticsList') .addUIArrayNumberInput_('Playback Rates', 'playbackRates') + .addUINumberInput_('Playback Rate Slider Min', + 'playbackRateSliderMin', + /* canBeDecimal= */ true) + .addUINumberInput_('Playback Rate Slider Max', + 'playbackRateSliderMax', + /* canBeDecimal= */ true) .addUIArrayNumberInput_('Fast Forward Rates', 'fastForwardRates') .addUIArrayNumberInput_('Rewind Rates', 'rewindRates') .addUIArrayNumberInput_('Captions Font Scale Factors', @@ -1215,6 +1259,14 @@ shakaDemo.Config = class { .addUITextInput_('Level Color', 'volumeBarColors.level'); } + /** @private */ + addUIPlaybackRateBarColorsSection_() { + const docLink = this.resolveExternLink_('.UIPlaybackRateBarColors'); + this.addSection_('UI: Playback Rate Bar Colors', docLink) + .addUITextInput_('Base Color', 'playbackRateBarColors.base') + .addUITextInput_('Level Color', 'playbackRateBarColors.level'); + } + /** @private */ addUIQualityMarksSection_() { const docLink = this.resolveExternLink_('.UIQualityMarks'); diff --git a/demo/custom.js b/demo/custom.js index 1a23e57afd..1042698861 100644 --- a/demo/custom.js +++ b/demo/custom.js @@ -947,6 +947,15 @@ shakaDemo.Custom = class { }; this.makeField_(container, 'MIME Type', mimeTypeSetup, mimeTypeOnChange); + // Toggle: when enabled, |manifestUri| is treated as an M3U + // playlist URL rather than a single-stream manifest. The queue + // manager will fetch the playlist and populate the queue from it. + const isPlaylistOnChange = (input) => { + assetInProgress.isPlaylist = input.checked; + }; + this.makeBoolInput_(container, 'Is M3U Playlist', + isPlaylistOnChange, assetInProgress.isPlaylist); + return mainDiv; } @@ -1148,7 +1157,14 @@ shakaDemo.Custom = class { const savedList = this.savedList_; const isFeatured = false; return new shakaDemo.AssetCard(savedList, asset, isFeatured, (c) => { - c.addBaseButtons(); + if (asset.isPlaylist) { + // Playlist assets only support Play, Edit, and Delete. + // "Add to queue" and "Start Preload" are shown but disabled; + // the Store button is omitted entirely. + c.addBaseButtonsPlaylist(); + } else { + c.addBaseButtons(); + } c.addButton('Edit', async () => { if (asset.unstoreCallback) { await asset.unstoreCallback(); @@ -1165,7 +1181,10 @@ shakaDemo.Custom = class { this.saveAssetInfos_(this.assets_); this.remakeSavedList_(); }, 'Delete this custom asset?'); - c.addStoreButton(); + if (!asset.isPlaylist) { + // The Store button is only relevant for regular stream assets. + c.addStoreButton(); + } }); } diff --git a/demo/demo.less b/demo/demo.less index 4e73b006be..0b5fdd48d2 100644 --- a/demo/demo.less +++ b/demo/demo.less @@ -81,6 +81,21 @@ html, body { font-family: Roboto, sans-serif; } +// This font supports the Sindarin (sjn) translation. +@font-face { + // The Tengwar Telcontar font + // From https://freetengwar.sourceforge.net/tengtelc.html + src: url("../demo/TengwarTelcontar.woff2") format("woff2"); + // Unicode Private Use Area (PUA) used by ConScript Unicode Registry (CSUR) + // to represent Tengwar glyphs. This will keep the font from being loaded + // for any other codepoints. + unicode-range: U+E000-E0FF; + // Fun trick to avoid hard-coding Tengwar support into the library: + // Add this font for these codepoint ranges in anything that uses "Roboto", + // without having to modify the CSS to point at another font family. + font-family: Roboto; +} + /* Change the default opacity of the ripple container, to get around an iOS bug. * See: https://github.com/google/material-design-lite/issues/5281 */ .mdl-ripple { @@ -621,3 +636,17 @@ footer .mdl-mega-footer__link-list { .extra-config-textarea { font-family: monospace; } + +.mdl-dialog .input-container-style-vertical { + .input-container-row:has(.mdl-switch) { + display: block; + width: 100%; + } + + .mdl-switch { + margin-top: 8px; + margin-bottom: 8px; + // Fill the full container width so the label text is never truncated. + width: 100%; + } +} diff --git a/demo/main.js b/demo/main.js index 564bb7e9ed..d7025ef214 100644 --- a/demo/main.js +++ b/demo/main.js @@ -429,6 +429,13 @@ shakaDemo.Main = class { this.player_.configure( 'manifest.dash.clockSyncUri', 'https://time.akamai.com/?ms&iso'); + // The library does not auto-detect the transmuxer worker URL — the demo + // is responsible for telling Shaka where to load it from. The path + // depends on which build the demo loaded (compiled, debug, uncompiled). + this.player_.configure( + 'mediaSource.transmuxWorkerUrl', + this.getTransmuxerWorkerUrl()); + // Get default config. this.defaultConfig_ = this.player_.getConfiguration(); this.desiredConfig_ = this.player_.getConfiguration(); @@ -473,6 +480,7 @@ shakaDemo.Main = class { }); this.localization_ = this.controls_.getLocalization(); + this.setupLazyLocalization_(); const drawerCloseButton = document.getElementById('drawer-close-button'); drawerCloseButton.addEventListener('click', () => { @@ -510,6 +518,33 @@ shakaDemo.Main = class { this.hideElement_(drawerCloseButton); } + /** + * @private + */ + setupLazyLocalization_() { + // Load locales on-demand. + const UNKNOWN_LOCALES = shaka.ui.Localization.UNKNOWN_LOCALES; + this.localization_.addEventListener(UNKNOWN_LOCALES, (event) => { + for (const locale of event['locales']) { + this.loadUILocale_(locale); + } + }); + + // Load the initial locale. + this.loadUILocale_(this.uiLocale_); + + // Also try to load the 'base' localization. This is so that, for example, + // the uiLocale_ is set to 'en-US', it will try to load 'en'. + if (this.uiLocale_.includes('-')) { + this.loadUILocale_(this.uiLocale_.split('-')[0]); + } + + // Load 'en' as a fallback option, if not already loaded. + if (!this.uiLocale_.startsWith('en')) { + this.loadUILocale_('en'); + } + } + /** @return {boolean} */ getIsDrawerOpen() { const drawer = document.querySelector('.mdl-layout__drawer'); @@ -920,6 +955,27 @@ shakaDemo.Main = class { return this.nativeControlsEnabled_; } + /** + * @param {string} locale + * @return {!Promise} + * @private + */ + async loadUILocale_(locale) { + if (!locale) { + return; + } + + const url = '../ui/locales/' + locale + '.json'; + try { + const text = await this.loadText_(url); + const obj = /** @type {!Object} */(JSON.parse(text)); + const map = new Map(Object.entries(obj)); + this.localization_.insert(locale, map); + } catch (error) { + console.warn('Unable to load locale', locale, 'from url', url); + } + } + /** @param {string} locale */ setUILocale(locale) { this.uiLocale_ = locale; @@ -1228,6 +1284,29 @@ shakaDemo.Main = class { return params; } + /** + * Picks the worker bundle that matches the build the demo loaded. + * The demo always serves the worker from `../dist/` (compiled) or from + * the repo root (`../transmuxer_worker.uncompiled.js`). + * @return {string} + */ + getTransmuxerWorkerUrl() { + const params = this.getParams_(); + let buildType = 'uncompiled'; + if (params.has('build')) { + buildType = params.get('build'); + } else if (params.has('compiled')) { + buildType = 'compiled'; + } + if (buildType === 'uncompiled') { + return '../transmuxer_worker.uncompiled.js'; + } + if (buildType === 'debug_compiled') { + return '../dist/shaka-player.transmuxer-worker.debug.js'; + } + return '../dist/shaka-player.transmuxer-worker.js'; + } + /** * Recovers the value from the given config field, from an arbitrary config * object. @@ -1531,11 +1610,15 @@ shakaDemo.Main = class { } // Finally, the asset can be loaded. - const queueItem = await this.getQueueItem_(asset); - queueManager.insertItems([queueItem]); - await queueManager.playItem(0); - - asset.preloadManager = null; + if (asset.isPlaylist) { + await queueManager.loadFromM3uPlaylist( + asset.manifestUri, /* playOnLoad= */ true); + } else { + const queueItem = await this.getQueueItem_(asset); + queueManager.insertItems([queueItem]); + await queueManager.playItem(0); + asset.preloadManager = null; + } if (this.visualizer_ && this.visualizer_.active) { this.visualizer_.start(); diff --git a/demo/visualizer_button.js b/demo/visualizer_button.js index b456284ca2..2992610318 100644 --- a/demo/visualizer_button.js +++ b/demo/visualizer_button.js @@ -65,25 +65,14 @@ shakaDemo.VisualizerButton = class extends shaka.ui.Element { shakaDemoMain.getIsVisualizerActive()) { shakaDemoMain.setIsVisualizerActive(false); } - this.checkAvailability_(); + this.checkAvailability(); }); this.eventManager.listen(document, 'fullscreenchange', () => { - this.checkAvailability_(); + this.checkAvailability(); }); - if (this.isSubMenu) { - this.eventManager.listenMulti( - this.controls, - [ - 'submenuopen', - 'submenuclose', - ], () => { - this.checkAvailability_(); - }); - } - - this.checkAvailability_(); + this.checkAvailability(); } /** @private */ @@ -97,11 +86,8 @@ shakaDemo.VisualizerButton = class extends shaka.ui.Element { } } - - /** - * @private - */ - checkAvailability_() { + /** @override */ + checkAvailability() { if (!this.castProxy_.isCasting() && !this.controls.isFullScreenEnabled() && !this.isSubMenuOpened) { diff --git a/docs/tutorials/index.json b/docs/tutorials/index.json index c6080f3f8b..aa0c9fc1e4 100644 --- a/docs/tutorials/index.json +++ b/docs/tutorials/index.json @@ -9,6 +9,7 @@ { "license-wrapping": { "title": "License Wrapping" } }, { "moq": { "title": "MoQ" } }, { "preload": { "title": "Preloading" } }, + { "transmuxing-in-worker": { "title": "Transmuxing in Worker" } }, { "ui": { "title": "UI Library" } }, { "ui-customization": { "title": "Configuring the UI" } }, { "text-displayer": { "title": "Configuring text displayer" } }, diff --git a/docs/tutorials/queue-manager.md b/docs/tutorials/queue-manager.md index 4fc6cd6601..8567faa403 100644 --- a/docs/tutorials/queue-manager.md +++ b/docs/tutorials/queue-manager.md @@ -249,6 +249,115 @@ queueManager.insertItems([ ``` +#### Loading an M3U Playlist + +`loadFromM3uPlaylist()` lets you populate the queue from a remote M3U or M3U8 +playlist in one call. The method fetches the file using the player's own +networking engine (so request filters, credentials, and retry parameters all +apply), parses every stream entry, and inserts the resulting items into the +queue. + +```js +// Load a playlist and start playing the first channel immediately. +await queueManager.loadFromM3uPlaylist( + 'https://example.com/channels.m3u', + /* playOnLoad= */ true, +); +``` + +The second argument, `playOnLoad`, is optional and defaults to `false`. When +`true`, `playItem(0)` is called automatically once the items have been +inserted. + +```js +// Load a playlist without starting playback — useful when you want to +// inspect or filter the items before choosing which one to play. +await queueManager.loadFromM3uPlaylist('https://example.com/channels.m3u'); + +const items = queueManager.getItems(); +const newsIndex = items.findIndex( + (item) => item.metadata?.groupTitle === 'News', +); +if (newsIndex >= 0) { + await queueManager.playItem(newsIndex); +} +``` + +##### EXTINF attributes and item metadata + +The parser supports the Extended M3U format (`#EXTM3U` / `#EXTINF`) with the +`tvg-*` and `group-title` attributes commonly found in IPTV playlists. All +attributes are copied into the item's `metadata` object **using their original +hyphenated names** (e.g. `tvg-id`, `tvg-name`, `group-title`). Two standard +`QueueItemMetadata` aliases are also set on top: + +| Playlist attribute | `metadata` property | Notes | +|--------------------|---------------------|--------------------------------------------| +| `tvg-name` | `tvg-name` + `title`| `title` falls back to the display name. | +| `tvg-logo` | `tvg-logo` + `poster`| | +| `tvg-id` | `tvg-id` | Also used for deduplication (see below). | +| `tvg-language` | `tvg-language` | | +| `tvg-country` | `tvg-country` | | +| `tvg-url` | `tvg-url` | EPG (Electronic Programme Guide) feed URL. | +| `group-title` | `group-title` | | +| Display name | `displayTitle` | The text after the last comma in `#EXTINF`.| +| *(any other)* | *(original name)* | Unknown attributes are preserved as-is. | + +A typical IPTV entry and the metadata it produces: + +``` +#EXTINF:-1 tvg-id="bbc1" tvg-name="BBC One" tvg-logo="https://example.com/bbc1.png" tvg-language="English" tvg-country="GB" group-title="Entertainment",BBC One HD +https://example.com/bbc1/stream.m3u8 +``` + +```js +{ + manifestUri: 'https://example.com/bbc1/stream.m3u8', + metadata: { + // Standard QueueItemMetadata aliases + title: 'BBC One', // from tvg-name + poster: 'https://example.com/bbc1.png', // from tvg-logo + // Raw attribute names, exactly as in the playlist + 'tvg-id': 'bbc1', + 'tvg-name': 'BBC One', + 'tvg-logo': 'https://example.com/bbc1.png', + 'tvg-language': 'English', + 'tvg-country': 'GB', + 'group-title': 'Entertainment', + // Extra helper added by the parser + displayTitle: 'BBC One HD', // raw text after the last comma + }, +} +``` + +You can use `metadata['group-title']` to build a channel-group UI, or +`metadata['tvg-url']` to load EPG schedule data for the currently playing +channel: + +```js +queueManager.addEventListener('currentitemchanged', () => { + const item = queueManager.getCurrentItem(); + if (item?.metadata) { + titleEl.textContent = item.metadata.title ?? ''; + posterEl.src = item.metadata.poster ?? ''; + groupEl.textContent = item.metadata['group-title'] ?? ''; + } +}); +``` + +##### Duplicate channel handling + +Channels that share the same `tvg-id` value are automatically deduplicated: +only the first occurrence is kept, and subsequent entries with the same id are +silently dropped. Channels without a `tvg-id` are always included regardless +of whether their stream URL appears more than once. + +Note: `loadFromM3uPlaylist()` uses `RequestType.PLAYLIST` and the +`manifest.retryParameters` from the current player configuration. If the +playlist URL requires custom headers or credentials, configure them via a +request filter before calling this method. + + #### Clearing the Queue To stop playback and remove all items from the queue: @@ -258,4 +367,4 @@ await queueManager.removeAllItems(); ``` This unloads the player, destroys any active or pending `PreloadManager` -instances, and resets the current index to `-1`. +instances, and resets the current index to `-1`. \ No newline at end of file diff --git a/docs/tutorials/transmuxing-in-worker.md b/docs/tutorials/transmuxing-in-worker.md new file mode 100644 index 0000000000..bb92ec6482 --- /dev/null +++ b/docs/tutorials/transmuxing-in-worker.md @@ -0,0 +1,205 @@ +# Transmuxing in a Web Worker + +#### Overview + +When playing HLS streams with MPEG-TS segments, Shaka Player must transmux +(convert) each segment from MPEG-TS to fMP4 before feeding it to +`MediaSource`. By default this work happens synchronously on the main thread, +which can cause frame drops or audio glitches on slower devices. + +Shaka Player can offload this work to a dedicated **Web Worker**, freeing the +main thread for rendering and UI. The worker is shared across all active +streams (audio and video), so only one worker thread is ever created per page. + +#### Quick Summary + +To enable worker-based transmuxing you must do **two** things: + +1. Make the compiled worker script reachable over HTTP from your page. +2. Tell Shaka where to find it via `mediaSource.transmuxWorkerUrl`. + +Shaka does **not** auto-detect the worker URL. The library cannot reliably +know where its assets live at runtime (script tags, bundler output, CDNs, +ES modules, hashed filenames all differ). The integrating application owns +how Shaka is loaded, so the application also owns the worker URL. + +If `transmuxWorkerUrl` is not set, transmux falls back to the main thread. +No error is thrown — the feature simply stays dormant. + +#### Configuration Key + +```js +player.configure({ + mediaSource: { + // URL of the worker script. Empty by default. Required for the worker + // to run; empty string keeps transmuxing on the main thread. + transmuxWorkerUrl: '', + }, +}); +``` + +When `transmuxWorkerUrl` is a non-empty string, Shaka creates the worker on +the first transmux call. Empty value (or device-level opt-out — see below) +falls back to main-thread transmuxing. + +#### The Worker File + +Compiled builds emit the worker as a standalone bundle next to the main +library bundle: + +| Build type | Worker filename | +| ---------- | --------------------------------------- | +| Release | `shaka-player.transmuxer-worker.js` | +| Debug | `shaka-player.transmuxer-worker.debug.js` | + +Both files live in `dist/` after running `python3 build/all.py`, and ship +inside the npm package under `node_modules/shaka-player/dist/`. + +You must serve the worker file from a URL the browser can reach. The exact +path depends on how you deploy Shaka — see the patterns below. + +#### Deployment Patterns + +##### Plain ` + +``` + +##### Webpack 5 / Vite / Rollup (modern bundlers) + +Use `new URL(..., import.meta.url)`. The bundler resolves the npm path, +copies the worker into the build output, and rewrites the URL at build +time: + +```js +const workerUrl = new URL( + 'shaka-player/dist/shaka-player.transmuxer-worker.js', + import.meta.url, +).toString(); + +player.configure('mediaSource.transmuxWorkerUrl', workerUrl); +``` + +##### Webpack 4 + +Webpack 4 has no `import.meta.url` support. Use `file-loader` or +`asset/resource`: + +```js +import workerUrl from + 'shaka-player/dist/shaka-player.transmuxer-worker.js?url'; + +player.configure('mediaSource.transmuxWorkerUrl', workerUrl); +``` + +##### Create React App / static `public/` folder + +Copy `node_modules/shaka-player/dist/shaka-player.transmuxer-worker.js` +into `public/` (or your equivalent static asset folder) and reference it +by absolute path: + +```js +player.configure( + 'mediaSource.transmuxWorkerUrl', + '/shaka-player.transmuxer-worker.js'); +``` + +A small build script that copies the file on `postinstall` keeps the +worker version in sync with the installed package. + +#### Same-Origin and CORS Requirements + +`new Worker(url)` requires the worker script to be either: + +- same origin as the host page, or +- served with CORS headers that allow the host origin + (`Access-Control-Allow-Origin`, plus `Cross-Origin-Resource-Policy` when + the page itself runs cross-origin). + +Self-hosted same-origin deployments need no extra headers. Cross-origin +deployments must serve the worker with proper CORS configuration, or the +browser will reject the `new Worker(url)` call and Shaka will fall back to +main-thread transmuxing. + +#### Uncompiled / Development Mode + +In uncompiled mode (running Shaka directly from source for development) +the worker is bootstrapped from `transmuxer_worker.uncompiled.js` at the +repository root. Set the URL the same way: + +```js +player.configure( + 'mediaSource.transmuxWorkerUrl', + '/path/to/shaka-player/transmuxer_worker.uncompiled.js'); +``` + +Run `python3 build/gendeps.py` first so the bootstrap script can locate +the Closure dependency graph. + +#### Disabling the Worker + +To force main-thread transmuxing — for debugging or for environments +where Web Workers are unreliable — leave `transmuxWorkerUrl` empty, or +clear it at runtime: + +```js +player.configure('mediaSource.transmuxWorkerUrl', ''); +``` + +Certain TV platforms (Tizen, WebOS, Hisense) also opt out of worker +transmuxing internally via the device layer regardless of this setting, +because Worker support is often limited or unstable on those devices. + +#### Fallback Chain + +Shaka silently falls back to main-thread transmuxing in any of these +cases: + +1. `transmuxWorkerUrl` is empty. +2. The device platform reports no Worker support (e.g. older Tizen/WebOS). +3. `new Worker(url)` throws — CSP block, network error, MIME mismatch. +4. The first `postMessage` to the worker fails. +5. The worker does not respond within 30 seconds for a given segment. + +The fallback is transparent: playback continues without error. A warning +is logged to the console when a fallback is taken. + +#### Troubleshooting + +**Worker URL returns 404.** Open DevTools → Network, filter by `worker`, +and compare the requested URL against your deployed asset paths. Most +often the worker file was not copied alongside the main bundle. + +**CSP blocks the worker.** Add the worker's origin to the relevant +Content-Security-Policy directives: + +``` +worker-src 'self'; +script-src 'self'; +``` + +If the worker is served from a different origin, list that origin in +both directives (and `connect-src` if your app also fetches it +directly). + +**Cross-origin Worker rejected.** Ensure the worker response includes +`Access-Control-Allow-Origin: ` and, when the page is +itself cross-origin-isolated, `Cross-Origin-Resource-Policy: +cross-origin`. + +**Need to isolate a transmux bug.** Disable the worker as shown above so +that any transmux failure surfaces on the main thread with a full stack +trace. + +For general configuration see {@tutorial config}, and for all +`mediaSource` options see {@link shaka.extern.MediaSourceConfiguration}. diff --git a/eslint.config.mjs b/eslint.config.mjs index 4d613d1b93..c47855c3c8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -428,4 +428,24 @@ export default [ 'no-console': 'off', }, }, + { + // Verbatim ports of upstream @svta/cml-cmcd test utilities. These + // files mirror the upstream TS source (function expressions, 2-space + // indent, longer lines, XHR.prototype patching, etc.) so that future + // bumps stay mechanical. Shaka style rules don't apply. + files: ['test/test/util/cml-cmcd/**/*.js'], + rules: { + '@stylistic/arrow-parens': 'off', + '@stylistic/indent': 'off', + '@stylistic/max-len': 'off', + 'consistent-return': 'off', + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/require-param': 'off', + 'jsdoc/require-returns': 'off', + 'new-cap': 'off', + 'no-restricted-syntax': 'off', + 'no-useless-call': 'off', + 'prefer-arrow-callback': 'off', + }, + }, ]; diff --git a/externs/shaka/player.js b/externs/shaka/player.js index c8c26b67a0..50b02e7a13 100644 --- a/externs/shaka/player.js +++ b/externs/shaka/player.js @@ -2357,7 +2357,8 @@ shaka.extern.NetworkingConfiguration; * modifyCueCallback: shaka.extern.TextParser.ModifyCueCallback, * dispatchAllEmsgBoxes: boolean, * useSourceElements: boolean, - * durationReductionEmitsUpdateEnd: boolean + * durationReductionEmitsUpdateEnd: boolean, + * transmuxWorkerUrl: string * }} * * @description @@ -2425,6 +2426,19 @@ shaka.extern.NetworkingConfiguration; * smaller than existing value. *
* Defaults to true. + * @property {string} transmuxWorkerUrl + * URL of the standalone transmuxer worker script. When set to a non-empty + * string, transmuxing (e.g., MPEG-TS to MP4) is offloaded to a Web Worker + * loaded from this URL, freeing the main thread. When empty, transmuxing + * runs on the main thread. + *
+ * The library does not auto-detect this URL; the integrating application + * is responsible for serving the worker script (e.g., + * shaka-player.transmuxer-worker.js from dist/) + * and providing the URL here. Falls back to main-thread transmuxing if the + * worker fails to load or the device does not support Workers. + *
+ * Defaults to '' (worker disabled). * @exportDoc */ shaka.extern.MediaSourceConfiguration; @@ -2724,11 +2738,14 @@ shaka.extern.AdvancedAbrConfiguration; * url: string, * includeKeys: !Array, * events: !Array, - * timeInterval: number, + * interval: (number|undefined), + * batchSize: (number|undefined), + * version: (number|undefined) * }} * * @description - * Common Media Client Data (CMCD) Target Configuration + * Common Media Client Data (CMCD) Target Configuration. Experimental + * v2 surface — field names are subject to change. * * @property {string} mode * Specifies the transmission strategy for the CMCD data. @@ -2756,14 +2773,23 @@ shaka.extern.AdvancedAbrConfiguration; *
* Defaults to []. * @property {!Array} events - * An array of events to include as part of ps and sta in the CMCD data. - * If not provided, all events will be included. + * An array of events that this target subscribes to. + * If not provided, no event reports will be sent to this target. *
* Defaults to []. - * @property {number} timeInterval - * Time Interval config in seconds - *
- * Defaults to 10. + * @property {(number|undefined)} interval + * Time-interval period in seconds for periodic event reports + * ('t' events). Set to 0 to disable periodic + * reports. + *
+ * Defaults to 30 (the CMCD v2 default). + * @property {(number|undefined)} batchSize + * Number of events to batch before dispatch. + *
+ * Defaults to 1 (no batching). + * @property {(number|undefined)} version + * Per-target CMCD version override. CMCD event mode is v2-only; + * leave unset to inherit the top-level version. * @exportDoc */ shaka.extern.CmcdTarget; @@ -2777,7 +2803,7 @@ shaka.extern.CmcdTarget; * rtpSafetyFactor: number, * includeKeys: !Array, * version: number, - * targets: ?Array + * eventTargets: ?Array * }} * * @description @@ -2825,8 +2851,11 @@ shaka.extern.CmcdTarget; * and CMCD v2 specifications, respectively. *
* Defaults to 1. - * @property {Array=} targets - * The event/response mode targets. + * @property {Array=} eventTargets + * Experimental v2: event-mode reporting targets. Each entry configures + * one endpoint that receives batched CMCD event reports + * (e.g., 'ps', 'rr') for the configured + * events. *
* @exportDoc */ diff --git a/externs/shaka/queue.js b/externs/shaka/queue.js index 633943ed05..8218a33cda 100644 --- a/externs/shaka/queue.js +++ b/externs/shaka/queue.js @@ -131,6 +131,29 @@ shaka.extern.IQueueManager = class extends EventTarget { * @return {!Promise} */ playItem(itemIndex) {} + + /** + * Fetches an M3U/M3U8 playlist from the given URL using the player's + * networking engine, parses it, and inserts the resulting items into the + * queue. Supports the extended EXTINF format with tvg-* attributes + * commonly found in IPTV playlists (tvg-id, tvg-name, tvg-logo, + * tvg-language, tvg-country, tvg-url, group-title). + * + * The parsed tvg-* attribute values are stored in the item's metadata: + * - tvg-name → metadata.title (falls back to the display name) + * - tvg-logo → metadata.poster + * - tvg-id → metadata.tvgId + * - tvg-language → metadata.tvgLanguage + * - tvg-country → metadata.tvgCountry + * - tvg-url → metadata.tvgUrl + * - group-title → metadata.groupTitle + * - display name → metadata.displayTitle + * + * @param {string} url + * @param {boolean=} playOnLoad + * @return {!Promise} + */ + loadFromM3uPlaylist(url, playOnLoad) {} }; diff --git a/karma.conf.js b/karma.conf.js index 0903ca7a1d..cfcfa5ea82 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -234,6 +234,18 @@ module.exports = (config) => { // test utilities next, which fill in that namespace 'test/test/util/*.js', + // validate_cmcd_keys.js builds Sets at module-eval time from CMCD_KEYS. + // CMCD_KEYS (cmcd_keys.js) is not required by the main library. Load it + // explicitly here, before the cml-cmcd test utilities, so the Sets are + // built correctly. Its transitive deps (CMCD_V1_KEYS, CMCD_REQUEST_KEYS, + // CMCD_RESPONSE_KEYS, CMCD_EVENT_KEYS) are already loaded by + // cmcd_manager. + 'third_party/cml-cmcd/cmcd_keys.js', + + // cml-cmcd test-only helpers (CmcdReportRecorder, validateCmcd, etc.) + // These use goog.provide so must be loaded before the integration tests. + 'test/test/util/cml-cmcd/*.js', + // bootstrapping for the test suite last; this will load the actual tests 'test/test/boot.js', @@ -309,6 +321,9 @@ module.exports = (config) => { {pattern: 'test/test/assets/lcevc-sei-ts/*', included: false}, {pattern: 'test/test/assets/lcevc-dual-track/*', included: false}, {pattern: 'dist/shaka-player.experimental.js', included: false}, + {pattern: 'dist/shaka-player.transmuxer-worker.js', included: false}, + // eslint-disable-next-line @stylistic/max-len + {pattern: 'dist/shaka-player.transmuxer-worker.debug.js', included: false}, {pattern: 'dist/locales.js', included: false}, {pattern: 'demo/**/*.js', included: false}, {pattern: 'demo/locales/en.json', included: false}, diff --git a/lib/cast/cast_utils.js b/lib/cast/cast_utils.js index 4536cdd879..bd6a2eba4b 100644 --- a/lib/cast/cast_utils.js +++ b/lib/cast/cast_utils.js @@ -536,6 +536,11 @@ shaka.cast.CastUtils.AdManagerEvents = [ 'ad-content-pause-requested', 'ad-content-resume-requested', 'ad-content-attach-requested', + 'ad-playing', + 'ad-break-started', + 'ad-break-ended', + 'ad-interstitial-preload', + 'ad-interstitial-preloaded', ]; diff --git a/lib/debug/log.js b/lib/debug/log.js index bac380df55..1063ae24fe 100644 --- a/lib/debug/log.js +++ b/lib/debug/log.js @@ -134,7 +134,9 @@ shaka.log.MAX_LOG_LEVEL = 3; shaka.log.oneTimeWarningIssued_ = new Set(); -if (window.console) { +// Use `self` instead of `window` so this code works in both the main thread +// and Web Workers (where `window` is undefined). +if (self.console) { /** @private {!Map} */ shaka.log.logMap_ = new Map() .set(shaka.log.Level.ERROR, (...args) => console.error(...args)) diff --git a/lib/device/abstract_device.js b/lib/device/abstract_device.js index 3f87aa3f2a..f6f55f4166 100644 --- a/lib/device/abstract_device.js +++ b/lib/device/abstract_device.js @@ -382,6 +382,13 @@ shaka.device.AbstractDevice = class { return null; } + /** + * @override + */ + supportsWorkerTransmux() { + return typeof Worker !== 'undefined'; + } + /** * @override */ diff --git a/lib/device/hisense.js b/lib/device/hisense.js index 9aad01de4b..42276d604c 100644 --- a/lib/device/hisense.js +++ b/lib/device/hisense.js @@ -91,6 +91,13 @@ shaka.device.Hisense = class extends shaka.device.AbstractDevice { return config; } + /** + * @override + */ + supportsWorkerTransmux() { + return false; + } + /** * @return {boolean} * @private diff --git a/lib/device/i_device.js b/lib/device/i_device.js index e26c7bfd94..ef99303df1 100644 --- a/lib/device/i_device.js +++ b/lib/device/i_device.js @@ -262,6 +262,15 @@ shaka.device.IDevice = class { * @return {?RemotePlayback} */ getRemote(video) {} + + /** + * Returns true if the platform reliably supports running transmuxing + * operations in a Web Worker. Devices with known Worker instability or + * broken Worker implementations should return false here. + * + * @return {boolean} + */ + supportsWorkerTransmux() {} }; /** diff --git a/lib/device/tizen.js b/lib/device/tizen.js index 2996936690..73189fcfcd 100644 --- a/lib/device/tizen.js +++ b/lib/device/tizen.js @@ -206,6 +206,18 @@ shaka.device.Tizen = class extends shaka.device.AbstractDevice { return this.getVersion() === 3; } + /** + * @override + */ + supportsWorkerTransmux() { + // Web Workers are unreliable on Tizen 2. + const version = this.getVersion(); + if (version !== null && version < 3) { + return false; + } + return super.supportsWorkerTransmux(); + } + /** * @return {boolean} * @private diff --git a/lib/device/webos.js b/lib/device/webos.js index 478e2ef613..c222bfde38 100644 --- a/lib/device/webos.js +++ b/lib/device/webos.js @@ -214,6 +214,18 @@ shaka.device.WebOS = class extends shaka.device.AbstractDevice { version >= 6 : super.supportsCbcsWithoutEncryptionSchemeSupport(); } + /** + * @override + */ + supportsWorkerTransmux() { + // Web Workers are unreliable on WebOS 3 and earlier. + const version = this.getVersion(); + if (version !== null && version < 4) { + return false; + } + return super.supportsWorkerTransmux(); + } + /** * @return {boolean} * @private diff --git a/lib/hls/hls_parser.js b/lib/hls/hls_parser.js index 7beca333f9..e63ff28f51 100644 --- a/lib/hls/hls_parser.js +++ b/lib/hls/hls_parser.js @@ -4835,7 +4835,7 @@ shaka.hls.HlsParser = class { // method. } else { references.push(reference); - if (isNewSegment) { + if (isNewSegment || !isNewStream) { newReferences.push(reference); } } diff --git a/lib/media/media_source_capabilities.js b/lib/media/media_source_capabilities.js index d52e158d26..182e155070 100644 --- a/lib/media/media_source_capabilities.js +++ b/lib/media/media_source_capabilities.js @@ -21,7 +21,9 @@ shaka.media.Capabilities = class { static isTypeSupported(type) { const supportMap = shaka.media.Capabilities.MediaSourceTypeSupportMap; return supportMap.getOrInsertComputed(type, () => { - const mediaSource = window.ManagedMediaSource || window.MediaSource; + // Use self instead of window so this works in Worker contexts + // (where window is not defined) as well as on the main thread. + const mediaSource = self.ManagedMediaSource || self.MediaSource; return mediaSource?.isTypeSupported(type) ?? false; }); } @@ -34,7 +36,8 @@ shaka.media.Capabilities = class { * @return {boolean} */ static isInfiniteLiveStreamDurationSupported() { - const mediaSource = window.ManagedMediaSource || window.MediaSource; + // Use self instead of window so this works in Worker contexts. + const mediaSource = self.ManagedMediaSource || self.MediaSource; // eslint-disable-next-line no-restricted-syntax if (mediaSource && mediaSource.prototype) { // eslint-disable-next-line no-restricted-syntax diff --git a/lib/media/media_source_engine.js b/lib/media/media_source_engine.js index 8792ecc7a0..f5f5d36f11 100644 --- a/lib/media/media_source_engine.js +++ b/lib/media/media_source_engine.js @@ -18,6 +18,7 @@ goog.require('shaka.media.SegmentReference'); goog.require('shaka.media.TimeRangesUtils'); goog.require('shaka.text.TextEngine'); goog.require('shaka.transmuxer.TransmuxerEngine'); +goog.require('shaka.transmuxer.TransmuxerProxy'); goog.require('shaka.util.ArrayUtils'); goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.Destroyer'); @@ -33,7 +34,6 @@ goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4BoxParsers'); goog.require('shaka.util.Mp4Parser'); goog.require('shaka.util.NumberUtils'); -goog.require('shaka.util.StreamUtils'); goog.require('shaka.util.TimeUtils'); goog.require('shaka.util.TsParser'); goog.require('shaka.lcevc.Dec'); @@ -388,7 +388,7 @@ shaka.media.MediaSourceEngine = class { support[type] = true; } else if (device.supportsMediaSource()) { const baseMimeType = MimeUtils.getBasicType(type); - const codecs = shaka.util.StreamUtils.getCorrectAudioCodecs( + const codecs = MimeUtils.getCorrectAudioCodecs( MimeUtils.getCodecs(type), baseMimeType); const newType = MimeUtils.getFullType(baseMimeType, codecs); support[type] = shaka.media.Capabilities.isTypeSupported(newType) || @@ -574,13 +574,13 @@ shaka.media.MediaSourceEngine = class { const ContentType = shaka.util.ManifestParserUtils.ContentType; if (contentType == ContentType.AUDIO && codecs) { - codecs = shaka.util.StreamUtils.getCorrectAudioCodecs( + codecs = shaka.util.MimeUtils.getCorrectAudioCodecs( codecs, stream.mimeType); } if (contentType == ContentType.VIDEO && codecs && stream.mimeType == 'video/mp4') { - codecs = shaka.util.StreamUtils.getCorrectVideoCodecs(codecs); + codecs = shaka.util.MimeUtils.getCorrectVideoCodecs(codecs); } let mimeType = shaka.util.MimeUtils.getFullType( @@ -611,7 +611,8 @@ shaka.media.MediaSourceEngine = class { const transmuxerPlugin = shaka.transmuxer.TransmuxerEngine .findTransmuxer(mimeTypeWithAllCodecs); if (transmuxerPlugin) { - const transmuxer = transmuxerPlugin(); + const transmuxer = new shaka.transmuxer.TransmuxerProxy( + transmuxerPlugin(), this.config_.transmuxWorkerUrl); this.transmuxers_.set(contentType, transmuxer); mimeType = transmuxer.convertCodecs(contentType, mimeTypeWithAllCodecs); @@ -2505,7 +2506,8 @@ shaka.media.MediaSourceEngine = class { const transmuxerPlugin = TransmuxerEngine.findTransmuxer(newMimeTypeWithAllCodecs); if (transmuxerPlugin) { - transmuxer = transmuxerPlugin(); + transmuxer = new shaka.transmuxer.TransmuxerProxy( + transmuxerPlugin(), this.config_.transmuxWorkerUrl); if (audioCodec && videoCodec) { transmuxerMuxed = true; } diff --git a/lib/net/networking_engine.js b/lib/net/networking_engine.js index 87ee1665f3..b50b6696d1 100644 --- a/lib/net/networking_engine.js +++ b/lib/net/networking_engine.js @@ -890,6 +890,7 @@ shaka.net.NetworkingEngine.RequestType = { 'CMCD': 9, 'SESSION_DATA': 10, 'FINGERPRINT': 11, + 'PLAYLIST': 12, }; /** diff --git a/lib/player.js b/lib/player.js index 777bd73ec8..69d9568f36 100644 --- a/lib/player.js +++ b/lib/player.js @@ -5322,6 +5322,9 @@ shaka.Player = class extends shaka.util.FakeEventTarget { return; } + const prevRealRate = this.video_.playbackRate; + const prevRate = this.playRateController_.getRealRate(); + this.playRateController_.set(rate); if (this.loadMode_ == shaka.Player.LoadMode.MEDIA_SOURCE) { @@ -5329,6 +5332,21 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.useTrickPlayTrackIfAvailable(useTrickPlayTrack && rate != 1); } this.setupTrickPlayEventListeners_(rate); + + // When trick play falls back to manual seeking (for example, rewind or + // unsupported playback rates), PlayRateController keeps the media element + // playbackRate at 0 and advances the playhead with a timer instead. + // + // Because the underlying media element playbackRate remains unchanged, + // the browser will not fire a native 'ratechange' event in this case. + // + // Invoke onRateChange_() manually so the player can run the normal + // playback-rate update flow and dispatch the corresponding RateChange + // event for the effective trick play rate change. + if (prevRealRate === 0 && this.video_.playbackRate === 0 && + rate != prevRate) { + this.onRateChange_(); + } } /** diff --git a/lib/queue/queue_manager.js b/lib/queue/queue_manager.js index c3e3308bbc..29fdcd844b 100644 --- a/lib/queue/queue_manager.js +++ b/lib/queue/queue_manager.js @@ -9,11 +9,14 @@ goog.provide('shaka.queue.QueueManager'); goog.require('goog.asserts'); goog.require('shaka.Player'); goog.require('shaka.config.RepeatMode'); +goog.require('shaka.log'); +goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.Error'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.FakeEventTarget'); goog.require('shaka.util.IDestroyable'); +goog.require('shaka.util.StringUtils'); goog.require('shaka.util.Timer'); goog.requireType('shaka.media.PreloadManager'); @@ -446,6 +449,205 @@ shaka.queue.QueueManager = class extends shaka.util.FakeEventTarget { ...chapterPromises, ]); } + + /** + * @param {string} url + * @param {boolean=} playOnLoad + * @return {!Promise} + * @override + * @export + */ + async loadFromM3uPlaylist(url, playOnLoad = false) { + goog.asserts.assert(this.player_, 'We should have player'); + + const networkingEngine = this.player_.getNetworkingEngine(); + goog.asserts.assert( + networkingEngine, 'We should have networking engine'); + + // Use manifest retry parameters — the playlist is a manifest-like + // resource and shares the same back-off / retry configuration. + const retryParams = + this.player_.getConfiguration().manifest.retryParameters; + + const request = shaka.net.NetworkingEngine.makeRequest( + [url], retryParams); + + // RequestType.PLAYLIST ensures the right request filters and + // credentials are applied for playlist-type resources. + const response = await networkingEngine.request( + shaka.net.NetworkingEngine.RequestType.PLAYLIST, + request).promise; + + const text = shaka.util.StringUtils.fromUTF8(response.data); + const items = shaka.queue.QueueManager.parseM3uPlaylist_(text); + + if (items.length === 0) { + shaka.log.warning( + 'QueueManager: No playable entries found in the M3U playlist.', + url); + return; + } + + this.insertItems(items); + + if (playOnLoad) { + await this.playItem(0); + } + } + + /** + * Parses raw M3U/M3U8 playlist text and returns an array of + * {@link shaka.extern.QueueItem} objects ready to be inserted into the + * queue. + * + * Only lines that follow an #EXTINF directive are treated as stream URLs; + * bare URL-only playlists (no #EXTM3U / #EXTINF) are also supported — + * every non-comment, non-empty line is treated as a stream URI with no + * metadata in that case. + * + * @param {string} text + * @return {!Array} + * @private + */ + static parseM3uPlaylist_(text) { + const lines = text.split(/\r?\n/); + + if (!lines[0]?.trim().startsWith('#EXTM3U')) { + shaka.log.warning( + 'QueueManager: Playlist does not start with #EXTM3U; ' + + 'attempting to parse anyway.'); + } + + /** @type {!Array} */ + const items = []; + + /** + * Used to avoid duplicated channels. + * We dedupe primarily by tvg-id. + * @type {!Set} + */ + const seenChannelIds = new Set(); + + /** @type {?shaka.extern.QueueItemMetadata} */ + let pendingMetadata = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip blank lines and header. + if (!trimmed || trimmed === '#EXTM3U') { + continue; + } + + // Parse metadata. + if (trimmed.startsWith('#EXTINF:')) { + pendingMetadata = + shaka.queue.QueueManager.parseExtInf_(trimmed); + continue; + } + + // Ignore unsupported directives. + if (trimmed.startsWith('#')) { + continue; + } + + // This is a stream URL. + const manifestUri = trimmed; + + // Skip duplicated channels by tvg-id. + const tvgId = pendingMetadata?.['tvg-id']; + + if (tvgId && seenChannelIds.has(tvgId)) { + shaka.log.debug('QueueManager: Skipping duplicated channel:', + tvgId, manifestUri); + continue; + } + + /** @type {shaka.extern.QueueItem} */ + const item = { + manifestUri, + preloadManager: null, + startTime: null, + mimeType: null, + config: null, + extraText: null, + extraThumbnail: null, + extraChapter: null, + metadata: pendingMetadata, + }; + + items.push(item); + + if (tvgId) { + seenChannelIds.add(tvgId); + } + + pendingMetadata = null; + } + + return items; + } + + /** + * Parses a single #EXTINF line and extracts its attributes together with + * the human-readable display name that follows the last comma. + * + * Expected format (newlines added here for readability only): + * #EXTINF:-1 tvg-id="ch1" tvg-name="Channel 1" + * tvg-logo="https://example.com/logo.png" + * tvg-language="English" tvg-country="US" + * group-title="News",Channel 1 Display Name + * + * All key="value" attributes are copied directly into the returned + * metadata object using their original attribute names. Unknown + * attributes are preserved automatically. + * + * The standard QueueItemMetadata fields are also populated: + * - title <- tvg-name or display title + * - poster <- tvg-logo + * + * Attribute parsing is intentionally lenient: malformed or unsupported + * attributes are ignored, and missing attributes are omitted rather than + * set to empty strings. + * + * @param {string} line A single #EXTINF line from the playlist. + * @return {!shaka.extern.QueueItemMetadata} + * @private + */ + static parseExtInf_(line) { + // The human-readable channel name follows the *last* comma in the line. + // Everything before that comma contains the duration and attributes. + const commaIndex = line.lastIndexOf(','); + const attrsPart = + commaIndex >= 0 ? line.substring(0, commaIndex) : line; + const displayTitle = + commaIndex >= 0 ? line.substring(commaIndex + 1).trim() : ''; + + const attrRegex = /([\w-]+)="([^"]*)"/g; + + /** @type {!Object} */ + const attrs = {}; + let match; + + while ((match = attrRegex.exec(attrsPart)) !== null) { + attrs[match[1]] = match[2]; + } + + /** @type {!shaka.extern.QueueItemMetadata} */ + const metadata = { + ...attrs, + title: attrs['tvg-name'] || displayTitle || undefined, + poster: attrs['tvg-logo'] || undefined, + }; + + // Always preserve the raw display title even when tvg-name is present, + // so callers can distinguish between the two if needed. + if (displayTitle) { + metadata['displayTitle'] = displayTitle; + } + + return metadata; + } }; diff --git a/lib/text/native_text_displayer.js b/lib/text/native_text_displayer.js index 6b72950b9a..ba7a8c7abf 100644 --- a/lib/text/native_text_displayer.js +++ b/lib/text/native_text_displayer.js @@ -38,6 +38,9 @@ shaka.text.NativeTextDisplayer = class { /** @private {shaka.util.EventManager} */ this.eventManager_ = new shaka.util.EventManager(); + /** @private {shaka.util.EventManager} */ + this.loadEventManager_ = new shaka.util.EventManager(); + /** @private {?shaka.extern.TextDisplayerConfiguration} */ this.config_ = null; @@ -47,156 +50,23 @@ shaka.text.NativeTextDisplayer = class { /** @private {Map} */ this.trackNodes_ = new Map(); - /** @private {number} */ + /** + * ID of the currently active text track. -1 means no track is active. + * @private {number} + */ this.trackId_ = -1; /** @private {boolean} */ this.visible_ = false; - /** @private {?shaka.util.Timer} */ + /** + * Timer used to debounce the textTracks 'change' event. + * @private {?shaka.util.Timer} + */ this.timer_ = null; - /** @private */ - this.onUnloading_ = () => { - this.timer_?.stop(); - this.timer_ = null; - this.eventManager_.unlisten(this.player_, - shaka.util.FakeEvent.EventName.TextChanged, this.onTextChanged_); - this.eventManager_.unlisten(this.video_.textTracks, 'change', - this.onChange_); - for (const trackNode of this.trackNodes_.values()) { - trackNode.remove(); - } - this.trackNodes_.clear(); - this.trackId_ = -1; - this.video_ = null; - }; - - /** @private */ - this.onTextChanged_ = () => { - /** @type {Map} */ - const newTrackNodes = new Map(); - const tracks = this.player_.getTextTracks(); - for (const track of tracks) { - let trackNode; - if (this.trackNodes_.has(track.id)) { - trackNode = this.trackNodes_.get(track.id); - if (!track.active && trackNode.track.mode !== 'disabled') { - trackNode.track.mode = 'disabled'; - } - this.trackNodes_.delete(track.id); - } else { - trackNode = /** @type {!HTMLTrackElement} */ - (this.video_.ownerDocument.createElement('track')); - trackNode.kind = shaka.text.NativeTextDisplayer.getTrackKind_(track); - trackNode.label = - shaka.text.NativeTextDisplayer.getTrackLabel_(track); - if (track.language) { - if (track.language in mozilla.LanguageMapping) { - trackNode.srclang = track.language; - } else { - const base = shaka.util.LanguageUtils.getBase(track.language); - trackNode.srclang = base ?? 'und'; - } - } else { - trackNode.srclang = 'und'; - } - // The built-in captions menu in Chrome may refuse to list invalid - // subtitles. - // In Safari, when a TextTrack's mode is set to "disabled" or "hidden" - // and then "showing" again causes a blink if there is no src. - // The data URL is just to avoid this. - trackNode.src = 'data:,WEBVTT'; - trackNode.track.mode = 'disabled'; - this.video_.appendChild(trackNode); - } - newTrackNodes.set(track.id, trackNode); - if (track.active) { - this.trackId_ = track.id; - } - } - // Remove all tracks that are not in the new list. - for (const trackNode of this.trackNodes_.values()) { - trackNode.remove(); - } - if (this.trackId_ > -1) { - if (!newTrackNodes.has(this.trackId_)) { - this.trackId_ = -1; - } else { - // enable current track after everything else is settled - const track = newTrackNodes.get(this.trackId_).track; - // Ignore if the mode is not disabled. Maybe the user has changed the - // mode manually. In that case, visible_ will be updated in onChange_ - if (track.mode === 'disabled') { - track.mode = this.visible_ ? 'showing' : 'hidden'; - } - } - } - this.trackNodes_ = newTrackNodes; - }; - - /** @private */ - this.onChange_ = () => { - // The change event may fire multiple times consecutively. So we need to - // use a timer to ensure the real task runs only once. - if (this.timer_) { - return; - } - const video = this.video_; - this.timer_ = new shaka.util.Timer(() => { - this.timer_ = null; - if (this.video_ !== video) { - return; - } - let trackId = -1; - let found = false; - // Prefer previously selected track. - if (this.trackNodes_.has(this.trackId_)) { - const trackNode = this.trackNodes_.get(this.trackId_); - if (trackNode.track.mode === 'showing') { - trackId = this.trackId_; - found = true; - } else if (trackNode.track.mode === 'hidden') { - trackId = this.trackId_; - } - } - if (!found) { - for (const [ - /** @type {number} */id, - /** @type {HTMLTrackElement} */trackNode, - ] of /** @type {!Map} */(this.trackNodes_)) { - if (trackNode.track.mode === 'showing') { - trackId = id; - break; - } else if (trackId < 0 && trackNode.track.mode === 'hidden') { - // If there is no showing track, we can use the hidden track - trackId = id; - } - } - } - for (const [ - /** @type {number} */id, - /** @type {HTMLTrackElement} */trackNode, - ] of /** @type {!Map} */(this.trackNodes_)) { - // Avoid triggering unnecessary change events. - if (id !== trackId && trackNode.track.mode !== 'disabled') { - trackNode.track.mode = 'disabled'; - } - } - if (this.trackId_ !== trackId) { - this.trackId_ = trackId; - if (trackId > -1) { - this.player_.selectTextTrack( - /** @type {!shaka.extern.TextTrack} */({id: trackId})); - } else { - this.player_.selectTextTrack(); - } - } - }).tickAfter(0); - }; - - this.eventManager_.listen(player, shaka.util.FakeEvent.EventName.Loaded, - () => this.checkMsePlayback_()); + this.eventManager_.listen(player, + shaka.util.FakeEvent.EventName.Loaded, () => this.checkMsePlayback_()); this.checkMsePlayback_(); } @@ -210,41 +80,49 @@ shaka.text.NativeTextDisplayer = class { } /** + * Removes cues whose time range overlaps with [start, end). + * Returns false only if this instance has already been destroyed. + * * @override * @export */ remove(start, end) { - // Should return false only if this instance is destroyed if (!this.player_) { return false; - } else if (this.trackNodes_.has(this.trackId_)) { + } + + const activeTrack = this.getActiveTrack_(); + if (activeTrack) { shaka.text.Utils.removeCuesFromTextTrack( - this.trackNodes_.get(this.trackId_).track, - (cue) => cue.startTime < end && cue.endTime > start); + activeTrack, (cue) => cue.startTime < end && cue.endTime > start); } return true; } /** + * Appends cues to the active track, applying the subtitle delay if set. + * * @override * @export */ append(cues) { - if (this.trackNodes_.has(this.trackId_)) { - const delay = this.config_?.subtitleDelay ?? 0; - let adjustedCues = cues; - if (delay != 0) { - adjustedCues = cues.map((cue) => { - const newCue = cue.clone(); - newCue.startTime = Math.max(0, newCue.startTime + delay); - newCue.endTime = Math.max(0, newCue.endTime + delay); - return newCue; - }); - } - shaka.text.Utils.appendCuesToTextTrack( - this.trackNodes_.get(this.trackId_).track, adjustedCues); + const activeTrack = this.getActiveTrack_(); + if (!activeTrack) { + return; } + + const delay = this.config_?.subtitleDelay ?? 0; + const adjustedCues = delay !== 0 ? + cues.map((cue) => { + const shifted = cue.clone(); + shifted.startTime = Math.max(0, shifted.startTime + delay); + shifted.endTime = Math.max(0, shifted.endTime + delay); + return shifted; + }) : + cues; + + shaka.text.Utils.appendCuesToTextTrack(activeTrack, adjustedCues); } /** @@ -258,10 +136,14 @@ shaka.text.NativeTextDisplayer = class { } this.player_ = null; } + this.timer_?.stop(); this.timer_ = null; this.eventManager_?.release(); this.eventManager_ = null; + this.loadEventManager_?.release(); + this.loadEventManager_ = null; + return Promise.resolve(); } @@ -274,79 +156,392 @@ shaka.text.NativeTextDisplayer = class { } /** + * Shows or hides subtitles. Handles both MSE and SRC_EQUALS playback modes. + * * @override * @export */ setTextVisibility(on) { this.visible_ = on; - if (this.trackNodes_.has(this.trackId_)) { - const textTrack = this.trackNodes_.get(this.trackId_).track; - if (textTrack.mode !== 'disabled') { - const mode = on ? 'showing' : 'hidden'; - if (textTrack.mode !== mode) { - textTrack.mode = mode; - } + + const activeTrack = this.getActiveTrack_(); + if (activeTrack) { + this.applyVisibilityToTrack_(activeTrack, on); + return; + } + + if (this.isSrcEqualsMode_()) { + this.applyVisibilityToSrcEqualsTracks_(on); + } + } + + /** + * @override + * @export + */ + setTextLanguage(_language) { + // unused + } + + /** + * Cleans up internal state when the player starts unloading content. + * Registered with listenOnce, so it fires at most once per playback session. + * + * @private + */ + onUnloading_() { + this.timer_?.stop(); + this.timer_ = null; + + this.loadEventManager_?.removeAll(); + + for (const trackNode of this.trackNodes_.values()) { + trackNode.remove(); + } + this.trackNodes_.clear(); + this.trackId_ = -1; + this.video_ = null; + } + + /** + * Synchronises the DOM elements with the player's track list. + * Creates elements for new tracks, reuses existing ones, and removes + * any that are no longer present. + * + * @private + */ + onTextChanged_() { + /** @type {Map} */ + const newTrackNodes = new Map(); + const tracks = this.player_.getTextTracks(); + + for (const track of tracks) { + const trackNode = this.trackNodes_.has(track.id) ? + this.reuseTrackNode_(track) : this.createTrackNode_(track); + + newTrackNodes.set(track.id, trackNode); + + if (track.active) { + this.trackId_ = track.id; } - } else if (this.player_ && this.player_.getLoadMode() === 3) { - // shaka.Player.LoadMode.SRC_EQUALS - const textTracks = Array.from(this.player_.getMediaElement().textTracks) - .filter((track) => - ['captions', 'subtitles', 'forced'].includes(track.kind)); - if (on) { - let toShow = null; - for (const track of textTracks) { - if (track.mode === 'showing') { - // One showing track is just enough. - toShow = null; - break; - } else if (!toShow && track.mode === 'hidden') { - toShow = track; - } - } - if (toShow) { - toShow.mode = 'showing'; + } + + // Remove from the DOM any tracks no longer in the player's list. + for (const trackNode of this.trackNodes_.values()) { + trackNode.remove(); + } + + this.trackNodes_ = newTrackNodes; + this.activateCurrentTrack_(); + } + + /** + * Handles manual changes to the video's textTracks (e.g. the user enables a + * track through the browser's native subtitle menu). Applies debounce because + * the 'change' event can fire multiple times in quick succession. + * + * @private + */ + onChange_() { + if (this.timer_) { + // A tick is already queued; the debounce absorbs additional events. + return; + } + + // Snapshot the current video reference so we can detect if it changes + // while the timer is pending (e.g. an unload happens in the meantime). + const videoSnapshot = this.video_; + + this.timer_ = new shaka.util.Timer(() => { + this.timer_ = null; + + if (this.video_ !== videoSnapshot) { + return; + } + + const resolvedTrackId = this.resolveActiveTrackId_(); + this.disableAllTracksExcept_(resolvedTrackId); + + if (this.trackId_ !== resolvedTrackId) { + this.trackId_ = resolvedTrackId; + this.syncTrackSelectionWithPlayer_(resolvedTrackId); + } + }).tickAfter(0); + } + + /** + * Returns the active TextTrack, or null if none is currently active. + * + * @return {?TextTrack} + * @private + */ + getActiveTrack_() { + return this.trackNodes_.has(this.trackId_) ? + this.trackNodes_.get(this.trackId_).track : null; + } + + /** + * Applies the visibility mode to a specific track without touching tracks + * that are already 'disabled' (e.g. manually turned off by the user). + * + * @param {TextTrack} track + * @param {boolean} visible + * @private + */ + applyVisibilityToTrack_(track, visible) { + if (track.mode === 'disabled') { + return; + } + const targetMode = visible ? 'showing' : 'hidden'; + if (track.mode !== targetMode) { + track.mode = targetMode; + } + } + + /** + * Returns true if the player is currently in SRC_EQUALS mode. + * + * @return {boolean} + * @private + */ + isSrcEqualsMode_() { + if (!this.player_) { + return false; + } + const LoadMode = shaka.text.NativeTextDisplayer.LoadMode; + return this.player_.getLoadMode() === LoadMode.SRC_EQUALS; + } + + /** + * Manages subtitle visibility in SRC_EQUALS mode, where tracks are controlled + * directly by the HTMLMediaElement rather than MSE. + * + * @param {boolean} on + * @private + */ + applyVisibilityToSrcEqualsTracks_(on) { + const textTracks = Array.from(this.player_.getMediaElement().textTracks) + .filter((track) => + ['captions', 'subtitles', 'forced'].includes(track.kind)); + + if (on) { + // If a track is already 'showing', do nothing to avoid disrupting state. + const alreadyShowing = textTracks.some((t) => t.mode === 'showing'); + if (!alreadyShowing) { + const firstHidden = textTracks.find((t) => t.mode === 'hidden'); + if (firstHidden) { + firstHidden.mode = 'showing'; } - } else { - for (const track of textTracks) { - if (track.mode === 'showing') { - track.mode = 'hidden'; - } + } + } else { + for (const track of textTracks) { + if (track.mode === 'showing') { + track.mode = 'hidden'; } } } } /** - * @override - * @export + * Reuses an existing DOM node for a known track. + * Disables the node if the track is no longer active, and removes the entry + * from the original map so that onTextChanged_ can detect orphaned nodes. + * + * @param {!shaka.extern.TextTrack} track + * @return {!HTMLTrackElement} + * @private */ - setTextLanguage(language) { - // unused + reuseTrackNode_(track) { + const trackNode = this.trackNodes_.get(track.id); + if (!track.active && trackNode.track.mode !== 'disabled') { + trackNode.track.mode = 'disabled'; + } + this.trackNodes_.delete(track.id); + return trackNode; + } + + /** + * Creates a new DOM element for the given track and appends it to + * the video element. + * + * @param {!shaka.extern.TextTrack} track + * @return {!HTMLTrackElement} + * @private + */ + createTrackNode_(track) { + const trackNode = /** @type {!HTMLTrackElement} */ ( + this.video_.ownerDocument.createElement('track')); + + trackNode.kind = this.getTrackKind_(track); + trackNode.label = this.getTrackLabel_(track); + trackNode.srclang = this.resolveTrackLanguage_(track); + + // Chrome may refuse to list tracks without a src in its built-in caption + // menu. In Safari, toggling a track from 'disabled'/'hidden' back to + // 'showing' without a src causes a visible flash. The minimal WEBVTT data + // URL prevents both issues. + trackNode.src = 'data:,WEBVTT'; + trackNode.track.mode = 'disabled'; + + this.video_.appendChild(trackNode); + return trackNode; + } + + /** + * Resolves the appropriate srclang value for a track based on its declared + * language. Falls back to 'und' (undetermined) if the language is unknown. + * + * @param {!shaka.extern.TextTrack} track + * @return {string} + * @private + */ + resolveTrackLanguage_(track) { + if (!track.language) { + return 'und'; + } + if (track.language in mozilla.LanguageMapping) { + return track.language; + } + return shaka.util.LanguageUtils.getBase(track.language) ?? 'und'; + } + + /** + * Activates the track identified by this.trackId_ among the newly built + * nodes, respecting the current mode if it was changed manually by the user. + * + * @private + */ + activateCurrentTrack_() { + if (this.trackId_ <= -1) { + return; + } + + if (!this.trackNodes_.has(this.trackId_)) { + this.trackId_ = -1; + return; + } + + const track = this.trackNodes_.get(this.trackId_).track; + // Only update the mode when the track is 'disabled'. If the user changed + // it manually (e.g. hid it), we respect that choice; onChange_ will update + // visible_ accordingly. + if (track.mode === 'disabled') { + track.mode = this.visible_ ? 'showing' : 'hidden'; + } } /** + * Determines which track should be active after a 'change' event. + * Prefers the previously selected track; otherwise picks the first 'showing' + * track, and falls back to the first 'hidden' track. + * + * @return {number} The ID of the track to activate, or -1 if none. + * @private + */ + resolveActiveTrackId_() { + let trackId = -1; + + // Prefer the previously active track. + if (this.trackNodes_.has(this.trackId_)) { + const mode = this.trackNodes_.get(this.trackId_).track.mode; + if (mode === 'showing') { + return this.trackId_; + } + if (mode === 'hidden') { + trackId = this.trackId_; + } + } + + // Fallback: find any 'showing' track, or the first 'hidden' one. + for (const id of this.trackNodes_.keys()) { + const trackNode = /** @type {!HTMLTrackElement} */ ( + this.trackNodes_.get(id)); + if (trackNode.track.mode === 'showing') { + return id; + } + if (trackId < 0 && trackNode.track.mode === 'hidden') { + trackId = id; + } + } + + return trackId; + } + + /** + * Sets all tracks except the specified one to 'disabled', avoiding + * unnecessary change events on tracks that are already disabled. + * + * @param {number} keepTrackId + * @private + */ + disableAllTracksExcept_(keepTrackId) { + const keepNode = this.trackNodes_.get(keepTrackId); + for (const trackNode of this.trackNodes_.values()) { + if (trackNode !== keepNode && trackNode.track.mode !== 'disabled') { + trackNode.track.mode = 'disabled'; + } + } + } + + /** + * Notifies the player of the newly selected track, or clears the selection + * if trackId is -1. + * + * @param {number} trackId + * @private + */ + syncTrackSelectionWithPlayer_(trackId) { + if (trackId > -1) { + const textTrack = + this.player_.getTextTracks().find((t) => t.id === trackId); + if (textTrack) { + this.player_.selectTextTrack(textTrack); + return; + } + } + this.player_.selectTextTrack(null); + } + + /** + * Initialises MSE integration if the player is already in MEDIA_SOURCE mode. + * Called from the constructor and again on each 'Loaded' event. + * * @private */ checkMsePlayback_() { - // shaka.Player.LoadMode.MEDIA_SOURCE - if (!this.video_ && this.player_ && this.player_.getLoadMode() === 2) { - this.video_ = this.player_.getMediaElement(); - this.eventManager_.listenOnce(this.player_, - shaka.util.FakeEvent.EventName.Unloading, this.onUnloading_); - this.eventManager_.listen(this.player_, - shaka.util.FakeEvent.EventName.TextChanged, this.onTextChanged_); - this.eventManager_.listen(this.video_.textTracks, 'change', - this.onChange_); - this.onTextChanged_(); + if (this.video_ || !this.player_) { + return; + } + + const LoadMode = shaka.text.NativeTextDisplayer.LoadMode; + if (this.player_.getLoadMode() !== LoadMode.MEDIA_SOURCE) { + return; } + + this.video_ = this.player_.getMediaElement(); + + const EventName = shaka.util.FakeEvent.EventName; + + this.eventManager_.listenOnce(this.player_, + EventName.Unloading, () => this.onUnloading_()); + this.loadEventManager_.listen(this.player_, + EventName.TextChanged, () => this.onTextChanged_()); + this.loadEventManager_.listen(this.video_.textTracks, + 'change', () => this.onChange_()); + + this.onTextChanged_(); } /** + * Returns the appropriate `kind` value for a element. + * WebKit requires the 'forced' kind for forced tracks; other browsers use + * 'captions' for closed captions and 'subtitles' as the default. + * * @param {!shaka.extern.TextTrack} track * @return {string} * @private */ - static getTrackKind_(track) { + getTrackKind_(track) { const device = shaka.device.DeviceFactory.getDevice(); if (track.forced && device.getBrowserEngine() === shaka.device.IDevice.BrowserEngine.WEBKIT) { @@ -360,33 +555,74 @@ shaka.text.NativeTextDisplayer = class { } /** + * Builds a human-readable label for a track. Priority order: + * 1. track.label (if explicitly set) + * 2. Intl.DisplayNames resolution (when available) + * 3. Full language name from LanguageMapping (exact match) + * 4. Base language name from LanguageMapping with variant in parentheses + * 5. originalTextId with the language code in parentheses if they differ + * * @param {!shaka.extern.TextTrack} track * @return {string} * @private */ - static getTrackLabel_(track) { - /** @type {string} */ - let label; + getTrackLabel_(track) { if (track.label) { - label = track.label; - } else if (track.language) { - if (track.language in mozilla.LanguageMapping) { - label = mozilla.LanguageMapping[track.language]; - } else { - const language = shaka.util.LanguageUtils.getBase(track.language); - if (language in mozilla.LanguageMapping) { - label = - `${mozilla.LanguageMapping[language]} (${track.language})`; + return track.label; + } + + if (track.language) { + const base = shaka.util.LanguageUtils.getBase(track.language); + + // 1. Intl.DisplayNames — preferred when available: provides OS-level + // resolution for any valid BCP-47 tag in the user's UI locale without + // relying on a hand-maintained mapping. + if (window.Intl && 'DisplayNames' in Intl) { + try { + const displayNames = new Intl.DisplayNames(track.language, + {type: 'language', languageDisplay: 'standard'}); + const displayName = displayNames.of(track.language); + // Only prefer it when it's reliable + if (displayName && + displayName.toLowerCase() != track.language.toLowerCase()) { + return displayName.charAt(0).toUpperCase() + displayName.slice(1); + } + } catch (_e) { + // Intl.DisplayNames may throw for malformed tags; fall through. } } - } - if (!label) { - label = /** @type {string} */(track.originalTextId); - if (track.language && track.language !== track.originalTextId) { - label += ` (${track.language})`; + + // 2. Exact match in mozilla.LanguageMapping. + const exactMatch = mozilla.LanguageMapping[track.language]; + if (exactMatch) { + return exactMatch; + } + + // 3. Base-language match in mozilla.LanguageMapping, with the full tag + // shown in parentheses so the variant is still visible to the user. + const baseMatch = base && mozilla.LanguageMapping[base]; + if (baseMatch) { + return base === track.language ? + baseMatch : `${baseMatch} (${track.language})`; } } - return label; + // Last resort: use originalTextId, coercing nullish values to an empty + // string. + const fallback = String(track.originalTextId ?? ''); + if (track.language && track.language !== track.originalTextId) { + return `${fallback} (${track.language})`; + } + + return fallback; } }; + +/** + * Named constants mirroring shaka.Player.LoadMode to avoid magic numbers. + * @enum {number} + */ +shaka.text.NativeTextDisplayer.LoadMode = { + MEDIA_SOURCE: 2, + SRC_EQUALS: 3, +}; diff --git a/lib/transmuxer/loc_transmuxer.js b/lib/transmuxer/loc_transmuxer.js index 69ab3f3b29..6e90dc99ae 100644 --- a/lib/transmuxer/loc_transmuxer.js +++ b/lib/transmuxer/loc_transmuxer.js @@ -17,7 +17,6 @@ goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4Generator'); -goog.require('shaka.util.StreamUtils'); goog.requireType('shaka.media.SegmentReference'); @@ -146,12 +145,12 @@ shaka.transmuxer.LocTransmuxer = class { convertCodecs(contentType, mimeType) { if (this.isLocContainer_(mimeType)) { const ContentType = shaka.util.ManifestParserUtils.ContentType; - const StreamUtils = shaka.util.StreamUtils; - const codecs = shaka.util.MimeUtils.getCodecs(mimeType).split(',') + const MimeUtils = shaka.util.MimeUtils; + const codecs = MimeUtils.getCodecs(mimeType).split(',') .map((codecs) => { - return StreamUtils.getCorrectAudioCodecs(codecs, 'audio/mp4'); + return MimeUtils.getCorrectAudioCodecs(codecs, 'audio/mp4'); }) - .map(StreamUtils.getCorrectVideoCodecs).join(','); + .map(MimeUtils.getCorrectVideoCodecs).join(','); if (contentType == ContentType.AUDIO) { return `audio/mp4; codecs="${codecs}"`; } diff --git a/lib/transmuxer/transmuxer_engine.js b/lib/transmuxer/transmuxer_engine.js index ad928d8964..6611061422 100644 --- a/lib/transmuxer/transmuxer_engine.js +++ b/lib/transmuxer/transmuxer_engine.js @@ -58,24 +58,45 @@ shaka.transmuxer.TransmuxerEngine = class { * @export */ static findTransmuxer(mimeType, contentType) { + const TransmuxerEngine = shaka.transmuxer.TransmuxerEngine; + for (const priority of TransmuxerEngine.PLUGIN_PRIORITY_ORDER) { + const plugin = TransmuxerEngine.findTransmuxerPlugin(mimeType, priority); + if (!plugin) { + continue; + } + const transmuxer = plugin(); + const isSupported = transmuxer.isSupported(mimeType, contentType); + transmuxer.destroy(); + if (isSupported) { + return plugin; + } + } + return null; + } + + /** + * Finds a plugin registered for |mimeType| without instantiating it or + * calling isSupported(). Used by the worker where MediaSource may not be + * available to validate support. + * + * When |priority| is null (default), returns the highest-priority plugin. + * When |priority| is specified, returns the plugin at that exact priority or + * null if none is registered at that level. + * + * @param {string} mimeType + * @param {?number=} priority + * @return {?shaka.extern.TransmuxerPlugin} + */ + static findTransmuxerPlugin(mimeType, priority = null) { const TransmuxerEngine = shaka.transmuxer.TransmuxerEngine; const normalizedMimetype = TransmuxerEngine.normalizeMimeType_(mimeType); - const priorities = [ - TransmuxerEngine.PluginPriority.APPLICATION, - TransmuxerEngine.PluginPriority.PREFERRED, - TransmuxerEngine.PluginPriority.PREFERRED_SECONDARY, - TransmuxerEngine.PluginPriority.FALLBACK, - ]; - for (const priority of priorities) { - const key = normalizedMimetype + '-' + priority; + const priorities = priority !== null ? + [priority] : TransmuxerEngine.PLUGIN_PRIORITY_ORDER; + for (const p of priorities) { + const key = normalizedMimetype + '-' + p; const object = TransmuxerEngine.transmuxerMap_.get(key); if (object) { - const transmuxer = object.plugin(); - const isSupported = transmuxer.isSupported(mimeType, contentType); - transmuxer.destroy(); - if (isSupported) { - return object.plugin; - } + return object.plugin; } } return null; @@ -159,3 +180,17 @@ shaka.transmuxer.TransmuxerEngine.PluginPriority = { 'APPLICATION': 4, }; + +/** + * Priorities in descending order (highest first), used when searching for + * a matching transmuxer plugin. + * + * @const {!Array} + */ +shaka.transmuxer.TransmuxerEngine.PLUGIN_PRIORITY_ORDER = [ + shaka.transmuxer.TransmuxerEngine.PluginPriority.APPLICATION, + shaka.transmuxer.TransmuxerEngine.PluginPriority.PREFERRED, + shaka.transmuxer.TransmuxerEngine.PluginPriority.PREFERRED_SECONDARY, + shaka.transmuxer.TransmuxerEngine.PluginPriority.FALLBACK, +]; + diff --git a/lib/transmuxer/transmuxer_proxy.js b/lib/transmuxer/transmuxer_proxy.js new file mode 100644 index 0000000000..fe1f9b78b4 --- /dev/null +++ b/lib/transmuxer/transmuxer_proxy.js @@ -0,0 +1,416 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.transmuxer.TransmuxerProxy'); + +goog.require('shaka.device.DeviceFactory'); +goog.require('shaka.log'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.Timer'); +goog.require('shaka.util.Uint8ArrayUtils'); + + +/** + * @summary A proxy transmuxer that delegates transmux() calls to a Web Worker. + * + * Synchronous methods (isSupported, convertCodecs, getOriginalMimeType) are + * handled on the main thread by the inner transmuxer. Only the heavy + * transmux() work is offloaded to the worker. + * + * The worker URL must be supplied by the integrating application via the + * `mediaSource.transmuxWorkerUrl` config option. The library does not attempt + * to discover it. If the URL is empty or the worker cannot be created, the + * proxy falls back to main-thread transmuxing. + * + * @implements {shaka.extern.Transmuxer} + * @export + */ +shaka.transmuxer.TransmuxerProxy = class { + /** + * @param {!shaka.extern.Transmuxer} innerTransmuxer + * The real transmuxer to use for sync methods and as fallback. + * @param {string=} workerUrl + * URL of the standalone transmuxer worker script. When empty, the proxy + * uses main-thread transmuxing. + */ + constructor(innerTransmuxer, workerUrl = '') { + /** @private {!shaka.extern.Transmuxer} */ + this.innerTransmuxer_ = innerTransmuxer; + + /** @private {string} */ + this.workerUrl_ = workerUrl; + + /** @private {boolean} */ + this.workerFailed_ = false; + + /** @private {number} */ + this.nextReqId_ = 0; + + /** + * Maps request IDs to pending promise resolvers and their timeout timers. + * @private {!Map} + */ + this.pendingRequests_ = new Map(); + + /** @private {number} */ + this.id_ = shaka.transmuxer.TransmuxerProxy.nextId_++; + + /** @private {boolean} */ + this.workerReady_ = false; + + /** @private {boolean} */ + this.attachedToWorker_ = false; + } + + /** + * @override + * @export + */ + destroy() { + // Reject all pending requests. + for (const pending of this.pendingRequests_.values()) { + pending.timer.stop(); + pending.reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.TRANSMUXING_FAILED, + 'Worker transmuxer destroyed')); + } + this.pendingRequests_.clear(); + + if (this.attachedToWorker_) { + const TransmuxerProxy = shaka.transmuxer.TransmuxerProxy; + if (TransmuxerProxy.sharedWorker_) { + TransmuxerProxy.sharedWorker_.postMessage( + {'cmd': 'destroy', 'id': this.id_}); + } + TransmuxerProxy.activeInstances_.delete(this.id_); + this.attachedToWorker_ = false; + + // Terminate the shared worker when no instances remain. + if (TransmuxerProxy.activeInstances_.size === 0 && + TransmuxerProxy.sharedWorker_) { + TransmuxerProxy.sharedWorker_.terminate(); + TransmuxerProxy.sharedWorker_ = null; + } + } + + this.innerTransmuxer_.destroy(); + } + + /** + * @param {string} mimeType + * @param {string=} contentType + * @return {boolean} + * @override + * @export + */ + isSupported(mimeType, contentType) { + return this.innerTransmuxer_.isSupported(mimeType, contentType); + } + + /** + * @param {string} contentType + * @param {string} mimeType + * @return {string} + * @override + * @export + */ + convertCodecs(contentType, mimeType) { + return this.innerTransmuxer_.convertCodecs(contentType, mimeType); + } + + /** + * @return {string} + * @override + * @export + */ + getOriginalMimeType() { + return this.innerTransmuxer_.getOriginalMimeType(); + } + + /** + * @override + * @export + */ + async transmux(data, stream, reference, duration, contentType) { + // If worker creation previously failed, fall back to main thread. + if (this.workerFailed_) { + return this.innerTransmuxer_.transmux( + data, stream, reference, duration, contentType); + } + + // Lazy-init: attach to the shared worker on first transmux call. + if (!this.attachedToWorker_) { + const TransmuxerProxy = shaka.transmuxer.TransmuxerProxy; + const worker = TransmuxerProxy.getOrCreateWorker_(this.workerUrl_); + if (!worker) { + this.workerFailed_ = true; + return this.innerTransmuxer_.transmux( + data, stream, reference, duration, contentType); + } + TransmuxerProxy.activeInstances_.set(this.id_, this); + this.attachedToWorker_ = true; + } + + const worker = shaka.transmuxer.TransmuxerProxy.sharedWorker_; + if (!worker) { + this.workerFailed_ = true; + return this.innerTransmuxer_.transmux( + data, stream, reference, duration, contentType); + } + + // Send init on first use so the worker creates the right transmuxer. + if (!this.workerReady_) { + const mimeType = this.innerTransmuxer_.getOriginalMimeType(); + worker.postMessage({ + 'cmd': 'init', + 'id': this.id_, + 'mimeType': mimeType, + }); + this.workerReady_ = true; + } + + const reqId = this.nextReqId_++; + + // Extract only the properties transmuxers actually read/write. + const streamProps = { + 'id': stream.id, + 'codecs': stream.codecs, + 'channelsCount': stream.channelsCount, + 'audioSamplingRate': stream.audioSamplingRate, + 'height': stream.height, + 'width': stream.width, + 'language': stream.language, + }; + + const refProps = reference ? { + 'discontinuitySequence': reference.discontinuitySequence, + 'startTime': reference.startTime, + 'endTime': reference.endTime, + 'uris': reference.getUris(), + } : null; + + // Copy the buffer before transferring so the original `data` stays valid. + // This is necessary because MediaSourceEngine may call transmux() twice + // with the same data (split muxed content: once for audio, once for video). + const buffer = shaka.util.BufferUtils.toArrayBuffer( + shaka.util.Uint8ArrayUtils.concat(data)); + + const {promise, resolve, reject} = Promise.withResolvers(); + const timer = new shaka.util.Timer(() => { + if (this.pendingRequests_.has(reqId)) { + this.pendingRequests_.delete(reqId); + this.workerFailed_ = true; + reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.TRANSMUXING_FAILED, + 'Worker transmux timed out')); + } + }); + timer.tickAfter(shaka.transmuxer.TransmuxerProxy.TIMEOUT_MS_ / 1000); + this.pendingRequests_.set(reqId, { + resolve, + reject, + timer, + }); + + try { + // Transfer the copied buffer to the worker for zero-copy delivery. + // The original data remains valid for any subsequent callers. + worker.postMessage({ + 'cmd': 'transmux', + 'id': this.id_, + 'reqId': reqId, + 'data': buffer, + 'streamProps': streamProps, + 'refProps': refProps, + 'duration': duration, + 'contentType': contentType, + }, [buffer]); + } catch (e) { + timer.stop(); + this.pendingRequests_.delete(reqId); + shaka.log.warning( + 'Failed to post message to worker, falling back to main thread', e); + const transmuxerProxy = shaka.transmuxer.TransmuxerProxy; + transmuxerProxy.terminateWorker_('Worker postMessage failed'); + return this.innerTransmuxer_.transmux( + data, stream, reference, duration, contentType); + } + + const response = await promise; + + // Apply stream mutations back to the real stream object. + const mutations = response['streamMutations']; + if (mutations && Object.keys(mutations).length > 0) { + for (const key of Object.keys(mutations)) { + stream[key] = mutations[key]; + } + } + + // Reconstruct the output. + const output = response['output']; + const BufferUtils = shaka.util.BufferUtils; + if (output['type'] === 'raw') { + return BufferUtils.toUint8( + /** @type {!ArrayBuffer} */(output['data'])); + } else { + return { + data: BufferUtils.toUint8( + /** @type {!ArrayBuffer} */(output['data'])), + init: output['init'] ? BufferUtils.toUint8( + /** @type {!ArrayBuffer} */(output['init'])) : null, + }; + } + } + + /** + * Handles messages from the shared worker for this instance. + * @param {!Object} msg + * @private + */ + onWorkerMessage_(msg) { + const cmd = msg['cmd']; + + if (cmd === 'transmuxed' || cmd === 'error') { + const reqId = msg['reqId']; + const pending = this.pendingRequests_.get(reqId); + if (!pending) { + return; + } + pending.timer.stop(); + this.pendingRequests_.delete(reqId); + + if (cmd === 'error') { + const errorObj = msg['error']; + pending.reject(new shaka.util.Error( + errorObj['severity'], + errorObj['category'], + errorObj['code'], + ...errorObj['data'])); + } else { + pending.resolve(msg); + } + } + } +}; + + +/** @private {number} */ +shaka.transmuxer.TransmuxerProxy.nextId_ = 0; + + +/** + * Timeout in milliseconds for a worker transmux response. If the worker does + * not respond within this time, the request is rejected and future calls fall + * back to the main thread. + * @private @const {number} + */ +shaka.transmuxer.TransmuxerProxy.TIMEOUT_MS_ = 30000; + + +/** + * Shared Worker instance used by all TransmuxerProxy instances. + * @private {?Worker} + */ +shaka.transmuxer.TransmuxerProxy.sharedWorker_ = null; + + +/** + * Map of active instances keyed by ID, for routing worker messages. + * @private {!Map} + */ +shaka.transmuxer.TransmuxerProxy.activeInstances_ = new Map(); + + +/** + * Gets or creates the shared worker. Returns null if the worker cannot be + * created (unsupported device, missing script URL, or creation error). + * @param {string} workerUrlOverride + * @return {?Worker} + * @private + */ +shaka.transmuxer.TransmuxerProxy.getOrCreateWorker_ = (workerUrlOverride) => { + const TransmuxerProxy = shaka.transmuxer.TransmuxerProxy; + if (TransmuxerProxy.sharedWorker_) { + return TransmuxerProxy.sharedWorker_; + } + + const device = shaka.device.DeviceFactory.getDevice(); + if (!device.supportsWorkerTransmux()) { + shaka.log.info( + 'Device does not support worker transmuxing; ' + + 'falling back to main-thread transmuxing'); + return null; + } + + if (!workerUrlOverride) { + shaka.log.warning( + 'Transmuxer worker URL is not configured ' + + '(mediaSource.transmuxWorkerUrl); ' + + 'falling back to main-thread transmuxing'); + return null; + } + + try { + const worker = new Worker(workerUrlOverride); + + worker.addEventListener('message', (event) => { + const msg = /** @type {!MessageEvent} */(event).data; + const cmd = msg['cmd']; + if (cmd === 'transmuxed' || cmd === 'error') { + // Route directly to the instance that owns this request. + const instance = TransmuxerProxy.activeInstances_.get(msg['id']); + if (instance) { + instance.onWorkerMessage_(msg); + } + } + }); + + worker.addEventListener('error', (event) => { + shaka.log.warning('Transmuxer worker error:', event); + TransmuxerProxy.terminateWorker_('Worker error'); + }); + + TransmuxerProxy.sharedWorker_ = worker; + return worker; + } catch (e) { + shaka.log.warning( + 'Failed to create transmuxer worker, falling back to main thread', e); + return null; + } +}; + + +/** + * Marks all active instances as failed, rejects their pending requests, and + * shuts down the shared worker. + * @param {string} message Error message for rejected promises. + * @private + */ +shaka.transmuxer.TransmuxerProxy.terminateWorker_ = (message) => { + const TransmuxerProxy = shaka.transmuxer.TransmuxerProxy; + for (const instance of TransmuxerProxy.activeInstances_.values()) { + instance.workerFailed_ = true; + for (const pending of instance.pendingRequests_.values()) { + pending.timer.stop(); + pending.reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.TRANSMUXING_FAILED, + message)); + } + instance.pendingRequests_.clear(); + } + TransmuxerProxy.sharedWorker_ = null; + TransmuxerProxy.activeInstances_.clear(); +}; + + diff --git a/lib/transmuxer/transmuxer_worker.js b/lib/transmuxer/transmuxer_worker.js new file mode 100644 index 0000000000..ea1cc8e3a6 --- /dev/null +++ b/lib/transmuxer/transmuxer_worker.js @@ -0,0 +1,267 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.transmuxer.TransmuxerWorker'); + +goog.require('shaka.log'); +goog.require('shaka.transmuxer.TransmuxerEngine'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.Error'); + + +/** + * @summary Web Worker entry point for offloading transmux operations. + * + * This class manages transmuxer instances inside a Web Worker and + * communicates with the main thread via postMessage. + * + * Message protocol (main -> worker): + * {cmd: 'init', id: number, mimeType: string} + * {cmd: 'transmux', id: number, reqId: number, data: ArrayBuffer, + * streamProps: Object, refProps: Object, duration: number, + * contentType: string} + * {cmd: 'destroy', id: number} + * + * Response protocol (worker -> main): + * {cmd: 'transmuxed', id: number, reqId: number, output: Object, + * streamMutations: Object} + * {cmd: 'error', id: number, reqId: number, error: Object} + * {cmd: 'destroyed', id: number} + * + * @export + */ +shaka.transmuxer.TransmuxerWorker = class { + constructor() { + /** + * @private {!Map} + */ + this.transmuxers_ = new Map(); + } + + /** + * Starts listening for messages. Call this from the worker global scope. + * @export + */ + start() { + self.addEventListener('message', (event) => { + this.onMessage_(/** @type {!MessageEvent} */(event)); + }); + } + + /** + * Handles incoming messages from the main thread. + * @param {!MessageEvent} event + * @private + */ + onMessage_(event) { + const msg = event.data; + switch (msg['cmd']) { + case 'init': + this.onInit_(msg); + break; + case 'transmux': + this.onTransmux_(msg); + break; + case 'destroy': + this.onDestroy_(msg); + break; + } + } + + /** + * Creates a transmuxer instance. + * @param {!Object} msg + * @private + */ + onInit_(msg) { + const id = msg['id']; + const mimeType = msg['mimeType']; + + // Look up the transmuxer plugin directly without calling isSupported(). + // The main thread already validated support; re-checking here would fail + // because MediaSource in Workers may not report the same type support as + // the main thread. + const plugin = + shaka.transmuxer.TransmuxerEngine.findTransmuxerPlugin(mimeType); + + if (plugin) { + this.transmuxers_.set(id, plugin()); + } else { + // Only log here; the subsequent onTransmux_ call will post an error + // with the correct reqId so the proxy can route it to the caller. + shaka.log.warning('TransmuxerWorker: no plugin found for', mimeType); + } + } + + /** + * Runs a transmux operation and posts back the result. + * @param {!Object} msg + * @private + */ + async onTransmux_(msg) { + const id = msg['id']; + const reqId = msg['reqId']; + + const transmuxer = this.transmuxers_.get(id); + if (!transmuxer) { + self.postMessage({ + 'cmd': 'error', + 'id': id, + 'reqId': reqId, + 'error': { + 'severity': shaka.util.Error.Severity.CRITICAL, + 'category': shaka.util.Error.Category.MEDIA, + 'code': shaka.util.Error.Code.TRANSMUXING_FAILED, + 'data': ['No transmuxer initialized for id ' + id], + }, + }); + return; + } + + const streamProps = msg['streamProps']; + const refProps = msg['refProps']; + + const stream = { + 'id': streamProps['id'], + 'codecs': streamProps['codecs'], + 'channelsCount': streamProps['channelsCount'], + 'audioSamplingRate': streamProps['audioSamplingRate'], + 'height': streamProps['height'], + 'width': streamProps['width'], + 'language': streamProps['language'], + }; + + const reference = refProps ? { + 'discontinuitySequence': refProps['discontinuitySequence'], + 'startTime': refProps['startTime'], + 'endTime': refProps['endTime'], + 'getUris': () => refProps['uris'], + } : null; + + const data = shaka.util.BufferUtils.toUint8( + /** @type {!ArrayBuffer} */(msg['data'])); + const duration = msg['duration']; + const contentType = msg['contentType']; + + try { + const result = await transmuxer.transmux( + data, + /** @type {shaka.extern.Stream} */(stream), + /** @type {?} */(reference), + duration, + contentType); + + // Compute mutations: which stream properties changed. + const streamMutations = {}; + const mutatedKeys = [ + 'audioSamplingRate', 'channelsCount', 'height', 'width', + ]; + for (const key of mutatedKeys) { + if (stream[key] !== streamProps[key]) { + streamMutations[key] = stream[key]; + } + } + + // Convert typed array views to ArrayBuffer before posting. Only + // ArrayBuffer (not views) can be transferred zero-copy via postMessage. + const BufferUtils = shaka.util.BufferUtils; + + if (ArrayBuffer.isView(result)) { + const buf = BufferUtils.toArrayBuffer( + /** @type {!Uint8Array} */(result)); + self.postMessage({ + 'cmd': 'transmuxed', + 'id': id, + 'reqId': reqId, + 'output': {'type': 'raw', 'data': buf}, + 'streamMutations': streamMutations, + }, [buf]); + } else { + const output = /** @type {!shaka.extern.TransmuxerOutput} */(result); + const dataBuf = BufferUtils.toArrayBuffer(output.data); + const initBuf = output.init ? + BufferUtils.toArrayBuffer(output.init) : null; + const transfers = [dataBuf]; + const response = { + 'cmd': 'transmuxed', + 'id': id, + 'reqId': reqId, + 'output': { + 'type': 'segments', + 'data': dataBuf, + 'init': initBuf, + }, + 'streamMutations': streamMutations, + }; + if (initBuf) { + transfers.push(initBuf); + } + self.postMessage(response, transfers); + } + } catch (e) { + self.postMessage({ + 'cmd': 'error', + 'id': id, + 'reqId': reqId, + 'error': shaka.transmuxer.TransmuxerWorker.errorToObject_(e), + }); + } + } + + /** + * Converts a caught error into a plain serializable object for postMessage. + * @param {*} e + * @return {!Object} + * @private + */ + static errorToObject_(e) { + if (e instanceof shaka.util.Error) { + return { + 'severity': e.severity, + 'category': e.category, + 'code': e.code, + 'data': e.data, + }; + } + return { + 'severity': shaka.util.Error.Severity.CRITICAL, + 'category': shaka.util.Error.Category.MEDIA, + 'code': shaka.util.Error.Code.TRANSMUXING_FAILED, + 'data': [e instanceof Error ? e.message : 'Unknown error'], + }; + } + + /** + * Destroys a transmuxer instance. + * @param {!Object} msg + * @private + */ + onDestroy_(msg) { + const id = msg['id']; + const transmuxer = this.transmuxers_.get(id); + if (transmuxer) { + transmuxer.destroy(); + this.transmuxers_.delete(id); + } + self.postMessage({'cmd': 'destroyed', 'id': id}); + } +}; + + +/** + * Boots the worker if running in a Worker global scope. + * This is called at load time so the worker is ready immediately. + */ +shaka.transmuxer.TransmuxerWorker.boot = () => { + if (typeof DedicatedWorkerGlobalScope !== 'undefined' && + self instanceof DedicatedWorkerGlobalScope) { + const worker = new shaka.transmuxer.TransmuxerWorker(); + worker.start(); + } +}; + +// Auto-boot when loaded in a worker context. +shaka.transmuxer.TransmuxerWorker.boot(); diff --git a/lib/transmuxer/ts_transmuxer.js b/lib/transmuxer/ts_transmuxer.js index 7b022b48ba..92998ef750 100644 --- a/lib/transmuxer/ts_transmuxer.js +++ b/lib/transmuxer/ts_transmuxer.js @@ -23,7 +23,6 @@ goog.require('shaka.util.Id3Utils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.Mp4Generator'); -goog.require('shaka.util.StreamUtils'); goog.require('shaka.util.TsParser'); goog.require('shaka.util.Uint8ArrayUtils'); @@ -148,17 +147,17 @@ shaka.transmuxer.TsTransmuxer = class { convertCodecs(contentType, mimeType) { if (this.isTsContainer_(mimeType)) { const ContentType = shaka.util.ManifestParserUtils.ContentType; - const StreamUtils = shaka.util.StreamUtils; + const MimeUtils = shaka.util.MimeUtils; // The replace it's necessary because Firefox(the only browser that // supports MP3 in MP4) only support the MP3 codec with the mp3 string. // MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.34"') -> false // MediaSource.isTypeSupported('audio/mp4; codecs="mp3"') -> true - const codecs = shaka.util.MimeUtils.getCodecs(mimeType) + const codecs = MimeUtils.getCodecs(mimeType) .replace('mp4a.40.34', 'mp3').split(',') .map((codecs) => { - return StreamUtils.getCorrectAudioCodecs(codecs, 'audio/mp4'); + return MimeUtils.getCorrectAudioCodecs(codecs, 'audio/mp4'); }) - .map(StreamUtils.getCorrectVideoCodecs).join(','); + .map(MimeUtils.getCorrectVideoCodecs).join(','); if (contentType == ContentType.AUDIO) { return `audio/mp4; codecs="${codecs}"`; } diff --git a/lib/util/cmcd_manager.js b/lib/util/cmcd_manager.js index 3d869a7567..aa7c50caaa 100644 --- a/lib/util/cmcd_manager.js +++ b/lib/util/cmcd_manager.js @@ -6,21 +6,57 @@ goog.provide('shaka.util.CmcdManager'); -goog.require('goog.Uri'); +goog.require('goog.asserts'); +goog.require('cml.cmcd.CMCD_EVENT_KEYS'); +goog.require('cml.cmcd.CMCD_HEADERS'); +goog.require('cml.cmcd.CMCD_QUERY'); +goog.require('cml.cmcd.CMCD_REQUEST_KEYS'); +goog.require('cml.cmcd.CMCD_REQUEST_MODE'); +goog.require('cml.cmcd.CMCD_RESPONSE_KEYS'); +goog.require('cml.cmcd.CMCD_V1'); +goog.require('cml.cmcd.CMCD_V1_KEYS'); +goog.require('cml.cmcd.CMCD_V2'); +goog.require('cml.cmcd.CmcdEventType'); +goog.require('cml.cmcd.CmcdObjectType'); +goog.require('cml.cmcd.CmcdPlayerState'); +goog.require('cml.cmcd.CmcdReporter'); +goog.require('cml.cmcd.CmcdStreamType'); +goog.require('cml.cmcd.CmcdStreamingFormat'); +goog.require('cml.cmcd.appendCmcdQuery'); goog.require('shaka.log'); goog.require('shaka.net.NetworkingEngine'); goog.require('shaka.util.ArrayUtils'); goog.require('shaka.util.EventManager'); -goog.require('shaka.util.Timer'); +goog.require('shaka.util.StringUtils'); +goog.requireType('cml.cmcd.Cmcd'); +goog.requireType('cml.cmcd.CmcdEncodeOptions'); +goog.requireType('cml.cmcd.CmcdReporterConfig'); goog.requireType('shaka.media.SegmentReference'); goog.requireType('shaka.Player'); + /** * @summary - * A CmcdManager maintains CMCD state as well as a collection of utility - * functions. + * Thin shaka adapter around `cml.cmcd.CmcdReporter`. The vendored + * reporter owns CMCD state, encoding, key filtering, sequence numbers, + * and event-mode dispatch. The adapter translates shaka's player / + * `