From d684cf3bbb9c5a0a7f5d5698fea760d55f35ccba Mon Sep 17 00:00:00 2001 From: farhan Date: Fri, 23 Jan 2026 17:17:15 +0500 Subject: [PATCH] chore: Lift and shift code of Video Block from edx-platform This commit contains original code copied from edx-platform. Purpose of keeping the first commit with original code is to get help in the review process. Reviewer just needs to open the second commit and code changes will be clearly visible. --- xblocks_contrib/video/ajax_handler_mixin.py | 45 + xblocks_contrib/video/bumper_utils.py | 149 ++ xblocks_contrib/video/cache_utils.py | 102 ++ xblocks_contrib/video/content.py | 60 + xblocks_contrib/video/mixin.py | 57 + xblocks_contrib/video/static/css/video.css | 1255 +++++++++++++- .../video/static/js/src/00_async_process.js | 52 + .../video/static/js/src/00_component.js | 81 + .../video/static/js/src/00_i18n.js | 35 + .../video/static/js/src/00_iterator.js | 83 + .../video/static/js/src/00_resizer.js | 236 +++ .../video/static/js/src/00_sjson.js | 108 ++ .../video/static/js/src/00_video_storage.js | 96 ++ .../video/static/js/src/01_initialize.js | 845 ++++++++++ .../video/static/js/src/025_focus_grabber.js | 132 ++ .../video/static/js/src/02_html5_hls_video.js | 151 ++ .../video/static/js/src/02_html5_video.js | 380 +++++ .../js/src/035_video_accessible_menu.js | 65 + .../static/js/src/036_video_social_sharing.js | 85 + .../js/src/037_video_transcript_feedback.js | 240 +++ .../video/static/js/src/03_video_player.js | 911 ++++++++++ .../video/static/js/src/04_video_control.js | 164 ++ .../static/js/src/04_video_full_screen.js | 309 ++++ .../static/js/src/05_video_quality_control.js | 176 ++ .../static/js/src/06_video_progress_slider.js | 360 ++++ .../static/js/src/07_video_volume_control.js | 554 +++++++ .../js/src/08_video_auto_advance_control.js | 134 ++ .../static/js/src/08_video_speed_control.js | 417 +++++ .../static/js/src/095_video_context_menu.js | 698 ++++++++ .../video/static/js/src/09_bumper.js | 108 ++ .../video/static/js/src/09_completion.js | 201 +++ .../static/js/src/09_events_bumper_plugin.js | 112 ++ .../video/static/js/src/09_events_plugin.js | 177 ++ .../static/js/src/09_play_pause_control.js | 96 ++ .../static/js/src/09_play_placeholder.js | 84 + .../static/js/src/09_play_skip_control.js | 86 + .../video/static/js/src/09_poster.js | 62 + .../static/js/src/09_save_state_plugin.js | 131 ++ .../video/static/js/src/09_skip_control.js | 72 + .../video/static/js/src/09_video_caption.js | 1459 +++++++++++++++++ .../video/static/js/src/10_commands.js | 108 ++ .../video/static/js/src/10_main.js | 133 ++ .../video/static/js/src/utils/time.js | 41 + xblocks_contrib/video/static/js/src/video.js | 38 - xblocks_contrib/video/templates/video.html | 216 ++- xblocks_contrib/video/validation.py | 130 ++ xblocks_contrib/video/video.py | 1253 +++++++++++++- xblocks_contrib/video/video_handlers.py | 553 +++++++ .../video/video_transcripts_utils.py | 241 +++ xblocks_contrib/video/video_utils.py | 211 +++ xblocks_contrib/video/video_xfields.py | 210 +++ 51 files changed, 13578 insertions(+), 124 deletions(-) create mode 100644 xblocks_contrib/video/ajax_handler_mixin.py create mode 100644 xblocks_contrib/video/bumper_utils.py create mode 100644 xblocks_contrib/video/cache_utils.py create mode 100644 xblocks_contrib/video/content.py create mode 100644 xblocks_contrib/video/mixin.py create mode 100644 xblocks_contrib/video/static/js/src/00_async_process.js create mode 100644 xblocks_contrib/video/static/js/src/00_component.js create mode 100644 xblocks_contrib/video/static/js/src/00_i18n.js create mode 100644 xblocks_contrib/video/static/js/src/00_iterator.js create mode 100644 xblocks_contrib/video/static/js/src/00_resizer.js create mode 100644 xblocks_contrib/video/static/js/src/00_sjson.js create mode 100644 xblocks_contrib/video/static/js/src/00_video_storage.js create mode 100644 xblocks_contrib/video/static/js/src/01_initialize.js create mode 100644 xblocks_contrib/video/static/js/src/025_focus_grabber.js create mode 100644 xblocks_contrib/video/static/js/src/02_html5_hls_video.js create mode 100644 xblocks_contrib/video/static/js/src/02_html5_video.js create mode 100644 xblocks_contrib/video/static/js/src/035_video_accessible_menu.js create mode 100644 xblocks_contrib/video/static/js/src/036_video_social_sharing.js create mode 100644 xblocks_contrib/video/static/js/src/037_video_transcript_feedback.js create mode 100644 xblocks_contrib/video/static/js/src/03_video_player.js create mode 100644 xblocks_contrib/video/static/js/src/04_video_control.js create mode 100644 xblocks_contrib/video/static/js/src/04_video_full_screen.js create mode 100644 xblocks_contrib/video/static/js/src/05_video_quality_control.js create mode 100644 xblocks_contrib/video/static/js/src/06_video_progress_slider.js create mode 100644 xblocks_contrib/video/static/js/src/07_video_volume_control.js create mode 100644 xblocks_contrib/video/static/js/src/08_video_auto_advance_control.js create mode 100644 xblocks_contrib/video/static/js/src/08_video_speed_control.js create mode 100644 xblocks_contrib/video/static/js/src/095_video_context_menu.js create mode 100644 xblocks_contrib/video/static/js/src/09_bumper.js create mode 100644 xblocks_contrib/video/static/js/src/09_completion.js create mode 100644 xblocks_contrib/video/static/js/src/09_events_bumper_plugin.js create mode 100644 xblocks_contrib/video/static/js/src/09_events_plugin.js create mode 100644 xblocks_contrib/video/static/js/src/09_play_pause_control.js create mode 100644 xblocks_contrib/video/static/js/src/09_play_placeholder.js create mode 100644 xblocks_contrib/video/static/js/src/09_play_skip_control.js create mode 100644 xblocks_contrib/video/static/js/src/09_poster.js create mode 100644 xblocks_contrib/video/static/js/src/09_save_state_plugin.js create mode 100644 xblocks_contrib/video/static/js/src/09_skip_control.js create mode 100644 xblocks_contrib/video/static/js/src/09_video_caption.js create mode 100644 xblocks_contrib/video/static/js/src/10_commands.js create mode 100644 xblocks_contrib/video/static/js/src/10_main.js create mode 100644 xblocks_contrib/video/static/js/src/utils/time.js delete mode 100644 xblocks_contrib/video/static/js/src/video.js create mode 100644 xblocks_contrib/video/validation.py create mode 100644 xblocks_contrib/video/video_handlers.py create mode 100644 xblocks_contrib/video/video_transcripts_utils.py create mode 100644 xblocks_contrib/video/video_utils.py create mode 100644 xblocks_contrib/video/video_xfields.py diff --git a/xblocks_contrib/video/ajax_handler_mixin.py b/xblocks_contrib/video/ajax_handler_mixin.py new file mode 100644 index 00000000..a0dd5e9b --- /dev/null +++ b/xblocks_contrib/video/ajax_handler_mixin.py @@ -0,0 +1,45 @@ +# NOTE: Code has been copied from the following source file +# https://github.com/openedx/edx-platform/blob/master/xmodule/x_module.py#L739 + +class XModuleToXBlockMixin: + """ + Common code needed by XModule and XBlocks converted from XModules. + """ + @property + def ajax_url(self): + """ + Returns the URL for the ajax handler. + """ + return self.runtime.handler_url(self, 'xmodule_handler', '', '').rstrip('/?') + + @XBlock.handler + def xmodule_handler(self, request, suffix=None): + """ + XBlock handler that wraps `handle_ajax` + """ + class FileObjForWebobFiles: + """ + Turn Webob cgi.FieldStorage uploaded files into pure file objects. + + Webob represents uploaded files as cgi.FieldStorage objects, which + have a .file attribute. We wrap the FieldStorage object, delegating + attribute access to the .file attribute. But the files have no + name, so we carry the FieldStorage .filename attribute as the .name. + + """ + def __init__(self, webob_file): + self.file = webob_file.file + self.name = webob_file.filename + + def __getattr__(self, name): + return getattr(self.file, name) + + # WebOb requests have multiple entries for uploaded files. handle_ajax + # expects a single entry as a list. + request_post = MultiDict(request.POST) + for key in set(request.POST.keys()): + if hasattr(request.POST[key], "file"): + request_post[key] = list(map(FileObjForWebobFiles, request.POST.getall(key))) + + response_data = self.handle_ajax(suffix, request_post) + return Response(response_data, content_type='application/json', charset='UTF-8') diff --git a/xblocks_contrib/video/bumper_utils.py b/xblocks_contrib/video/bumper_utils.py new file mode 100644 index 00000000..f9f2f5b1 --- /dev/null +++ b/xblocks_contrib/video/bumper_utils.py @@ -0,0 +1,149 @@ +# NOTE: Code has been copied from the following source files +# https://github.com/openedx/edx-platform/blob/master/xmodule/video_block/bumper_utils.py +""" +Utils for video bumper +""" + + +import copy +import json +import logging +from collections import OrderedDict +from datetime import datetime, timedelta + +import pytz +from django.conf import settings + +from .video_utils import set_query_parameter + +try: + import edxval.api as edxval_api +except ImportError: + edxval_api = None + +log = logging.getLogger(__name__) + + +def get_bumper_settings(video): + """ + Get bumper settings from video instance. + """ + bumper_settings = copy.deepcopy(getattr(video, 'video_bumper', {})) + + # clean up /static/ prefix from bumper transcripts + for lang, transcript_url in bumper_settings.get('transcripts', {}).items(): + bumper_settings['transcripts'][lang] = transcript_url.replace("/static/", "") + + return bumper_settings + + +def is_bumper_enabled(video): + """ + Check if bumper enabled. + + - Feature flag ENABLE_VIDEO_BUMPER should be set to True + - Do not show again button should not be clicked by user. + - Current time minus periodicity must be greater that last time viewed + - edxval_api should be presented + + Returns: + bool. + """ + bumper_last_view_date = getattr(video, 'bumper_last_view_date', None) + utc_now = datetime.utcnow().replace(tzinfo=pytz.utc) + periodicity = settings.FEATURES.get('SHOW_BUMPER_PERIODICITY', 0) + has_viewed = any([ + video.bumper_do_not_show_again, + (bumper_last_view_date and bumper_last_view_date + timedelta(seconds=periodicity) > utc_now) + ]) + is_studio = getattr(video.runtime, "is_author_mode", False) + return bool( + not is_studio and + settings.FEATURES.get('ENABLE_VIDEO_BUMPER') and + get_bumper_settings(video) and + edxval_api and + not has_viewed + ) + + +def bumperize(video): + """ + Populate video with bumper settings, if they are presented. + """ + video.bumper = { + 'enabled': False, + 'edx_video_id': "", + 'transcripts': {}, + 'metadata': None, + } + + if not is_bumper_enabled(video): + return + + bumper_settings = get_bumper_settings(video) + + try: + video.bumper['edx_video_id'] = bumper_settings['video_id'] + video.bumper['transcripts'] = bumper_settings['transcripts'] + except (TypeError, KeyError): + log.warning( + "Could not retrieve video bumper information from course settings" + ) + return + + sources = get_bumper_sources(video) + if not sources: + return + + video.bumper.update({ + 'metadata': bumper_metadata(video, sources), + 'enabled': True, # Video poster needs this. + }) + + +def get_bumper_sources(video): + """ + Get bumper sources from edxval. + + Returns list of sources. + """ + try: + val_profiles = ["desktop_webm", "desktop_mp4"] + val_video_urls = edxval_api.get_urls_for_profiles(video.bumper['edx_video_id'], val_profiles) + bumper_sources = [url for url in [val_video_urls[p] for p in val_profiles] if url] + except edxval_api.ValInternalError: + # if no bumper sources, nothing will be showed + log.warning( + "Could not retrieve information from VAL for Bumper edx Video ID: %s.", video.bumper['edx_video_id'] + ) + return [] + + return bumper_sources + + +def bumper_metadata(video, sources): + """ + Generate bumper metadata. + """ + transcripts = video.get_transcripts_info(is_bumper=True) + unused_track_url, bumper_transcript_language, bumper_languages = video.get_transcripts_for_student(transcripts) + + metadata = OrderedDict({ + 'saveStateUrl': video.ajax_url + '/save_user_state', + 'showCaptions': json.dumps(video.show_captions), + 'sources': sources, + 'streams': '', + 'transcriptLanguage': bumper_transcript_language, + 'transcriptLanguages': bumper_languages, + 'transcriptTranslationUrl': set_query_parameter( + video.runtime.handler_url(video, 'transcript', 'translation/__lang__').rstrip('/?'), 'is_bumper', 1 + ), + 'transcriptAvailableTranslationsUrl': set_query_parameter( + video.runtime.handler_url(video, 'transcript', 'available_translations').rstrip('/?'), 'is_bumper', 1 + ), + 'publishCompletionUrl': set_query_parameter( + video.runtime.handler_url(video, 'publish_completion', '').rstrip('?'), 'is_bumper', 1 + ), + }) + + return metadata diff --git a/xblocks_contrib/video/cache_utils.py b/xblocks_contrib/video/cache_utils.py new file mode 100644 index 00000000..45ff22b2 --- /dev/null +++ b/xblocks_contrib/video/cache_utils.py @@ -0,0 +1,102 @@ +# NOTE: Code has been copied from the following source files +# https://github.com/openedx/edx-platform/blob/master/openedx/core/lib/cache_utils.py +""" +Utilities related to caching. +""" + + +import itertools + +import wrapt +from django.utils.encoding import force_str + +from edx_django_utils.cache import RequestCache + + +def request_cached(namespace=None, arg_map_function=None, request_cache_getter=None): + """ + A function decorator that automatically handles caching its return value for + the duration of the request. It returns the cached value for subsequent + calls to the same function, with the same parameters, within a given request. + + Notes: + - We convert arguments and keyword arguments to their string form to build the cache key. So if you have + args/kwargs that can't be converted to strings, you're gonna have a bad time (don't do it). + - Cache key cardinality depends on the args/kwargs. So if you're caching a function that takes five arguments, + you might have deceptively low cache efficiency. Prefer functions with fewer arguments. + - WATCH OUT: Don't use this decorator for instance methods that take in a "self" argument that changes each + time the method is called. This will result in constant cache misses and not provide the performance benefit + you are looking for. Rather, change your instance method to a class method. + - Benchmark, benchmark, benchmark! If you never measure, how will you know you've improved? or regressed? + + Arguments: + namespace (string): An optional namespace to use for the cache. By default, we use the default request cache, + not a namespaced request cache. Since the code automatically creates a unique cache key with the module and + function's name, storing the cached value in the default cache, you won't usually need to specify a + namespace value. + But you can specify a namespace value here if you need to use your own namespaced cache - for example, + if you want to clear out your own cache by calling RequestCache(namespace=NAMESPACE).clear(). + NOTE: This argument is ignored if you supply a ``request_cache_getter``. + arg_map_function (function: arg->string): Function to use for mapping the wrapped function's arguments to + strings to use in the cache key. If not provided, defaults to force_text, which converts the given + argument to a string. + request_cache_getter (function: args, kwargs->RequestCache): Function that returns the RequestCache to use. + If not provided, defaults to edx_django_utils.cache.RequestCache. If ``request_cache_getter`` returns None, + the function's return values are not cached. + + Returns: + func: a wrapper function which will call the wrapped function, passing in the same args/kwargs, + cache the value it returns, and return that cached value for subsequent calls with the + same args/kwargs within a single request. + """ + @wrapt.decorator + def decorator(wrapped, instance, args, kwargs): + """ + Arguments: + args, kwargs: values passed into the wrapped function + """ + # Check to see if we have a result in cache. If not, invoke our wrapped + # function. Cache and return the result to the caller. + if request_cache_getter: + request_cache = request_cache_getter(args if instance is None else (instance,) + args, kwargs) + else: + request_cache = RequestCache(namespace) + + if request_cache: + cache_key = _func_call_cache_key(wrapped, arg_map_function, *args, **kwargs) + cached_response = request_cache.get_cached_response(cache_key) + if cached_response.is_found: + return cached_response.value + + result = wrapped(*args, **kwargs) + + if request_cache: + request_cache.set(cache_key, result) + + return result + + return decorator + + +def _func_call_cache_key(func, arg_map_function, *args, **kwargs): + """ + Returns a cache key based on the function's module, + the function's name, a stringified list of arguments + and a stringified list of keyword arguments. + """ + arg_map_function = arg_map_function or force_str + + converted_args = list(map(arg_map_function, args)) + converted_kwargs = list(map(arg_map_function, _sorted_kwargs_list(kwargs))) + + cache_keys = [func.__module__, func.__name__] + converted_args + converted_kwargs + return '.'.join(cache_keys) + + +def _sorted_kwargs_list(kwargs): + """ + Returns a unique and deterministic ordered list from the given kwargs. + """ + sorted_kwargs = sorted(kwargs.items()) + sorted_kwargs_list = list(itertools.chain(*sorted_kwargs)) + return sorted_kwargs_list diff --git a/xblocks_contrib/video/content.py b/xblocks_contrib/video/content.py new file mode 100644 index 00000000..b57e05fa --- /dev/null +++ b/xblocks_contrib/video/content.py @@ -0,0 +1,60 @@ +# NOTE: Original code has been copied from the following file: +# https://github.com/openedx/edx-platform/blob/farhan/extract-video-xblock/xmodule/contentstore/content.py#L28 + + +class StaticContent: # lint-amnesty, pylint: disable=missing-class-docstring + def __init__(self, loc, name, content_type, data, last_modified_at=None, thumbnail_location=None, import_path=None, + length=None, locked=False, content_digest=None): + self.location = loc + self.name = name # a display string which can be edited, and thus not part of the location which needs to be fixed # lint-amnesty, pylint: disable=line-too-long + self.content_type = content_type + self._data = data + self.length = length + self.last_modified_at = last_modified_at + self.thumbnail_location = thumbnail_location + # optional information about where this file was imported from. This is needed to support import/export + # cycles + self.import_path = import_path + self.locked = locked + self.content_digest = content_digest + + @staticmethod + def compute_location(course_key, path, revision=None, is_thumbnail=False): # lint-amnesty, pylint: disable=unused-argument + """ + Constructs a location object for static content. + + - course_key: the course that this asset belongs to + - path: is the name of the static asset + - revision: is the object's revision information + - is_thumbnail: is whether or not we want the thumbnail version of this + asset + """ + path = path.replace('/', '_') + return course_key.make_asset_key( + 'asset' if not is_thumbnail else 'thumbnail', + AssetLocator.clean_keeping_underscores(path) + ).for_branch(None) + + @staticmethod + def get_location_from_path(path): + """ + Generate an AssetKey for the given path (old c4x/org/course/asset/name syntax) + """ + try: + return AssetKey.from_string(path) + except InvalidKeyError: + # TODO - re-address this once LMS-11198 is tackled. + if path.startswith('/') or path.endswith('/'): + # try stripping off the leading slash and try again + return AssetKey.from_string(path.strip('/')) + + @staticmethod + def serialize_asset_key_with_slash(asset_key): + """ + Legacy code expects the serialized asset key to start w/ a slash; so, do that in one place + :param asset_key: + """ + url = str(asset_key) + if not url.startswith('/'): + url = '/' + url # TODO - re-address this once LMS-11198 is tackled. + return url diff --git a/xblocks_contrib/video/mixin.py b/xblocks_contrib/video/mixin.py new file mode 100644 index 00000000..dd212089 --- /dev/null +++ b/xblocks_contrib/video/mixin.py @@ -0,0 +1,57 @@ +# NOTE: Code has been copied from the following source files +# https://github.com/openedx/edx-platform/blob/master/openedx/core/lib/license/mixin.py +""" +License mixin for XBlocks and XModules +""" + +from xblock.core import XBlockMixin +from xblock.fields import Scope, String + +# Make '_' a no-op so we can scrape strings. Using lambda instead of +# `django.utils.translation.gettext_noop` because Django cannot be imported in this file +_ = lambda text: text + + +class LicenseMixin(XBlockMixin): + """ + Mixin that allows an author to indicate a license on the contents of an + XBlock. For example, a video could be marked as Creative Commons SA-BY + licensed. You can even indicate the license on an entire course. + + If this mixin is not applied to an XBlock, or if the license field is + blank, then the content is subject to whatever legal licensing terms that + apply to content by default. For example, in the United States, that content + is exclusively owned by the creator of the content by default. Other + countries may have similar laws. + """ + license = String( + display_name=_("License"), + help=_("A license defines how the contents of this block can be shared and reused."), + default=None, + scope=Scope.content, + ) + + @classmethod + def parse_license_from_xml(cls, definition, node): + """ + When importing an XBlock from XML, this method will parse the license + information out of the XML and attach it to the block. + It is defined here so that classes that use this mixin can simply refer + to this method, rather than reimplementing it in their XML import + functions. + """ + license = node.get('license', default=None) # pylint: disable=redefined-builtin + if license: + definition['license'] = license + return definition + + def add_license_to_xml(self, node, default=None): + """ + When generating XML from an XBlock, this method will add the XBlock's + license to the XML representation before it is serialized. + It is defined here so that classes that use this mixin can simply refer + to this method, rather than reimplementing it in their XML export + functions. + """ + if getattr(self, "license", default): + node.set('license', self.license) diff --git a/xblocks_contrib/video/static/css/video.css b/xblocks_contrib/video/static/css/video.css index 563fa0d5..ca20ba91 100644 --- a/xblocks_contrib/video/static/css/video.css +++ b/xblocks_contrib/video/static/css/video.css @@ -1,9 +1,1258 @@ -/* CSS for VideoBlock */ + -.video .count { +@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); + +.xmodule_display.xmodule_VideoBlock { + margin-bottom: calc((var(--baseline, 20px) * 1.5)); +} + +.xmodule_display.xmodule_VideoBlock .is-hidden, +.xmodule_display.xmodule_VideoBlock .video.closed .subtitles { + display: none; +} + +.xmodule_display.xmodule_VideoBlock .video { + background: whitesmoke; + display: block; + margin: 0 -12px; + padding: 12px; + border-radius: 5px; + outline: none; +} + +.xmodule_display.xmodule_VideoBlock .video:after { + content: ""; + display: table; + clear: both; +} + +.xmodule_display.xmodule_VideoBlock .video:focus, +.xmodule_display.xmodule_VideoBlock .video:active, +.xmodule_display.xmodule_VideoBlock .video:hover { + border: 0; +} + +.xmodule_display.xmodule_VideoBlock .video.is-initialized .video-wrapper .spinner { + display: none; +} + +.xmodule_display.xmodule_VideoBlock .video.is-pre-roll .slider { + visibility: hidden; +} + +.xmodule_display.xmodule_VideoBlock .video.is-pre-roll .video-player { + position: relative; +} + +.xmodule_display.xmodule_VideoBlock .video.is-pre-roll .video-player::before { + display: block; + content: ""; + width: 100%; + padding-top: 55%; +} + +.xmodule_display.xmodule_VideoBlock .video .tc-wrapper { + position: relative; +} + +.xmodule_display.xmodule_VideoBlock .video .tc-wrapper:after { + content: ""; + display: table; + clear: both; +} + +.xmodule_display.xmodule_VideoBlock .video .focus_grabber { + position: relative; + display: inline; + width: 0; + height: 0; +} + +.xmodule_display.xmodule_VideoBlock .video .downloads-heading { + margin: 1em 0 0; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section { + display: flex; + justify-content: space-between; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section .wrapper-download-video, +.xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section .wrapper-download-transcripts, +.xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section .wrapper-handouts, +.xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section , +.xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section .wrapper-transcript-feedback { + margin-top: var(--baseline, 20px); + padding-right: var(--baseline, 20px); + vertical-align: top; +} + +@media (min-width: 768px) { + .xmodule_display.xmodule_VideoBlock .video .wrapper-downloads { + display: flex; + } +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .hd { + margin: 0; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .wrapper-download-video .video-sources { + margin: 0; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts { + margin: 0; + padding: 0; + list-style: none; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option { + display: flex; + align-items: center; + margin: 0; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option a.btn, +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option a.btn-link { + font-size: 16px !important; + font-weight: unset; + padding-left: 4px; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads { + padding-right: 0; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .host-tag { + position: absolute; + left: -9999em; + display: inline-block; + vertical-align: middle; + color: var(--body-color, #313131); +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .brand-logo { + display: inline-block; + max-width: 100%; + max-height: calc((var(--baseline, 20px) * 2)); + padding: calc((var(--baseline, 20px) / 4)) 0; + vertical-align: middle; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-transcript-feedback { + display: none; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-transcript-feedback .transcript-feedback-buttons { + display: flex; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-transcript-feedback .transcript-feedback-btn-wrapper { + margin-right: 10px; +} + +.xmodule_display.xmodule_VideoBlock .video .wrapper-transcript-feedback .thumbs-up-btn, +.xmodule_display.xmodule_VideoBlock .video .wrapper-transcript-feedback .thumbs-down-btn { + border: none; + box-shadow: none; + background: transparent; +} + +.xmodule_display.xmodule_VideoBlock .video .google-disclaimer { + display: none; + margin-top: var(--baseline, 20px); + padding-right: var(--baseline, 20px); + vertical-align: top; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper { + float: left; + margin-right: 2.27273%; + width: 65.90909%; + background-color: black; + position: relative; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper:hover .btn-play { + color: #0075b4; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper:hover .btn-play::after { + background: #fff; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player-pre, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player-post { + height: 50px; + background-color: #111010; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .spinner { + transform: translate(-50%, -50%); + position: absolute; + z-index: 1; + background: rgba(0, 0, 0, 0.7); + top: 50%; + left: 50%; + padding: 30px; + border-radius: 25%; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .spinner::after { + animation: rotateCW 3s infinite linear; + content: ''; + display: block; + width: 30px; + height: 30px; + border: 7px solid white; + border-top-color: transparent; + border-radius: 100%; + position: relative; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .btn-play { + transform: translate(-50%, -50%); + position: absolute; + z-index: 1; + top: 46%; + left: 50%; + font-size: 4em; + cursor: pointer; + opacity: 0.1; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .btn-play::after { + background: var(--white, #fff); + position: absolute; + width: 50%; + height: 50%; + content: ''; + left: 0; + top: 0; + bottom: 0; + right: 0; + margin: auto; + z-index: -1; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions { + left: 5%; + position: absolute; + width: 90%; + box-sizing: border-box; + top: 70%; + text-align: center; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible { + max-height: calc((var(--baseline, 20px) * 3)); + border-radius: calc((var(--baseline, 20px) / 5)); + padding: 8px calc((var(--baseline, 20px) / 2)) 8px calc((var(--baseline, 20px) * 1.5)); + background: rgba(0, 0, 0, 0.75); + color: var(--yellow, #e2c01f); +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible::before { + position: absolute; + display: inline-block; + top: 50%; + left: var(--baseline, 20px); + margin-top: -0.6em; + font-family: 'FontAwesome'; + content: "\f142"; + color: var(--white, #fff); + opacity: 0.5; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible:hover, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible.is-dragging { + background: black; + cursor: move; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible:hover::before, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible.is-dragging::before { + opacity: 1; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player { + overflow: hidden; + min-height: 158px; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player>div { + height: 100%; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player>div.hidden { + display: none; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player .video-error, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player .video-hls-error { + padding: calc((var(--baseline, 20px) / 5)); + background: black; + color: white !important; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player object, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player iframe, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player video { + left: 0; + display: block; + border: none; + width: 100%; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player h4 { + text-align: center; + color: white; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player h4.hidden { + display: none; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls { + position: relative; + border: 0; + background: #282c2e; + color: #f0f3f5; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:after { + content: ""; + display: table; + clear: both; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:hover ul, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:hover div, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:focus ul, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:focus div { + opacity: 1; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control { + display: inline-block; + vertical-align: middle; + margin: 0; + border: 0; + border-radius: 0; + padding: calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 1.5)); + background: #282c2e; + box-shadow: none; + text-shadow: none; + color: #cfd8dc; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control:hover, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control:focus { + background: #171a1b; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control:active, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .is-active.control, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .active.control { + color: #0ea6ec; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control .icon { + width: 1em; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control .icon.icon-hd { + width: auto; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider { + transform-origin: bottom left; + transition: height 0.7s ease-in-out 0s; + box-sizing: border-box; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + z-index: 1; + height: calc((var(--baseline, 20px) / 4)); + margin-left: 0; + border: 1px solid #4f595d; + border-radius: 0; + background: #4f595d; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider:after { + content: ""; + display: table; + clear: both; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-widget-header { + background: #8e3e63; + border: 1px solid #8e3e63; + box-shadow: none; + top: -1px; + left: -1px; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-corner-all.slider-range { + opacity: 0.3; + background-color: #1e91d3; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle { + transform-origin: bottom left; + transition: all 0.7s ease-in-out 0s; + box-sizing: border-box; + top: -1px; + height: calc((var(--baseline, 20px) / 4)); + width: calc((var(--baseline, 20px) / 4)); + margin-left: calc(-1 * (var(--baseline, 20px) / 8)); + border: 1px solid #cb598d; + border-radius: calc((var(--baseline, 20px) / 5)); + padding: 0; + background: #cb598d; + box-shadow: none; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle:hover { + background-color: #db8baf; + border-color: #db8baf; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .vcr { + float: left; + list-style: none; + border-right: 1px solid #282c2e; + padding: 0; +} + +@media (max-width: 1120px) { + .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .vcr { + margin-right: lh(0.5); + font-size: 0.875em; + } +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .vcr .video_control:focus { + position: relative; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .vcr .video_control.skip { + white-space: nowrap; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .vcr .vidtime { + padding-left: lh(0.75); + display: inline-block; + color: #cfd8dc; + -webkit-font-smoothing: antialiased; +} + +@media (max-width: 1120px) { + .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .vcr .vidtime { + padding-left: lh(0.5); + } +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls { + float: right; + border-left: 1px dotted #4f595d; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .add-fullscreen, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .grouped-controls, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .auto-advance, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .quality-control { + border-left: 1px dotted #4f595d; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .speed-button:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume>.control:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .add-fullscreen:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .auto-advance:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .quality-control:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .toggle-transcript:focus { + position: relative; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container { + position: relative; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu { + transition: none; + position: absolute; + display: none; + bottom: 100%; + right: 0; + width: 120px; + margin: 0; + border: none; + padding: 0; + box-shadow: none; + background-color: #282c2e; + list-style: none; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li { + color: #e7ecee; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .speed-option, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .control-lang { + text-align: left; + display: block; + width: 100%; + border: 0; + border-radius: 0; + padding: lh(0.5); + background: #282c2e; + box-shadow: none; + color: #e7ecee; + overflow: hidden; + text-shadow: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .speed-option:hover, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .speed-option:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .control-lang:hover, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .control-lang:focus { + background-color: #4f595d; + color: #fcfcfc; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li.is-active .speed-option, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li.is-active .control-lang { + border-left: calc(var(--baseline, 20px) / 10) solid #90d7f9; + font-weight: var(--font-bold, 700); + color: #90d7f9; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container.is-opened .menu { + display: block; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .speeds, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .lang, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .grouped-controls { + display: inline-block; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .speeds.is-opened .control .icon { + transform: rotate(-90deg); +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .speeds .speed-button .label { + padding: 0 calc((var(--baseline, 20px) / 3)) 0 0; + font-family: var(--font-family-sans-serif); + color: #e7ecee; +} + +@media (max-width: 1120px) { + .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .speeds .speed-button .label { + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + } +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .speeds .speed-button .value { + padding: 0 lh(0.5) 0 0; + color: #e7ecee; font-weight: bold; } -.video p { +@media (max-width: 1120px) { + .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .speeds .speed-button .value { + padding: 0 lh(0.5); + } +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .lang .language-menu { + width: var(--baseline, 20px); + padding: calc((var(--baseline, 20px) / 2)) 0; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .lang.is-opened .control .icon { + transform: rotate(90deg); +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume { + display: inline-block; + position: relative; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume.is-opened .volume-slider-container { + display: block; + opacity: 1; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume:not(:first-child)>a { + border-left: none; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container { + transition: none; + display: none; + position: absolute; + bottom: 100%; + right: 0; + width: 41px; + height: 120px; + background-color: #282c2e; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider { + height: 100px; + width: calc((var(--baseline, 20px) / 4)); + margin: 14px auto; + box-sizing: border-box; + border: 1px solid #4f595d; + background: #4f595d; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle { + transition: height var(--tmg-s2, 2s) ease-in-out 0s, width var(--tmg-s2, 2s) ease-in-out 0s; + left: -5px; + box-sizing: border-box; + height: 13px; + width: 13px; + border: 1px solid #cb598d; + border-radius: calc((var(--baseline, 20px) / 5)); + padding: 0; + background: #cb598d; + box-shadow: none; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle:hover, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle:focus { + background: #db8baf; + border-color: #db8baf; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-range { + background: #8e3e63; + border: 1px solid #8e3e63; + left: -1px; + bottom: -1px; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .quality-control { + font-weight: 700; + letter-spacing: -1px; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .quality-control.active { + color: #0ea6ec; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .quality-control.is-hidden, +.xmodule_display.xmodule_VideoBlock .video.closed .video-wrapper .video-controls .secondary-controls .quality-control.subtitles { + display: none !important; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .toggle-transcript.is-active { + color: #0ea6ec; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .lang>.hide-subtitles { + transition: none; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper:hover .video-controls .slider { + height: calc((var(--baseline, 20px) / 1.5)); +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper:hover .video-controls .slider .ui-slider-handle { + height: calc((var(--baseline, 20px) / 1.5)); + width: calc((var(--baseline, 20px) / 1.5)); +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .closed-captions { + width: 65%; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen.closed .closed-captions { + width: 90%; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles { + float: left; + overflow: auto; + max-height: 460px; + width: 31.81818%; + padding: 0; + font-size: 14px; + visibility: visible; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles a { + color: #0074b5; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu { + height: 100%; + margin: 0; + padding: 0 3px; + list-style: none; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li { + margin-bottom: 8px; + border: 0; + padding: 0; + color: #0074b5; + line-height: lh(); +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li:has(> span:empty) { + display: none; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li span { + display: block; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li.current { + color: #333; + font-weight: 700; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li.focused { + outline: #000 dotted thin; + outline-offset: -1px; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li:hover, +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li:focus { + text-decoration: underline; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li:empty { + margin-bottom: 0; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li.spacing:last-of-type { + position: relative; +} + +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li.spacing:last-of-type .transcript-end { + position: absolute; + bottom: 0; +} + +.xmodule_display.xmodule_VideoBlock .video.closed .video-wrapper { + width: 100%; + background-color: inherit; +} + +.xmodule_display.xmodule_VideoBlock .video.closed .video-wrapper .video-controls.html5 { + bottom: 0; + left: 0; + right: 0; + position: absolute; + z-index: 1; +} + +.xmodule_display.xmodule_VideoBlock .video.closed .video-wrapper .video-player-pre, +.xmodule_display.xmodule_VideoBlock .video.closed .video-wrapper .video-player-post { + height: 0; +} + +.xmodule_display.xmodule_VideoBlock .video.closed .video-wrapper .video-player h3 { + color: black; +} + +.xmodule_display.xmodule_VideoBlock .video.closed .subtitles.html5 { + background-color: rgba(243, 243, 243, 0.8); + height: 100%; + position: absolute; + right: 0; + bottom: 0; + top: 0; + width: 275px; + padding: 0 var(--baseline, 20px); + display: none; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen { + background: rgba(0, 0, 0, 0.95); + border: 0; + bottom: 0; + height: 100%; + left: 0; + margin: 0; + padding: 0; + position: fixed; + top: 0; + width: 100%; + vertical-align: middle; + border-radius: 0; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen.closed .tc-wrapper .video-wrapper { + width: 100%; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .video-wrapper .video-player-pre, +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .video-wrapper .video-player-post { + height: 0; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .video-wrapper { + position: static; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .video-wrapper .video-player h3 { + color: white; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .tc-wrapper { + width: 100%; + height: 100%; + position: static; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .tc-wrapper:after { + content: ""; + display: table; + clear: both; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .tc-wrapper .video-wrapper { + height: 100%; + width: 75%; + margin-right: 0; + vertical-align: middle; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .tc-wrapper .video-wrapper object, +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .tc-wrapper .video-wrapper iframe, +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .tc-wrapper .video-wrapper video { + position: absolute; + width: auto; + height: auto; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .tc-wrapper .video-controls { + position: absolute; + bottom: 0; + left: 0; + width: 100%; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .subtitles { + height: 100%; + width: 25%; + padding: lh(); + box-sizing: border-box; + transition: none; + background: var(--black, #000); + visibility: visible; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .subtitles li { + color: #aaa; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .subtitles li.current { + color: var(--white, #fff); +} + +.xmodule_display.xmodule_VideoBlock .video.is-touch .tc-wrapper .video-wrapper object, +.xmodule_display.xmodule_VideoBlock .video.is-touch .tc-wrapper .video-wrapper iframe, +.xmodule_display.xmodule_VideoBlock .video.is-touch .tc-wrapper .video-wrapper video { + width: 100%; + height: 100%; +} + +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-position: 50% 50%; + background-repeat: no-repeat; + background-size: 100%; + background-color: var(--black, #000); +} + +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll.is-html5 { + background-size: 15%; +} + +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll { + padding: var(--baseline, 20px); + border: none; + border-radius: var(--baseline, 20px); + background: var(--black-t2, rgba(0, 0, 0, 0.5)); + box-shadow: none; +} + +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll::after { + display: none; +} + +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll img { + height: calc((var(--baseline, 20px) * 4)); + width: calc((var(--baseline, 20px) * 4)); +} + +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll:hover, +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll:focus { + background: var(--blue, #0075b4); +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle, +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li, +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li { + cursor: pointer; +} + +.xmodule_display.xmodule_VideoBlock .video.closed .subtitles.html5 { + z-index: 0; +} + +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container { + z-index: 10; +} + +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll, +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list { + z-index: 1000; +} + +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen, +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .tc-wrapper .video-controls, +.xmodule_display.xmodule_VideoBlock .overlay { + z-index: 10000; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu, +.xmodule_display.xmodule_VideoBlock .submenu { + z-index: 100000; +} + +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container>a::after { + font-family: FontAwesome; + -webkit-font-smoothing: antialiased; + display: inline-block; + speak: none; +} + +.xmodule_display.xmodule_VideoBlock .a11y-menu-container { + position: relative; +} + +.xmodule_display.xmodule_VideoBlock .a11y-menu-container.open .a11y-menu-list { + display: block; +} + +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list { + top: 100%; + margin: 0; + padding: 0; + display: none; + position: absolute; + list-style: none; + background-color: var(--white, #fff); + border: 1px solid #eee; +} + +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li { + margin: 0; + padding: 0; + border-bottom: 1px solid #eee; + color: var(--white, #fff); +} + +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--gray-l2, #adadad); + font-size: 14px; + line-height: 23px; +} + +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a:hover, +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a:focus { + color: var(--gray-d1, #5e5e5e); +} + +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li.active a { + color: #009fe6; +} + +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li:last-child { + box-shadow: none; + border-bottom: 0; + margin-top: 0; +} + +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container { + display: inline-block; + vertical-align: top; + border-left: 1px solid #eee; +} + +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container.open>a { + background-color: var(--action-primary-active-bg, #0075b4); + color: var(--very-light-text, white); +} + +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container.open>a::after { + color: var(--very-light-text, white); +} + +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container>a { + transition: all var(--tmg-f2, 0.25s) ease-in-out 0s; + font-size: 12px; + display: block; + border-radius: 0 3px 3px 0; + background-color: var(--very-light-text, white); + padding: calc((var(--baseline, 20px) * 0.75)) calc((var(--baseline, 20px) * 1.25)) calc((var(--baseline, 20px) * 0.75)) calc((var(--baseline, 20px) * 0.75)); + color: var(--gray-l2, #adadad); + min-width: 1.5em; + line-height: 14px; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; +} + +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container>a::after { + content: "\f0d7"; + position: absolute; + right: calc((var(--baseline, 20px) * 0.5)); + top: 33%; + color: var(--lighter-base-font-color, #646464); +} + +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container .a11y-menu-list { + right: 0; +} + +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container .a11y-menu-list li { + font-size: 0.875em; +} + +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container .a11y-menu-list li a { + border: 0; + display: block; + padding: 0.70788em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu, +.xmodule_display.xmodule_VideoBlock .submenu { + border: 1px solid #333; + background: var(--white, #fff); + color: #333; + padding: 0; + margin: 0; + list-style: none; + position: absolute; + top: 0; + display: none; + outline: none; + cursor: default; + white-space: nowrap; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu.is-opened, +.xmodule_display.xmodule_VideoBlock .submenu.is-opened { + display: block; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .menu-item, +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item, +.xmodule_display.xmodule_VideoBlock .submenu .menu-item, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item { + border-top: 1px solid var(--gray-l3, #c8c8c8); + padding: calc((var(--baseline, 20px) / 4)) calc((var(--baseline, 20px) / 2)); + outline: none; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .menu-item>span, +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item>span, +.xmodule_display.xmodule_VideoBlock .submenu .menu-item>span, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item>span { + color: #333; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .menu-item:first-child, +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item:first-child, +.xmodule_display.xmodule_VideoBlock .submenu .menu-item:first-child, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item:first-child { + border-top: none; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .menu-item:focus, +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item:focus, +.xmodule_display.xmodule_VideoBlock .submenu .menu-item:focus, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item:focus { + background: #333; + color: var(--white, #fff); +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .menu-item:focus>span, +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item:focus>span, +.xmodule_display.xmodule_VideoBlock .submenu .menu-item:focus>span, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item:focus>span { + color: var(--white, #fff); +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item { + position: relative; + padding: calc((var(--baseline, 20px) / 4)) var(--baseline, 20px) calc((var(--baseline, 20px) / 4)) calc((var(--baseline, 20px) / 2)); +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item::after, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item::after { + content: '\25B6'; + position: absolute; + right: 5px; + line-height: 25px; + font-size: 10px; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item .submenu, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item .submenu { + display: none; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item.is-opened { + background: #333; + color: var(--white, #fff); +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened>span, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item.is-opened>span { + color: var(--white, #fff); +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened>.submenu, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item.is-opened>.submenu { + display: block; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item .is-selected, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item .is-selected { + font-weight: bold; +} + +.xmodule_display.xmodule_VideoBlock .contextmenu .is-disabled, +.xmodule_display.xmodule_VideoBlock .submenu .is-disabled { + pointer-events: none; + color: var(--gray-l3, #c8c8c8); +} + +.xmodule_display.xmodule_VideoBlock .overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: transparent; +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .social-toggle-btn { + background: var(--primary); + font-size: 13px; + font-weight: 700; + padding: calc(var(--baseline) * 0.35) calc(var(--baseline) * 0.9); + color: var(--white); + box-shadow: none; + text-shadow: none; + border-radius: 3px; + border: none; +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .social-toggle-btn:hover, +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .social-toggle-btn:focus { + background: var(--btn-brand-focus-background); +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .social-toggle-btn .fa { + margin-right: calc(var(--baseline) * 0.4); +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .container-social-share { + padding: calc(var(--baseline) * 0.4); + width: 300px; + border-radius: 6px; + background-color: var(--white); + box-shadow: rgba(0, 0, 0, 0.15) 0 0.5rem 1rem, rgba(0, 0, 0, 0.15) 0 0.25rem 0.625rem; +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .container-social-share .close-btn { + float: right; + cursor: pointer; + vertical-align: top; + display: inline-flex; + color: var(--black); + text-decoration: none !important; +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .container-social-share .social-share-link { + margin-right: calc(var(--baseline) * 0.2); + font-size: 24px; + height: 24px; + vertical-align: middle; + text-decoration: none; + display: inline-flex; +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .container-social-share .social-share-link > span > svg { + width: auto; + height: 24px; + vertical-align: top; + display: inline-flex; +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .container-social-share .public-video-url-container { + padding: calc(var(--baseline) * 0.4); + display: flex; + align-items: center; + justify-content: space-between; + background-color: #f2f0ef; +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .container-social-share .public-video-url-link { + color: var(--black); + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + white-space: nowrap; +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .container-social-share .public-video-url-link:hover { + text-decoration: underline; +} + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .container-social-share .public-video-copy-btn { + margin-left: calc(var(--baseline) * 0.7); + flex-shrink: 0; + color: var(--primary); cursor: pointer; } + +.xmodule_display.xmodule_VideoBlock .wrapper-social-share .container-social-share .public-video-copy-btn:hover { + text-decoration: none; + color: var(--link-hover-color); +} diff --git a/xblocks_contrib/video/static/js/src/00_async_process.js b/xblocks_contrib/video/static/js/src/00_async_process.js new file mode 100644 index 00000000..a909e822 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_async_process.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Provides convenient way to process big amount of data without UI blocking. + * + * @param {array} list Array to process. + * @param {function} process Calls this function on each item in the list. + * @return {array} Returns a Promise object to observe when all actions of a + * certain type bound to the collection, queued or not, have finished. + */ +let AsyncProcess = { + array: function(list, process) { + if (!_.isArray(list)) { + return $.Deferred().reject().promise(); + } + + if (!_.isFunction(process) || !list.length) { + return $.Deferred().resolve(list).promise(); + } + + let MAX_DELAY = 50, // maximum amount of time that js code should be allowed to run continuously + dfd = $.Deferred(); + let result = []; + let index = 0; + let len = list.length; + + let getCurrentTime = function() { + return (new Date()).getTime(); + }; + + let handler = function() { + let start = getCurrentTime(); + + do { + result[index] = process(list[index], index); + index++; + } while (index < len && getCurrentTime() - start < MAX_DELAY); + + if (index < len) { + setTimeout(handler, 25); + } else { + dfd.resolve(result); + } + }; + + setTimeout(handler, 25); + + return dfd.promise(); + } +}; + +export default AsyncProcess; diff --git a/xblocks_contrib/video/static/js/src/00_component.js b/xblocks_contrib/video/static/js/src/00_component.js new file mode 100644 index 00000000..2ac183b1 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_component.js @@ -0,0 +1,81 @@ +'use strict'; + +import _ from 'underscore'; + + +/** + * Creates a new object with the specified prototype object and properties. + * @param {Object} o The object which should be the prototype of the + * newly-created object. + * @private + * @throws {TypeError, Error} + * @return {Object} + */ +let inherit = Object.create || (function() { + let F = function() {}; + + return function(o) { + if (arguments.length > 1) { + throw Error('Second argument not supported'); + } + if (_.isNull(o) || _.isUndefined(o)) { + throw Error('Cannot set a null [[Prototype]]'); + } + if (!_.isObject(o)) { + throw TypeError('Argument must be an object'); + } + + F.prototype = o; + + return new F(); + }; +}()); + +/** + * Component module. + * @exports video/00_component.js + * @constructor + * @return {jquery Promise} + */ +let Component = function() { + if ($.isFunction(this.initialize)) { + // eslint-disable-next-line prefer-spread + return this.initialize.apply(this, arguments); + } +}; + +/** + * Returns new constructor that inherits form the current constructor. + * @static + * @param {Object} protoProps The object containing which will be added to + * the prototype. + * @return {Object} + */ +Component.extend = function(protoProps, staticProps) { + let Parent = this; + let Child = function() { + if ($.isFunction(this.initialize)) { + // eslint-disable-next-line prefer-spread + return this.initialize.apply(this, arguments); + } + }; + + // Inherit methods and properties from the Parent prototype. + Child.prototype = inherit(Parent.prototype); + Child.constructor = Parent; + // Provide access to parent's methods and properties + Child.__super__ = Parent.prototype; + + // Extends inherited methods and properties by methods/properties + // passed as argument. + if (protoProps) { + $.extend(Child.prototype, protoProps); + } + + // Inherit static methods and properties + $.extend(Child, Parent, staticProps); + + return Child; +}; + +export default Component; diff --git a/xblocks_contrib/video/static/js/src/00_i18n.js b/xblocks_contrib/video/static/js/src/00_i18n.js new file mode 100644 index 00000000..1962ed4e --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_i18n.js @@ -0,0 +1,35 @@ +'use strict'; + +/** + * i18n module. + * @exports video/00_i18n.js + * @return {object} + */ + +let i18n = { + Play: gettext('Play'), + Pause: gettext('Pause'), + Mute: gettext('Mute'), + Unmute: gettext('Unmute'), + 'Exit full browser': gettext('Exit full browser'), + 'Fill browser': gettext('Fill browser'), + Speed: gettext('Speed'), + 'Auto-advance': gettext('Auto-advance'), + Volume: gettext('Volume'), + // Translators: Volume level equals 0%. + Muted: gettext('Muted'), + // Translators: Volume level in range ]0,20]% + 'Very low': gettext('Very low'), + // Translators: Volume level in range ]20,40]% + Low: gettext('Low'), + // Translators: Volume level in range ]40,60]% + Average: gettext('Average'), + // Translators: Volume level in range ]60,80]% + Loud: gettext('Loud'), + // Translators: Volume level in range ]80,99]% + 'Very loud': gettext('Very loud'), + // Translators: Volume level equals 100%. + Maximum: gettext('Maximum') +}; + +export default i18n; diff --git a/xblocks_contrib/video/static/js/src/00_iterator.js b/xblocks_contrib/video/static/js/src/00_iterator.js new file mode 100644 index 00000000..5b597f20 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_iterator.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Provides convenient way to work with iterable data. + * @exports video/00_iterator.js + * @constructor + * @param {array} list Array to be iterated. + */ +let Iterator = function(list) { + this.list = list; + this.index = 0; + this.size = this.list.length; + this.lastIndex = this.list.length - 1; +}; + +Iterator.prototype = { + + /** + * Checks validity of provided index for the iterator. + * @access protected + * @param {numebr} index + * @return {boolean} + */ + _isValid: function(index) { + return _.isNumber(index) && index < this.size && index >= 0; + }, + + /** + * Returns next element. + * @param {number} [index] Updates current position. + * @return {any} + */ + next: function(index) { + if (!(this._isValid(index))) { + index = this.index; + } + + this.index = (index >= this.lastIndex) ? 0 : index + 1; + + return this.list[this.index]; + }, + + /** + * Returns previous element. + * @param {number} [index] Updates current position. + * @return {any} + */ + prev: function(index) { + if (!(this._isValid(index))) { + index = this.index; + } + + this.index = (index < 1) ? this.lastIndex : index - 1; + + return this.list[this.index]; + }, + + /** + * Returns last element in the list. + * @return {any} + */ + last: function() { + return this.list[this.lastIndex]; + }, + + /** + * Returns first element in the list. + * @return {any} + */ + first: function() { + return this.list[0]; + }, + + /** + * Returns `true` if current position is last for the iterator. + * @return {boolean} + */ + isEnd: function() { + return this.index === this.lastIndex; + } +}; + +export default Iterator; diff --git a/xblocks_contrib/video/static/js/src/00_resizer.js b/xblocks_contrib/video/static/js/src/00_resizer.js new file mode 100644 index 00000000..d892ec4d --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_resizer.js @@ -0,0 +1,236 @@ +'use strict'; + +import _ from 'underscore'; + + +let Resizer = function(params) { + let defaults = { + container: window, + element: null, + containerRatio: null, + elementRatio: null + }, + callbacksList = [], + delta = { + height: 0, + width: 0 + }, + module = {}; + let mode = null, + config; + + // eslint-disable-next-line no-shadow + let initialize = function(params) { + if (!config) { + config = defaults; + } + + config = $.extend(true, {}, config, params); + + if (!config.element) { + console.log( + 'Required parameter `element` is not passed.' + ); + } + + return module; + }; + + let getData = function() { + let $container = $(config.container), + containerWidth = $container.width() + delta.width, + containerHeight = $container.height() + delta.height; + let containerRatio = config.containerRatio; + + let $element = $(config.element); + let elementRatio = config.elementRatio; + + if (!containerRatio) { + containerRatio = containerWidth / containerHeight; + } + + if (!elementRatio) { + elementRatio = $element.width() / $element.height(); + } + + return { + containerWidth: containerWidth, + containerHeight: containerHeight, + containerRatio: containerRatio, + element: $element, + elementRatio: elementRatio + }; + }; + + let align = function() { + let data = getData(); + + switch (mode) { + case 'height': + alignByHeightOnly(); + break; + + case 'width': + alignByWidthOnly(); + break; + + default: + if (data.containerRatio >= data.elementRatio) { + alignByHeightOnly(); + } else { + alignByWidthOnly(); + } + break; + } + + fireCallbacks(); + + return module; + }; + + let alignByWidthOnly = function() { + let data = getData(), + height = data.containerWidth / data.elementRatio; + + data.element.css({ + height: height, + width: data.containerWidth, + top: 0.5 * (data.containerHeight - height), + left: 0 + }); + + return module; + }; + + let alignByHeightOnly = function() { + let data = getData(), + width = data.containerHeight * data.elementRatio; + + data.element.css({ + height: data.containerHeight, + width: data.containerHeight * data.elementRatio, + top: 0, + left: 0.5 * (data.containerWidth - width) + }); + + return module; + }; + + let setMode = function(param) { + if (_.isString(param)) { + mode = param; + align(); + } + + return module; + }; + + let setElement = function(element) { + config.element = element; + + return module; + }; + + let addCallback = function(func) { + if ($.isFunction(func)) { + callbacksList.push(func); + } else { + console.error('[Video info]: TypeError: Argument is not a function.'); + } + + return module; + }; + + let addOnceCallback = function(func) { + if ($.isFunction(func)) { + let decorator = function() { + func(); + removeCallback(func); + }; + + addCallback(decorator); + } else { + console.error('TypeError: Argument is not a function.'); + } + + return module; + }; + + let fireCallbacks = function() { + $.each(callbacksList, function(index, callback) { + callback(); + }); + }; + + let removeCallbacks = function() { + callbacksList.length = 0; + + return module; + }; + + let removeCallback = function(func) { + let index = $.inArray(func, callbacksList); + + if (index !== -1) { + return callbacksList.splice(index, 1); + } + }; + + let resetDelta = function() { + // eslint-disable-next-line no-multi-assign + delta.height = delta.width = 0; + + return module; + }; + + let addDelta = function(value, side) { + if (_.isNumber(value) && _.isNumber(delta[side])) { + delta[side] += value; + } + + return module; + }; + + let substractDelta = function(value, side) { + if (_.isNumber(value) && _.isNumber(delta[side])) { + delta[side] -= value; + } + + return module; + }; + + let destroy = function() { + let data = getData(); + data.element.css({ + height: '', width: '', top: '', left: '' + }); + removeCallbacks(); + resetDelta(); + mode = null; + }; + + initialize.apply(module, arguments); + + return $.extend(true, module, { + align: align, + alignByWidthOnly: alignByWidthOnly, + alignByHeightOnly: alignByHeightOnly, + destroy: destroy, + setParams: initialize, + setMode: setMode, + setElement: setElement, + callbacks: { + add: addCallback, + once: addOnceCallback, + remove: removeCallback, + removeAll: removeCallbacks + }, + delta: { + add: addDelta, + substract: substractDelta, + reset: resetDelta + } + }); +}; + +export default Resizer; diff --git a/xblocks_contrib/video/static/js/src/00_sjson.js b/xblocks_contrib/video/static/js/src/00_sjson.js new file mode 100644 index 00000000..99d870ff --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_sjson.js @@ -0,0 +1,108 @@ +'use strict'; + +let Sjson = function(data) { + let sjson = { + start: data.start.concat(), + text: data.text.concat() + }, + module = {}; + + let getter = function(propertyName) { + return function() { + return sjson[propertyName]; + }; + }; + + let getStartTimes = getter('start'); + + let getCaptions = getter('text'); + + let size = function() { + return sjson.text.length; + }; + + function search(time, startTime, endTime) { + let start = getStartTimes(), + max = size() - 1, + min = 0, + results, + index; + + // if we specify a start and end time to search, + // search the filtered list of captions in between + // the start / end times. + // Else, search the unfiltered list. + if (typeof startTime !== 'undefined' + && typeof endTime !== 'undefined') { + results = filter(startTime, endTime); + start = results.start; + max = results.captions.length - 1; + } else { + start = getStartTimes(); + } + while (min < max) { + index = Math.ceil((max + min) / 2); + + if (time < start[index]) { + max = index - 1; + } + + if (time >= start[index]) { + min = index; + } + } + + return min; + } + + function filter(start, end) { + /* filters captions that occur between inputs + * `start` and `end`. Start and end should + * be Numbers (doubles) corresponding to the + * number of seconds elapsed since the beginning + * of the video. + * + * Returns an object with properties + * "start" and "captions" representing + * parallel arrays of start times and + * their corresponding captions. + */ + let filteredTimes = []; + let filteredCaptions = []; + let startTimes = getStartTimes(); + let captions = getCaptions(); + + if (startTimes.length !== captions.length) { + console.warn('video caption and start time arrays do not match in length'); + } + + // if end is null, then it's been set to + // some erroneous value, so filter using the + // entire array as long as it's not empty + if (end === null && startTimes.length) { + end = startTimes[startTimes.length - 1]; + } + + _.filter(startTimes, function(currentStartTime, i) { + if (currentStartTime >= start && currentStartTime <= end) { + filteredTimes.push(currentStartTime); + filteredCaptions.push(captions[i]); + } + }); + + return { + start: filteredTimes, + captions: filteredCaptions + }; + } + + return { + getCaptions: getCaptions, + getStartTimes: getStartTimes, + getSize: size, + filter: filter, + search: search + }; +}; + +export default Sjson; diff --git a/xblocks_contrib/video/static/js/src/00_video_storage.js b/xblocks_contrib/video/static/js/src/00_video_storage.js new file mode 100644 index 00000000..f2293336 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/00_video_storage.js @@ -0,0 +1,96 @@ +'use strict'; + +/** + * Provides convenient way to store key value pairs. + * + * @param {string} namespace Namespace that is used to store data. + * @return {object} VideoStorage API. + */ +let VideoStorage = function(namespace, id) { + /** + * Adds new value to the storage or rewrites existent. + * + * @param {string} name Identifier of the data. + * @param {any} value Data to store. + * @param {boolean} instanceSpecific Data with this flag will be added + * to instance specific storage. + */ + let setItem = function(name, value, instanceSpecific) { + if (name) { + if (instanceSpecific) { + window[namespace][id][name] = value; + } else { + window[namespace][name] = value; + } + } + }; + + /** + * Returns the current value associated with the given name. + * + * @param {string} name Identifier of the data. + * @param {boolean} instanceSpecific Data with this flag will be added + * to instance specific storage. + * @return {any} The current value associated with the given name. + * If the given key does not exist in the list + * associated with the object then this method must return null. + */ + let getItem = function(name, instanceSpecific) { + if (instanceSpecific) { + return window[namespace][id][name]; + } else { + return window[namespace][name]; + } + }; + + /** + * Removes the current value associated with the given name. + * + * @param {string} name Identifier of the data. + * @param {boolean} instanceSpecific Data with this flag will be added + * to instance specific storage. + */ + let removeItem = function(name, instanceSpecific) { + if (instanceSpecific) { + delete window[namespace][id][name]; + } else { + delete window[namespace][name]; + } + }; + + /** + * Empties the storage. + * + */ + let clear = function() { + window[namespace] = {}; + window[namespace][id] = {}; + }; + + /** + * Initializes the module: creates a storage with proper namespace. + * + * @private + */ + (function initialize() { + if (!namespace) { + namespace = 'VideoStorage'; + } + if (!id) { + // Generate random alpha-numeric string. + id = Math.random().toString(36).slice(2); + } + + window[namespace] = window[namespace] || {}; + window[namespace][id] = window[namespace][id] || {}; + }()); + + return { + clear: clear, + getItem: getItem, + removeItem: removeItem, + setItem: setItem + }; +}; + +export default VideoStorage; diff --git a/xblocks_contrib/video/static/js/src/01_initialize.js b/xblocks_contrib/video/static/js/src/01_initialize.js new file mode 100644 index 00000000..85248b3f --- /dev/null +++ b/xblocks_contrib/video/static/js/src/01_initialize.js @@ -0,0 +1,845 @@ +/* eslint-disable no-console, no-param-reassign */ +/** + * @file Initialize module works with the JSON config, and sets up various + * settings, parameters, variables. After all setup actions are performed, it + * invokes the video player to play the specified video. This module must be + * invoked first. It provides several functions which do not fit in with other + * modules. + * + * @external VideoPlayer + * + * @module Initialize + */ + +import VideoPlayer from './03_video_player.js'; +import i18n from './00_i18n.js'; +import _ from 'underscore'; +import moment from 'moment'; + +/** + * @function + * + * Initialize module exports this function. + * + * @param {object} state The object containg the state of the video player. + * All other modules, their parameters, public variables, etc. are + * available via this object. + * @param {DOM element} element Container of the entire Video DOM element. + */ +let Initialize = function(state, element) { + _makeFunctionsPublic(state); + + state.initialize(element) + .done(function() { + if (state.isYoutubeType()) { + state.parseSpeed(); + } + // On iPhones and iPods native controls are used. + if (/iP(hone|od)/i.test(state.isTouch[0])) { + _hideWaitPlaceholder(state); + state.el.trigger('initialize', arguments); + + return false; + } + + _initializeModules(state, i18n) + .done(function() { + // On iPad ready state occurs just after start playing. + // We hide controls before video starts playing. + if (/iPad|Android/i.test(state.isTouch[0])) { + state.el.on('play', _.once(function() { + state.trigger('videoControl.show', null); + })); + } else { + // On PC show controls immediately. + state.trigger('videoControl.show', null); + } + + _hideWaitPlaceholder(state); + state.el.trigger('initialize', arguments); + }); + }); +}; + +/* eslint-disable no-use-before-define */ +let methodsDict = { + bindTo: bindTo, + fetchMetadata: fetchMetadata, + getCurrentLanguage: getCurrentLanguage, + getDuration: getDuration, + getPlayerMode: getPlayerMode, + getVideoMetadata: getVideoMetadata, + initialize: initialize, + isHtml5Mode: isHtml5Mode, + isFlashMode: isFlashMode, + isYoutubeType: isYoutubeType, + parseSpeed: parseSpeed, + parseYoutubeStreams: parseYoutubeStreams, + setPlayerMode: setPlayerMode, + setSpeed: setSpeed, + setAutoAdvance: setAutoAdvance, + speedToString: speedToString, + trigger: trigger, + youtubeId: youtubeId, + loadHtmlPlayer: loadHtmlPlayer, + loadYoutubePlayer: loadYoutubePlayer, + loadYouTubeIFrameAPI: loadYouTubeIFrameAPI +}; +/* eslint-enable no-use-before-define */ + +let _youtubeApiDeferred = null; +let _oldOnYouTubeIframeAPIReady; + +Initialize.prototype = methodsDict; + +export default Initialize; + +// *************************************************************** +// Private functions start here. Private functions start with underscore. +// *************************************************************** + +/** + * @function _makeFunctionsPublic + * + * Functions which will be accessible via 'state' object. When called, + * these functions will get the 'state' + * object as a context. + * + * @param {object} state The object containg the state (properties, + * methods, modules) of the Video player. + */ +function _makeFunctionsPublic(state) { + bindTo(methodsDict, state, state); +} + +// function _renderElements(state) +// +// Create any necessary DOM elements, attach them, and set their +// initial configuration. Also make the created DOM elements available +// via the 'state' object. Much easier to work this way - you don't +// have to do repeated jQuery element selects. +function _renderElements(state) { + // Launch embedding of actual video content, or set it up so that it + // will be done as soon as the appropriate video player (YouTube or + // stand-alone HTML5) is loaded, and can handle embedding. + // + // Note that the loading of stand alone HTML5 player API is handled by + // Require JS. At the time when we reach this code, the stand alone + // HTML5 player is already loaded, so no further testing in that case + // is required. + let video; + let onYTApiReady; + let setupOnYouTubeIframeAPIReady; + + if (state.videoType === 'youtube') { + state.youtubeApiAvailable = false; + + onYTApiReady = function() { + console.log('[Video info]: YouTube API is available and is loaded.'); + if (state.htmlPlayerLoaded) { return; } + + console.log('[Video info]: Starting YouTube player.'); + video = VideoPlayer(state); + + state.modules.push(video); + state.__dfd__.resolve(); + state.youtubeApiAvailable = true; + }; + + if (window.YT) { + // If we have a Deferred object responsible for calling OnYouTubeIframeAPIReady + // callbacks, make sure that they have all been called by trying to resolve the + // Deferred object. Upon resolving, all the OnYouTubeIframeAPIReady will be + // called. If the object has been already resolved, the callbacks will not + // be called a second time. + if (_youtubeApiDeferred) { + _youtubeApiDeferred.resolve(); + } + + window.YT.ready(onYTApiReady); + } else { + // There is only one global variable window.onYouTubeIframeAPIReady which + // is supposed to be a function that will be called by the YouTube API + // when it finished initializing. This function will update this global function + // so that it resolves our Deferred object, which will call all of the + // OnYouTubeIframeAPIReady callbacks. + // + // If this global function is already defined, we store it first, and make + // sure that it gets executed when our Deferred object is resolved. + setupOnYouTubeIframeAPIReady = function() { + _oldOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady || undefined; + + window.onYouTubeIframeAPIReady = function() { + _youtubeApiDeferred.resolve(); + }; + + window.onYouTubeIframeAPIReady.done = _youtubeApiDeferred.done; + + if (_oldOnYouTubeIframeAPIReady) { + window.onYouTubeIframeAPIReady.done(_oldOnYouTubeIframeAPIReady); + } + }; + + // If a Deferred object hasn't been created yet, create one now. It will + // be responsible for calling OnYouTubeIframeAPIReady callbacks once the + // YouTube API loads. After creating the Deferred object, load the YouTube + // API. + if (!_youtubeApiDeferred) { + _youtubeApiDeferred = $.Deferred(); + setupOnYouTubeIframeAPIReady(); + } else if (!window.onYouTubeIframeAPIReady || !window.onYouTubeIframeAPIReady.done) { + // The Deferred object could have been already defined in a previous + // initialization of the video module. However, since then the global variable + // window.onYouTubeIframeAPIReady could have been overwritten. If so, + // we should set it up again. + setupOnYouTubeIframeAPIReady(); + } + + // Attach a callback to our Deferred object to be called once the + // YouTube API loads. + window.onYouTubeIframeAPIReady.done(function() { + window.YT.ready(onYTApiReady); + }); + } + } else { + video = VideoPlayer(state); + + state.modules.push(video); + state.__dfd__.resolve(); + state.htmlPlayerLoaded = true; + } +} + +function _waitForYoutubeApi(state) { + console.log('[Video info]: Starting to wait for YouTube API to load.'); + window.setTimeout(function() { + // If YouTube API will load OK, it will run `onYouTubeIframeAPIReady` + // callback, which will set `state.youtubeApiAvailable` to `true`. + // If something goes wrong at this stage, `state.youtubeApiAvailable` is + // `false`. + if (!state.youtubeApiAvailable) { + console.log('[Video info]: YouTube API is not available.'); + if (!state.htmlPlayerLoaded) { + state.loadHtmlPlayer(); + } + } + state.el.trigger('youtube_availability', [state.youtubeApiAvailable]); + }, state.config.ytTestTimeout); +} + +function loadYouTubeIFrameAPI(scriptTag) { + let firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(scriptTag, firstScriptTag); +} + +// function _parseYouTubeIDs(state) +// The function parse YouTube stream ID's. +// @return +// false: We don't have YouTube video IDs to work with; most likely +// we have HTML5 video sources. +// true: Parsing of YouTube video IDs went OK, and we can proceed +// onwards to play YouTube videos. +function _parseYouTubeIDs(state) { + if (state.parseYoutubeStreams(state.config.streams)) { + state.videoType = 'youtube'; + + return true; + } + + console.log( + '[Video info]: Youtube Video IDs are incorrect or absent.' + ); + + return false; +} + +/** + * Extract HLS video URLs from available video URLs. + * + * @param {object} state The object contaning the state (properties, methods, modules) of the Video player. + * @returns Array of available HLS video source urls. + */ +function extractHLSVideoSources(state) { + return _.filter(state.config.sources, function(source) { + return /\.m3u8(\?.*)?$/.test(source); + }); +} + +// function _prepareHTML5Video(state) +// The function prepare HTML5 video, parse HTML5 +// video sources etc. +function _prepareHTML5Video(state) { + state.speeds = ['0.75', '1.0', '1.25', '1.50', '2.0']; + // If none of the supported video formats can be played and there is no + // short-hand video links, than hide the spinner and show error message. + if (!state.config.sources.length) { + _hideWaitPlaceholder(state); + state.el + .find('.video-player div') + .addClass('hidden'); + state.el + .find('.video-player .video-error') + .removeClass('is-hidden'); + + return false; + } + + state.videoType = 'html5'; + + if (!_.keys(state.config.transcriptLanguages).length) { + state.config.showCaptions = false; + } + state.setSpeed(state.speed); + + return true; +} + +function _hideWaitPlaceholder(state) { + state.el + .addClass('is-initialized') + .find('.spinner') + .attr({ + 'aria-hidden': 'true', + tabindex: -1 + }); +} + +function _setConfigurations(state) { + state.setPlayerMode(state.config.mode); + // Possible value are: 'visible', 'hiding', and 'invisible'. + state.controlState = 'visible'; + state.controlHideTimeout = null; + state.captionState = 'invisible'; + state.captionHideTimeout = null; + state.HLSVideoSources = extractHLSVideoSources(state); +} + +// eslint-disable-next-line no-shadow +function _initializeModules(state, i18n) { + let dfd = $.Deferred(), + modulesList = $.map(state.modules, function(module) { + let options = state.options[module.moduleName] || {}; + if (_.isFunction(module)) { + return module(state, i18n, options); + } else if ($.isPlainObject(module)) { + return module; + } + }); + + $.when.apply(null, modulesList) + .done(dfd.resolve); + + return dfd.promise(); +} + +function _getConfiguration(data, storage) { + let isBoolean = function(value) { + let regExp = /^true$/i; + return regExp.test(value.toString()); + }, + // List of keys that will be extracted form the configuration. + extractKeys = [], + // Compatibility keys used to change names of some parameters in + // the final configuration. + compatKeys = { + start: 'startTime', + end: 'endTime' + }, + // Conversions used to pre-process some configuration data. + conversions = { + showCaptions: isBoolean, + autoplay: isBoolean, + autohideHtml5: isBoolean, + autoAdvance: function(value) { + let shouldAutoAdvance = storage.getItem('auto_advance'); + if (_.isUndefined(shouldAutoAdvance)) { + return isBoolean(value) || false; + } else { + return shouldAutoAdvance; + } + }, + savedVideoPosition: function(value) { + return storage.getItem('savedVideoPosition', true) + || Number(value) + || 0; + }, + speed: function(value) { + return storage.getItem('speed', true) || value; + }, + generalSpeed: function(value) { + return storage.getItem('general_speed') + || value + || '1.0'; + }, + transcriptLanguage: function(value) { + return storage.getItem('language') + || value + || 'en'; + }, + ytTestTimeout: function(value) { + value = parseInt(value, 10); + + if (!isFinite(value)) { + value = 1500; + } + + return value; + }, + startTime: function(value) { + value = parseInt(value, 10); + if (!isFinite(value) || value < 0) { + return 0; + } + + return value; + }, + endTime: function(value) { + value = parseInt(value, 10); + + if (!isFinite(value) || value === 0) { + return null; + } + + return value; + } + }, + config = {}; + + data = _.extend({ + startTime: 0, + endTime: null, + sub: '', + streams: '' + }, data); + + $.each(data, function(option, value) { + // Extract option that is in `extractKeys`. + if ($.inArray(option, extractKeys) !== -1) { + return; + } + + // Change option name to key that is in `compatKeys`. + if (compatKeys[option]) { + option = compatKeys[option]; + } + + // Pre-process data. + if (conversions[option]) { + if (_.isFunction(conversions[option])) { + value = conversions[option].call(this, value); + } else { + throw new TypeError(option + ' is not a function.'); + } + } + config[option] = value; + }); + + return config; +} + +// *************************************************************** +// Public functions start here. +// These are available via the 'state' object. Their context ('this' +// keyword) is the 'state' object. The magic private function that makes +// them available and sets up their context is makeFunctionsPublic(). +// *************************************************************** + +// function bindTo(methodsDict, obj, context, rewrite) +// Creates a new function with specific context and assigns it to the provided +// object. +// eslint-disable-next-line no-shadow +function bindTo(methodsDict, obj, context, rewrite) { + $.each(methodsDict, function(name, method) { + if (_.isFunction(method)) { + if (_.isUndefined(rewrite)) { + rewrite = true; + } + + if (_.isUndefined(obj[name]) || rewrite) { + obj[name] = _.bind(method, context); + } + } + }); +} + +function loadYoutubePlayer() { + if (this.htmlPlayerLoaded) { return; } + + console.log( + '[Video info]: Fetch metadata for YouTube video.' + ); + + this.fetchMetadata(); + this.parseSpeed(); +} + +function loadHtmlPlayer() { + // When the youtube link doesn't work for any reason + // (for example, firewall) any + // alternate sources should automatically play. + if (!_prepareHTML5Video(this)) { + console.log( + '[Video info]: Continue loading ' + + 'YouTube video.' + ); + + // Non-YouTube sources were not found either. + + this.el.find('.video-player div') + .removeClass('hidden'); + this.el.find('.video-player .video-error') + .addClass('is-hidden'); + + // If in reality the timeout was to short, try to + // continue loading the YouTube video anyways. + this.loadYoutubePlayer(); + } else { + console.log( + '[Video info]: Start HTML5 player.' + ); + + // In-browser HTML5 player does not support quality + // control. + this.el.find('.quality_control').hide(); + _renderElements(this); + } +} + +// function initialize(element) +// The function set initial configuration and preparation. + +function initialize(element) { + let self = this, + el = this.el, + id = this.id, + container = el.find('.video-wrapper'), + __dfd__ = $.Deferred(), + isTouch = onTouchBasedDevice() || ''; + + if (isTouch) { + el.addClass('is-touch'); + } + + $.extend(this, { + __dfd__: __dfd__, + container: container, + isFullScreen: false, + isTouch: isTouch + }); + + console.log('[Video info]: Initializing video with id "%s".', id); + + // We store all settings passed to us by the server in one place. These + // are "read only", so don't modify them. All variable content lives in + // 'state' object. + // jQuery .data() return object with keys in lower camelCase format. + this.config = $.extend({}, _getConfiguration(this.metadata, this.storage), { + element: element, + fadeOutTimeout: 1400, + captionsFreezeTime: 10000, + mode: $.cookie('edX_video_player_mode'), + // Available HD qualities will only be accessible once the video has + // been played once, via player.getAvailableQualityLevels. + availableHDQualities: [] + }); + + if (this.config.endTime < this.config.startTime) { + this.config.endTime = null; + } + + this.lang = this.config.transcriptLanguage; + this.speed = this.speedToString( + this.config.speed || this.config.generalSpeed + ); + this.auto_advance = this.config.autoAdvance; + this.htmlPlayerLoaded = false; + this.duration = this.metadata.duration; + + _setConfigurations(this); + + // If `prioritizeHls` is set to true than `hls` is the primary playback + if (this.config.prioritizeHls || !(_parseYouTubeIDs(this))) { + // If we do not have YouTube ID's, try parsing HTML5 video sources. + if (!_prepareHTML5Video(this)) { + __dfd__.reject(); + // Non-YouTube sources were not found either. + return __dfd__.promise(); + } + + console.log('[Video info]: Start player in HTML5 mode.'); + _renderElements(this); + } else { + _renderElements(this); + + _waitForYoutubeApi(this); + + let scriptTag = document.createElement('script'); + + scriptTag.src = this.config.ytApiUrl; + scriptTag.async = true; + + $(scriptTag).on('load', function() { + self.loadYoutubePlayer(); + }); + $(scriptTag).on('error', function() { + console.log( + '[Video info]: YouTube returned an error for ' + + 'video with id "' + self.id + '".' + ); + // If the video is already loaded in `_waitForYoutubeApi` by the + // time we get here, then we shouldn't load it again. + if (!self.htmlPlayerLoaded) { + self.loadHtmlPlayer(); + } + }); + + window.Video.loadYouTubeIFrameAPI(scriptTag); + } + return __dfd__.promise(); +} + +// function parseYoutubeStreams(state, youtubeStreams) +// +// Take a string in the form: +// "iCawTYPtehk:0.75,KgpclqP-LBA:1.0,9-2670d5nvU:1.5" +// parse it, and make it available via the 'state' object. If we are +// not given a string, or it's length is zero, then we return false. +// +// @return +// false: We don't have YouTube video IDs to work with; most likely +// we have HTML5 video sources. +// true: Parsing of YouTube video IDs went OK, and we can proceed +// onwards to play YouTube videos. +function parseYoutubeStreams(youtubeStreams) { + if (_.isUndefined(youtubeStreams) || !youtubeStreams.length) { + return false; + } + + this.videos = {}; + + _.each(youtubeStreams.split(/,/), function(video) { + let speed; + video = video.split(/:/); + speed = this.speedToString(video[0]); + this.videos[speed] = video[1]; + }, this); + + return _.isString(this.videos['1.0']); +} + +// function fetchMetadata() +// +// When dealing with YouTube videos, we must fetch meta data that has +// certain key facts not available while the video is loading. For +// example the length of the video can be determined from the meta +// data. +function fetchMetadata() { + let self = this, + metadataXHRs = []; + + this.metadata = {}; + + metadataXHRs = _.map(this.videos, function(url, speed) { + return self.getVideoMetadata(url, function(data) { + if (data.items.length > 0) { + let metaDataItem = data.items[0]; + self.metadata[metaDataItem.id] = metaDataItem.contentDetails; + } + }); + }); + + $.when.apply(this, metadataXHRs).done(function() { + self.el.trigger('metadata_received'); + + // Not only do we trigger the "metadata_received" event, we also + // set a flag to notify that metadata has been received. This + // allows for code that will miss the "metadata_received" event + // to know that metadata has been received. This is important in + // cases when some code will subscribe to the "metadata_received" + // event after it has been triggered. + self.youtubeMetadataReceived = true; + }); +} + +// function parseSpeed() +// +// Create a separate array of available speeds. +function parseSpeed() { + this.speeds = _.keys(this.videos).sort(); +} + +function setSpeed(newSpeed) { + // Possible speeds for each player type. + // HTML5 = [0.75, 1, 1.25, 1.5, 2] + // Youtube Flash = [0.75, 1, 1.25, 1.5] + // Youtube HTML5 = [0.25, 0.5, 1, 1.5, 2] + let map = { + 0.25: '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + '0.50': '0.75', // Youtube HTML5 -> HTML5 or Youtube Flash + 0.75: '0.50', // HTML5 or Youtube Flash -> Youtube HTML5 + 1.25: '1.50', // HTML5 or Youtube Flash -> Youtube HTML5 + 2.0: '1.50' // HTML5 or Youtube HTML5 -> Youtube Flash + }; + + if (_.contains(this.speeds, newSpeed)) { + this.speed = newSpeed; + } else { + newSpeed = map[newSpeed]; + this.speed = _.contains(this.speeds, newSpeed) ? newSpeed : '1.0'; + } + this.speed = parseFloat(this.speed); +} + +function setAutoAdvance(enabled) { + this.auto_advance = enabled; +} + +function getVideoMetadata(url, callback) { + let youTubeEndpoint; + if (!(_.isString(url))) { + url = this.videos['1.0'] || ''; + } + // Will hit the API URL to get the youtube video metadata. + youTubeEndpoint = this.config.ytMetadataEndpoint; // The new runtime supports anonymous users + // and uses an XBlock handler to get YouTube metadata + if (!youTubeEndpoint) { + // The old runtime has a full/separate LMS API for getting YouTube metadata, but it doesn't + // support anonymous users nor videos that play in a sandboxed iframe. + youTubeEndpoint = [this.config.lmsRootURL, '/courses/yt_video_metadata', '?id=', url].join(''); + } + return $.ajax({ + url: youTubeEndpoint, + success: _.isFunction(callback) ? callback : null, + error: function() { + console.warn( + 'Unable to get youtube video metadata. Some video metadata may be unavailable.' + ); + }, + notifyOnError: false + }); +} + +function youtubeId(speed) { + let currentSpeed = this.isFlashMode() ? this.speed : '1.0'; + + return this.videos[speed] + || this.videos[currentSpeed] + || this.videos['1.0']; +} + +function getDuration() { + try { + let safeMoment = typeof moment !== 'undefined' ? moment : window.moment; + return safeMoment.duration(this.metadata[this.youtubeId()].duration, safeMoment.ISO_8601).asSeconds(); + } catch (err) { + return _.result(this.metadata[this.youtubeId('1.0')], 'duration') || 0; + } +} + +/** + * Sets player mode. + * + * @param {string} mode Mode to set for the video player if it is supported. + * Otherwise, `html5` is used by default. + */ +function setPlayerMode(mode) { + let supportedModes = ['html5', 'flash']; + + mode = _.contains(supportedModes, mode) ? mode : 'html5'; + this.currentPlayerMode = mode; +} + +/** + * Returns current player mode. + * + * @return {string} Returns string that describes player mode + */ +function getPlayerMode() { + return this.currentPlayerMode; +} + +/** + * Checks if current player mode is Flash. + * + * @return {boolean} Returns `true` if current mode is `flash`, otherwise + * it returns `false` + */ +function isFlashMode() { + return this.getPlayerMode() === 'flash'; +} + +/** + * Checks if current player mode is Html5. + * + * @return {boolean} Returns `true` if current mode is `html5`, otherwise + * it returns `false` + */ +function isHtml5Mode() { + return this.getPlayerMode() === 'html5'; +} + +function isYoutubeType() { + return this.videoType === 'youtube'; +} + +function speedToString(speed) { + return parseFloat(speed).toFixed(2).replace(/\.00$/, '.0'); +} + +function getCurrentLanguage() { + let keys = _.keys(this.config.transcriptLanguages); + + if (keys.length) { + if (!_.contains(keys, this.lang)) { + if (_.contains(keys, 'en')) { + this.lang = 'en'; + } else { + this.lang = keys.pop(); + } + } + } else { + return null; + } + + return this.lang; +} + +/* + * The trigger() function will assume that the @objChain is a complete + * chain with a method (function) at the end. It will call this function. + * So for example, when trigger() is called like so: + * + * state.trigger('videoPlayer.pause', {'param1': 10}); + * + * Then trigger() will execute: + * + * state.videoPlayer.pause({'param1': 10}); + */ +function trigger(objChain) { + let extraParameters = Array.prototype.slice.call(arguments, 1), + i, tmpObj, chain; + + // Remember that 'this' is the 'state' object. + tmpObj = this; + chain = objChain.split('.'); + + // At the end of the loop the variable 'tmpObj' will either be the + // correct object/function to trigger/invoke. If the 'chain' chain of + // object is incorrect (one of the link is non-existent), then the loop + // will immediately exit. + while (chain.length) { + i = chain.shift(); + + if (tmpObj.hasOwnProperty(i)) { + tmpObj = tmpObj[i]; + } else { + // An incorrect object chain was specified. + + return false; + } + } + + tmpObj.apply(this, extraParameters); + + return true; +} diff --git a/xblocks_contrib/video/static/js/src/025_focus_grabber.js b/xblocks_contrib/video/static/js/src/025_focus_grabber.js new file mode 100644 index 00000000..48ec5527 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/025_focus_grabber.js @@ -0,0 +1,132 @@ +/* + * 025_focus_grabber.js + * + * Purpose: Provide a way to focus on autohidden Video controls. + * + * + * Because in HTML player mode we have a feature of autohiding controls on + * mouse inactivity, sometimes focus is lost from the currently selected + * control. What's more, when all controls are autohidden, we can't get to any + * of them because by default browser does not place hidden elements on the + * focus chain. + * + * To get around this minor annoyance, this module will manage 2 placeholder + * elements that will be invisible to the user's eye, but visible to the + * browser. This will allow for a sneaky stealing of focus and placing it where + * we need (on hidden controls). + * + * This code has been moved to a separate module because it provides a concrete + * block of functionality that can be turned on (off). + */ + +/* + * "If you want to climb a mountain, begin at the top." + * + * ~ Zen saying + */ + + + +// FocusGrabber module. +let FocusGrabber = function(state) { + let dfd = $.Deferred(); + + state.focusGrabber = {}; + + _makeFunctionsPublic(state); + _renderElements(state); + _bindHandlers(state); + + dfd.resolve(); + return dfd.promise(); +}; + +// Private functions. + +function _makeFunctionsPublic(state) { + let methodsDict = { + disableFocusGrabber: disableFocusGrabber, + enableFocusGrabber: enableFocusGrabber, + onFocus: onFocus + }; + + state.bindTo(methodsDict, state.focusGrabber, state); +} + +function _renderElements(state) { + state.focusGrabber.elFirst = state.el.find('.focus_grabber.first'); + state.focusGrabber.elLast = state.el.find('.focus_grabber.last'); + + // From the start, the Focus Grabber must be disabled so that + // tabbing (switching focus) does not land the user on one of the + // placeholder elements (elFirst, elLast). + state.focusGrabber.disableFocusGrabber(); +} + +function _bindHandlers(state) { + state.focusGrabber.elFirst.on('focus', state.focusGrabber.onFocus); + state.focusGrabber.elLast.on('focus', state.focusGrabber.onFocus); + + // When the video container element receives programmatic focus, then + // on un-focus ('blur' event) we should trigger a 'mousemove' event so + // as to reveal autohidden controls. + state.el.on('blur', function() { + state.el.trigger('mousemove'); + }); +} + +// Public functions. + +function enableFocusGrabber() { + let tabIndex; + + // When the Focus Grabber is being enabled, there are two different + // scenarios: + // + // 1.) Currently focused element was inside the video player. + // 2.) Currently focused element was somewhere else on the page. + // + // In the first case we must make sure that the video player doesn't + // loose focus, even though the controls are autohidden. + if ($(document.activeElement).parents().hasClass('video')) { + tabIndex = -1; + } else { + tabIndex = 0; + } + + this.focusGrabber.elFirst.attr('tabindex', tabIndex); + this.focusGrabber.elLast.attr('tabindex', tabIndex); + + // Don't loose focus. We are inside video player on some control, but + // because we can't remain focused on a hidden element, we will shift + // focus to the main video element. + // + // Once the main element will receive the un-focus ('blur') event, a + // 'mousemove' event will be triggered, and the video controls will + // receive focus once again. + if (tabIndex === -1) { + this.el.focus(); + + this.focusGrabber.elFirst.attr('tabindex', 0); + this.focusGrabber.elLast.attr('tabindex', 0); + } +} + +function disableFocusGrabber() { + // Only programmatic focusing on these elements will be available. + // We don't want the user to focus on them (for example with the 'Tab' + // key). + this.focusGrabber.elFirst.attr('tabindex', -1); + this.focusGrabber.elLast.attr('tabindex', -1); +} + +function onFocus(event, params) { + // Once the Focus Grabber placeholder elements will gain focus, we will + // trigger 'mousemove' event so that the autohidden controls will + // become visible. + this.el.trigger('mousemove'); + + this.focusGrabber.disableFocusGrabber(); +} + +export default FocusGrabber; diff --git a/xblocks_contrib/video/static/js/src/02_html5_hls_video.js b/xblocks_contrib/video/static/js/src/02_html5_hls_video.js new file mode 100644 index 00000000..094b6d87 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/02_html5_hls_video.js @@ -0,0 +1,151 @@ +/* eslint-disable no-console, no-param-reassign */ +/** + * HTML5 video player module to support HLS video playback. + * + */ + +'use strict'; + +import _ from 'underscore'; +import HTML5Video from './02_html5_video.js'; +import HLS from 'hls'; + +let HLSVideo = {}; + +HLSVideo.Player = (function() { + /** + * Initialize HLS video player. + * + * @param {jQuery} el Reference to video player container element + * @param {Object} config Contains common config for video player + */ + function Player(el, config) { + let self = this; + + this.config = config; + + // do common initialization independent of player type + this.init(el, config); + + // set a default audio codec if not provided, this helps reduce issues + // switching audio codecs during playback + if (!this.config.defaultAudioCodec) { + this.config.defaultAudioCodec = "mp4a.40.5"; + } + + _.bindAll(this, 'playVideo', 'pauseVideo', 'onReady'); + + // If we have only HLS sources and browser doesn't support HLS then show error message. + if (config.HLSOnlySources && !config.canPlayHLS) { + this.showErrorMessage(null, '.video-hls-error'); + return; + } + + this.config.state.el.on('initialize', _.once(function() { + console.log('[HLS Video]: HLS Player initialized'); + self.showPlayButton(); + })); + + // Safari has native support to play HLS videos + if (config.browserIsSafari) { + this.videoEl.attr('src', config.videoSources[0]); + } else { + // load auto start if auto_advance is enabled + if (config.state.auto_advance) { + this.hls = new HLS({autoStartLoad: true}); + } else { + this.hls = new HLS({autoStartLoad: false}); + } + this.hls.loadSource(config.videoSources[0]); + this.hls.attachMedia(this.video); + + this.hls.on(HLS.Events.ERROR, this.onError.bind(this)); + + this.hls.on(HLS.Events.MANIFEST_PARSED, function(event, data) { + console.log( + '[HLS Video]: MANIFEST_PARSED, qualityLevelsInfo: ', + data.levels.map(function(level) { + return { + bitrate: level.bitrate, + resolution: level.width + 'x' + level.height + }; + }) + ); + self.config.onReadyHLS(); + }); + this.hls.on(HLS.Events.LEVEL_SWITCHED, function(event, data) { + let level = self.hls.levels[data.level]; + console.log( + '[HLS Video]: LEVEL_SWITCHED, qualityLevelInfo: ', + { + bitrate: level.bitrate, + resolution: level.width + 'x' + level.height + } + ); + }); + } + } + + Player.prototype = Object.create(HTML5Video.Player.prototype); + Player.prototype.constructor = Player; + + Player.prototype.playVideo = function() { + HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['show']); + if (!this.config.browserIsSafari) { + this.hls.startLoad(); + } + HTML5Video.Player.prototype.playVideo.apply(this); + }; + + Player.prototype.pauseVideo = function() { + HTML5Video.Player.prototype.pauseVideo.apply(this); + HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['hide']); + }; + + Player.prototype.onPlaying = function() { + HTML5Video.Player.prototype.onPlaying.apply(this); + HTML5Video.Player.prototype.updatePlayerLoadingState.apply(this, ['hide']); + }; + + Player.prototype.onReady = function() { + this.config.events.onReady(null); + }; + + /** + * Handler for HLS video errors. This only takes care of fatal erros, non-fatal errors + * are automatically handled by hls.js + * + * @param {String} event `hlsError` + * @param {Object} data Contains the information regarding error occurred. + */ + Player.prototype.onError = function(event, data) { + if (data.fatal) { + switch (data.type) { + case HLS.ErrorTypes.NETWORK_ERROR: + console.error( + '[HLS Video]: Fatal network error encountered, try to recover. Details: %s', + data.details + ); + this.hls.startLoad(); + break; + case HLS.ErrorTypes.MEDIA_ERROR: + console.error( + '[HLS Video]: Fatal media error encountered, try to recover. Details: %s', + data.details + ); + this.hls.recoverMediaError(); + break; + default: + console.error( + '[HLS Video]: Unrecoverable error encountered. Details: %s', + data.details + ); + break; + } + } + }; + + return Player; +}()); + +export default HLSVideo; diff --git a/xblocks_contrib/video/static/js/src/02_html5_video.js b/xblocks_contrib/video/static/js/src/02_html5_video.js new file mode 100644 index 00000000..83937205 --- /dev/null +++ b/xblocks_contrib/video/static/js/src/02_html5_video.js @@ -0,0 +1,380 @@ +/* eslint-disable no-console, no-param-reassign */ +/** + * @file HTML5 video player module. Provides methods to control the in-browser + * HTML5 video player. + * + * The goal was to write this module so that it closely resembles the YouTube + * API. The main reason for this is because initially the edX video player + * supported only YouTube videos. When HTML5 support was added, for greater + * compatibility, and to reduce the amount of code that needed to be modified, + * it was decided to write a similar API as the one provided by YouTube. + * + * @module HTML5Video + */ + +import _ from 'underscore'; + +let HTML5Video = {}; + +HTML5Video.Player = (function() { + /* + * Constructor function for HTML5 Video player. + * + * @param {String|Object} el A DOM element where the HTML5 player will + * be inserted (as returned by jQuery(selector) function), or a + * selector string which will be used to select an element. This is a + * required parameter. + * + * @param config - An object whose properties will be used as + * configuration options for the HTML5 video player. This is an + * optional parameter. In the case if this parameter is missing, or + * some of the config object's properties are missing, defaults will be + * used. The available options (and their defaults) are as + * follows: + * + * config = { + * + * videoSources: [], // An array with properties being video + * // sources. The property name is the + * // video format of the source. Supported + * // video formats are: 'mp4', 'webm', and + * // 'ogg'. + * poster: Video poster URL + * + * browserIsSafari: Flag to tell if current browser is Safari + * + * events: { // Object's properties identify the + * // events that the API fires, and the + * // functions (event listeners) that the + * // API will call when those events occur. + * // If value is null, or property is not + * // specified, then no callback will be + * // called for that event. + * + * onReady: null, + * onStateChange: null + * } + * } + */ + function Player(el, config) { + let errorMessage, lastSource, sourceList; + + // Create HTML markup for individual sources of the HTML5