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