Add Video Bumper.

Fix n-click behaviour on poster.
Fix unit tests.
Fix handler for non_en lang for bumper.
Add more tests.
Fix docstrings.
Fix pep8.
Fix static redirection with bumper.
Fix button in IE11.
Add video_bumper field in bok_choy.

Fix pylink violations.

Update docstrings and some clean up.

Rename edx_video_id in bumper tests.

Fix too long lines in help text.

Address ui comments.

Fix bumper events.

Refactor bumper-transcripts code, fix bugs, address comments.
Squashed commits:
Fix download transcript button.
[74e0c8c] Fix quality
[a759f33] Fix error, when sub contains extension.
[b30755c] Revert "Add video files to host for transcripts."

This reverts commit cf8a96bf84346e17b6ad57ad4cc6a27d7a9118cd.
[36f038a] Add video files to host for transcripts.
[23f1655] Fix pep8 and pyling issues.
[0f1f9d2] Update acceptance test.
[765a27d] Wait for ajax in captions.
[8ae72a3] Fix logic.
[063450f] Fix unit tests.
[d1075fc] Fix handlers tests.
[25d31ad] Update bumper_utils.
[cb5f9df] Remove maxDiff.
[8738b1a] Code cleanup.
[87dbcb7] Fix issues with transcripts.
[ec899de] Fix transcripts in serializers.
[444b1fc] Fix transcripts typo.
[d524cb5] Fix bumper.
[f62cf22] Fix video mongo tests.
[8f1b55a] Fix dispatches.
[53bc308] Add more fixes.
[d5e3723] Fix test_video_handlers and rename the method.
[93efc23] Fix mobile tests.
[740e2ae] Fix pep8 and pylint.
[47cfb66] Address comments, add fixes.
[4e499d9] Add fixes.
[8353553] Add improvements.

Updated dispatch values)
.

Use ddt in bumper handler tests.

Move common metadata to single place.

Fix style.

Update docstring.

Fix poster button.

Improve bumper events.

Fix test after rebase.

Address comments.

Download transcript: use def video lang, not bump.

Renamed date_last_view_bumper to bumper_last_view_date.

Rename do_not_show_again_bumper to bumper_...

Address comments.

Fix tests for download for en lang.

Fix bumper logic.

Update strings.

Update resizer.

Remove resizer.

Fix unit tests.

Add tests.

Fix bumper events.

Clean up tests.

Fix pylint violations.

Fix pep8 and pylint violations.

Update docs and method names.

Update events.

Make /static/ prefix a must.

Fix wrong code.
This commit is contained in:
Alexander Kryklia
2015-05-04 13:09:42 +03:00
parent 93faba006c
commit 4c7bfb44dd
80 changed files with 4649 additions and 2418 deletions

View File

@@ -78,6 +78,9 @@ class CourseMetadata(object):
if not settings.FEATURES.get('ENABLE_TEAMS'):
filtered_list.append('teams_configuration')
if not settings.FEATURES.get('ENABLE_VIDEO_BUMPER'):
filtered_list.append('video_bumper')
return filtered_list
@classmethod

View File

@@ -74,6 +74,9 @@ FEATURES['ENABLE_TEAMS'] = True
# Enable custom content licensing
FEATURES['LICENSING'] = True
FEATURES['ENABLE_MOBILE_REST_API'] = True # Enable video bumper in Studio
FEATURES['ENABLE_VIDEO_BUMPER'] = True # Enable video bumper in Studio settings
########################### Entrance Exams #################################
FEATURES['ENTRANCE_EXAMS'] = True

View File

@@ -163,6 +163,13 @@ FEATURES = {
# Teams feature
'ENABLE_TEAMS': False,
# Show video bumper in Studio
'ENABLE_VIDEO_BUMPER': False,
# How many seconds to show the bumper again, default is 7 days:
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
}
ENABLE_JASMINE = False
@@ -645,6 +652,8 @@ YOUTUBE = {
'v': 'set_youtube_id_of_11_symbols_here',
},
},
'IMAGE_API': 'http://img.youtube.com/vi/{youtube_id}/0.jpg', # /maxresdefault.jpg for 1920*1080
}
############################# VIDEO UPLOAD PIPELINE #############################

View File

@@ -22,7 +22,7 @@ $a11y--blue-s1: saturate($blue,15%);
}
.a11y-menu-list {
@extend %ui-depth1;
@extend %ui-depth3;
top: 100%;
margin: 0;
padding: 0;

View File

@@ -27,6 +27,23 @@ div.video {
}
}
// CASE: video pre-roll state
&.is-pre-roll {
.slider {
visibility: hidden;
}
.video-player {
position: relative;
&:before {
display: block;
content: "";
width: 100%;
padding-top: 55%;
}
}
}
div.tc-wrapper {
@include clearfix();
position: relative;
@@ -169,6 +186,7 @@ div.video {
}
object, iframe, video {
display: block;
border: none;
width: 100%;
}
@@ -282,7 +300,7 @@ div.video {
}
}
ul.vcr {
.vcr {
float: left;
list-style: none;
margin: 0 lh() 0 0;
@@ -293,49 +311,52 @@ div.video {
font-size: em(14);
}
li {
.video_control {
@extend %video-button;
float: left;
margin-bottom: 0;
background-image: url('../images/vcr.png');
background-position: 15px 15px ;
background-repeat: no-repeat;
border-left: none;
padding: 0 lh(.75);
width: 14px;
a {
@extend %video-button;
background-image: url('../images/vcr.png');
background-position: 15px 15px ;
background-repeat: no-repeat;
border-left: none;
box-shadow: 1px 0 0 #555;
padding: 0 lh(.75);
width: 14px;
&:focus {
@extend %ui-depth4;
position: relative;
outline: $white dotted thin;
outline-offset: -2px;
}
&:empty {
height: 46px;
background-position: 15px 15px;
}
&.play {
background-position: 17px -114px;
}
&.pause {
background-position: 16px -50px;
}
&:focus {
@extend %ui-depth4;
position: relative;
outline: $white dotted thin;
outline-offset: -2px;
}
div.vidtime {
font-weight: bold;
line-height: 46px; //height of play pause buttons
-webkit-font-smoothing: antialiased;
padding-left: lh(.75);
@media (max-width: 1120px) {
padding-left: lh(0.5);
}
&:empty {
height: 46px;
background-position: 15px 15px;
}
&.play {
background-position: 17px -114px;
}
&.pause {
background-position: 16px -50px;
}
&.skip {
background-image: none;
text-indent: 0;
width: initial;
white-space: nowrap;
}
}
div.vidtime {
@extend %t-strong;
float: left;
line-height: 46px; //height of play pause buttons
-webkit-font-smoothing: antialiased;
padding-left: lh(.75);
@media (max-width: 1120px) {
padding-left: lh(0.5);
}
}
}
@@ -504,11 +525,14 @@ div.video {
background-image: url('../images/volume.png');
background-position: 10px center;
background-repeat: no-repeat;
border-left: none;
width: 30px;
height: 46px;
}
&:not(:first-child) > a {
border-left: none;
}
.volume-slider-container {
@include transition(none);
@extend %ui-depth1;
@@ -686,8 +710,7 @@ div.video {
}
ol.subtitles {
width: 0;
height: 0;
@extend .is-hidden;
}
ol.subtitles.html5 {
@@ -792,13 +815,38 @@ div.video {
&.is-touch {
div.tc-wrapper {
article.video-wrapper {
object, iframe, video{
object, iframe, video {
width: 100%;
height: 100%;
}
}
}
}
.video-pre-roll {
@extend %ui-depth3;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: 100%;
background-color: $black;
&.is-html5 {
background-size: 15%;
}
.btn-play {
text-indent: -999px;
overflow: hidden;
border: none;
box-shadow: none;
line-height: 0;
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -4,22 +4,7 @@
<div
id="video_id"
class="video closed"
data-streams="0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl"
data-show-captions="true"
data-save-state-url="/save_user_state"
data-speed="1.5"
data-start=""
data-end=""
data-saved-video-position="0"
data-transcript-language="en"
data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}'
data-transcript-translation-url="/transcript/translation"
data-transcript-available-translations-url="/transcript/available_translations"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-api-url="www.youtube.com/iframe_api"
data-yt-test-url="gdata.youtube.com/feeds/api/videos/"
data-autohide-html5="True"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": "[]", "speed": "1.5", "startTime": "", "streams": "0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "www.youtube.com/iframe_api", "ytImageUrl": "", "ytTestTimeout": "1500", "ytTestUrl": "gdata.youtube.com/feeds/api/videos/"}'
>
<div class="focus_grabber first"></div>
@@ -35,35 +20,11 @@
<section class="video-controls is-hidden">
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#" title="Play" role="button" aria-disabled="false"></a></li>
<li><div class="vidtime">0:00 / 0:00</div></li>
</ul>
<div class="secondary-controls">
<div class="speeds">
<a class="speed-button" href="#" title="Speeds" role="button" aria-disabled="false">
<span class="label">Speed</span>
<span class="value"></span>
</a>
<ol class="video-speeds"></ol>
</div>
<div class="volume">
<a href="#" title="Volume" role="button" aria-disabled="false"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
<a href="#" class="add-fullscreen" title="Fill browser" role="button" aria-disabled="false">Fill Browser</a>
<a href="#" class="quality-control is-hidden" title="HD off" role="button" aria-disabled="false">HD off</a>
<div class="lang menu-container">
<a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a>
</div>
</div>
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
<div class="secondary-controls"></div>
</div>
</section>
</article>
<ol class="subtitles"><li></li></ol>
</div>
<div class="focus_grabber last"></div>

View File

@@ -4,23 +4,7 @@
<div
id="video_id"
class="video closed"
data-show-captions="true"
data-save-state-url="/save_user_state"
data-speed="1.5"
data-start=""
data-end=""
data-saved-video-position="0"
data-transcript-language="en"
data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}'
data-transcript-translation-url="/transcript/translation"
data-transcript-available-translations-url="/transcript/available_translations"
data-sub="Z5KLxerq05Y"
data-sources='["xmodule/include/fixtures/test.mp4","xmodule/include/fixtures/test.webm","xmodule/include/fixtures/test.ogv"]'
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-api-url="www.youtube.com/iframe_api"
data-yt-test-url="gdata.youtube.com/feeds/api/videos/"
data-autohide-html5="True"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["xmodule/include/fixtures/test.mp4","xmodule/include/fixtures/test.webm","xmodule/include/fixtures/test.ogv"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "www.youtube.com/iframe_api", "ytImageUrl": "", "ytTestTimeout": "1500", "ytTestUrl": "gdata.youtube.com/feeds/api/videos/"}'
>
<div class="focus_grabber first"></div>
@@ -36,35 +20,11 @@
<section class="video-controls is-hidden">
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#" title="Play" role="button" aria-disabled="false"></a></li>
<li><div class="vidtime">0:00 / 0:00</div></li>
</ul>
<div class="secondary-controls">
<div class="speeds">
<a class="speed-button" href="#" title="Speeds" role="button" aria-disabled="false">
<span class="label">Speed</span>
<span class="value"></span>
</a>
<ol class="video-speeds"></ol>
</div>
<div class="volume">
<a href="#" title="Volume" role="button" aria-disabled="false"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
<a href="#" class="add-fullscreen" title="Fill browser" role="button" aria-disabled="false">Fill Browser</a>
<a href="#" class="quality-control is-hidden" title="HD off" role="button" aria-disabled="false">HD off</a>
<div class="lang menu-container">
<a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a>
</div>
</div>
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
<div class="secondary-controls"></div>
</div>
</section>
</article>
<ol class="subtitles"><li></li></ol>
</div>
<div class="focus_grabber last"></div>

View File

@@ -4,23 +4,7 @@
<div
id="video_id"
class="video closed"
data-show-captions="true"
data-save-state-url="/save_user_state"
data-speed="1.5"
data-start=""
data-end=""
data-saved-video-position="0"
data-transcript-language="en"
data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}'
data-transcript-translation-url="/transcript/translation"
data-transcript-available-translations-url="/transcript/available_translations"
data-sub="Z5KLxerq05Y"
data-sources='["xmodule/include/fixtures/test.mp4","xmodule/include/fixtures/test.webm","xmodule/include/fixtures/test.ogv"]'
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-api-url="www.youtube.com/iframe_api"
data-yt-test-url="gdata.youtube.com/feeds/api/videos/"
data-autohide-html5="True"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["xmodule/include/fixtures/test.mp4","xmodule/include/fixtures/test.webm","xmodule/include/fixtures/test.ogv"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "www.youtube.com/iframe_api", "ytImageUrl": "", "ytTestTimeout": "1500", "ytTestUrl": "gdata.youtube.com/feeds/api/videos/", "source": "", "html5_sources": ["http://youtu.be/3_yD_cEKoCk.mp4"]}'
>
<div class="focus_grabber first"></div>
@@ -33,8 +17,6 @@
</section>
<section class="video-controls is-hidden"></section>
</article>
<ol class="subtitles"><li></li></ol>
</div>
<div class="focus_grabber last"></div>

View File

@@ -4,22 +4,7 @@
<div
id="video_id"
class="video closed"
data-streams="0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl"
data-show-captions="false"
data-save-state-url="/save_user_state"
data-speed="1.5"
data-start=""
data-end=""
data-saved-video-position="0"
data-transcript-language="en"
data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}'
data-transcript-translation-url="/transcript/translation"
data-transcript-available-translations-url="/transcript/available_translations"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-api-url="www.youtube.com/iframe_api"
data-yt-test-url="gdata.youtube.com/feeds/api/videos/"
data-autohide-html5="True"
data-metadata='{"streams":"0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl", "showCaptions": false, "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "speed": "1.5", "startTime": "", "end": "", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "www.youtube.com/iframe_api", "ytTestTimeout": "1500", "ytTestUrl": "gdata.youtube.com/feeds/api/videos/"}'
>
<div class="focus_grabber first"></div>

View File

@@ -0,0 +1,36 @@
<div class="course-content">
<div id="video_example">
<div id="example">
<div
id="video_id"
class="video closed"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["xmodule/include/fixtures/test.mp4","xmodule/include/fixtures/test.webm","xmodule/include/fixtures/test.ogv"], "speed": "1.5", "startTime": "", "streams": "", "sub": "Z5KLxerq05Y", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "www.youtube.com/iframe_api", "ytImageUrl": "", "ytTestTimeout": "1500", "ytTestUrl": "gdata.youtube.com/feeds/api/videos/"}'
data-bumper-metadata='{"transcriptLanguage": "en", "showCaptions": "true", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "sources": ["xmodule/include/fixtures/test.mp4","xmodule/include/fixtures/test.webm","xmodule/include/fixtures/test.ogv"], "transcriptTranslationUrl": "/transcript/translation/__lang__/?is_bumper=1", "transcriptAvailableTranslationsUrl": "/transcript/available_translations/?is_bumper=1", "streams": "", "saveStateUrl": "/save_user_state"}'
data-poster='{"url": "xmodule/include/fixtures/poster.jpg", "type": "youtube"}'
>
<div class="focus_grabber first"></div>
<div class="tc-wrapper">
<article class="video-wrapper">
<span tabindex="0" class="spinner" aria-hidden="false" aria-label="${_('Loading video player')}"></span>
<span tabindex="-1" class="btn-play is-hidden" aria-hidden="true" aria-label="${_('Play video')}"></span>
<div class="video-player-pre"></div>
<section class="video-player">
<iframe id="id"></iframe>
</section>
<div class="video-player-post"></div>
<section class="video-controls is-hidden">
<div class="slider"></div>
<div>
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
<div class="secondary-controls"></div>
</div>
</section>
</article>
</div>
<div class="focus_grabber last"></div>
</div>
</div>
</div>
</div>

View File

@@ -4,22 +4,7 @@
<div
id="video_id1"
class="video closed"
data-streams="0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl"
data-show-captions="true"
data-save-state-url="/save_user_state"
data-speed="1.5"
data-start=""
data-end=""
data-saved-video-position="0"
data-transcript-language="en"
data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}'
data-transcript-translation-url="/transcript/translation"
data-transcript-available-translations-url="/transcript/available_translations"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-api-url="www.youtube.com/iframe_api"
data-yt-test-url="gdata.youtube.com/feeds/api/videos/"
data-autohide-html5="True"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["xmodule/include/fixtures/test.mp4","xmodule/include/fixtures/test.webm","xmodule/include/fixtures/test.ogv"], "speed": "1.5", "startTime": "", "streams": "0.5:7tqY6eQzVhE,1.0:cogebirgzzM,1.5:abcdefghijkl", "sub": "", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "www.youtube.com/iframe_api", "ytImageUrl": "", "ytTestTimeout": "1500", "ytTestUrl": "gdata.youtube.com/feeds/api/videos/"}'
>
<div class="focus_grabber first"></div>
@@ -35,35 +20,11 @@
<section class="video-controls is-hidden">
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#" title="Play" role="button" aria-disabled="false"></a></li>
<li><div class="vidtime">0:00 / 0:00</div></li>
</ul>
<div class="secondary-controls">
<div class="speeds">
<a class="speed-button" href="#" title="Speeds" role="button" aria-disabled="false">
<span class="label">Speed</span>
<span class="value"></span>
</a>
<ol class="video-speeds"></ol>
</div>
<div class="volume">
<a href="#" title="Volume" role="button" aria-disabled="false"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
<a href="#" class="add-fullscreen" title="Fill browser" role="button" aria-disabled="false">Fill Browser</a>
<a href="#" class="quality-control is-hidden" title="HD off" role="button" aria-disabled="false">HD off</a>
<div class="lang menu-container">
<a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a>
</div>
</div>
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
<div class="secondary-controls"></div>
</div>
</section>
</article>
<ol class="subtitles"><li></li></ol>
</div>
<div class="focus_grabber last"></div>
@@ -77,20 +38,7 @@
<div
id="video_id2"
class="video"
data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM"
data-show-captions="true"
data-speed="1.0"
data-start=""
data-end=""
data-transcript-language="en"
data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}'
data-transcript-translation-url="/transcript/translation"
data-transcript-available-translations-url="/transcript/available_translations"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/"
data-autohide-html5="True"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["xmodule/include/fixtures/test.mp4","xmodule/include/fixtures/test.webm","xmodule/include/fixtures/test.ogv"], "speed": "1.0", "startTime": "", "streams": "0.75:7tqY6eQzVhE,1.0:cogebirgzzM", "sub": "", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "www.youtube.com/iframe_api", "ytImageUrl": "", "ytTestTimeout": "1500", "ytTestUrl": "gdata.youtube.com/feeds/api/videos/"}'
>
<div class="tc-wrapper">
<article class="video-wrapper">
@@ -102,30 +50,8 @@
<section class="video-controls">
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#" title="Play"></a></li>
<li><div class="vidtime">0:00 / 0:00</div></li>
</ul>
<div class="secondary-controls">
<div class="speeds">
<a class="speed-button" href="#" title="Speeds" role="button" aria-disabled="false">
<span class="label">Speed</span>
<span class="value"></span>
</a>
<ol class="video-speeds"></ol>
</div>
<div class="volume">
<a href="#"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
<a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a>
<a href="#" class="quality-control is-hidden" title="HD">HD</a>
<div class="lang menu-container">
<a href="#" class="hide-subtitles" title="Turn off captions" role="button" aria-disabled="false">Captions</a>
</div>
</div>
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
<div class="secondary-controls"></div>
</div>
</section>
</article>
@@ -142,20 +68,7 @@
<div
id="video_id3"
class="video"
data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM"
data-show-captions="true"
data-speed="1.0"
data-start=""
data-end=""
data-transcript-language="en"
data-transcript-languages='{"en": "English", "de": "Deutsch", "zh": "普通话"}'
data-transcript-translation-url="/transcript/translation"
data-transcript-available-translations-url="/transcript/available_translations"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/"
data-autohide-html5="True"
data-metadata='{"autohideHtml5": "true", "autoplay": "false", "captionDataDir": "", "endTime": "", "generalSpeed": "1.0", "saveStateUrl": "/save_user_state", "savedVideoPosition": "0", "showCaptions": "true", "sources": ["xmodule/include/fixtures/test.mp4","xmodule/include/fixtures/test.webm","xmodule/include/fixtures/test.ogv"], "speed": "1.0", "startTime": "", "streams": "0.75:7tqY6eQzVhE,1.0:cogebirgzzM", "sub": "", "transcriptAvailableTranslationsUrl": "/transcript/available_translations", "transcriptLanguage": "en", "transcriptLanguages": {"en": "English", "de": "Deutsch", "zh": "普通话"}, "transcriptTranslationUrl": "/transcript/translation/__lang__", "ytApiUrl": "www.youtube.com/iframe_api", "ytImageUrl": "", "ytTestTimeout": "1500", "ytTestUrl": "gdata.youtube.com/feeds/api/videos/"}'
>
<div class="tc-wrapper">
<article class="video-wrapper">

View File

@@ -206,6 +206,9 @@
},
toBeInArray: function (array) {
return $.inArray(this.actual, array) > -1;
},
toBeFocused: function () {
return $(this.actual)[0] === $(this.actual)[0].ownerDocument.activeElement;
}
});
@@ -239,12 +242,11 @@
loadFixtures('video_all.html');
}
// If `params` is an object, assign it's properties as data attributes
// If `params` is an object, assign its properties as data attributes
// to the main video DIV element.
if (_.isObject(params)) {
$('#example')
.find('#video_id')
.data(params);
var metadata = _.extend($('#video_id').data('metadata'), params);
$('#video_id').data('metadata', metadata);
}
jasmine.stubRequests();

View File

@@ -1,6 +1,6 @@
(function (undefined) {
describe('Video', function () {
var oldOTBD;
var oldOTBD, state;
beforeEach(function () {
jasmine.stubRequests();
@@ -17,11 +17,12 @@
beforeEach(function () {
loadFixtures('video.html');
$.cookie.andReturn('0.50');
this.state = jasmine.initializePlayerYouTube('video_html5.html');
});
describe('by default', function () {
beforeEach(function () {
this.state = new window.Video('#example');
afterEach(function () {
this.state.videoPlayer.destroy();
});
it('check videoType', function () {
@@ -54,19 +55,16 @@
var state;
beforeEach(function () {
loadFixtures('video_html5.html');
$.cookie.andReturn('0.75');
state = jasmine.initializePlayer('video_html5.html');
});
afterEach(function () {
state.videoPlayer.destroy();
state = undefined;
});
describe('by default', function () {
beforeEach(function () {
state = new window.Video('#example');
});
afterEach(function () {
state = undefined;
});
it('check videoType', function () {
expect(state.videoType).toEqual('html5');
});
@@ -95,14 +93,6 @@
// the stand alone HTML5 player object is already loaded, so no
// further testing in that case is required.
describe('HTML5 API is available', function () {
beforeEach(function () {
state = new Video('#example');
});
afterEach(function () {
state = null;
});
it('create the Video Player', function () {
expect(state.videoPlayer.player).not.toBeUndefined();
});
@@ -113,8 +103,11 @@
describe('YouTube API is not loaded', function () {
beforeEach(function () {
window.YT = undefined;
state = jasmine.initializePlayerYouTube();
})
state = jasmine.initializePlayerYouTube('video.html');
afterEach(function () {
state.videoPlayer.destroy();
});
it('callback, to be called after YouTube API loads, exists and is called', function () {
@@ -159,9 +152,8 @@
}
];
beforeEach(function () {
loadFixtures('video.html');
afterEach(function () {
state.videoPlayer.destroy();
});
$.each(miniTestSuite, function (index, test) {
@@ -172,13 +164,10 @@
function itFabrique(itDescription, data, expectData) {
it(itDescription, function () {
$('#example').find('.video')
.data({
'start': data.start,
'end': data.end
});
state = new Video('#example');
state = jasmine.initializePlayer('video.html', {
'start': data.start,
'end': data.end
});
expect(state.config.startTime).toBe(expectData.start);
expect(state.config.endTime).toBe(expectData.end);
@@ -238,26 +227,5 @@
expect(numAjaxCalls).toBe(1);
});
});
describe('log', function () {
beforeEach(function () {
loadFixtures('video_html5.html');
state = new Video('#example');
spyOn(Logger, 'log');
state.videoPlayer.log('someEvent', {
currentTime: 25,
speed: '1.0'
});
});
it('call the logger with valid extra parameters', function () {
expect(Logger.log).toHaveBeenCalledWith('someEvent', {
id: 'id',
code: 'html5',
currentTime: 25,
speed: '1.0'
});
});
});
});
}).call(this);

View File

@@ -10,8 +10,8 @@
afterEach(function () {
state.storage.clear();
state.videoPlayer.destroy();
$.fn.scrollTo.reset();
$('.subtitles').remove();
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
});

View File

@@ -12,158 +12,6 @@ function (Initialize) {
state = {};
});
describe('saveState function', function () {
var videoPlayerCurrentTime, newCurrentTime, speed;
// We make sure that `currentTime` is a float. We need to test
// that Math.round() is called.
videoPlayerCurrentTime = 3.1242;
// We have two times, because one is stored in
// `videoPlayer.currentTime`, and the other is passed directly to
// `saveState` in `data` object. In each case, there is different
// code that handles these times. They have to be different for
// test completeness sake. Also, make sure it is float, as is the
// time above.
newCurrentTime = 5.4;
speed = '0.75';
beforeEach(function () {
state = {
videoPlayer: {
currentTime: videoPlayerCurrentTime
},
storage: {
setItem: jasmine.createSpy()
},
config: {
saveStateUrl: 'http://example.com/save_user_state'
}
};
spyOn($, 'ajax');
spyOn(Time, 'formatFull').andCallThrough();
});
it('data is not an object, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: videoPlayerCurrentTime,
data: undefined,
ajaxData: {
saved_video_position: Time.formatFull(Math.round(videoPlayerCurrentTime))
}
});
});
it('data contains speed, async is false', function () {
itSpec({
asyncVal: false,
speedVal: speed,
positionVal: undefined,
data: {
speed: speed
},
ajaxData: {
speed: speed
}
});
});
it('data contains float position, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: newCurrentTime,
data: {
saved_video_position: newCurrentTime
},
ajaxData: {
saved_video_position: Time.formatFull(Math.round(newCurrentTime))
}
});
});
it('data contains speed and rounded position, async is false', function () {
itSpec({
asyncVal: false,
speedVal: speed,
positionVal: Math.round(newCurrentTime),
data: {
speed: speed,
saved_video_position: Math.round(newCurrentTime)
},
ajaxData: {
speed: speed,
saved_video_position: Time.formatFull(Math.round(newCurrentTime))
}
});
});
it('data contains empty object, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: undefined,
data: {},
ajaxData: {}
});
});
it('data contains position 0, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: 0,
data: {
saved_video_position: 0
},
ajaxData: {
saved_video_position: Time.formatFull(Math.round(0))
}
});
});
return;
function itSpec(value) {
var asyncVal = value.asyncVal,
speedVal = value.speedVal,
positionVal = value.positionVal,
data = value.data,
ajaxData = value.ajaxData;
Initialize.prototype.saveState.call(state, asyncVal, data);
if (speedVal) {
expect(state.storage.setItem).toHaveBeenCalledWith(
'speed',
speedVal,
true
);
}
if (positionVal) {
expect(state.storage.setItem).toHaveBeenCalledWith(
'savedVideoPosition',
positionVal,
true
);
expect(Time.formatFull).toHaveBeenCalledWith(
positionVal
);
}
expect($.ajax).toHaveBeenCalledWith({
url: state.config.saveStateUrl,
type: 'POST',
async: asyncVal,
dataType: 'json',
data: ajaxData
});
}
});
describe('getCurrentLanguage', function () {
var msg;
@@ -356,20 +204,12 @@ function (Initialize) {
describe('when new speed is available', function () {
beforeEach(function () {
Initialize.prototype.setSpeed.call(state, '0.75', true);
Initialize.prototype.setSpeed.call(state, '0.75');
});
it('set new speed', function () {
expect(state.speed).toEqual('0.75');
});
it('save setting for new speed', function () {
expect(state.storage.setItem.calls[0].args)
.toEqual(['speed', '0.75', true]);
expect(state.storage.setItem.calls[1].args)
.toEqual(['general_speed', '0.75']);
});
});
describe('when new speed is not available', function () {
@@ -390,7 +230,7 @@ function (Initialize) {
};
$.each(map, function(key, expected) {
Initialize.prototype.setSpeed.call(state, key, true);
Initialize.prototype.setSpeed.call(state, key);
expect(state.speed).toBe(expected);
});
});

View File

@@ -5,6 +5,7 @@
afterEach(function () {
$('source').remove();
state.storage.clear();
state.videoPlayer.destroy();
});
describe('constructor', function () {
@@ -56,24 +57,6 @@
});
*/
});
it('add ARIA attributes to button, menu, and menu items links',
function () {
expect(button).toHaveAttrs({
'role': 'button',
'title': '.srt',
'aria-disabled': 'false'
});
expect(menuList).toHaveAttr('role', 'menu');
menuItemsLinks.each(function(){
expect($(this)).toHaveAttrs({
'role': 'menuitem',
'aria-disabled': 'false'
});
});
});
});
describe('when running', function () {

View File

@@ -0,0 +1,109 @@
(function (WAIT_TIMEOUT) {
'use strict';
describe('VideoBumper', function () {
var state, oldOTBD, waitForPlaying;
waitForPlaying = function (state) {
waitsFor(function () {
return state.el.hasClass('is-playing');
}, 'Player is not playing.', WAIT_TIMEOUT);
};
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice').andReturn(null);
state = jasmine.initializePlayer('video_with_bumper.html');
$('.poster .btn-play').click();
jasmine.Clock.useMock();
});
afterEach(function () {
$('source').remove();
state.storage.clear();
if (state.bumperState && state.bumperState.videoPlayer) {
state.bumperState.videoPlayer.destroy();
}
if (state.videoPlayer) {
state.videoPlayer.destroy();
}
window.onTouchBasedDevice = oldOTBD;
});
it('can render the bumper video', function () {
expect($('.is-bumper')).toExist();
});
it('can show the main video on error', function () {
state.el.trigger('error');
jasmine.Clock.tick(20);
expect($('.is-bumper')).not.toExist();
waitForPlaying(state);
});
it('can show the main video once bumper ends', function () {
state.el.trigger('ended');
jasmine.Clock.tick(20);
expect($('.is-bumper')).not.toExist();
waitForPlaying(state);
});
it('can show the main video on skip', function () {
state.bumperState.videoBumper.skip();
jasmine.Clock.tick(20);
expect($('.is-bumper')).not.toExist();
waitForPlaying(state);
});
it('can stop the bumper video playing if it is too long', function () {
state.el.trigger('timeupdate', [state.bumperState.videoBumper.maxBumperDuration + 1]);
jasmine.Clock.tick(20);
expect($('.is-bumper')).not.toExist();
waitForPlaying(state);
});
it('can save appropriate states correctly on ended', function () {
var saveState = jasmine.createSpy('saveState');
state.bumperState.videoSaveStatePlugin.saveState = saveState;
state.el.trigger('ended');
jasmine.Clock.tick(20);
expect(saveState).toHaveBeenCalledWith(true, {
bumper_last_view_date: true});
});
it('can save appropriate states correctly on skip', function () {
var saveState = jasmine.createSpy('saveState');
state.bumperState.videoSaveStatePlugin.saveState = saveState;
state.bumperState.videoBumper.skip();
expect(state.storage.getItem('isBumperShown')).toBeTruthy();
jasmine.Clock.tick(20);
expect(saveState).toHaveBeenCalledWith(true, {
bumper_last_view_date: true});
});
it('can save appropriate states correctly on error', function () {
var saveState = jasmine.createSpy('saveState');
state.bumperState.videoSaveStatePlugin.saveState = saveState;
state.el.trigger('error');
expect(state.storage.getItem('isBumperShown')).toBeTruthy();
jasmine.Clock.tick(20);
expect(saveState).toHaveBeenCalledWith(true, {
bumper_last_view_date: true});
});
it('can save appropriate states correctly on skip and do not show again', function () {
var saveState = jasmine.createSpy('saveState');
state.bumperState.videoSaveStatePlugin.saveState = saveState;
state.bumperState.videoBumper.skipAndDoNotShowAgain();
expect(state.storage.getItem('isBumperShown')).toBeTruthy();
jasmine.Clock.tick(20);
expect(saveState).toHaveBeenCalledWith(true, {
bumper_last_view_date: true, bumper_do_not_show_again: true});
});
it('can destroy itself', function () {
state.bumperState.videoBumper.destroy();
expect(state.videoBumper).toBeUndefined();
});
});
}).call(this, window.WAIT_TIMEOUT);

View File

@@ -11,14 +11,13 @@
});
afterEach(function () {
$('.subtitles').remove();
// `source` tags should be removed to avoid memory leak bug that we
// had before. Removing of `source` tag, not `video` tag, stops
// loading video source and clears the memory.
$('source').remove();
$.fn.scrollTo.reset();
state.storage.clear();
state.videoPlayer.destroy();
window.onTouchBasedDevice = oldOTBD;
});
@@ -121,11 +120,6 @@
});
});
it('bind the hide caption button', function () {
state = jasmine.initializePlayer();
expect($('.hide-subtitles')).toHandle('click');
});
it('bind the mouse movement', function () {
state = jasmine.initializePlayer();
expect($('.subtitles')).toHandle('mouseover');
@@ -143,6 +137,27 @@
});
it('can destroy itself', function () {
spyOn($, 'ajaxWithPrefix');
state = jasmine.initializePlayer();
var plugin = state.videoCaption;
spyOn($.fn, 'off').andCallThrough();
state.videoCaption.destroy();
expect(state.videoCaption).toBeUndefined();
expect($.fn.off).toHaveBeenCalledWith({
'caption:fetch': plugin.fetchCaption,
'caption:resize': plugin.onResize,
'caption:update': plugin.onCaptionUpdate,
'ended': plugin.pause,
'fullscreen': plugin.onResize,
'pause': plugin.pause,
'play': plugin.play,
'destroy': plugin.destroy
});
});
describe('renderLanguageMenu', function () {
describe('is rendered', function () {
it('if languages more than 1', function () {
@@ -593,7 +608,7 @@
it(msg, function () {
spyOn(Caption, 'fetchAvailableTranslations');
$.ajax.andCallFake(function (settings) {
settings.error([]);
_.result(settings, 'error');
});
state.config.transcriptLanguages = {};
@@ -612,7 +627,7 @@
xit(msg, function () {
$.ajax
.andCallFake(function (settings) {
settings.error([]);
_.result(settings, 'error');
});
state.config.transcriptLanguages = {
@@ -690,7 +705,7 @@
msg = 'on error: captions are hidden if there are no transcript';
it(msg, function () {
$.ajax.andCallFake(function (settings) {
settings.error();
_.result(settings, 'error');
});
Caption.fetchAvailableTranslations();
@@ -907,8 +922,8 @@
$('.subtitles').css('maxHeight'), 10
);
videoWrapperHeight = $('.video-wrapper').height();
progressSliderHeight = videoControl.sliderEl.height();
controlHeight = videoControl.el.height();
progressSliderHeight = state.el.find('.slider').height();
controlHeight = state.el.find('.video-controls').height();
shouldBeHeight = videoWrapperHeight -
0.5 * progressSliderHeight -
controlHeight;
@@ -1043,7 +1058,6 @@
describe('toggle', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
spyOn(state.videoPlayer, 'log');
$('.subtitles li[data-index=1]').addClass('current');
});
@@ -1053,15 +1067,6 @@
state.videoCaption.toggle(jQuery.Event('click'));
});
it('log the hide_transcript event', function () {
expect(state.videoPlayer.log).toHaveBeenCalledWith(
'hide_transcript',
{
currentTime: state.videoPlayer.currentTime
}
);
});
it('hide the caption', function () {
expect(state.el).toHaveClass('closed');
});
@@ -1079,15 +1084,6 @@
jasmine.Clock.useMock();
});
it('log the show_transcript event', function () {
expect(state.videoPlayer.log).toHaveBeenCalledWith(
'show_transcript',
{
currentTime: state.videoPlayer.currentTime
}
);
});
it('show the caption', function () {
expect(state.el).not.toHaveClass('closed');
});

View File

@@ -68,6 +68,7 @@
$('source').remove();
_.result(state.storage, 'clear');
_.result($('video').data('contextmenu'), 'destroy');
_.result(state.videoPlayer, 'destroy');
});
describe('constructor', function () {
@@ -219,12 +220,13 @@
it('mouse left/right-clicking behaves as expected on play/pause menu item', function () {
var menuItem = menuItems.first();
spyOn(state.videoPlayer, 'isPlaying');
spyOn(state.videoPlayer, 'play').andCallFake(function () {
state.videoControl.isPlaying = true;
state.videoPlayer.isPlaying.andReturn(true);
state.el.trigger('play');
});
spyOn(state.videoPlayer, 'pause').andCallFake(function () {
state.videoControl.isPlaying = false;
state.videoPlayer.isPlaying.andReturn(false);
state.el.trigger('pause');
});
// Left-click on play

View File

@@ -13,12 +13,13 @@
afterEach(function () {
$('source').remove();
state.storage.clear();
window.Video.previousState = null;
state.videoPlayer.destroy();
window.onTouchBasedDevice = oldOTBD;
});
describe('constructor', function () {
beforeEach(function () {
window.VideoState = {};
state = jasmine.initializePlayer();
});
@@ -28,83 +29,13 @@
'.slider',
'ul.vcr',
'a.play',
'.vidtime',
'.add-fullscreen'
'.vidtime'
].join(',')
);
expect($('.video-controls').find('.vidtime'))
.toHaveText('0:00 / 0:00');
});
it('add ARIA attributes to time control', function () {
var timeControl = $('div.slider > a');
expect(timeControl).toHaveAttrs({
'role': 'slider',
'title': 'Video position',
'aria-disabled': 'false'
});
expect(timeControl).toHaveAttr('aria-valuetext');
});
it('add ARIA attributes to play control', function () {
var playControl = $('ul.vcr a');
expect(playControl).toHaveAttrs({
'role': 'button',
'title': 'Play',
'aria-disabled': 'false'
});
});
it('add ARIA attributes to fullscreen control', function () {
var fullScreenControl = $('a.add-fullscreen');
expect(fullScreenControl).toHaveAttrs({
'role': 'button',
'title': 'Fill browser',
'aria-disabled': 'false'
});
});
it('bind the playback button', function () {
expect($('.video_control')).toHandleWith(
'click',
state.videoControl.togglePlayback
);
});
describe('when on a non-touch based device', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
});
it('add the play class to video control', function () {
expect($('.video_control')).toHaveClass('play');
expect($('.video_control')).toHaveAttr(
'title', 'Play'
);
});
});
describe('when on a touch based device', function () {
beforeEach(function () {
window.onTouchBasedDevice.andReturn(['iPad']);
state = jasmine.initializePlayer();
});
it(
'does not add the play class to video control',
function ()
{
expect($('.video_control')).toHaveClass('play');
expect($('.video_control')).toHaveAttr(
'title', 'Play'
);
});
});
});
describe('constructor with start-time', function () {
@@ -115,6 +46,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
start: 10,
savedVideoPosition: 0
@@ -147,6 +79,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
start: 10,
savedVideoPosition: 15
@@ -181,6 +114,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
start: 10,
savedVideoPosition: -15
@@ -215,6 +149,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
start: 10,
savedVideoPosition: 'a'
@@ -249,6 +184,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
start: 10,
savedVideoPosition: 10000
@@ -278,13 +214,14 @@
describe('constructor with end-time', function () {
it(
'saved position is 0, timer slider and VCR set to 0:00 ' +
'saved position is 0, timer slider and VCR set to 0:00 ' +
'and ending at specified end-time',
function ()
{
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
end: 20,
savedVideoPosition: 0
@@ -319,6 +256,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
end: 20,
savedVideoPosition: 15
@@ -353,6 +291,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
end: 20,
savedVideoPosition: -15
@@ -387,6 +326,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
end: 20,
savedVideoPosition: 'a'
@@ -422,6 +362,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
end: 20,
savedVideoPosition: 10000
@@ -457,6 +398,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
start: 10,
end: 20,
@@ -492,6 +434,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
start: 10,
end: 20,
@@ -527,6 +470,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
start: 10,
end: 20,
@@ -562,6 +506,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
start: 10,
end: 20,
@@ -597,6 +542,7 @@
var duration, sliderEl, expectedValue;
runs(function () {
window.VideoState = {};
state = jasmine.initializePlayer({
start: 10,
end: 20,
@@ -625,217 +571,8 @@
});
});
it('Controls height is actual on switch to fullscreen', function () {
spyOn($.fn, 'height').andCallFake(function (val) {
return _.isUndefined(val) ? 100: this;
});
state = jasmine.initializePlayer();
$(state.el).trigger('fullscreen');
expect(state.videoControl.height).toBe(150);
});
describe('play', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
state.videoControl.play();
});
it('switch playback button to play state', function () {
expect($('.video_control')).not.toHaveClass('play');
expect($('.video_control')).toHaveClass('pause');
expect($('.video_control')).toHaveAttr('title', 'Pause');
});
});
describe('pause', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
state.videoControl.pause();
});
it('switch playback button to pause state', function () {
expect($('.video_control')).not.toHaveClass('pause');
expect($('.video_control')).toHaveClass('play');
expect($('.video_control')).toHaveAttr('title', 'Play');
});
});
describe('togglePlayback', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
});
describe(
'when the control does not have play or pause class',
function ()
{
beforeEach(function () {
$('.video_control').removeClass('play')
.removeClass('pause');
});
describe('when the video is playing', function () {
beforeEach(function () {
$('.video_control').addClass('play');
spyOnEvent(state.videoControl, 'pause');
state.videoControl.togglePlayback(
$.Event('click')
);
});
it('does not trigger the pause event', function () {
expect('pause').not
.toHaveBeenTriggeredOn(state.videoControl);
});
});
describe('when the video is paused', function () {
beforeEach(function () {
$('.video_control').addClass('pause');
spyOnEvent(state.videoControl, 'play');
state.videoControl.togglePlayback(
$.Event('click')
);
});
it('does not trigger the play event', function () {
expect('play').not
.toHaveBeenTriggeredOn(state.videoControl);
});
});
});
});
describe('Play placeholder', function () {
var cases = [
{
name: 'PC',
isShown: false,
isTouch: null
}, {
name: 'iPad',
isShown: true,
isTouch: ['iPad']
}, {
name: 'Android',
isShown: true,
isTouch: ['Android']
}, {
name: 'iPhone',
isShown: false,
isTouch: ['iPhone']
}
];
beforeEach(function () {
jasmine.stubRequests();
spyOn(window.YT, 'Player').andCallThrough();
});
it ('works correctly on calling proper methods', function () {
var btnPlay;
state = jasmine.initializePlayer();
btnPlay = state.el.find('.btn-play');
state.videoControl.showPlayPlaceholder();
expect(btnPlay).not.toHaveClass('is-hidden');
expect(btnPlay).toHaveAttrs({
'aria-hidden': 'false',
'tabindex': 0
});
state.videoControl.hidePlayPlaceholder();
expect(btnPlay).toHaveClass('is-hidden');
expect(btnPlay).toHaveAttrs({
'aria-hidden': 'true',
'tabindex': -1
});
});
$.each(cases, function (index, data) {
var message = [
(data.isShown) ? 'is' : 'is not',
' shown on',
data.name
].join('');
it(message, function () {
var btnPlay;
window.onTouchBasedDevice.andReturn(data.isTouch);
state = jasmine.initializePlayer();
btnPlay = state.el.find('.btn-play');
if (data.isShown) {
expect(btnPlay).not.toHaveClass('is-hidden');
} else {
expect(btnPlay).toHaveClass('is-hidden');
}
});
});
$.each(['iPad', 'Android'], function (index, device) {
it(
'is shown on paused video on ' + device +
' in HTML5 player',
function ()
{
var btnPlay;
window.onTouchBasedDevice.andReturn([device]);
state = jasmine.initializePlayer();
btnPlay = state.el.find('.btn-play');
state.videoControl.play();
state.videoControl.pause();
expect(btnPlay).not.toHaveClass('is-hidden');
});
it(
'is hidden on playing video on ' + device +
' in HTML5 player',
function ()
{
var btnPlay;
window.onTouchBasedDevice.andReturn([device]);
state = jasmine.initializePlayer();
btnPlay = state.el.find('.btn-play');
state.videoControl.play();
expect(btnPlay).toHaveClass('is-hidden');
});
it(
'is hidden on paused video on ' + device +
' in YouTube player',
function ()
{
var btnPlay;
window.onTouchBasedDevice.andReturn([device]);
state = jasmine.initializePlayerYouTube();
btnPlay = state.el.find('.btn-play');
state.videoControl.play();
state.videoControl.pause();
expect(btnPlay).toHaveClass('is-hidden');
});
});
});
it('show', function () {
var controls;
state = jasmine.initializePlayer();
controls = state.el.find('.video-controls');
controls.addClass('is-hidden');
@@ -843,5 +580,23 @@
state.videoControl.show();
expect(controls).not.toHaveClass('is-hidden');
});
it('can destroy itself', function () {
state = jasmine.initializePlayer();
state.videoControl.destroy();
expect(state.videoControl).toBeUndefined();
});
it('can focus the first control', function () {
var btnPlay;
state = jasmine.initializePlayer({focusFirstControl: true});
btnPlay = state.el.find('.video-controls .play');
waitsFor(function () {
return state.el.hasClass('is-initialized');
}, 'Player is not initialized', WAIT_TIMEOUT);
runs(function () {
expect(btnPlay).toBeFocused();
});
});
});
}).call(this, window.WAIT_TIMEOUT);

View File

@@ -0,0 +1,157 @@
(function (undefined) {
'use strict';
describe('VideoPlayer Events Bumper plugin', function () {
var state, oldOTBD;
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice')
.andReturn(null);
jasmine.stubRequests();
state = jasmine.initializePlayer('video_with_bumper.html');
spyOn(Logger, 'log');
$('.poster .btn-play').click();
spyOn(state.bumperState.videoEventsBumperPlugin, 'getCurrentTime').andReturn(10);
spyOn(state.bumperState.videoEventsBumperPlugin, 'getDuration').andReturn(20);
});
afterEach(function () {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
if (state.bumperState && state.bumperState.videoPlayer) {
state.bumperState.videoPlayer.destroy();
}
if (state.videoPlayer) {
state.videoPlayer.destroy();
}
});
it('can emit "edx.video.bumper.loaded" event', function () {
state.el.trigger('ready');
expect(Logger.log).toHaveBeenCalledWith('edx.video.bumper.loaded', {
host_component_id: 'id',
bumper_id: 'xmodule/include/fixtures/test.mp4',
code: 'html5',
duration: 20
});
});
it('can emit "edx.video.bumper.played" event', function () {
state.el.trigger('play');
expect(Logger.log).toHaveBeenCalledWith('edx.video.bumper.played', {
host_component_id: 'id',
bumper_id: 'xmodule/include/fixtures/test.mp4',
code: 'html5',
currentTime: 10,
duration: 20
});
});
it('can emit "edx.video.bumper.stopped" event', function () {
state.el.trigger('ended');
expect(Logger.log).toHaveBeenCalledWith('edx.video.bumper.stopped', {
host_component_id: 'id',
bumper_id: 'xmodule/include/fixtures/test.mp4',
code: 'html5',
currentTime: 10,
duration: 20
});
Logger.log.reset();
state.el.trigger('stop');
expect(Logger.log).toHaveBeenCalledWith('edx.video.bumper.stopped', {
host_component_id: 'id',
bumper_id: 'xmodule/include/fixtures/test.mp4',
code: 'html5',
currentTime: 10,
duration: 20
});
});
it('can emit "edx.video.bumper.skipped" event', function () {
state.el.trigger('skip', [false]);
expect(Logger.log).toHaveBeenCalledWith('edx.video.bumper.skipped', {
host_component_id: 'id',
bumper_id: 'xmodule/include/fixtures/test.mp4',
code: 'html5',
currentTime: 10,
duration: 20
});
});
it('can emit "edx.video.bumper.dismissed" event', function () {
state.el.trigger('skip', [true]);
expect(Logger.log).toHaveBeenCalledWith('edx.video.bumper.dismissed', {
host_component_id: 'id',
bumper_id: 'xmodule/include/fixtures/test.mp4',
code: 'html5',
currentTime: 10,
duration: 20
});
});
it('can emit "edx.video.bumper.transcript.menu.shown" event', function () {
state.el.trigger('language_menu:show');
expect(Logger.log).toHaveBeenCalledWith('edx.video.bumper.transcript.menu.shown', {
host_component_id: 'id',
bumper_id: 'xmodule/include/fixtures/test.mp4',
code: 'html5',
duration: 20
});
});
it('can emit "edx.video.bumper.transcript.menu.hidden" event', function () {
state.el.trigger('language_menu:hide');
expect(Logger.log).toHaveBeenCalledWith('edx.video.bumper.transcript.menu.hidden', {
host_component_id: 'id',
bumper_id: 'xmodule/include/fixtures/test.mp4',
code: 'html5',
duration: 20
});
});
it('can emit "edx.video.bumper.transcript.shown" event', function () {
state.el.trigger('captions:show');
expect(Logger.log).toHaveBeenCalledWith('edx.video.bumper.transcript.shown', {
host_component_id: 'id',
bumper_id: 'xmodule/include/fixtures/test.mp4',
code: 'html5',
currentTime: 10,
duration: 20
});
});
it('can emit "edx.video.bumper.transcript.hidden" event', function () {
state.el.trigger('captions:hide');
expect(Logger.log).toHaveBeenCalledWith('edx.video.bumper.transcript.hidden', {
host_component_id: 'id',
bumper_id: 'xmodule/include/fixtures/test.mp4',
code: 'html5',
currentTime: 10,
duration: 20
});
});
it('can destroy itself', function () {
var plugin = state.bumperState.videoEventsBumperPlugin;
spyOn($.fn, 'off').andCallThrough();
plugin.destroy();
expect(state.bumperState.videoEventsBumperPlugin).toBeUndefined();
expect($.fn.off).toHaveBeenCalledWith({
'ready': plugin.onReady,
'play': plugin.onPlay,
'ended stop': plugin.onEnded,
'skip': plugin.onSkip,
'language_menu:show': plugin.onShowLanguageMenu,
'language_menu:hide': plugin.onHideLanguageMenu,
'captions:show': plugin.onShowCaptions,
'captions:hide': plugin.onHideCaptions,
'destroy': plugin.destroy
});
});
});
}).call(this);

View File

@@ -0,0 +1,166 @@
(function (undefined) {
'use strict';
describe('VideoPlayer Events plugin', function () {
var state, oldOTBD;
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice')
.andReturn(null);
jasmine.stubRequests();
state = jasmine.initializePlayer();
spyOn(Logger, 'log');
spyOn(state.videoEventsPlugin, 'getCurrentTime').andReturn(10);
});
afterEach(function () {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
if (state.videoPlayer) {
state.videoPlayer.destroy();
}
});
it('can emit "load_video" event', function () {
state.el.trigger('ready');
expect(Logger.log).toHaveBeenCalledWith('load_video', {
id: 'id',
code: 'html5'
});
});
it('can emit "play_video" event', function () {
state.el.trigger('play');
expect(Logger.log).toHaveBeenCalledWith('play_video', {
id: 'id',
code: 'html5',
currentTime: 10
});
});
it('can emit "pause_video" event', function () {
state.el.trigger('pause');
expect(Logger.log).toHaveBeenCalledWith('pause_video', {
id: 'id',
code: 'html5',
currentTime: 10
});
});
it('can emit "speed_change_video" event', function () {
state.el.trigger('speedchange', ['2.0', '1.0']);
expect(Logger.log).toHaveBeenCalledWith('speed_change_video', {
id: 'id',
code: 'html5',
current_time: 10,
old_speed: '1.0',
new_speed: '2.0'
});
});
it('can emit "seek_video" event', function () {
state.el.trigger('seek', [1, 0, 'any']);
expect(Logger.log).toHaveBeenCalledWith('seek_video', {
id: 'id',
code: 'html5',
old_time: 0,
new_time: 1,
type: 'any'
});
});
it('can emit "stop_video" event', function () {
state.el.trigger('ended');
expect(Logger.log).toHaveBeenCalledWith('stop_video', {
id: 'id',
code: 'html5',
currentTime: 10
});
Logger.log.reset();
state.el.trigger('stop');
expect(Logger.log).toHaveBeenCalledWith('stop_video', {
id: 'id',
code: 'html5',
currentTime: 10
});
});
it('can emit "skip_video" event', function () {
state.el.trigger('skip', [false]);
expect(Logger.log).toHaveBeenCalledWith('skip_video', {
id: 'id',
code: 'html5',
currentTime: 10
});
});
it('can emit "do_not_show_again_video" event', function () {
state.el.trigger('skip', [true]);
expect(Logger.log).toHaveBeenCalledWith('do_not_show_again_video', {
id: 'id',
code: 'html5',
currentTime: 10
});
});
it('can emit "video_show_cc_menu" event', function () {
state.el.trigger('language_menu:show');
expect(Logger.log).toHaveBeenCalledWith('video_show_cc_menu', {
id: 'id',
code: 'html5'
});
});
it('can emit "video_hide_cc_menu" event', function () {
state.el.trigger('language_menu:hide');
expect(Logger.log).toHaveBeenCalledWith('video_hide_cc_menu', {
id: 'id',
code: 'html5'
});
});
it('can emit "show_transcript" event', function () {
state.el.trigger('captions:show');
expect(Logger.log).toHaveBeenCalledWith('show_transcript', {
id: 'id',
code: 'html5',
current_time: 10
});
});
it('can emit "hide_transcript" event', function () {
state.el.trigger('captions:hide');
expect(Logger.log).toHaveBeenCalledWith('hide_transcript', {
id: 'id',
code: 'html5',
current_time: 10
});
});
it('can destroy itself', function () {
var plugin = state.videoEventsPlugin;
spyOn($.fn, 'off').andCallThrough();
state.videoEventsPlugin.destroy();
expect(state.videoEventsPlugin).toBeUndefined();
expect($.fn.off).toHaveBeenCalledWith({
'ready': plugin.onReady,
'play': plugin.onPlay,
'pause': plugin.onPause,
'ended stop': plugin.onEnded,
'seek': plugin.onSeek,
'skip': plugin.onSkip,
'speedchange': plugin.onSpeedChange,
'language_menu:show': plugin.onShowLanguageMenu,
'language_menu:hide': plugin.onHideLanguageMenu,
'captions:show': plugin.onShowCaptions,
'captions:hide': plugin.onHideCaptions,
'destroy': plugin.destroy
});
});
});
}).call(this);

View File

@@ -26,6 +26,7 @@
afterEach(function () {
// Turn jQuery animations back on.
jQuery.fx.off = true;
state.videoPlayer.destroy();
});
it(

View File

@@ -0,0 +1,102 @@
(function () {
'use strict';
describe('VideoFullScreen', function () {
var state, oldOTBD;
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice').andReturn(null);
});
afterEach(function () {
$('source').remove();
state.storage.clear();
state.videoPlayer.destroy();
window.onTouchBasedDevice = oldOTBD;
});
describe('constructor', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
});
it('renders the fullscreen control', function () {
expect($('.add-fullscreen')).toExist();
expect(state.videoFullScreen.fullScreenState).toBe(false);
});
it('correctly adds ARIA attributes to fullscreen control', function () {
var fullScreenControl = $('.add-fullscreen');
expect(fullScreenControl).toHaveAttrs({
'role': 'button',
'title': 'Fill browser',
'aria-disabled': 'false'
});
});
it('correctly triggers the event handler to toggle fullscreen mode', function () {
spyOn(state.videoFullScreen, 'exit');
spyOn(state.videoFullScreen, 'enter');
state.videoFullScreen.fullScreenState = false;
state.videoFullScreen.toggle();
expect(state.videoFullScreen.enter).toHaveBeenCalled();
state.videoFullScreen.fullScreenState = true;
state.videoFullScreen.toggle();
expect(state.videoFullScreen.exit).toHaveBeenCalled();
});
it('correctly updates ARIA on state change', function () {
var fullScreenControl = $('.add-fullscreen');
fullScreenControl.click();
expect(fullScreenControl).toHaveAttrs({
'role': 'button',
'title': 'Exit full browser',
'aria-disabled': 'false'
});
fullScreenControl.click();
expect(fullScreenControl).toHaveAttrs({
'role': 'button',
'title': 'Fill browser',
'aria-disabled': 'false'
});
});
it('correctly can out of fullscreen by pressing esc', function () {
spyOn(state.videoCommands, 'execute');
var esc = $.Event('keyup');
esc.keyCode = 27;
state.isFullScreen = true;
$(document).trigger(esc);
expect(state.videoCommands.execute).toHaveBeenCalledWith('toggleFullScreen');
});
it('can update video dimensions on state change', function () {
state.el.trigger('fullscreen', [true]);
expect(state.resizer.setMode).toHaveBeenCalledWith('both');
state.el.trigger('fullscreen', [false]);
expect(state.resizer.setMode).toHaveBeenCalledWith('width');
});
it('can destroy itself', function () {
state.videoFullScreen.destroy();
expect($('.add-fullscreen')).not.toExist();
expect(state.videoFullScreen).toBeUndefined();
});
});
it('Controls height is actual on switch to fullscreen', function () {
spyOn($.fn, 'height').andCallFake(function (val) {
return _.isUndefined(val) ? 100: this;
});
state = jasmine.initializePlayer();
$(state.el).trigger('fullscreen');
expect(state.videoFullScreen.height).toBe(150);
});
});
}).call(this);

View File

@@ -0,0 +1,68 @@
(function () {
'use strict';
describe('VideoPlayPauseControl', function () {
var state, oldOTBD;
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice').andReturn(null);
state = jasmine.initializePlayer();
spyOn(state.videoCommands, 'execute');
spyOn(state.videoSaveStatePlugin, 'saveState');
});
afterEach(function () {
$('source').remove();
state.storage.clear();
state.videoPlayer.destroy();
window.onTouchBasedDevice = oldOTBD;
});
it('can render the control', function () {
expect($('.video_control.play')).toExist();
});
it('add ARIA attributes to play control', function () {
expect($('.video_control.play')).toHaveAttrs({
'role': 'button',
'title': 'Play',
'aria-disabled': 'false'
});
});
it('can update ARIA state on play', function () {
state.el.trigger('play');
expect($('.video_control.pause')).toHaveAttrs({
'role': 'button',
'title': 'Pause',
'aria-disabled': 'false'
});
});
it('can update ARIA state on video ends', function () {
state.el.trigger('play');
state.el.trigger('ended');
expect($('.video_control.play')).toHaveAttrs({
'role': 'button',
'title': 'Play',
'aria-disabled': 'false'
});
});
it('can update state on pause', function () {
state.el.trigger('pause');
expect(state.videoSaveStatePlugin.saveState).toHaveBeenCalledWith(true);
});
it('can start video playing on click', function () {
$('.video_control.play').click();
expect(state.videoCommands.execute).toHaveBeenCalledWith('togglePlayback');
});
it('can destroy itself', function () {
state.videoPlayPauseControl.destroy();
expect(state.videoPlayPauseControl).toBeUndefined();
});
});
}).call(this);

View File

@@ -0,0 +1,151 @@
(function () {
'use strict';
describe('VideoPlayPlaceholder', function () {
var state, oldOTBD;
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice').andReturn(['iPad']);
state = jasmine.initializePlayer();
spyOn(state.videoCommands, 'execute');
});
afterEach(function () {
$('source').remove();
state.storage.clear();
state.videoPlayer.destroy();
window.onTouchBasedDevice = oldOTBD;
});
var cases = [
{
name: 'PC',
isShown: false,
isTouch: null
}, {
name: 'iPad',
isShown: true,
isTouch: ['iPad']
}, {
name: 'Android',
isShown: true,
isTouch: ['Android']
}, {
name: 'iPhone',
isShown: false,
isTouch: ['iPhone']
}
];
beforeEach(function () {
jasmine.stubRequests();
spyOn(window.YT, 'Player').andCallThrough();
});
it ('works correctly on calling proper methods', function () {
var btnPlay;
state = jasmine.initializePlayer();
btnPlay = state.el.find('.btn-play');
state.videoPlayPlaceholder.show();
expect(btnPlay).not.toHaveClass('is-hidden');
expect(btnPlay).toHaveAttrs({
'aria-hidden': 'false',
'tabindex': 0
});
state.videoPlayPlaceholder.hide();
expect(btnPlay).toHaveClass('is-hidden');
expect(btnPlay).toHaveAttrs({
'aria-hidden': 'true',
'tabindex': -1
});
});
$.each(cases, function (index, data) {
var message = [
(data.isShown) ? 'is' : 'is not',
' shown on',
data.name
].join('');
it(message, function () {
var btnPlay;
window.onTouchBasedDevice.andReturn(data.isTouch);
state = jasmine.initializePlayer();
btnPlay = state.el.find('.btn-play');
if (data.isShown) {
expect(btnPlay).not.toHaveClass('is-hidden');
} else {
expect(btnPlay).toHaveClass('is-hidden');
}
});
});
$.each(['iPad', 'Android'], function (index, device) {
it(
'is shown on paused video on ' + device +
' in HTML5 player',
function ()
{
var btnPlay;
window.onTouchBasedDevice.andReturn([device]);
state = jasmine.initializePlayer();
btnPlay = state.el.find('.btn-play');
state.el.trigger('play');
state.el.trigger('pause');
expect(btnPlay).not.toHaveClass('is-hidden');
});
it(
'is hidden on playing video on ' + device +
' in HTML5 player',
function ()
{
var btnPlay;
window.onTouchBasedDevice.andReturn([device]);
state = jasmine.initializePlayer();
btnPlay = state.el.find('.btn-play');
state.el.trigger('play');
expect(btnPlay).toHaveClass('is-hidden');
});
it(
'is hidden on paused video on ' + device +
' in YouTube player',
function ()
{
var btnPlay;
window.onTouchBasedDevice.andReturn([device]);
state = jasmine.initializePlayerYouTube();
btnPlay = state.el.find('.btn-play');
state.el.trigger('play');
state.el.trigger('pause');
expect(btnPlay).toHaveClass('is-hidden');
});
});
it('starts play the video on click', function () {
$('.btn-play').click();
expect(state.videoCommands.execute).toHaveBeenCalledWith('play');
});
it('can destroy itself', function () {
state.videoPlayPlaceholder.destroy();
expect(state.videoPlayPlaceholder).toBeUndefined();
});
});
}).call(this);

View File

@@ -0,0 +1,64 @@
(function () {
'use strict';
describe('VideoPlaySkipControl', function () {
var state, oldOTBD;
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice').andReturn(null);
state = jasmine.initializePlayer('video_with_bumper.html');
$('.poster .btn-play').click();
spyOn(state.bumperState.videoCommands, 'execute');
});
afterEach(function () {
$('source').remove();
state.storage.clear();
if (state.bumperState && state.bumperState.videoPlayer) {
state.bumperState.videoPlayer.destroy();
}
window.onTouchBasedDevice = oldOTBD;
});
it('can render the control', function () {
expect($('.video_control.play')).toExist();
});
it('add ARIA attributes to play control', function () {
expect($('.video_control.play')).toHaveAttrs({
'role': 'button',
'title': 'Play',
'aria-disabled': 'false'
});
});
it('can update state on play', function () {
state.el.trigger('play');
expect($('.video_control.play')).not.toExist();
expect($('.video_control.skip')).toExist();
});
it('can start video playing on click', function () {
$('.video_control.play').click();
expect(state.bumperState.videoCommands.execute).toHaveBeenCalledWith('play');
});
it('can skip the video on click', function () {
state.el.trigger('play');
spyOn(state.bumperState.videoPlayer, 'isPlaying').andReturn(true);
$('.video_control.skip').first().click();
expect(state.bumperState.videoCommands.execute).toHaveBeenCalledWith('skip');
});
it('can destroy itself', function () {
var plugin = state.bumperState.videoPlaySkipControl,
el = plugin.el;
spyOn($.fn, 'off').andCallThrough();
plugin.destroy();
expect(state.bumperState.videoPlaySkipControl).toBeUndefined();
expect(el).not.toExist();
expect($.fn.off).toHaveBeenCalledWith('destroy', plugin.destroy);
});
});
}).call(this);

View File

@@ -1,5 +1,4 @@
(function (requirejs, require, define, undefined) {
'use strict';
require(
@@ -21,6 +20,9 @@ function (VideoPlayer) {
if (state.storage) {
state.storage.clear();
}
if (state.videoPlayer) {
_.result(state.videoPlayer, 'destroy');
}
});
describe('constructor', function () {
@@ -47,7 +49,7 @@ function (VideoPlayer) {
expect(state.videoCaption).toBeDefined();
expect(state.speed).toEqual('1.50');
expect(state.config.transcriptTranslationUrl)
.toEqual('/transcript/translation');
.toEqual('/transcript/translation/__lang__');
});
it('create video speed control', function () {
@@ -71,18 +73,15 @@ function (VideoPlayer) {
var events;
jasmine.stubRequests();
spyOn(window.YT, 'Player').andCallThrough();
state = jasmine.initializePlayerYouTube();
state.videoEl = $('video, iframe');
events = {
onReady: state.videoPlayer.onReady,
onStateChange: state.videoPlayer.onStateChange,
onPlaybackQualityChange: state.videoPlayer
.onPlaybackQualityChange
onPlaybackQualityChange: state.videoPlayer.onPlaybackQualityChange,
onError: state.videoPlayer.onError
};
expect(YT.Player).toHaveBeenCalledWith('id', {
@@ -156,7 +155,7 @@ function (VideoPlayer) {
});
it('controls are in paused state', function () {
expect(state.videoControl.isPlaying).toBe(false);
expect(state.videoPlayer.isPlaying()).toBe(false);
});
});
});
@@ -166,16 +165,10 @@ function (VideoPlayer) {
state = jasmine.initializePlayer();
state.videoEl = $('video, iframe');
spyOn(state.videoPlayer, 'log').andCallThrough();
spyOn(state.videoPlayer, 'play').andCallThrough();
state.videoPlayer.onReady();
});
it('log the load_video event', function () {
expect(state.videoPlayer.log).toHaveBeenCalledWith('load_video');
});
it('autoplay the first video', function () {
expect(state.videoPlayer.play).not.toHaveBeenCalled();
});
@@ -197,9 +190,7 @@ function (VideoPlayer) {
var playbackRates = state.videoPlayer.player.getAvailablePlaybackRates();
state.currentPlayerMode = 'flash';
state.videoPlayer.onReady();
expect(playbackRates.length).toBe(4);
expect(state.currentPlayerMode).toBe('html5');
});
@@ -209,10 +200,7 @@ function (VideoPlayer) {
describe('when the video is unstarted', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
state.videoEl = $('video, iframe');
spyOn(state.videoControl, 'pause').andCallThrough();
spyOn($.fn, 'trigger').andCallThrough();
state.videoPlayer.onStateChange({
@@ -221,7 +209,7 @@ function (VideoPlayer) {
});
it('pause the video control', function () {
expect(state.videoControl.pause).toHaveBeenCalled();
expect($('.video_control')).toHaveClass('play');
});
it('pause the video caption', function () {
@@ -244,9 +232,7 @@ function (VideoPlayer) {
state.videoEl = $('video, iframe');
spyOn(state.videoPlayer, 'log').andCallThrough();
spyOn(window, 'setInterval').andReturn(100);
spyOn(state.videoControl, 'play');
spyOn($.fn, 'trigger').andCallThrough();
state.videoPlayer.onStateChange({
@@ -254,23 +240,6 @@ function (VideoPlayer) {
});
});
it('speed_change_video event is not logged when speed not change', function () {
expect(state.videoPlayer.log).not.toHaveBeenCalledWith(
'speed_change_video',
{
current_time: state.videoPlayer.currentTime,
old_speed: state.speed,
new_speed: state.speed
}
);
});
it('log the play_video event', function () {
expect(state.videoPlayer.log).toHaveBeenCalledWith(
'play_video', { currentTime: 0 }
);
});
it('set update interval', function () {
expect(window.setInterval).toHaveBeenCalledWith(
state.videoPlayer.update, 200
@@ -279,7 +248,7 @@ function (VideoPlayer) {
});
it('play the video control', function () {
expect(state.videoControl.play).toHaveBeenCalled();
expect($('.video_control')).toHaveClass('pause');
});
it('play the video caption', function () {
@@ -295,10 +264,7 @@ function (VideoPlayer) {
state.videoEl = $('video, iframe');
spyOn(state.videoPlayer, 'log').andCallThrough();
spyOn(state.videoControl, 'pause').andCallThrough();
spyOn($.fn, 'trigger').andCallThrough();
state.videoPlayer.onStateChange({
data: YT.PlayerState.PLAYING
});
@@ -310,18 +276,12 @@ function (VideoPlayer) {
});
});
it('log the pause_video event', function () {
expect(state.videoPlayer.log).toHaveBeenCalledWith(
'pause_video', { currentTime: 0 }
);
});
it('clear update interval', function () {
expect(state.videoPlayer.updateInterval).toBeUndefined();
});
it('pause the video control', function () {
expect(state.videoControl.pause).toHaveBeenCalled();
expect($('.video_control')).toHaveClass('play');
});
it('pause the video caption', function () {
@@ -334,32 +294,19 @@ function (VideoPlayer) {
state = jasmine.initializePlayer();
state.videoEl = $('video, iframe');
spyOn(state.videoPlayer, 'log').andCallThrough();
spyOn(state.videoControl, 'pause').andCallThrough();
spyOn($.fn, 'trigger').andCallThrough();
state.videoPlayer.onStateChange({
data: YT.PlayerState.ENDED
});
});
it('pause the video control', function () {
expect(state.videoControl.pause).toHaveBeenCalled();
expect($('.video_control')).toHaveClass('play');
});
it('pause the video caption', function () {
expect($.fn.trigger).toHaveBeenCalledWith('ended', {});
});
it('log stop_video event', function () {
expect(state.videoPlayer.log).toHaveBeenCalledWith(
'stop_video',
{
currentTime: state.videoPlayer.currentTime
}
);
});
});
});
@@ -397,25 +344,6 @@ function (VideoPlayer) {
});
});
it('slider event causes log update', function () {
runs(function () {
spyOn(state.videoPlayer, 'log');
state.videoProgressSlider.onSlide(
jQuery.Event('slide'), { value: 2 }
);
// Video player uses _.debounce (with a wait time in 300 ms) for seeking.
// That's why we have to do this tick(300).
jasmine.Clock.tick(300);
expect(state.videoPlayer.currentTime).toBe(2);
expect(state.videoPlayer.log).toHaveBeenCalledWith('seek_video', {
old_time: jasmine.any(Number),
new_time: 2,
type: 'onSlideSeek'
});
});
});
it('seek the player', function () {
runs(function () {
spyOn(state.videoPlayer.player, 'seekTo').andCallThrough();
@@ -469,24 +397,6 @@ function (VideoPlayer) {
.andCallThrough();
});
it('slider event causes log update', function () {
spyOn(state.videoPlayer, 'log');
state.videoProgressSlider.onSlide(
jQuery.Event('slide'), { value: 2 }
);
// Video player uses _.debounce (with a wait time in 300 ms) for seeking.
// That's why we have to do this tick(300).
jasmine.Clock.tick(300);
expect(state.videoPlayer.currentTime).toBe(2);
expect(state.videoPlayer.log).toHaveBeenCalledWith(
'seek_video', {
old_time: 0,
new_time: 2,
type: 'onSlideSeek'
}
);
});
it('video has a correct speed', function () {
state.speed = '2.0';
state.videoPlayer.onPlay();
@@ -785,7 +695,7 @@ function (VideoPlayer) {
state = jasmine.initializePlayer();
state.videoEl = $('video, iframe');
spyOn($.fn, 'trigger').andCallThrough();
state.videoControl.toggleFullScreen(jQuery.Event('click'));
$('.add-fullscreen').click();
});
it('replace the full screen button tooltip', function () {
@@ -810,11 +720,10 @@ function (VideoPlayer) {
state.videoEl = $('video, iframe');
spyOn($.fn, 'trigger').andCallThrough();
state.el.addClass('video-fullscreen');
state.videoControl.fullScreenState = true;
state.videoControl.isFullScreen = true;
state.videoControl.fullScreenEl.attr('title', 'Exit-fullscreen');
state.videoControl.toggleFullScreen(jQuery.Event('click'));
state.videoFullScreen.fullScreenState = true;
state.videoFullScreen.isFullScreen = true;
state.videoFullScreen.fullScreenEl.attr('title', 'Exit-fullscreen');
$('.add-fullscreen').click();
});
it('replace the full screen button tooltip', function () {
@@ -835,83 +744,6 @@ function (VideoPlayer) {
});
});
describe('play', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
state.videoEl = $('video, iframe');
spyOn(state.videoPlayer.player, 'playVideo').andCallThrough();
});
describe('when the player is not ready', function () {
beforeEach(function () {
state.videoPlayer.player.playVideo = void 0;
state.videoPlayer.play();
});
it('does nothing', function () {
expect(state.videoPlayer.player.playVideo).toBeUndefined();
});
});
describe('when the player is ready', function () {
beforeEach(function () {
state.videoPlayer.player.playVideo.andReturn(true);
state.videoPlayer.play();
});
it('delegate to the player', function () {
expect(state.videoPlayer.player.playVideo).toHaveBeenCalled();
});
});
});
describe('isPlaying', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
state.videoEl = $('video, iframe');
spyOn(state.videoPlayer.player, 'getPlayerState').andCallThrough();
});
describe('when the video is playing', function () {
beforeEach(function () {
state.videoPlayer.player.getPlayerState.andReturn(YT.PlayerState.PLAYING);
});
it('return true', function () {
expect(state.videoPlayer.isPlaying()).toBeTruthy();
});
});
describe('when the video is not playing', function () {
beforeEach(function () {
state.videoPlayer.player.getPlayerState.andReturn(YT.PlayerState.PAUSED);
});
it('return false', function () {
expect(state.videoPlayer.isPlaying()).toBeFalsy();
});
});
});
describe('pause', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
state.videoEl = $('video, iframe');
spyOn(state.videoPlayer.player, 'pauseVideo').andCallThrough();
state.videoPlayer.pause();
});
it('delegate to the player', function () {
expect(state.videoPlayer.player.pauseVideo).toHaveBeenCalled();
});
});
describe('duration', function () {
beforeEach(function () {
state = jasmine.initializePlayer();
@@ -1016,9 +848,7 @@ function (VideoPlayer) {
runs(function () {
state = jasmine.initializePlayer();
state.videoEl = $('video, iframe');
controls = state.el.find('.video-controls');
});
@@ -1053,7 +883,6 @@ function (VideoPlayer) {
saveState: jasmine.createSpy(),
videoPlayer: {
currentTime: 60,
log: jasmine.createSpy(),
updatePlayTime: jasmine.createSpy(),
setPlaybackRate: jasmine.createSpy(),
player: jasmine.createSpyObj('player', ['setPlaybackRate'])
@@ -1063,18 +892,6 @@ function (VideoPlayer) {
});
describe('always', function () {
it('check if speed_change_video is logged', function () {
VideoPlayer.prototype.onSpeedChange.call(state, '0.75', false);
expect(state.videoPlayer.log).toHaveBeenCalledWith(
'speed_change_video',
{
current_time: state.videoPlayer.currentTime,
old_speed: '1.50',
new_speed: '0.75'
}
);
});
it('convert the current time to the new speed', function () {
state.isFlashMode.andReturn(true);
VideoPlayer.prototype.onSpeedChange.call(state, '0.75', false);
@@ -1083,10 +900,7 @@ function (VideoPlayer) {
it('set video speed to the new speed', function () {
VideoPlayer.prototype.onSpeedChange.call(state, '0.75', false);
expect(state.setSpeed).toHaveBeenCalledWith('0.75', true);
expect(state.saveState).toHaveBeenCalledWith(true, {
speed: '0.75'
});
expect(state.setSpeed).toHaveBeenCalledWith('0.75');
expect(state.videoPlayer.setPlaybackRate)
.toHaveBeenCalledWith('0.75');
});

View File

@@ -0,0 +1,42 @@
(function (WAIT_TIMEOUT) {
'use strict';
describe('VideoPoster', function () {
var state, oldOTBD;
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice').andReturn(null);
state = jasmine.initializePlayer('video_with_bumper.html');
});
afterEach(function () {
$('source').remove();
state.storage.clear();
if (state.bumperState && state.bumperState.videoPlayer) {
state.bumperState.videoPlayer.destroy();
}
if (state.videoPlayer) {
state.videoPlayer.destroy();
}
window.onTouchBasedDevice = oldOTBD;
});
it('can render the poster', function () {
expect($('.poster')).toExist();
expect($('.btn-play')).toExist();
});
it('can start playing the video on click', function () {
$('.btn-play').click();
waitsFor(function () {
return state.el.hasClass('is-playing');
}, 'Player is not playing.', WAIT_TIMEOUT);
});
it('destroy itself on "play" event', function () {
$('.btn-play').click();
expect($('.poster')).not.toExist();
});
});
}).call(this, window.WAIT_TIMEOUT);

View File

@@ -12,6 +12,7 @@
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
state.videoPlayer.destroy();
});
describe('constructor', function () {
@@ -38,6 +39,18 @@
expect(state.videoProgressSlider.handle)
.toBe('.slider .ui-slider-handle');
});
it('add ARIA attributes to time control', function () {
var timeControl = $('div.slider > a');
expect(timeControl).toHaveAttrs({
'role': 'slider',
'title': 'Video position',
'aria-disabled': 'false'
});
expect(timeControl).toHaveAttr('aria-valuetext');
});
});
describe('on a touch-based device', function () {
@@ -304,6 +317,13 @@
});
});
it('can destroy itself', function () {
state = jasmine.initializePlayer();
state.videoProgressSlider.destroy();
expect(state.videoProgressSlider).toBeUndefined();
expect($('.slider')).toBeEmpty();
});
});
}).call(this);

View File

@@ -1,12 +1,13 @@
(function (undefined) {
describe('VideoQualityControl', function () {
var state, qualityControl, qualityControlEl, videoPlayer, player;
var state, qualityControl, videoPlayer, player;
afterEach(function () {
$('source').remove();
if (state.storage) {
state.storage.clear();
}
state.videoPlayer.destroy();
});
describe('constructor, YouTube mode', function () {
@@ -105,6 +106,11 @@
expect(qualityControl.el).toHaveClass('active');
});
it('can destroy itself', function () {
state.videoQualityControl.destroy();
expect(state.videoQualityControl).toBeUndefined();
expect($('.quality-control')).not.toExist();
});
});
describe('constructor, HTML5 mode', function () {

View File

@@ -0,0 +1,230 @@
(function (undefined) {
'use strict';
describe('VideoPlayer Save State plugin', function () {
var state, oldOTBD;
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice')
.andReturn(null);
jasmine.stubRequests();
state = jasmine.initializePlayer();
spyOn(state.storage, 'setItem');
});
afterEach(function () {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
if (state.videoPlayer) {
state.videoPlayer.destroy();
}
});
describe('saveState function', function () {
var videoPlayerCurrentTime, newCurrentTime, speed;
// We make sure that `currentTime` is a float. We need to test
// that Math.round() is called.
videoPlayerCurrentTime = 3.1242;
// We have two times, because one is stored in
// `videoPlayer.currentTime`, and the other is passed directly to
// `saveState` in `data` object. In each case, there is different
// code that handles these times. They have to be different for
// test completeness sake. Also, make sure it is float, as is the
// time above.
newCurrentTime = 5.4;
speed = '0.75';
beforeEach(function () {
state.videoPlayer.currentTime = videoPlayerCurrentTime;
spyOn(Time, 'formatFull').andCallThrough();
});
it('data is not an object, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: videoPlayerCurrentTime,
data: undefined,
ajaxData: {
saved_video_position: Time.formatFull(Math.round(videoPlayerCurrentTime))
}
});
});
it('data contains speed, async is false', function () {
itSpec({
asyncVal: false,
speedVal: speed,
positionVal: undefined,
data: {
speed: speed
},
ajaxData: {
speed: speed
}
});
});
it('data contains float position, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: newCurrentTime,
data: {
saved_video_position: newCurrentTime
},
ajaxData: {
saved_video_position: Time.formatFull(Math.round(newCurrentTime))
}
});
});
it('data contains speed and rounded position, async is false', function () {
itSpec({
asyncVal: false,
speedVal: speed,
positionVal: Math.round(newCurrentTime),
data: {
speed: speed,
saved_video_position: Math.round(newCurrentTime)
},
ajaxData: {
speed: speed,
saved_video_position: Time.formatFull(Math.round(newCurrentTime))
}
});
});
it('data contains empty object, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: undefined,
data: {},
ajaxData: {}
});
});
it('data contains position 0, async is true', function () {
itSpec({
asyncVal: true,
speedVal: undefined,
positionVal: 0,
data: {
saved_video_position: 0
},
ajaxData: {
saved_video_position: Time.formatFull(Math.round(0))
}
});
});
function itSpec(value) {
var asyncVal = value.asyncVal,
speedVal = value.speedVal,
positionVal = value.positionVal,
data = value.data,
ajaxData = value.ajaxData;
state.videoSaveStatePlugin.saveState(asyncVal, data);
if (speedVal) {
expect(state.storage.setItem).toHaveBeenCalledWith(
'speed',
speedVal,
true
);
}
if (positionVal) {
expect(state.storage.setItem).toHaveBeenCalledWith(
'savedVideoPosition',
positionVal,
true
);
expect(Time.formatFull).toHaveBeenCalledWith(
positionVal
);
}
expect($.ajax).toHaveBeenCalledWith({
url: state.config.saveStateUrl,
type: 'POST',
async: asyncVal,
dataType: 'json',
data: ajaxData
});
}
});
it('can save state on speed change', function () {
state.el.trigger('speedchange', ['2.0']);
expect($.ajax).toHaveBeenCalledWith({
url: state.config.saveStateUrl,
type: 'POST',
async: true,
dataType: 'json',
data: {speed: '2.0'}
});
});
it('can save state on page unload', function () {
$.ajax.reset();
state.videoSaveStatePlugin.onUnload();
expect($.ajax).toHaveBeenCalledWith({
url: state.config.saveStateUrl,
type: 'POST',
async: false,
dataType: 'json',
data: {saved_video_position: '00:00:00'}
});
});
it('can save state on pause', function () {
state.el.trigger('pause');
expect($.ajax).toHaveBeenCalledWith({
url: state.config.saveStateUrl,
type: 'POST',
async: true,
dataType: 'json',
data: {saved_video_position: '00:00:00'}
});
});
it('can save state on language change', function () {
state.el.trigger('language_menu:change', ['ua']);
expect(state.storage.setItem).toHaveBeenCalledWith('language', 'ua');
});
it('can save information about youtube availability', function () {
state.el.trigger('youtube_availability', [true]);
expect($.ajax).toHaveBeenCalledWith({
url: state.config.saveStateUrl,
type: 'POST',
async: true,
dataType: 'json',
data: {youtube_is_available: true}
});
});
it('can destroy itself', function () {
var plugin = state.videoSaveStatePlugin;
spyOn($.fn, 'off').andCallThrough();
state.videoSaveStatePlugin.destroy();
expect(state.videoSaveStatePlugin).toBeUndefined();
expect($.fn.off).toHaveBeenCalledWith({
'speedchange': plugin.onSpeedChange,
'play': plugin.bindUnloadHandler,
'pause destroy': plugin.saveStateHandler,
'language_menu:change': plugin.onLanguageChange,
'youtube_availability': plugin.onYoutubeAvailability
});
expect($.fn.off).toHaveBeenCalledWith('destroy', plugin.destroy);
expect($.fn.off).toHaveBeenCalledWith('unload', plugin.onUnload);
});
});
}).call(this);

View File

@@ -0,0 +1,55 @@
(function () {
'use strict';
describe('VideoSkipControl', function () {
var state, oldOTBD;
beforeEach(function () {
oldOTBD = window.onTouchBasedDevice;
window.onTouchBasedDevice = jasmine
.createSpy('onTouchBasedDevice').andReturn(null);
state = jasmine.initializePlayer('video_with_bumper.html');
$('.poster .btn-play').click();
spyOn(state.bumperState.videoCommands, 'execute').andCallThrough();
});
afterEach(function () {
$('source').remove();
state.storage.clear();
if (state.bumperState && state.bumperState.videoPlayer) {
state.bumperState.videoPlayer.destroy();
}
if (state.videoPlayer) {
state.videoPlayer.destroy();
}
window.onTouchBasedDevice = oldOTBD;
});
it('can render the control when video starts playing', function () {
expect($('.skip-control')).not.toExist();
state.el.trigger('play');
expect($('.skip-control')).toExist();
});
it('add ARIA attributes to play control', function () {
state.el.trigger('play');
expect($('.skip-control')).toHaveAttrs({
'role': 'button',
'title': 'Do not show again',
'aria-disabled': 'false'
});
});
it('can skip the video on click', function () {
spyOn(state.bumperState.videoBumper, 'skipAndDoNotShowAgain');
state.el.trigger('play');
$('.skip-control').click();
expect(state.bumperState.videoCommands.execute).toHaveBeenCalledWith('skip', true);
expect(state.bumperState.videoBumper.skipAndDoNotShowAgain).toHaveBeenCalled();
});
it('can destroy itself', function () {
state.bumperState.videoPlaySkipControl.destroy();
expect(state.bumperState.videoPlaySkipControl).toBeUndefined();
});
});
}).call(this);

View File

@@ -12,6 +12,7 @@
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
state.videoPlayer.destroy();
});
describe('constructor', function () {
@@ -247,5 +248,13 @@
expect($('.speeds .value')).toHaveHtml('0.75x');
});
});
it('can destroy itself', function () {
state = jasmine.initializePlayer();
state.videoSpeedControl.destroy();
expect(state.videoSpeedControl).toBeUndefined();
expect($('.video-speeds')).not.toExist();
expect($('.speed-button')).not.toExist();
});
});
}).call(this);

View File

@@ -13,6 +13,7 @@ describe('VideoVolumeControl', function () {
$('source').remove();
window.onTouchBasedDevice = oldOTBD;
state.storage.clear();
state.videoPlayer.destroy();
});
it('Volume level has correct value even if cookie is broken', function () {
@@ -35,8 +36,7 @@ describe('VideoVolumeControl', function () {
});
it('render the volume control', function () {
expect(state.videoControl.secondaryControlsEl.html())
.toContain('<div class="volume">\n');
expect($('.volume')).toExist();
});
it('create the slider', function () {
@@ -292,7 +292,7 @@ describe('VideoVolumeControl', function () {
shiftKey: true
});
});
})
});
describe('keyDownButtonHandler', function () {
beforeEach(function () {
@@ -308,6 +308,6 @@ describe('VideoVolumeControl', function () {
}));
expect(volumeControl.getMuteStatus()).toEqual(isMuted);
});
})
});
});
}).call(this);

View File

@@ -177,9 +177,8 @@ function () {
}
};
var cleanDelta = function () {
delta['height'] = 0;
delta['width'] = 0;
var resetDelta = function () {
delta['height'] = delta['width'] = 0;
return module;
};
@@ -200,12 +199,23 @@ function () {
return module;
};
var destroy = function () {
var 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,
@@ -218,7 +228,7 @@ function () {
delta: {
add: addDelta,
substract: substractDelta,
reset: cleanDelta
reset: resetDelta
}
});
};

View File

@@ -14,8 +14,8 @@
define(
'video/01_initialize.js',
['video/03_video_player.js', 'video/00_video_storage.js', 'video/00_i18n.js'],
function (VideoPlayer, VideoStorage, i18n) {
['video/03_video_player.js', 'video/00_i18n.js'],
function (VideoPlayer, i18n) {
/**
* @function
*
@@ -71,7 +71,6 @@ function (VideoPlayer, VideoStorage, i18n) {
isYoutubeType: isYoutubeType,
parseSpeed: parseSpeed,
parseYoutubeStreams: parseYoutubeStreams,
saveState: saveState,
setPlayerMode: setPlayerMode,
setSpeed: setSpeed,
speedToString: speedToString,
@@ -145,9 +144,7 @@ function (VideoPlayer, VideoStorage, i18n) {
_youtubeApiDeferred.resolve();
}
window.YT.ready(function () {
onYTApiReady();
});
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
@@ -191,9 +188,7 @@ function (VideoPlayer, VideoStorage, i18n) {
// Attach a callback to our Deferred object to be called once the
// YouTube API loads.
window.onYouTubeIframeAPIReady.done(function () {
window.YT.ready(function () {
onYTApiReady();
});
window.YT.ready(onYTApiReady);
});
}
} else {
@@ -212,20 +207,15 @@ function (VideoPlayer, VideoStorage, i18n) {
// callback, which will set `state.youtubeApiAvailable` to `true`.
// If something goes wrong at this stage, `state.youtubeApiAvailable` is
// `false`.
_reportToServer(state, state.youtubeApiAvailable);
if (!state.youtubeIsAvailable) {
console.log('[Video info]: YouTube API is not available.');
}
state.el.trigger('youtube_availability', [state.youtubeIsAvailable]);
}, state.config.ytTestTimeout);
$.getScript(document.location.protocol + '//' + state.config.ytApiUrl);
}
function _reportToServer(state, youtubeIsAvailable) {
if (!youtubeIsAvailable) {
console.log('[Video info]: YouTube API is not available.');
}
state.saveState(true, { youtube_is_available: youtubeIsAvailable });
}
// function _configureCaptions(state)
// Configure displaying of captions.
//
@@ -296,8 +286,7 @@ function (VideoPlayer, VideoStorage, i18n) {
state.videoType = 'html5';
if (!state.config.sub || !state.config.sub.length) {
state.config.sub = '';
if (!_.keys(state.config.transcriptLanguages).length) {
state.config.showCaptions = false;
}
state.setSpeed(state.speed);
@@ -328,8 +317,9 @@ function (VideoPlayer, VideoStorage, i18n) {
function _initializeModules(state, i18n) {
var dfd = $.Deferred(),
modulesList = $.map(state.modules, function(module) {
if ($.isFunction(module)) {
return module(state, i18n);
var options = state.options[module.moduleName] || {};
if (_.isFunction(module)) {
return module(state, i18n, options);
} else if ($.isPlainObject(module)) {
return module;
}
@@ -388,7 +378,6 @@ function (VideoPlayer, VideoStorage, i18n) {
},
'startTime': function (value) {
value = parseInt(value, 10);
if (!isFinite(value) || value < 0) {
return 0;
}
@@ -407,6 +396,13 @@ function (VideoPlayer, VideoStorage, i18n) {
},
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) {
@@ -420,7 +416,7 @@ function (VideoPlayer, VideoStorage, i18n) {
// Pre-process data.
if (conversions[option]) {
if ($.isFunction(conversions[option])) {
if (_.isFunction(conversions[option])) {
value = conversions[option].call(this, value);
} else {
throw new TypeError(option + ' is not a function.');
@@ -463,12 +459,11 @@ function (VideoPlayer, VideoStorage, i18n) {
function initialize(element) {
var self = this,
el = $(element).find('.video'),
el = this.el,
id = this.id,
container = el.find('.video-wrapper'),
id = el.attr('id').replace(/video_/, ''),
__dfd__ = $.Deferred(),
isTouch = onTouchBasedDevice() || '',
storage = VideoStorage('VideoState', id);
isTouch = onTouchBasedDevice() || '';
if (isTouch) {
el.addClass('is-touch');
@@ -476,23 +471,18 @@ function (VideoPlayer, VideoStorage, i18n) {
$.extend(this, {
__dfd__: __dfd__,
el: el,
container: container,
id: id,
isFullScreen: false,
isTouch: isTouch,
storage: storage
isTouch: isTouch
});
console.log(
'[Video info]: Initializing video with id "' + id + '".'
);
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(el.data(), storage), {
this.config = $.extend({}, _getConfiguration(this.metadata, this.storage), {
element: element,
fadeOutTimeout: 1400,
captionsFreezeTime: 10000,
@@ -602,26 +592,18 @@ function (VideoPlayer, VideoStorage, i18n) {
// true: Parsing of YouTube video IDs went OK, and we can proceed
// onwards to play YouTube videos.
function parseYoutubeStreams(youtubeStreams) {
var _this;
if (
typeof youtubeStreams === 'undefined' ||
youtubeStreams.length === 0
) {
if (_.isUndefined(youtubeStreams) || !youtubeStreams.length) {
return false;
}
_this = this;
this.videos = {};
$.each(youtubeStreams.split(/,/), function (index, video) {
_.each(youtubeStreams.split(/,/), function (video) {
var speed;
video = video.split(/:/);
speed = _this.speedToString(video[0]);
_this.videos[speed] = video[1];
});
speed = this.speedToString(video[0]);
this.videos[speed] = video[1];
}, this);
return _.isString(this.videos['1.0']);
}
@@ -633,23 +615,21 @@ function (VideoPlayer, VideoStorage, i18n) {
// example the length of the video can be determined from the meta
// data.
function fetchMetadata() {
var _this = this,
var self = this,
metadataXHRs = [];
this.metadata = {};
$.each(this.videos, function (speed, url) {
var xhr = _this.getVideoMetadata(url, function (data) {
metadataXHRs = _.map(this.videos, function (url, speed) {
return self.getVideoMetadata(url, function (data) {
if (data.data) {
_this.metadata[data.data.id] = data.data;
self.metadata[data.data.id] = data.data;
}
});
metadataXHRs.push(xhr);
});
$.when.apply(this, metadataXHRs).done(function () {
_this.el.trigger('metadata_received');
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
@@ -657,7 +637,7 @@ function (VideoPlayer, VideoStorage, i18n) {
// 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.
_this.youtubeMetadataReceived = true;
self.youtubeMetadataReceived = true;
});
}
@@ -666,23 +646,21 @@ function (VideoPlayer, VideoStorage, i18n) {
//
// Create a separate array of available speeds.
function parseSpeed() {
this.speeds = ($.map(this.videos, function (url, speed) {
return speed;
})).sort();
this.speeds = _.keys(this.videos).sort();
}
function setSpeed(newSpeed, updateStorage) {
function setSpeed(newSpeed) {
// Possible speeds for each player type.
// HTML5 = [0.75, 1, 1.25, 1.5]
// Youtube Flash = [0.75, 1, 1.25, 1.5]
// Youtube HTML5 = [0.25, 0.5, 1, 1.5, 2]
var 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' // Youtube HTML5 -> HTML5 or Youtube Flash
};
'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' // Youtube HTML5 -> HTML5 or Youtube Flash
};
if (_.contains(this.speeds, newSpeed)) {
this.speed = newSpeed;
@@ -690,57 +668,21 @@ function (VideoPlayer, VideoStorage, i18n) {
newSpeed = map[newSpeed];
this.speed = _.contains(this.speeds, newSpeed) ? newSpeed : '1.0';
}
if (updateStorage) {
this.storage.setItem('speed', this.speed, true);
this.storage.setItem('general_speed', this.speed);
}
}
function getVideoMetadata(url, callback) {
var successHandler, xhr;
if (typeof url !== 'string') {
if (!(_.isString(url))) {
url = this.videos['1.0'] || '';
}
successHandler = ($.isFunction(callback)) ? callback : null;
xhr = $.ajax({
return $.ajax({
url: [
document.location.protocol, '//', this.config.ytTestUrl, url,
'?v=2&alt=jsonc'
].join(''),
dataType: 'jsonp',
timeout: this.config.ytTestTimeout,
success: successHandler
});
return xhr;
}
function saveState(async, data) {
if (!($.isPlainObject(data))) {
data = {
saved_video_position: this.videoPlayer.currentTime
};
}
if (data.speed) {
this.storage.setItem('speed', data.speed, true);
}
if (data.hasOwnProperty('saved_video_position')) {
this.storage.setItem('savedVideoPosition', data.saved_video_position, true);
data.saved_video_position = Time.formatFull(data.saved_video_position);
}
$.ajax({
url: this.config.saveStateUrl,
type: 'POST',
async: async ? true : false,
dataType: 'json',
data: data,
success: _.isFunction(callback) ? callback : null
});
}

View File

@@ -110,6 +110,54 @@ function () {
});
};
Player.prototype.onError = function (event) {
if ($.isFunction(this.config.events.onError)) {
this.config.events.onError();
}
};
Player.prototype.destroy = function () {
this.video.removeEventListener('loadedmetadata', this.onLoadedMetadata, false);
this.video.removeEventListener('play', this.onPlay, false);
this.video.removeEventListener('playing', this.onPlaying, false);
this.video.removeEventListener('pause', this.onPause, false);
this.video.removeEventListener('ended', this.onEnded, false);
this.el
.find('.video-player div').removeClass('hidden')
.end()
.find('.video-player h3').addClass('hidden')
.end().removeClass('is-initialized')
.find('.spinner').attr({'aria-hidden': 'false'});
this.videoEl.remove();
};
Player.prototype.onLoadedMetadata = function () {
this.playerState = HTML5Video.PlayerState.PAUSED;
if ($.isFunction(this.config.events.onReady)) {
this.config.events.onReady(null);
}
};
Player.prototype.onPlay = function () {
this.playerState = HTML5Video.PlayerState.BUFFERING;
this.callStateChangeCallback();
};
Player.prototype.onPlaying = function () {
this.playerState = HTML5Video.PlayerState.PLAYING;
this.callStateChangeCallback();
};
Player.prototype.onPause = function () {
this.playerState = HTML5Video.PlayerState.PAUSED;
this.callStateChangeCallback();
};
Player.prototype.onEnded = function () {
this.playerState = HTML5Video.PlayerState.ENDED;
this.callStateChangeCallback();
};
return Player;
/*
@@ -152,6 +200,7 @@ function () {
var isTouch = onTouchBasedDevice() || '',
sourceList, _this, errorMessage, lastSource;
_.bindAll(this, 'onLoadedMetadata', 'onPlay', 'onPlaying', 'onPause', 'onEnded');
this.logs = [];
// Initially we assume that el is a DOM element. If jQuery selector
// fails to select something, we assume that el is an ID of a DOM
@@ -226,6 +275,8 @@ function () {
lastSource = this.videoEl.find('source').last();
lastSource.on('error', this.showErrorMessage.bind(this));
lastSource.on('error', this.onError.bind(this));
this.videoEl.on('error', this.onError.bind(this));
if (/iP(hone|od)/i.test(isTouch[0])) {
this.videoEl.prop('controls', true);
@@ -280,35 +331,11 @@ function () {
// When the <video> tag has been processed by the browser, and it
// is ready for playback, notify other parts of the VideoPlayer,
// and initially pause the video.
this.video.addEventListener('loadedmetadata', function () {
_this.playerState = HTML5Video.PlayerState.PAUSED;
if ($.isFunction(_this.config.events.onReady)) {
_this.config.events.onReady(null);
}
}, false);
// Register the 'play' event.
this.video.addEventListener('play', function () {
_this.playerState = HTML5Video.PlayerState.BUFFERING;
_this.callStateChangeCallback();
}, false);
this.video.addEventListener('playing', function () {
_this.playerState = HTML5Video.PlayerState.PLAYING;
_this.callStateChangeCallback();
}, false);
// Register the 'pause' event.
this.video.addEventListener('pause', function () {
_this.playerState = HTML5Video.PlayerState.PAUSED;
_this.callStateChangeCallback();
}, false);
// Register the 'ended' event.
this.video.addEventListener('ended', function () {
_this.playerState = HTML5Video.PlayerState.ENDED;
_this.callStateChangeCallback();
}, false);
this.video.addEventListener('loadedmetadata', this.onLoadedMetadata, false);
this.video.addEventListener('play', this.onPlay, false);
this.video.addEventListener('playing', this.onPlaying, false);
this.video.addEventListener('pause', this.onPause, false);
this.video.addEventListener('ended', this.onEnded, false);
// Place the <video> element on the page.
this.videoEl.appendTo(this.el.find('.video-player div'));

View File

@@ -1,308 +1,241 @@
(function (requirejs, require, define) {
(function(define) {
'use strict';
// VideoAccessibleMenu module.
define(
'video/035_video_accessible_menu.js',
[],
function () {
// VideoAccessibleMenu() function - what this module "exports".
return function (state) {
var dfd = $.Deferred();
if (state.el.find('li.video-tracks') === 0) {
dfd.resolve();
return dfd.promise();
'video/035_video_accessible_menu.js', [],
function() {
/**
* Video Download Transcript control module.
* @exports video/035_video_accessible_menu.js
* @constructor
* @param {jquery Element} element
* @param {Object} options
*/
var VideoAccessibleMenu = function(element, options) {
if (!(this instanceof VideoAccessibleMenu)) {
return new VideoAccessibleMenu(element, options);
}
state.videoAccessibleMenu = {
value: state.storage.getItem('transcript_download_format')
};
_.bindAll(this, 'openMenu', 'openMenuHandler', 'closeMenu', 'closeMenuHandler', 'toggleMenuHandler',
'clickHandler', 'keyDownHandler', 'render', 'menuItemsLinksFocused', 'changeFileType', 'setValue'
);
_initialize(state);
dfd.resolve();
return dfd.promise();
this.container = element;
this.options = options || {};
if (this.container.find('.video-tracks')) {
this.initialize();
}
};
// ***************************************************************
// Private functions start here.
// ***************************************************************
VideoAccessibleMenu.prototype = {
/** Initializes the module. */
initialize: function() {
this.value = this.options.storage.getItem('transcript_download_format');
this.el = this.container.find('.video-tracks .a11y-menu-container');
this.render();
this.bindHandlers();
},
function _initialize(state) {
_makeFunctionsPublic(state);
_renderElements(state);
_addAriaAttributes(state);
_bindHandlers(state);
}
// function _makeFunctionsPublic(state)
//
// Functions which will be accessible via 'state' object. When called,
// these functions will get the 'state' object as a context.
function _makeFunctionsPublic(state) {
var methodsDict = {
changeFileType: changeFileType,
setValue: setValue
};
state.bindTo(methodsDict, state.videoAccessibleMenu, 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) {
// For the time being, we assume that the menu structure is present in
// the template HTML. In the future accessible menu plugin, everything
// inside <div class='menu-container'></div> will be generated in this
// file.
var container = state.el.find('li.video-tracks>div.a11y-menu-container'),
button = container.children('a.a11y-menu-button'),
menuList = container.children('ol.a11y-menu-list'),
menuItems = menuList.children('li.a11y-menu-item'),
menuItemsLinks = menuItems.children('a.a11y-menu-item-link'),
/**
* Creates any necessary DOM elements, attach them, and set their,
* initial configuration.
*/
render: function() {
var value, msg;
// For the time being, we assume that the menu structure is present in
// the template HTML. In the future accessible menu plugin, everything
// inside <div class='menu-container'></div> will be generated in this
// file.
this.button = this.el.children('.a11y-menu-button');
this.menuList = this.el.children('.a11y-menu-list');
this.menuItems = this.menuList.children('.a11y-menu-item');
this.menuItemsLinks = this.menuItems.children('.a11y-menu-item-link');
value = (function (val, activeElement) {
return val || activeElement.find('a').data('value') || 'srt';
}(state.videoAccessibleMenu.value, menuItems.filter('.active'))),
}(this.value, this.menuItems.filter('.active')));
msg = '.' + value;
$.extend(state.videoAccessibleMenu, {
container: container,
button: button,
menuList: menuList,
menuItems: menuItems,
menuItemsLinks: menuItemsLinks
});
if (value) {
this.setValue(value);
this.button.text(gettext(msg));
}
},
if (value) {
state.videoAccessibleMenu.setValue(value);
button.text(gettext(msg));
}
}
function _addAriaAttributes(state) {
var menu = state.videoAccessibleMenu;
menu.button.attr({
'role': 'button',
'aria-disabled': 'false'
});
menu.menuList.attr('role', 'menu');
menu.menuItemsLinks.each(function(){
$(this).attr({
'role': 'menuitem',
'aria-disabled': 'false'
/** Bind any necessary function callbacks to DOM events. */
bindHandlers: function() {
// Attach various events handlers to menu container.
this.el.on({
'mouseenter': this.openMenuHandler,
'mouseleave': this.closeMenuHandler,
'click': this.toggleMenuHandler,
'keydown': this.keyDownHandler
});
});
}
// Get previous element in array or cyles back to the last if it is the
// first.
function _previousMenuItemLink(links, index) {
return $(links.eq(index < 1 ? links.length - 1 : index - 1));
}
// Attach click and keydown event handlers to individual menu items.
this.menuItems
.on('click', 'a.a11y-menu-item-link', this.clickHandler)
.on('keydown', 'a.a11y-menu-item-link', this.keyDownHandler);
},
// Get next element in array or cyles back to the first if it is the last.
function _nextMenuItemLink(links, index) {
return $(links.eq(index >= links.length - 1 ? 0 : index + 1));
}
// Get previous element in array or cyles back to the last if it is the
// first.
previousMenuItemLink: function(links, index) {
return index < 1 ? links.last() : links.eq(index - 1);
},
function _menuItemsLinksFocused(menu) {
return menu.menuItemsLinks.is(':focus');
}
// Get next element in array or cyles back to the first if it is the last.
nextMenuItemLink: function(links, index) {
return index >= links.length - 1 ? links.first() : links.eq(index + 1);
},
function _openMenu(menu, without_handler) {
// When menu items have focus, the menu stays open on
// mouseleave. A _closeMenuHandler is added to the window
// element to have clicks close the menu when they happen
// outside of it. We namespace the click event to easily remove it (and
// only it) in _closeMenu.
menu.container.addClass('open');
menu.button.text('...');
if (!without_handler) {
$(window).on('click.currentMenu', _closeMenuHandler.bind(menu));
}
menuItemsLinksFocused: function() {
return this.menuItemsLinks.is(':focus');
},
// @TODO: onOpen callback
}
openMenu: function(withoutHandler) {
// When menu items have focus, the menu stays open on
// mouseleave. A closeMenuHandler is added to the window
// element to have clicks close the menu when they happen
// outside of it. We namespace the click event to easily remove it (and
// only it) in closeMenu.
this.el.addClass('open');
this.button.text('...');
if (!withoutHandler) {
$(window).on('click.currentMenu', this.closeMenuHandler);
}
// @TODO: onOpen callback
},
function _closeMenu(menu, without_handler) {
// Remove the previously added clickHandler from window element.
var msg = '.' + menu.value;
closeMenu: function(withoutHandler) {
// Remove the previously added clickHandler from window element.
var msg = '.' + this.value;
menu.container.removeClass('open');
menu.button.text(gettext(msg));
if (!without_handler) {
$(window).off('click.currentMenu');
}
this.el.removeClass('open');
this.button.text(gettext(msg));
if (!withoutHandler) {
$(window).off('click.currentMenu');
}
// @TODO: onClose callback
},
// @TODO: onClose callback
}
openMenuHandler: function() {
this.openMenu(true);
return false;
},
function _openMenuHandler(event) {
_openMenu(this, true);
return false;
}
function _closeMenuHandler(event) {
// Only close the menu if no menu item link has focus or `click` event.
if (!_menuItemsLinksFocused(this) || event.type == 'click') {
_closeMenu(this, true);
}
return false;
}
function _toggleMenuHandler(event) {
if (this.container.hasClass('open')) {
_closeMenu(this, true);
} else {
_openMenu(this, true);
}
return false;
}
// Various event handlers. They all return false to stop propagation and
// prevent default behavior.
function _clickHandler(event) {
var target = $(event.currentTarget);
this.changeFileType.call(this, event);
_closeMenu(this, true);
return false;
}
function _keyDownHandler(event) {
var KEY = $.ui.keyCode,
keyCode = event.keyCode,
target = $(event.currentTarget),
index;
if (target.is('a.a11y-menu-item-link')) {
index = target.parent().index();
switch (keyCode) {
// Scroll up menu, wrapping at the top. Keep menu open.
case KEY.UP:
_previousMenuItemLink(this.menuItemsLinks, index).focus();
break;
// Scroll down menu, wrapping at the bottom. Keep menu
// open.
case KEY.DOWN:
_nextMenuItemLink(this.menuItemsLinks, index).focus();
break;
// Close menu.
case KEY.TAB:
_closeMenu(this);
// TODO
// What has to happen here? In speed menu, tabbing backward
// will give focus to Play/Pause button and tabbing
// forward to Volume button.
break;
// Close menu, give focus to button and change
// file type.
case KEY.ENTER:
case KEY.SPACE:
this.button.focus();
this.changeFileType.call(this, event);
_closeMenu(this);
break;
// Close menu and give focus to speed control.
case KEY.ESCAPE:
_closeMenu(this);
this.button.focus();
break;
closeMenuHandler: function(event) {
// Only close the menu if no menu item link has focus or `click` event.
if (!this.menuItemsLinksFocused() || event.type === 'click') {
this.closeMenu(true);
}
return false;
}
else {
switch(keyCode) {
// Open menu and focus on last element of list above it.
case KEY.ENTER:
case KEY.SPACE:
case KEY.UP:
_openMenu(this);
this.menuItemsLinks.last().focus();
break;
// Close menu.
case KEY.ESCAPE:
_closeMenu(this);
break;
},
toggleMenuHandler: function() {
if (this.el.hasClass('open')) {
this.closeMenu(true);
} else {
this.openMenu(true);
}
// We do not stop propagation and default behavior on a TAB
// keypress.
return event.keyCode === KEY.TAB;
return false;
},
// Various event handlers. They all return false to stop propagation and
// prevent default behavior.
clickHandler: function(event) {
this.changeFileType.call(this, event);
this.closeMenu(true);
return false;
},
keyDownHandler: function(event) {
var KEY = $.ui.keyCode,
keyCode = event.keyCode,
target = $(event.currentTarget),
index;
if (target.is('a.a11y-menu-item-link')) {
index = target.parent().index();
switch (keyCode) {
// Scroll up menu, wrapping at the top. Keep menu open.
case KEY.UP:
this.previousMenuItemLink(this.menuItemsLinks, index).focus();
break;
// Scroll down menu, wrapping at the bottom. Keep menu
// open.
case KEY.DOWN:
this.nextMenuItemLink(this.menuItemsLinks, index).focus();
break;
// Close menu.
case KEY.TAB:
this.closeMenu();
// TODO
// What has to happen here? In speed menu, tabbing backward
// will give focus to Play/Pause button and tabbing
// forward to Volume button.
break;
// Close menu, give focus to button and change
// file type.
case KEY.ENTER:
case KEY.SPACE:
this.button.focus();
this.changeFileType.call(this, event);
this.closeMenu();
break;
// Close menu and give focus to speed control.
case KEY.ESCAPE:
this.closeMenu();
this.button.focus();
break;
}
return false;
}
else {
switch(keyCode) {
// Open menu and focus on last element of list above it.
case KEY.ENTER:
case KEY.SPACE:
case KEY.UP:
this.openMenu();
this.menuItemsLinks.last().focus();
break;
// Close menu.
case KEY.ESCAPE:
this.closeMenu();
break;
}
// We do not stop propagation and default behavior on a TAB
// keypress.
return event.keyCode === KEY.TAB;
}
},
setValue: function(value) {
this.value = value;
this.menuItems
.removeClass('active')
.find("a[data-value='" + value + "']")
.parent()
.addClass('active');
},
changeFileType: function(event) {
var fileType = $(event.currentTarget).data('value'),
data = {'transcript_download_format': fileType};
this.setValue(fileType);
this.options.storage.setItem('transcript_download_format', fileType);
$.ajax({
url: this.options.saveStateUrl,
type: 'POST',
dataType: 'json',
data: data
});
}
}
/**
* @desc Bind any necessary function callbacks to DOM events (click,
* mousemove, etc.).
*
* @type {function}
* @access private
*
* @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.
*
* @this {object} The global window object.
*
* @returns {undefined}
*/
function _bindHandlers(state) {
var menu = state.videoAccessibleMenu;
// Attach various events handlers to menu container.
menu.container.on({
'mouseenter': _openMenuHandler.bind(menu),
'mouseleave': _closeMenuHandler.bind(menu),
'click': _toggleMenuHandler.bind(menu),
'keydown': _keyDownHandler.bind(menu)
});
// Attach click and keydown event handlers to individual menu items.
menu.menuItems
.on('click', 'a.a11y-menu-item-link', _clickHandler.bind(menu))
.on('keydown', 'a.a11y-menu-item-link', _keyDownHandler.bind(menu));
}
function setValue(value) {
var menu = this.videoAccessibleMenu;
menu.value = value;
menu.menuItems
.removeClass('active')
.find("a[data-value='" + value + "']")
.parent()
.addClass('active');
}
// ***************************************************************
// 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 changeFileType(event) {
var fileType = $(event.currentTarget).data('value');
this.videoAccessibleMenu.setValue(fileType);
this.saveState(true, {'transcript_download_format': fileType});
this.storage.setItem('transcript_download_format', fileType);
}
};
return VideoAccessibleMenu;
});
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
}(RequireJS.define));

View File

@@ -15,6 +15,7 @@ function (HTML5Video, Resizer) {
return dfd.promise();
},
methodsDict = {
destroy: destroy,
duration: duration,
handlePlaybackQualityChange: handlePlaybackQualityChange,
@@ -28,13 +29,14 @@ function (HTML5Video, Resizer) {
isEnded: isEnded,
isPlaying: isPlaying,
isUnstarted: isUnstarted,
log: log,
onCaptionSeek: onSeek,
onEnded: onEnded,
onError: onError,
onPause: onPause,
onPlay: onPlay,
runTimer: runTimer,
stopTimer: stopTimer,
onLoadMetadataHtml5: onLoadMetadataHtml5,
onPlaybackQualityChange: onPlaybackQualityChange,
onReady: onReady,
onSlideSeek: onSeek,
@@ -49,8 +51,7 @@ function (HTML5Video, Resizer) {
update: update,
figureOutStartEndTime: figureOutStartEndTime,
figureOutStartingTime: figureOutStartingTime,
updatePlayTime: updatePlayTime,
logStopVideo:logStopVideo
updatePlayTime: updatePlayTime
};
VideoPlayer.prototype = methodsDict;
@@ -80,6 +81,17 @@ function (HTML5Video, Resizer) {
state.videoPlayer.onCaptionSeek = debouncedF;
}
// Updates players state, once metadata is loaded for html5 player.
function onLoadMetadataHtml5() {
var player = this.videoPlayer.player.videoEl,
videoWidth = player[0].videoWidth || player.width(),
videoHeight = player[0].videoHeight || player.height();
_resize(this, videoWidth, videoHeight);
_updateVcrAndRegion(this);
}
// function _initialize(state)
//
// Create any necessary DOM elements, attach them, and set their
@@ -94,8 +106,6 @@ function (HTML5Video, Resizer) {
// metadata is loaded, which normally happens just after the video
// starts playing. Just after that configurations can be applied.
state.videoPlayer.ready = _.once(function () {
$(window).on('unload', state.saveState);
if (!state.isFlashMode() && state.speed != '1.0') {
// Work around a bug in the Youtube API that causes videos to
@@ -150,20 +160,13 @@ function (HTML5Video, Resizer) {
videoSources: state.config.sources,
events: {
onReady: state.videoPlayer.onReady,
onStateChange: state.videoPlayer.onStateChange
onStateChange: state.videoPlayer.onStateChange,
onError: state.videoPlayer.onError
}
});
player = state.videoEl = state.videoPlayer.player.videoEl;
player[0].addEventListener('loadedmetadata', function () {
var videoWidth = player[0].videoWidth || player.width(),
videoHeight = player[0].videoHeight || player.height();
_resize(state, videoWidth, videoHeight);
_updateVcrAndRegion(state);
}, false);
player[0].addEventListener('loadedmetadata', state.videoPlayer.onLoadMetadataHtml5, false);
} else {
youTubeId = state.youtubeId();
@@ -174,8 +177,8 @@ function (HTML5Video, Resizer) {
events: {
onReady: state.videoPlayer.onReady,
onStateChange: state.videoPlayer.onStateChange,
onPlaybackQualityChange: state.videoPlayer
.onPlaybackQualityChange
onPlaybackQualityChange: state.videoPlayer.onPlaybackQualityChange,
onError: state.videoPlayer.onError
}
});
@@ -261,8 +264,8 @@ function (HTML5Video, Resizer) {
});
}
$(window).on('resize', _.debounce(function () {
state.trigger('videoControl.updateControlsHeight', null);
$(window).on('resize.video', _.debounce(function () {
state.trigger('videoFullScreen.updateControlsHeight', null);
state.el.trigger('caption:resize');
state.resizer.align();
}, 100));
@@ -292,8 +295,8 @@ function (HTML5Video, Resizer) {
events: {
onReady: state.videoPlayer.onReady,
onStateChange: state.videoPlayer.onStateChange,
onPlaybackQualityChange: state.videoPlayer
.onPlaybackQualityChange
onPlaybackQualityChange: state.videoPlayer.onPlaybackQualityChange,
onError: state.videoPlayer.onError
}
});
@@ -309,6 +312,28 @@ function (HTML5Video, Resizer) {
// them available and sets up their context is makeFunctionsPublic().
// ***************************************************************
function destroy() {
var player = this.videoPlayer.player;
this.el.removeClass([
'is-unstarted', 'is-playing', 'is-paused', 'is-buffered',
'is-ended', 'is-cued'
].join(' '));
$(window).off('.video');
this.el.trigger('destroy');
this.el.off();
this.videoPlayer.stopTimer();
if (this.resizer && this.resizer.destroy) {
this.resizer.destroy();
}
if (player && player.video) {
player.video.removeEventListener('loadedmetadata', this.videoPlayer.onLoadMetadataHtml5, false);
}
if (player && _.isFunction(player.destroy)) {
player.destroy();
}
delete this.videoPlayer;
}
function pause() {
if (this.videoPlayer.player.pauseVideo) {
this.videoPlayer.player.pauseVideo();
@@ -349,9 +374,10 @@ function (HTML5Video, Resizer) {
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: true
});
// Emit `stop_video` event
this.videoPlayer.logStopVideo();
this.el.trigger('stop');
}
this.el.trigger('timeupdate', [this.videoPlayer.currentTime]);
}
}
@@ -436,19 +462,8 @@ function (HTML5Video, Resizer) {
}
newSpeed = parseFloat(newSpeed).toFixed(2).replace(/\.00$/, '.0');
this.videoPlayer.log(
'speed_change_video',
{
current_time: time,
old_speed: this.speed,
new_speed: newSpeed
}
);
this.setSpeed(newSpeed, true);
this.setSpeed(newSpeed);
this.videoPlayer.setPlaybackRate(newSpeed);
this.saveState(true, { speed: newSpeed });
}
// Every 200 ms, if the video is playing, we call the function update, via
@@ -459,20 +474,12 @@ function (HTML5Video, Resizer) {
var time = params.time,
type = params.type,
oldTime = this.videoPlayer.currentTime;
// After the user seeks, the video will start playing from
// the sought point, and stop playing at the end.
this.videoPlayer.goToStartTime = false;
this.videoPlayer.seekTo(time);
this.videoPlayer.log(
'seek_video',
{
old_time: oldTime,
new_time: time,
type: type
}
);
this.el.trigger('seek', [time, oldTime, type]);
}
function seekTo(time) {
@@ -509,7 +516,6 @@ function (HTML5Video, Resizer) {
}
this.videoPlayer.updatePlayTime(time, true);
this.el.trigger('seek', arguments);
// the timer is stopped above; restart it.
if (this.videoPlayer.isPlaying()) {
@@ -534,9 +540,8 @@ function (HTML5Video, Resizer) {
function onEnded() {
var time = this.videoPlayer.duration();
this.videoPlayer.logStopVideo();
this.trigger('videoControl.pause', null);
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: true
});
@@ -544,40 +549,20 @@ function (HTML5Video, Resizer) {
if (this.videoPlayer.skipOnEndedStartEndReset) {
this.videoPlayer.skipOnEndedStartEndReset = undefined;
}
// Sometimes `onEnded` events fires when `currentTime` not equal
// `duration`. In this case, slider doesn't reach the end point of
// timeline.
this.videoPlayer.updatePlayTime(time);
this.el.trigger('ended', arguments);
}
function onPause() {
this.videoPlayer.log(
'pause_video',
{
currentTime: this.videoPlayer.currentTime
}
);
this.videoPlayer.stopTimer();
this.trigger('videoControl.pause', null);
this.saveState(true);
this.el.trigger('pause', arguments);
}
function onPlay() {
this.videoPlayer.log(
'play_video',
{
currentTime: this.videoPlayer.currentTime
}
);
this.videoPlayer.runTimer();
this.trigger('videoControl.play', null);
this.trigger('videoProgressSlider.notifyThroughHandleEnd', {
end: false
});
@@ -591,22 +576,12 @@ function (HTML5Video, Resizer) {
this.videoPlayer.player.setPlaybackQuality(value);
}
function logStopVideo(){
this.videoPlayer.log(
'stop_video',
{
currentTime: this.videoPlayer.currentTime
}
);
}
function onPlaybackQualityChange() {
var quality;
quality = this.videoPlayer.player.getPlaybackQuality();
this.trigger('videoQualityControl.onQualityChange', quality);
this.el.trigger('qualitychange', arguments);
}
@@ -625,8 +600,6 @@ function (HTML5Video, Resizer) {
_this.videoPlayer.onVolumeChange(volume);
});
this.videoPlayer.log('load_video');
availablePlaybackRates = this.videoPlayer.player
.getAvailablePlaybackRates();
@@ -717,6 +690,10 @@ function (HTML5Video, Resizer) {
}
this.el.trigger('ready', arguments);
if (this.config.autoplay) {
this.videoPlayer.play();
}
}
function onStateChange(event) {
@@ -755,6 +732,10 @@ function (HTML5Video, Resizer) {
}
}
function onError (code) {
this.el.trigger('error', [code]);
}
function figureOutStartEndTime(duration) {
var videoPlayer = this.videoPlayer;
@@ -937,30 +918,6 @@ function (HTML5Video, Resizer) {
return Math.floor(dur);
}
function log(eventName, data) {
var logInfo;
// Default parameters that always get logged.
logInfo = {
id: this.id
};
// If extra parameters were passed to the log.
if (data) {
$.each(data, function (paramName, value) {
logInfo[paramName] = value;
});
}
if (this.isYoutubeType()) {
logInfo.code = this.youtubeId();
} else {
logInfo.code = 'html5';
}
Logger.log(eventName, logInfo);
}
function onVolumeChange(volume) {
this.videoPlayer.player.setVolume(volume);
}

View File

@@ -1,5 +1,4 @@
(function (requirejs, require, define) {
// VideoControl module.
define(
'video/04_video_control.js',
@@ -30,24 +29,29 @@ function () {
// get the 'state' object as a context.
function _makeFunctionsPublic(state) {
var methodsDict = {
exitFullScreenHandler: exitFullScreenHandler,
destroy: destroy,
hideControls: hideControls,
hidePlayPlaceholder: hidePlayPlaceholder,
pause: pause,
play: play,
show: show,
showControls: showControls,
showPlayPlaceholder: showPlayPlaceholder,
toggleFullScreen: toggleFullScreen,
toggleFullScreenHandler: toggleFullScreenHandler,
togglePlayback: togglePlayback,
updateControlsHeight: updateControlsHeight,
focusFirst: focusFirst,
updateVcrVidTime: updateVcrVidTime
};
state.bindTo(methodsDict, state.videoControl, state);
}
function destroy() {
this.el.off({
'mousemove': this.videoControl.showControls,
'keydown': this.videoControl.showControls,
'destroy': this.videoControl.destroy,
'initialize': this.videoControl.focusFirst
});
this.el.off('controls:show');
delete this.videoControl;
}
// function _renderElements(state)
//
// Create any necessary DOM elements, attach them, and set their initial configuration. Also
@@ -55,21 +59,7 @@ function () {
// way - you don't have to do repeated jQuery element selects.
function _renderElements(state) {
state.videoControl.el = state.el.find('.video-controls');
// state.videoControl.el.append(el);
state.videoControl.sliderEl = state.videoControl.el.find('.slider');
state.videoControl.playPauseEl = state.videoControl.el.find('.video_control');
state.videoControl.playPlaceholder = state.el.find('.btn-play');
state.videoControl.secondaryControlsEl = state.videoControl.el.find('.secondary-controls');
state.videoControl.fullScreenEl = state.videoControl.el.find('.add-fullscreen');
state.videoControl.vidTimeEl = state.videoControl.el.find('.vidtime');
state.videoControl.fullScreenState = false;
state.videoControl.pause();
if (state.isTouch && state.videoType === 'html5') {
state.videoControl.showPlayPlaceholder();
}
state.videoControl.vidTimeEl = state.videoControl.el.find('.vidtime');
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
state.videoControl.fadeOutTimeout = state.config.fadeOutTimeout;
@@ -77,62 +67,23 @@ function () {
state.videoControl.el.addClass('html5');
state.controlHideTimeout = setTimeout(state.videoControl.hideControls, state.videoControl.fadeOutTimeout);
}
// ARIA
// Let screen readers know that this anchor, representing the slider
// handle, behaves as a slider named 'video slider'.
state.videoControl.sliderEl.find('.ui-slider-handle').attr({
'role': 'slider',
'title': gettext('Video slider')
});
state.videoControl.updateControlsHeight();
}
// function _bindHandlers(state)
//
// Bind any necessary function callbacks to DOM events (click, mousemove, etc.).
function _bindHandlers(state) {
state.videoControl.playPauseEl.on('click', state.videoControl.togglePlayback);
state.videoControl.fullScreenEl.on('click', state.videoControl.toggleFullScreenHandler);
state.el.on('fullscreen', function (event, isFullScreen) {
var height = state.videoControl.updateControlsHeight();
if (isFullScreen) {
state.resizer
.delta
.substract(height, 'height')
.setMode('both');
} else {
state.resizer
.delta
.reset()
.setMode('width');
}
});
$(document).on('keyup', state.videoControl.exitFullScreenHandler);
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
state.el.on('mousemove', state.videoControl.showControls);
state.el.on('keydown', state.videoControl.showControls);
state.el.on({
'mousemove': state.videoControl.showControls,
'keydown': state.videoControl.showControls
});
}
// The state.previousFocus is used in video_speed_control to track
// the element that had the focus before it.
state.videoControl.playPauseEl.on('blur', function () {
state.previousFocus = 'playPause';
});
if (/iPad|Android/i.test(state.isTouch[0])) {
state.videoControl.playPlaceholder
.on('click', function () {
state.trigger('videoPlayer.play', null);
});
if (state.config.focusFirstControl) {
state.el.on('initialize', state.videoControl.focusFirst);
}
}
function _getControlsHeight(control) {
return control.el.height() + 0.5 * control.sliderEl.height();
state.el.on('destroy', state.videoControl.destroy);
}
// ***************************************************************
@@ -141,10 +92,8 @@ function () {
// The magic private function that makes them available and sets up their context is makeFunctionsPublic().
// ***************************************************************
function updateControlsHeight () {
this.videoControl.height = _getControlsHeight(this.videoControl);
return this.videoControl.height;
function focusFirst() {
this.videoControl.el.find('.vcr a, .vcr button').first().focus();
}
function show() {
@@ -171,13 +120,12 @@ function () {
}
this.controlHideTimeout = setTimeout(this.videoControl.hideControls, this.videoControl.fadeOutTimeout);
this.controlShowLock = false;
}
}
function hideControls() {
var _this;
var _this = this;
this.controlHideTimeout = null;
@@ -186,12 +134,8 @@ function () {
}
this.controlState = 'hiding';
_this = this;
this.videoControl.el.fadeOut(this.videoControl.fadeOutTimeout, function () {
_this.controlState = 'invisible';
// If the focus was on the video control or the volume control,
// then we must make sure to close these dialogs. Otherwise, after
// next autofocus, these dialogs will be open, but the focus will
@@ -203,98 +147,6 @@ function () {
});
}
function showPlayPlaceholder(event) {
this.videoControl.playPlaceholder
.removeClass('is-hidden')
.attr({
'aria-hidden': 'false',
'tabindex': 0
});
}
function hidePlayPlaceholder(event) {
this.videoControl.playPlaceholder
.addClass('is-hidden')
.attr({
'aria-hidden': 'true',
'tabindex': -1
});
}
function play() {
this.videoControl.isPlaying = true;
this.videoControl.playPauseEl
.removeClass('play')
.addClass('pause')
.attr('title', gettext('Pause'));
if (/iPad|Android/i.test(this.isTouch[0]) && this.videoType === 'html5') {
this.videoControl.hidePlayPlaceholder();
}
}
function pause() {
this.videoControl.isPlaying = false;
this.videoControl.playPauseEl
.removeClass('pause')
.addClass('play')
.attr('title', gettext('Play'));
if (/iPad|Android/i.test(this.isTouch[0]) && this.videoType === 'html5') {
this.videoControl.showPlayPlaceholder();
}
}
function togglePlayback(event) {
event.preventDefault();
this.videoCommands.execute('togglePlayback');
}
/**
* Event handler to toggle fullscreen mode.
* @param {jquery Event} event
*/
function toggleFullScreenHandler(event) {
event.preventDefault();
this.videoCommands.execute('toggleFullScreen');
}
/** Toggle fullscreen mode. */
function toggleFullScreen() {
var fullScreenClassNameEl = this.el.add(document.documentElement),
win = $(window), text;
if (this.videoControl.fullScreenState) {
this.videoControl.fullScreenState = this.isFullScreen = false;
fullScreenClassNameEl.removeClass('video-fullscreen');
text = gettext('Fill browser');
win.scrollTop(this.scrollPos);
} else {
this.scrollPos = win.scrollTop();
win.scrollTop(0);
this.videoControl.fullScreenState = this.isFullScreen = true;
fullScreenClassNameEl.addClass('video-fullscreen');
text = gettext('Exit full browser');
}
this.videoControl.fullScreenEl
.attr('title', text)
.text(text);
this.el.trigger('fullscreen', [this.isFullScreen]);
}
/**
* Event handler to exit from fullscreen mode.
* @param {jquery Event} event
*/
function exitFullScreenHandler(event) {
if ((this.isFullScreen) && (event.keyCode === 27)) {
event.preventDefault();
this.videoCommands.execute('toggleFullScreen');
}
}
function updateVcrVidTime(params) {
var endTime = (this.config.endTime !== null) ? this.config.endTime : params.duration;
// in case endTime is accidentally specified as being greater than the video

View File

@@ -0,0 +1,175 @@
(function (define) {
'use strict';
define('video/04_video_full_screen.js', [], function () {
var template = [
'<a href="#" class="add-fullscreen" title="',
gettext('Fill browser'), '" role="button" aria-disabled="false">',
gettext('Fill browser'),
'</a>'
].join('');
// VideoControl() function - what this module "exports".
return function (state) {
var dfd = $.Deferred();
state.videoFullScreen = {};
_makeFunctionsPublic(state);
_renderElements(state);
_bindHandlers(state);
dfd.resolve();
return dfd.promise();
};
// ***************************************************************
// Private functions start here.
// ***************************************************************
// function _makeFunctionsPublic(state)
//
// Functions which will be accessible via 'state' object. When called, these functions will
// get the 'state' object as a context.
function _makeFunctionsPublic(state) {
var methodsDict = {
destroy: destroy,
enter: enter,
exitHandler: exitHandler,
exit: exit,
onFullscreenChange: onFullscreenChange,
toggle: toggle,
toggleHandler: toggleHandler,
updateControlsHeight: updateControlsHeight
};
state.bindTo(methodsDict, state.videoFullScreen, state);
}
function destroy() {
$(document).off('keyup', this.videoFullScreen.exitHandler);
this.videoFullScreen.fullScreenEl.remove();
this.el.off({
'fullscreen': this.videoFullScreen.onFullscreenChange,
'destroy': this.videoFullScreen.destroy
});
if (this.isFullScreen) {
this.videoFullScreen.exit();
}
delete this.videoFullScreen;
}
// 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) {
state.videoFullScreen.fullScreenEl = $(template);
state.videoFullScreen.sliderEl = state.el.find('.slider');
state.videoFullScreen.fullScreenState = false;
state.el.find('.secondary-controls').append(state.videoFullScreen.fullScreenEl);
state.videoFullScreen.updateControlsHeight();
}
// function _bindHandlers(state)
//
// Bind any necessary function callbacks to DOM events (click, mousemove, etc.).
function _bindHandlers(state) {
state.videoFullScreen.fullScreenEl.on('click', state.videoFullScreen.toggleHandler);
state.el.on({
'fullscreen': state.videoFullScreen.onFullscreenChange,
'destroy': state.videoFullScreen.destroy
});
$(document).on('keyup', state.videoFullScreen.exitHandler);
}
function _getControlsHeight(controls, slider) {
return controls.height() + 0.5 * slider.height();
}
// ***************************************************************
// 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 onFullscreenChange (event, isFullScreen) {
var height = this.videoFullScreen.updateControlsHeight();
if (isFullScreen) {
this.resizer
.delta
.substract(height, 'height')
.setMode('both');
} else {
this.resizer
.delta
.reset()
.setMode('width');
}
}
function updateControlsHeight() {
var controls = this.el.find('.video-controls'),
slider = this.videoFullScreen.sliderEl;
this.videoFullScreen.height = _getControlsHeight(controls, slider);
return this.videoFullScreen.height;
}
/**
* Event handler to toggle fullscreen mode.
* @param {jquery Event} event
*/
function toggleHandler(event) {
event.preventDefault();
this.videoCommands.execute('toggleFullScreen');
}
function exit() {
var fullScreenClassNameEl = this.el.add(document.documentElement);
this.videoFullScreen.fullScreenState = this.isFullScreen = false;
fullScreenClassNameEl.removeClass('video-fullscreen');
$(window).scrollTop(this.scrollPos);
this.videoFullScreen.fullScreenEl
.attr('title', gettext('Fill browser'))
.text(gettext('Fill browser'));
this.el.trigger('fullscreen', [this.isFullScreen]);
}
function enter() {
var fullScreenClassNameEl = this.el.add(document.documentElement);
this.scrollPos = $(window).scrollTop();
$(window).scrollTop(0);
this.videoFullScreen.fullScreenState = this.isFullScreen = true;
fullScreenClassNameEl.addClass('video-fullscreen');
this.videoFullScreen.fullScreenEl
.attr('title', gettext('Exit full browser'))
.text(gettext('Exit full browser'));
this.el.trigger('fullscreen', [this.isFullScreen]);
}
/** Toggle fullscreen mode. */
function toggle() {
if (this.videoFullScreen.fullScreenState) {
this.videoFullScreen.exit();
} else {
this.videoFullScreen.enter();
}
}
/**
* Event handler to exit from fullscreen mode.
* @param {jquery Event} event
*/
function exitHandler(event) {
if ((this.isFullScreen) && (event.keyCode === 27)) {
event.preventDefault();
this.videoCommands.execute('toggleFullScreen');
}
}
});
}(RequireJS.define));

View File

@@ -5,6 +5,12 @@ define(
'video/05_video_quality_control.js',
[],
function () {
var template = [
'<a href="#" class="quality-control is-hidden" title="',
gettext('HD off'), '" role="button" aria-disabled="false">',
gettext('HD off'),
'</a>'
].join('');
// VideoQualityControl() function - what this module "exports".
return function (state) {
@@ -12,7 +18,6 @@ function () {
// Changing quality for now only works for YouTube videos.
if (state.videoType !== 'youtube') {
state.el.find('a.quality-control').remove();
return;
}
@@ -36,6 +41,7 @@ function () {
// get the 'state' object as a context.
function _makeFunctionsPublic(state) {
var methodsDict = {
destroy: destroy,
fetchAvailableQualities: fetchAvailableQualities,
onQualityChange: onQualityChange,
showQualityControl: showQualityControl,
@@ -45,16 +51,25 @@ function () {
state.bindTo(methodsDict, state.videoQualityControl, state);
}
function destroy() {
this.videoQualityControl.el.off({
'click': this.videoQualityControl.toggleQuality,
'destroy': this.videoQualityControl.destroy
});
this.el.off('.quality');
this.videoQualityControl.el.remove();
delete this.videoQualityControl;
}
// 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) {
state.videoQualityControl.el = state.el.find('a.quality-control');
state.videoQualityControl.el.show();
var element = state.videoQualityControl.el = $(template);
state.videoQualityControl.quality = 'large';
state.el.find('.secondary-controls').append(element);
}
// function _bindHandlers(state)
@@ -64,9 +79,11 @@ function () {
state.videoQualityControl.el.on('click',
state.videoQualityControl.toggleQuality
);
state.el.on('play', _.once(
state.el.on('play.quality', _.once(
state.videoQualityControl.fetchAvailableQualities
));
state.el.on('destroy.quality', state.videoQualityControl.destroy);
}
// ***************************************************************
@@ -141,7 +158,7 @@ function () {
event.preventDefault();
newQuality = isHD ? 'large' : 'highres';
this.trigger('videoPlayer.handlePlaybackQualityChange', newQuality);
}

View File

@@ -12,15 +12,17 @@ define(
'video/06_video_progress_slider.js',
[],
function () {
var template = [
'<div class="slider" title="', gettext('Video position'), '"></div>'
].join('');
// VideoProgressSlider() function - what this module "exports".
return function (state) {
var dfd = $.Deferred();
state.videoProgressSlider = {};
_makeFunctionsPublic(state);
_renderElements(state);
// No callbacks to DOM events (click, mousemove, etc.).
dfd.resolve();
return dfd.promise();
@@ -36,6 +38,7 @@ function () {
// these functions will get the 'state' object as a context.
function _makeFunctionsPublic(state) {
var methodsDict = {
destroy: destroy,
buildSlider: buildSlider,
getRangeParams: getRangeParams,
onSlide: onSlide,
@@ -49,6 +52,12 @@ function () {
state.bindTo(methodsDict, state.videoProgressSlider, state);
}
function destroy() {
this.videoProgressSlider.el.removeAttr('tabindex').slider('destroy');
this.el.off('destroy', this.videoProgressSlider.destroy);
delete this.videoProgressSlider;
}
// function _renderElements(state)
//
// Create any necessary DOM elements, attach them, and set their
@@ -56,8 +65,9 @@ function () {
// via the 'state' object. Much easier to work this way - you don't
// have to do repeated jQuery element selects.
function _renderElements(state) {
state.videoProgressSlider.el = state.videoControl.sliderEl;
state.videoProgressSlider.el = $(template);
state.el.find('.video-controls').prepend(state.videoProgressSlider.el);
state.videoProgressSlider.buildSlider();
_buildHandle(state);
}
@@ -81,6 +91,8 @@ function () {
'aria-valuemin': '0',
'aria-valuenow': state.videoPlayer.currentTime
});
state.el.on('destroy', state.videoProgressSlider.destroy);
}
// ***************************************************************
@@ -109,7 +121,7 @@ function () {
// whole slider). Remember that endTime === null means the end-time
// is set to the end of video by default.
function updateStartEndTimeRegion(params) {
var left, width, start, end, duration, rangeParams;
var start, end, duration, rangeParams;
// We must have a duration in order to determine the area of range.
// It also must be non-zero.

View File

@@ -17,6 +17,10 @@ function() {
return new VolumeControl(state, i18n);
}
_.bindAll(this, 'keyDownHandler', 'updateVolumeSilently',
'onVolumeChangeHandler', 'openMenu', 'closeMenu',
'toggleMuteHandler', 'keyDownButtonHandler', 'destroy'
);
this.state = state;
this.state.videoVolumeControl = this;
this.i18n = i18n;
@@ -33,17 +37,55 @@ function() {
/** Step to increase/decrease volume level via keyboard. */
step: 20,
template: [
'<div class="volume">',
'<a href="#" role="button" aria-disabled="false" title="',
gettext('Volume'), '" aria-label="',
gettext('Click on this button to mute or unmute this video or press UP or DOWN buttons to increase or decrease volume level.'),
'"></a>',
'<div role="presentation" class="volume-slider-container">',
'<div class="volume-slider"></div>',
'</div>',
'</div>'
].join(''),
destroy: function () {
this.volumeSlider.slider('destroy');
this.state.el.find('iframe').removeAttr('tabindex');
this.a11y.destroy();
this.cookie = this.a11y = null;
this.closeMenu();
this.state.el
.off('play.volume')
.off({
'keydown': this.keyDownHandler,
'volumechange': this.onVolumeChangeHandler
});
this.el.off({
'mouseenter': this.openMenu,
'mouseleave': this.closeMenu
});
this.button.off({
'mousedown': this.toggleMuteHandler,
'keydown': this.keyDownButtonHandler,
'focus': this.openMenu,
'blur': this.closeMenu
});
this.el.remove();
delete this.state.videoVolumeControl;
},
/** Initializes the module. */
initialize: function() {
var volume;
this.el = this.state.el.find('.volume');
if (this.state.isTouch) {
// iOS doesn't support volume change
this.el.remove();
return false;
}
this.el = $(this.template);
// Youtube iframe react on key buttons and has his own handlers.
// So, we disallow focusing on iframe.
this.state.el.find('iframe').attr('tabindex', -1);
@@ -80,26 +122,28 @@ function() {
// Therefore, we do not need redundant focusing on slider in TAB
// order.
container.find('a').attr('tabindex', -1);
this.state.el.find('.secondary-controls').append(this.el);
},
/** Bind any necessary function callbacks to DOM events. */
bindHandlers: function() {
this.state.el.on({
'keydown': this.keyDownHandler.bind(this),
'play': _.once(this.updateVolumeSilently.bind(this)),
'volumechange': this.onVolumeChangeHandler.bind(this)
'keydown': this.keyDownHandler,
'play.volume': _.once(this.updateVolumeSilently),
'volumechange': this.onVolumeChangeHandler
});
this.el.on({
'mouseenter': this.openMenu.bind(this),
'mouseleave': this.closeMenu.bind(this)
'mouseenter': this.openMenu,
'mouseleave': this.closeMenu
});
this.button.on({
'click': false,
'mousedown': this.toggleMuteHandler.bind(this),
'keydown': this.keyDownButtonHandler.bind(this),
'focus': this.openMenu.bind(this),
'blur': this.closeMenu.bind(this)
'mousedown': this.toggleMuteHandler,
'keydown': this.keyDownButtonHandler,
'focus': this.openMenu,
'blur': this.closeMenu
});
this.state.el.on('destroy', this.destroy);
},
/**
@@ -343,6 +387,10 @@ function() {
};
Accessibility.prototype = {
destroy: function () {
this.liveRegion.remove();
},
/** Initializes the module. */
initialize: function() {
this.liveRegion = $('<div />', {

View File

@@ -16,6 +16,10 @@ function (Iterator) {
return new SpeedControl(state);
}
_.bindAll(this, 'onSetSpeed', 'onRenderSpeed', 'clickLinkHandler',
'keyDownLinkHandler', 'mouseEnterHandler', 'mouseLeaveHandler',
'clickMenuHandler', 'keyDownMenuHandler', 'destroy'
);
this.state = state;
this.state.videoSpeedControl = this;
this.initialize();
@@ -24,24 +28,51 @@ function (Iterator) {
};
SpeedControl.prototype = {
template: [
'<div class="speeds menu-container">',
'<a class="speed-button" href="#" title="',
gettext('Speeds'), '" role="button" aria-disabled="false">',
'<span class="label">', gettext('Speed'), '</span>',
'<span class="value"></span>',
'</a>',
'<ol class="video-speeds menu" role="menu"></ol>',
'</div>'
].join(''),
destroy: function () {
this.el.off({
'mouseenter': this.mouseEnterHandler,
'mouseleave': this.mouseLeaveHandler,
'click': this.clickMenuHandler,
'keydown': this.keyDownMenuHandler
});
this.state.el.off({
'speed:set': this.onSetSpeed,
'speed:render': this.onRenderSpeed
});
this.closeMenu(true);
this.speedsContainer.remove();
this.el.remove();
delete this.state.videoSpeedControl;
},
/** Initializes the module. */
initialize: function () {
var state = this.state;
this.el = state.el.find('.speeds');
this.speedsContainer = this.el.find('.video-speeds');
this.speedButton = this.el.find('.speed-button');
if (!this.isPlaybackRatesSupported(state)) {
this.el.remove();
console.log(
'[Video info]: playbackRate is not supported.'
);
return false;
}
this.el = $(this.template);
this.speedsContainer = this.el.find('.video-speeds');
this.speedButton = this.el.find('.speed-button');
this.render(state.speeds, state.speed);
this.setSpeed(state.speed, true, true);
this.bindHandlers();
return true;
@@ -51,13 +82,11 @@ function (Iterator) {
* Creates any necessary DOM elements, attach them, and set their,
* initial configuration.
* @param {array} speeds List of speeds available for the player.
* @param {string|number} currentSpeed Current speed for the player.
*/
render: function (speeds, currentSpeed) {
var self = this,
speedsContainer = this.speedsContainer,
render: function (speeds) {
var speedsContainer = this.speedsContainer,
reversedSpeeds = speeds.concat().reverse(),
speedsList = $.map(reversedSpeeds, function (speed, index) {
speedsList = $.map(reversedSpeeds, function (speed) {
return [
'<li data-speed="', speed, '" role="presentation">',
'<a class="speed-link" href="#" role="menuitem" tabindex="-1">',
@@ -69,7 +98,7 @@ function (Iterator) {
speedsContainer.html(speedsList.join(''));
this.speedLinks = new Iterator(speedsContainer.find('.speed-link'));
this.setSpeed(currentSpeed, true, true);
this.state.el.find('.secondary-controls').prepend(this.el);
},
/**
@@ -77,31 +106,34 @@ function (Iterator) {
* mousemove, etc.).
*/
bindHandlers: function () {
var self = this;
// Attach various events handlers to the speed menu button.
this.el.on({
'mouseenter': this.mouseEnterHandler.bind(this),
'mouseleave': this.mouseLeaveHandler.bind(this),
'click': this.clickMenuHandler.bind(this),
'keydown': this.keyDownMenuHandler.bind(this)
'mouseenter': this.mouseEnterHandler,
'mouseleave': this.mouseLeaveHandler,
'click': this.clickMenuHandler,
'keydown': this.keyDownMenuHandler
});
// Attach click and keydown event handlers to the individual speed
// entries.
this.speedsContainer.on({
click: this.clickLinkHandler.bind(this),
keydown: this.keyDownLinkHandler.bind(this)
click: this.clickLinkHandler,
keydown: this.keyDownLinkHandler
}, 'a.speed-link');
this.state.el.on({
'speed:set': function (event, speed) {
self.setSpeed(speed, true);
},
'speed:render': function (event, speeds, currentSpeed) {
self.render(speeds, currentSpeed);
}
'speed:set': this.onSetSpeed,
'speed:render': this.onRenderSpeed
});
this.state.el.on('destroy', this.destroy);
},
onSetSpeed: function (event, speed) {
this.setSpeed(speed, true);
},
onRenderSpeed: function (event, speeds, currentSpeed) {
this.render(speeds, currentSpeed);
},
/**
@@ -133,7 +165,7 @@ function (Iterator) {
// element to have clicks close the menu when they happen
// outside of it.
if (bindEvent) {
$(window).on('click.speedMenu', this.clickMenuHandler.bind(this));
$(window).on('click.speedMenu', this.clickMenuHandler);
}
this.el.addClass('is-opened');
@@ -175,7 +207,7 @@ function (Iterator) {
this.currentSpeed = speed;
if (!silent) {
this.el.trigger('speedchange', [speed]);
this.el.trigger('speedchange', [speed, this.state.speed]);
}
}
},

View File

@@ -656,6 +656,12 @@ function (Component) {
if (!state.isYoutubeType()) {
state.el.find('video').contextmenu(state.el, options);
state.el.on('destroy', function () {
var contextmenu = $(this).find('video').data('contextmenu');
if (contextmenu) {
contextmenu.destroy();
}
});
}
return $.Deferred().resolve().promise();

View File

@@ -0,0 +1,109 @@
(function (define) {
'use strict';
define('video/09_bumper.js',[], function () {
/**
* VideoBumper module.
* @exports video/09_bumper.js
* @constructor
* @param {Object} player The player factory.
* @param {Object} state The object containing the state of the video
* @return {jquery Promise}
*/
var VideoBumper = function (player, state) {
if (!(this instanceof VideoBumper)) {
return new VideoBumper(player, state);
}
_.bindAll(
this, 'showMainVideoHandler', 'destroy', 'skipByDuration', 'destroyAndResolve'
);
this.dfd = $.Deferred();
this.element = state.el;
this.element.addClass('is-bumper');
this.player = player;
this.state = state;
this.doNotShowAgain = false;
this.state.videoBumper = this;
this.bindHandlers();
this.initialize();
this.maxBumperDuration = 35; // seconds
};
VideoBumper.prototype = {
initialize: function () {
this.player();
},
getPromise: function () {
return this.dfd.promise();
},
showMainVideoHandler: function () {
this.state.storage.setItem('isBumperShown', true);
setTimeout(function () {
this.saveState();
this.showMainVideo();
}.bind(this), 20);
},
destroyAndResolve: function () {
this.destroy();
this.dfd.resolve();
},
showMainVideo: function () {
if (this.state.videoPlayer) {
this.destroyAndResolve();
} else {
this.state.el.on('initialize', this.destroyAndResolve);
}
},
skip: function () {
this.element.trigger('skip', [this.doNotShowAgain]);
this.showMainVideoHandler();
},
skipAndDoNotShowAgain: function () {
this.doNotShowAgain = true;
this.skip();
},
skipByDuration: function (event, time) {
if (time > this.maxBumperDuration) {
this.element.trigger('ended');
}
},
bindHandlers: function () {
var events = ['ended', 'error'].join(' ');
this.element.on(events, this.showMainVideoHandler);
this.element.on('timeupdate', this.skipByDuration);
},
saveState: function () {
var info = {bumper_last_view_date: true};
if (this.doNotShowAgain) {
_.extend(info, {bumper_do_not_show_again: true});
}
this.state.videoSaveStatePlugin.saveState(true, info);
},
destroy: function () {
var events = ['ended', 'error'].join(' ');
this.element.off(events, this.showMainVideoHandler);
this.element.off({
'timeupdate': this.skipByDuration,
'initialize': this.destroyAndResolve
});
this.element.removeClass('is-bumper');
if (_.isFunction(this.state.videoPlayer.destroy)) {
this.state.videoPlayer.destroy();
}
delete this.state.videoBumper;
}
};
return VideoBumper;
});
}(RequireJS.define));

View File

@@ -0,0 +1,112 @@
(function(define) {
'use strict';
define('video/09_events_bumper_plugin.js', [], function() {
/**
* Events module.
* @exports video/09_events_bumper_plugin.js
* @constructor
* @param {Object} state The object containing the state of the video
* @param {Object} i18n The object containing strings with translations.
* @param {Object} options
* @return {jquery Promise}
*/
var EventsBumperPlugin = function(state, i18n, options) {
if (!(this instanceof EventsBumperPlugin)) {
return new EventsBumperPlugin(state, i18n, options);
}
_.bindAll(this, 'onReady', 'onPlay', 'onEnded', 'onShowLanguageMenu', 'onHideLanguageMenu', 'onSkip',
'onShowCaptions', 'onHideCaptions', 'destroy');
this.state = state;
this.options = _.extend({}, options);
this.state.videoEventsBumperPlugin = this;
this.i18n = i18n;
this.initialize();
return $.Deferred().resolve().promise();
};
EventsBumperPlugin.moduleName = 'EventsBumperPlugin';
EventsBumperPlugin.prototype = {
destroy: function () {
this.state.el.off(this.events);
delete this.state.videoEventsBumperPlugin;
},
initialize: function() {
this.events = {
'ready': this.onReady,
'play': this.onPlay,
'ended stop': this.onEnded,
'skip': this.onSkip,
'language_menu:show': this.onShowLanguageMenu,
'language_menu:hide': this.onHideLanguageMenu,
'captions:show': this.onShowCaptions,
'captions:hide': this.onHideCaptions,
'destroy': this.destroy
};
this.bindHandlers();
},
bindHandlers: function() {
this.state.el.on(this.events);
},
onReady: function () {
this.log('edx.video.bumper.loaded');
},
onPlay: function () {
this.log('edx.video.bumper.played', {currentTime: this.getCurrentTime()});
},
onEnded: function () {
this.log('edx.video.bumper.stopped', {currentTime: this.getCurrentTime()});
},
onSkip: function (event, doNotShowAgain) {
var info = {currentTime: this.getCurrentTime()},
eventName = 'edx.video.bumper.' + (doNotShowAgain ? 'dismissed': 'skipped');
this.log(eventName, info);
},
onShowLanguageMenu: function () {
this.log('edx.video.bumper.transcript.menu.shown');
},
onHideLanguageMenu: function () {
this.log('edx.video.bumper.transcript.menu.hidden');
},
onShowCaptions: function () {
this.log('edx.video.bumper.transcript.shown', {currentTime: this.getCurrentTime()});
},
onHideCaptions: function () {
this.log('edx.video.bumper.transcript.hidden', {currentTime: this.getCurrentTime()});
},
getCurrentTime: function () {
var player = this.state.videoPlayer;
return player ? player.currentTime : 0;
},
getDuration: function () {
var player = this.state.videoPlayer;
return player ? player.duration() : 0;
},
log: function (eventName, data) {
var logInfo = _.extend({
host_component_id: this.state.id,
bumper_id: this.state.config.sources[0] || '',
duration: this.getDuration(),
code: 'html5'
}, data, this.options.data);
Logger.log(eventName, logInfo);
}
};
return EventsBumperPlugin;
});
}(RequireJS.define));

View File

@@ -0,0 +1,129 @@
(function(define) {
'use strict';
define('video/09_events_plugin.js', [], function() {
/**
* Events module.
* @exports video/09_events_plugin.js
* @constructor
* @param {Object} state The object containing the state of the video
* @param {Object} i18n The object containing strings with translations.
* @param {Object} options
* @return {jquery Promise}
*/
var EventsPlugin = function(state, i18n, options) {
if (!(this instanceof EventsPlugin)) {
return new EventsPlugin(state, i18n, options);
}
_.bindAll(this, 'onReady', 'onPlay', 'onPause', 'onEnded', 'onSeek',
'onSpeedChange', 'onShowLanguageMenu', 'onHideLanguageMenu', 'onSkip',
'onShowCaptions', 'onHideCaptions', 'destroy');
this.state = state;
this.options = _.extend({}, options);
this.state.videoEventsPlugin = this;
this.i18n = i18n;
this.initialize();
return $.Deferred().resolve().promise();
};
EventsPlugin.moduleName = 'EventsPlugin';
EventsPlugin.prototype = {
destroy: function () {
this.state.el.off(this.events);
delete this.state.videoEventsPlugin;
},
initialize: function() {
this.events = {
'ready': this.onReady,
'play': this.onPlay,
'pause': this.onPause,
'ended stop': this.onEnded,
'seek': this.onSeek,
'skip': this.onSkip,
'speedchange': this.onSpeedChange,
'language_menu:show': this.onShowLanguageMenu,
'language_menu:hide': this.onHideLanguageMenu,
'captions:show': this.onShowCaptions,
'captions:hide': this.onHideCaptions,
'destroy': this.destroy
};
this.bindHandlers();
},
bindHandlers: function() {
this.state.el.on(this.events);
},
onReady: function () {
this.log('load_video');
},
onPlay: function () {
this.log('play_video', {currentTime: this.getCurrentTime()});
},
onPause: function () {
this.log('pause_video', {currentTime: this.getCurrentTime()});
},
onEnded: function () {
this.log('stop_video', {currentTime: this.getCurrentTime()});
},
onSkip: function (event, doNotShowAgain) {
var info = {currentTime: this.getCurrentTime()},
eventName = doNotShowAgain ? 'do_not_show_again_video': 'skip_video';
this.log(eventName, info);
},
onSeek: function (event, time, oldTime, type) {
this.log('seek_video', {
old_time: oldTime,
new_time: time,
type: type
});
},
onSpeedChange: function (event, newSpeed, oldSpeed) {
this.log('speed_change_video', {
current_time: this.getCurrentTime(),
old_speed: oldSpeed,
new_speed: newSpeed
});
},
onShowLanguageMenu: function () {
this.log('video_show_cc_menu');
},
onHideLanguageMenu: function () {
this.log('video_hide_cc_menu');
},
onShowCaptions: function () {
this.log('show_transcript', {current_time: this.getCurrentTime()});
},
onHideCaptions: function () {
this.log('hide_transcript', {current_time: this.getCurrentTime()});
},
getCurrentTime: function () {
var player = this.state.videoPlayer;
return player ? player.currentTime : 0;
},
log: function (eventName, data) {
var logInfo = _.extend({
id: this.state.id,
code: this.state.isYoutubeType() ? this.state.youtubeId() : 'html5'
}, data, this.options.data);
Logger.log(eventName, logInfo);
}
};
return EventsPlugin;
});
}(RequireJS.define));

View File

@@ -0,0 +1,87 @@
(function(define) {
'use strict';
define('video/09_play_pause_control.js', [], function() {
/**
* Play/pause control module.
* @exports video/09_play_pause_control.js
* @constructor
* @param {Object} state The object containing the state of the video
* @param {Object} i18n The object containing strings with translations.
* @return {jquery Promise}
*/
var PlayPauseControl = function(state, i18n) {
if (!(this instanceof PlayPauseControl)) {
return new PlayPauseControl(state, i18n);
}
_.bindAll(this, 'play', 'pause', 'onClick', 'destroy');
this.state = state;
this.state.videoPlayPauseControl = this;
this.i18n = i18n;
this.initialize();
return $.Deferred().resolve().promise();
};
PlayPauseControl.prototype = {
template: [
'<a class="video_control play" href="#" title="',
gettext('Play'), '" role="button" aria-disabled="false">',
gettext('Play'),
'</a>'
].join(''),
destroy: function () {
this.el.remove();
this.state.el.off('destroy', this.destroy);
delete this.state.videoPlayPauseControl;
},
/** Initializes the module. */
initialize: function() {
this.el = $(this.template);
this.render();
this.bindHandlers();
},
/**
* Creates any necessary DOM elements, attach them, and set their,
* initial configuration.
*/
render: function() {
this.state.el.find('.vcr').prepend(this.el);
},
/** Bind any necessary function callbacks to DOM events. */
bindHandlers: function() {
this.el.on({
'click': this.onClick
});
this.state.el.on({
'play': this.play,
'pause ended': this.pause,
'destroy': this.destroy
});
},
onClick: function (event) {
event.preventDefault();
this.state.videoCommands.execute('togglePlayback');
},
play: function () {
this.el
.attr('title', this.i18n['Pause']).text(this.i18n['Pause'])
.removeClass('play').addClass('pause');
},
pause: function () {
this.el
.attr('title', this.i18n['Play']).text(this.i18n['Play'])
.removeClass('pause').addClass('play');
}
};
return PlayPauseControl;
});
}(RequireJS.define));

View File

@@ -0,0 +1,87 @@
(function(define) {
'use strict';
define('video/09_play_placeholder.js', [], function() {
/**
* Play placeholder control module.
* @exports video/09_play_placeholder.js
* @constructor
* @param {Object} state The object containing the state of the video
* @param {Object} i18n The object containing strings with translations.
* @return {jquery Promise}
*/
var PlayPlaceholder = function(state, i18n) {
if (!(this instanceof PlayPlaceholder)) {
return new PlayPlaceholder(state, i18n);
}
_.bindAll(this, 'onClick', 'hide', 'show', 'destroy');
this.state = state;
this.state.videoPlayPlaceholder = this;
this.i18n = i18n;
this.initialize();
return $.Deferred().resolve().promise();
};
PlayPlaceholder.prototype = {
destroy: function () {
this.el.off('click', this.onClick);
this.state.el.on({
'destroy': this.destroy,
'play': this.hide,
'ended pause': this.show
});
this.hide();
delete this.state.videoPlayPlaceholder;
},
/**
* Indicates whether the placeholder should be shown. We display it
* for html5 videos on iPad and Android devices.
* @return {Boolean}
*/
shouldBeShown: function () {
return /iPad|Android/i.test(this.state.isTouch[0]) && !this.state.isYoutubeType();
},
/** Initializes the module. */
initialize: function() {
if (!this.shouldBeShown()) {
return false;
}
this.el = this.state.el.find('.btn-play');
this.bindHandlers();
this.show();
},
/** Bind any necessary function callbacks to DOM events. */
bindHandlers: function() {
this.el.on('click', this.onClick);
this.state.el.on({
'destroy': this.destroy,
'play': this.hide,
'ended pause': this.show
});
},
onClick: function () {
this.state.videoCommands.execute('play');
},
hide: function () {
this.el
.addClass('is-hidden')
.attr({'aria-hidden': 'true', 'tabindex': -1});
},
show: function () {
this.el
.removeClass('is-hidden')
.attr({'aria-hidden': 'false', 'tabindex': 0});
}
};
return PlayPlaceholder;
});
}(RequireJS.define));

View File

@@ -0,0 +1,84 @@
(function(define) {
'use strict';
define('video/09_play_skip_control.js', [], function() {
/**
* Play/skip control module.
* @exports video/09_play_skip_control.js
* @constructor
* @param {Object} state The object containing the state of the video
* @param {Object} i18n The object containing strings with translations.
* @return {jquery Promise}
*/
var PlaySkipControl = function(state, i18n) {
if (!(this instanceof PlaySkipControl)) {
return new PlaySkipControl(state, i18n);
}
_.bindAll(this, 'play', 'onClick', 'destroy');
this.state = state;
this.state.videoPlaySkipControl = this;
this.i18n = i18n;
this.initialize();
return $.Deferred().resolve().promise();
};
PlaySkipControl.prototype = {
template: [
'<a class="video_control play play-skip-control" href="#" title="',
gettext('Play'), '" role="button" aria-disabled="false">',
gettext('Play'),
'</a>'
].join(''),
destroy: function () {
this.el.remove();
this.state.el.off('destroy', this.destroy);
delete this.state.videoPlaySkipControl;
},
/** Initializes the module. */
initialize: function() {
this.el = $(this.template);
this.render();
this.bindHandlers();
},
/**
* Creates any necessary DOM elements, attach them, and set their,
* initial configuration.
*/
render: function() {
this.state.el.find('.vcr').prepend(this.el);
},
/** Bind any necessary function callbacks to DOM events. */
bindHandlers: function() {
this.el.on('click', this.onClick);
this.state.el.on({
'play': this.play,
'destroy': this.destroy
});
},
onClick: function (event) {
event.preventDefault();
if (this.state.videoPlayer.isPlaying()) {
this.state.videoCommands.execute('skip');
} else {
this.state.videoCommands.execute('play');
}
},
play: function () {
this.el
.attr('title', gettext('Skip')).text(gettext('Skip'))
.removeClass('play').addClass('skip');
// Disable possibility to pause the video.
this.state.el.find('video').off('click');
}
};
return PlaySkipControl;
});
}(RequireJS.define));

View File

@@ -0,0 +1,66 @@
(function (define) {
'use strict';
define('video/09_poster.js', [], function () {
/**
* Poster module.
* @exports video/09_poster.js
* @constructor
* @param {jquery Element} element
* @param {Object} options
*/
var VideoPoster = function (element, options) {
if (!(this instanceof VideoPoster)) {
return new VideoPoster(element, options);
}
_.bindAll(this, 'onClick', 'destroy');
this.element = element;
this.container = element.find('.video-player');
this.options = options || {};
this.initialize();
};
VideoPoster.moduleName = 'Poster';
VideoPoster.prototype = {
template: _.template([
'<div class="video-pre-roll is-<%= type %> poster" ',
'style="background-image: url(<%= url %>)">',
'<button class="btn-play">', gettext('Play video'), '</button>',
'</div>'
].join('')),
initialize: function () {
this.el = $(this.template({
url: this.options.poster.url,
type: this.options.poster.type
}));
this.element.addClass('is-pre-roll');
this.render();
this.bindHandlers();
},
bindHandlers: function () {
this.el.on('click', this.onClick);
this.element.on('destroy', this.destroy);
},
render: function () {
this.container.append(this.el);
},
onClick: function () {
if (_.isFunction(this.options.onClick)) {
this.options.onClick();
}
this.destroy();
},
destroy: function () {
this.element.off('destroy', this.destroy).removeClass('is-pre-roll');
this.el.remove();
}
};
return VideoPoster;
});
}(RequireJS.define));

View File

@@ -0,0 +1,118 @@
(function(define) {
'use strict';
define('video/09_save_state_plugin.js', [], function() {
/**
* Save state module.
* @exports video/09_save_state_plugin.js
* @constructor
* @param {Object} state The object containing the state of the video
* @param {Object} i18n The object containing strings with translations.
* @param {Object} options
* @return {jquery Promise}
*/
var SaveStatePlugin = function(state, i18n, options) {
if (!(this instanceof SaveStatePlugin)) {
return new SaveStatePlugin(state, i18n, options);
}
_.bindAll(this, 'onSpeedChange', 'saveStateHandler', 'bindUnloadHandler', 'onUnload', 'onYoutubeAvailability',
'onLanguageChange', 'destroy');
this.state = state;
this.options = _.extend({events: []}, options);
this.state.videoSaveStatePlugin = this;
this.i18n = i18n;
this.initialize();
return $.Deferred().resolve().promise();
};
SaveStatePlugin.moduleName = 'SaveStatePlugin';
SaveStatePlugin.prototype = {
destroy: function () {
this.state.el.off(this.events).off('destroy', this.destroy);
$(window).off('unload', this.onUnload);
delete this.state.videoSaveStatePlugin;
},
initialize: function() {
this.events = {
'speedchange': this.onSpeedChange,
'play': this.bindUnloadHandler,
'pause destroy': this.saveStateHandler,
'language_menu:change': this.onLanguageChange,
'youtube_availability': this.onYoutubeAvailability
};
this.bindHandlers();
},
bindHandlers: function() {
if (this.options.events.length) {
_.each(this.options.events, function (eventName) {
var callback;
if (_.has(this.events, eventName)) {
callback = this.events[eventName];
this.state.el.on(eventName, callback);
}
}, this);
} else {
this.state.el.on(this.events);
}
this.state.el.on('destroy', this.destroy);
},
bindUnloadHandler: _.once(function () {
$(window).on('unload.video', this.onUnload);
}),
onSpeedChange: function (event, newSpeed) {
this.saveState(true, {speed: newSpeed});
this.state.storage.setItem('speed', newSpeed, true);
this.state.storage.setItem('general_speed', newSpeed);
},
saveStateHandler: function () {
this.saveState(true);
},
onUnload: function () {
this.saveState();
},
onLanguageChange: function (event, langCode) {
this.state.storage.setItem('language', langCode);
},
onYoutubeAvailability: function (event, youtubeIsAvailable) {
this.saveState(true, {youtube_is_available: youtubeIsAvailable});
},
saveState: function (async, data) {
if (!($.isPlainObject(data))) {
data = {
saved_video_position: this.state.videoPlayer.currentTime
};
}
if (data.speed) {
this.state.storage.setItem('speed', data.speed, true);
}
if (_.has(data, 'saved_video_position')) {
this.state.storage.setItem('savedVideoPosition', data.saved_video_position, true);
data.saved_video_position = Time.formatFull(data.saved_video_position);
}
$.ajax({
url: this.state.config.saveStateUrl,
type: 'POST',
async: async ? true : false,
dataType: 'json',
data: data
});
}
};
return SaveStatePlugin;
});
}(RequireJS.define));

View File

@@ -0,0 +1,74 @@
(function(define) {
'use strict';
// VideoSkipControl module.
define(
'video/09_skip_control.js', [],
function() {
/**
* Video skip control module.
* @exports video/09_skip_control.js
* @constructor
* @param {Object} state The object containing the state of the video
* @param {Object} i18n The object containing strings with translations.
* @return {jquery Promise}
*/
var SkipControl = function(state, i18n) {
if (!(this instanceof SkipControl)) {
return new SkipControl(state, i18n);
}
_.bindAll(this, 'onClick', 'render', 'destroy');
this.state = state;
this.state.videoSkipControl = this;
this.i18n = i18n;
this.initialize();
return $.Deferred().resolve().promise();
};
SkipControl.prototype = {
template: [
'<a class="video_control skip skip-control" href="#" title="',
gettext('Do not show again'), '" role="button" aria-disabled="false">',
gettext('Do not show again'),
'</a>'
].join(''),
destroy: function () {
this.el.remove();
this.state.el.off('.skip');
delete this.state.videoSkipControl;
},
/** Initializes the module. */
initialize: function() {
this.el = $(this.template);
this.bindHandlers();
},
/**
* Creates any necessary DOM elements, attach them, and set their,
* initial configuration.
*/
render: function() {
this.state.el.find('.vcr a').after(this.el);
},
/** Bind any necessary function callbacks to DOM events. */
bindHandlers: function() {
this.el.on('click', this.onClick);
this.state.el.on({
'play.skip': _.once(this.render),
'destroy.skip': this.destroy
});
},
onClick: function (event) {
event.preventDefault();
this.state.videoCommands.execute('skip', true);
}
};
return SkipControl;
});
}(RequireJS.define));

View File

@@ -1,5 +1,4 @@
(function (define) {
// VideoCaption module.
define(
'video/09_video_caption.js',
@@ -24,6 +23,10 @@ function (Sjson, AsyncProcess) {
return new VideoCaption(state);
}
_.bindAll(this, 'toggle', 'onMouseEnter', 'onMouseLeave', 'onMovement',
'onContainerMouseEnter', 'onContainerMouseLeave', 'fetchCaption',
'onResize', 'pause', 'play', 'onCaptionUpdate', 'onCaptionHandler', 'destroy'
);
this.state = state;
this.state.videoCaption = this;
this.renderElements();
@@ -32,29 +35,61 @@ function (Sjson, AsyncProcess) {
};
VideoCaption.prototype = {
langTemplate: [
'<div class="lang menu-container">',
'<a href="#" class="hide-subtitles" title="',
gettext('Turn off captions'), '" role="button" aria-disabled="false">',
gettext('Turn off captions'),
'</a>',
'</div>'
].join(''),
template: [
'<ol id="transcript-captions" class="subtitles" tabindex="0" role="group" aria-label="',
gettext('Activating an item in this group will spool the video to the corresponding time point. To skip transcript, go to previous item.'),
'">',
'<li></li>',
'</ol>'
].join(''),
destroy: function () {
this.state.el
.off({
'caption:fetch': this.fetchCaption,
'caption:resize': this.onResize,
'caption:update': this.onCaptionUpdate,
'ended': this.pause,
'fullscreen': this.onResize,
'pause': this.pause,
'play': this.play,
'destroy': this.destroy
})
.removeClass('is-captions-rendered');
if (this.fetchXHR && this.fetchXHR.abort) {
this.fetchXHR.abort();
}
if (this.availableTranslationsXHR && this.availableTranslationsXHR.abort) {
this.availableTranslationsXHR.abort();
}
this.subtitlesEl.remove();
this.container.remove();
delete this.state.videoCaption;
},
/**
* @desc Initiate rendering of elements, and set their initial configuration.
*
*/
renderElements: function () {
var state = this.state,
languages = this.state.config.transcriptLanguages;
var languages = this.state.config.transcriptLanguages;
this.loaded = false;
this.subtitlesEl = state.el.find('ol.subtitles');
this.container = state.el.find('.lang');
this.hideSubtitlesEl = state.el.find('a.hide-subtitles');
this.subtitlesEl = $(this.template);
this.container = $(this.langTemplate);
this.hideSubtitlesEl = this.container.find('a.hide-subtitles');
if (_.keys(languages).length) {
this.renderLanguageMenu(languages);
if (!this.fetchCaption()) {
this.hideCaptions(true);
this.hideSubtitlesEl.hide();
}
} else {
this.hideCaptions(true, false);
this.hideSubtitlesEl.hide();
this.fetchCaption();
}
},
@@ -64,65 +99,40 @@ function (Sjson, AsyncProcess) {
*
*/
bindHandlers: function () {
var self = this,
state = this.state,
var state = this.state,
events = [
'mouseover', 'mouseout', 'mousedown', 'click', 'focus', 'blur',
'keydown'
].join(' ');
// Change context to VideoCaption of event handlers using `bind`.
this.hideSubtitlesEl.on('click', this.toggle.bind(this));
this.hideSubtitlesEl.on('click', this.toggle);
this.subtitlesEl
.on({
mouseenter: this.onMouseEnter.bind(this),
mouseleave: this.onMouseLeave.bind(this),
mousemove: this.onMovement.bind(this),
mousewheel: this.onMovement.bind(this),
DOMMouseScroll: this.onMovement.bind(this)
mouseenter: this.onMouseEnter,
mouseleave: this.onMouseLeave,
mousemove: this.onMovement,
mousewheel: this.onMovement,
DOMMouseScroll: this.onMovement
})
.on(events, 'li[data-index]', function (event) {
switch (event.type) {
case 'mouseover':
case 'mouseout':
self.captionMouseOverOut(event);
break;
case 'mousedown':
self.captionMouseDown(event);
break;
case 'click':
self.captionClick(event);
break;
case 'focusin':
self.captionFocus(event);
break;
case 'focusout':
self.captionBlur(event);
break;
case 'keydown':
self.captionKeyDown(event);
break;
}
});
.on(events, 'li[data-index]', this.onCaptionHandler);
if (this.showLanguageMenu) {
this.container.on({
mouseenter: this.onContainerMouseEnter.bind(this),
mouseleave: this.onContainerMouseLeave.bind(this)
mouseenter: this.onContainerMouseEnter,
mouseleave: this.onContainerMouseLeave
});
}
state.el
.on({
'caption:fetch': this.fetchCaption.bind(this),
'caption:resize': this.onResize.bind(this),
'caption:update': function (event, time) {
self.updatePlayTime(time);
},
'ended': this.pause.bind(this),
'fullscreen': this.onResize.bind(this),
'pause': this.pause.bind(this),
'play': this.play.bind(this)
'caption:fetch': this.fetchCaption,
'caption:resize': this.onResize,
'caption:update': this.onCaptionUpdate,
'ended': this.pause,
'fullscreen': this.onResize,
'pause': this.pause,
'play': this.play,
'destroy': this.destroy
});
if ((state.videoType === 'html5') && (state.config.autohideHtml5)) {
@@ -130,6 +140,33 @@ function (Sjson, AsyncProcess) {
}
},
onCaptionUpdate: function (event, time) {
this.updatePlayTime(time);
},
onCaptionHandler: function (event) {
switch (event.type) {
case 'mouseover':
case 'mouseout':
this.captionMouseOverOut(event);
break;
case 'mousedown':
this.captionMouseDown(event);
break;
case 'click':
this.captionClick(event);
break;
case 'focusin':
this.captionFocus(event);
break;
case 'focusout':
this.captionBlur(event);
break;
case 'keydown':
this.captionKeyDown(event);
break;
}
},
/**
* @desc Opens language menu.
@@ -138,8 +175,8 @@ function (Sjson, AsyncProcess) {
*/
onContainerMouseEnter: function (event) {
event.preventDefault();
this.state.videoPlayer.log('video_show_cc_menu', {});
$(event.currentTarget).addClass('is-opened');
this.state.el.trigger('language_menu:show');
},
/**
@@ -149,8 +186,8 @@ function (Sjson, AsyncProcess) {
*/
onContainerMouseLeave: function (event) {
event.preventDefault();
this.state.videoPlayer.log('video_hide_cc_menu', {});
$(event.currentTarget).removeClass('is-opened');
this.state.el.trigger('language_menu:hide');
},
/**
@@ -247,12 +284,11 @@ function (Sjson, AsyncProcess) {
var self = this,
state = this.state,
language = state.getCurrentLanguage(),
url = state.config.transcriptTranslationUrl.replace('__lang__', language),
data, youtubeId;
if (this.loaded) {
this.hideCaptions(false);
} else {
this.hideCaptions(state.hide_captions, false);
}
if (this.fetchXHR && this.fetchXHR.abort) {
@@ -266,16 +302,14 @@ function (Sjson, AsyncProcess) {
return false;
}
data = {
videoId: youtubeId
};
data = {videoId: youtubeId};
}
state.el.removeClass('is-captions-rendered');
// Fetch the captions file. If no file was specified, or if an error
// occurred, then we hide the captions panel, and the "CC" button
this.fetchXHR = $.ajaxWithPrefix({
url: state.config.transcriptTranslationUrl + '/' + language,
url: url,
notifyOnError: false,
data: data,
success: function (sjson) {
@@ -300,7 +334,9 @@ function (Sjson, AsyncProcess) {
} else {
self.renderCaption(start, captions);
}
self.hideCaptions(state.hide_captions, false);
self.state.el.find('.video-wrapper').after(self.subtitlesEl);
self.state.el.find('.secondary-controls').append(self.container);
self.bindHandlers();
}
@@ -336,7 +372,7 @@ function (Sjson, AsyncProcess) {
var self = this,
state = this.state;
return $.ajaxWithPrefix({
this.availableTranslationsXHR = $.ajaxWithPrefix({
url: state.config.transcriptAvailableTranslationsUrl,
notifyOnError: false,
success: function (response) {
@@ -359,6 +395,8 @@ function (Sjson, AsyncProcess) {
self.hideSubtitlesEl.hide();
}
});
return this.availableTranslationsXHR;
},
/**
@@ -417,11 +455,11 @@ function (Sjson, AsyncProcess) {
if (state.lang !== langCode) {
state.lang = langCode;
state.storage.setItem('language', langCode);
el .addClass('is-active')
.siblings('li')
.removeClass('is-active');
state.el.trigger('language_menu:change', [langCode]);
self.fetchCaption();
}
});
@@ -658,7 +696,7 @@ function (Sjson, AsyncProcess) {
*
*/
play: function () {
var startAndCaptions, start, end;
var captions, startAndCaptions, start;
if (this.loaded) {
if (!this.rendered) {
startAndCaptions = this.getBoundedCaptions();
@@ -689,10 +727,7 @@ function (Sjson, AsyncProcess) {
*/
updatePlayTime: function (time) {
var state = this.state,
startTime,
endTime,
params,
newIndex;
params, newIndex;
if (this.loaded) {
if (state.isFlashMode()) {
@@ -797,9 +832,9 @@ function (Sjson, AsyncProcess) {
event.preventDefault();
if (this.state.el.hasClass('closed')) {
this.hideCaptions(false);
this.hideCaptions(false, true, true);
} else {
this.hideCaptions(true);
this.hideCaptions(true, true, true);
}
},
@@ -811,38 +846,35 @@ function (Sjson, AsyncProcess) {
* @param {boolean} update_cookie Flag to update or not the cookie.
*
*/
hideCaptions: function (hide_captions, update_cookie) {
hideCaptions: function (hide_captions, update_cookie, trigger_event) {
var hideSubtitlesEl = this.hideSubtitlesEl,
state = this.state,
type, text;
state = this.state, text;
if (typeof update_cookie === 'undefined') {
update_cookie = true;
}
if (hide_captions) {
type = 'hide_transcript';
state.captionsHidden = true;
state.el.addClass('closed');
text = gettext('Turn on captions');
if (trigger_event) {
this.state.el.trigger('captions:hide');
}
} else {
type = 'show_transcript';
state.captionsHidden = false;
state.el.removeClass('closed');
this.scrollCaption();
text = gettext('Turn off captions');
if (trigger_event) {
this.state.el.trigger('captions:show');
}
}
hideSubtitlesEl
.attr('title', text)
.text(gettext(text));
if (state.videoPlayer) {
state.videoPlayer.log(type, {
currentTime: state.videoPlayer.currentTime
});
}
if (state.resizer) {
if (state.isFullScreen) {
state.resizer.setMode('both');
@@ -868,9 +900,8 @@ function (Sjson, AsyncProcess) {
*/
captionHeight: function () {
var state = this.state;
if (state.isFullScreen) {
return state.container.height() - state.videoControl.height;
return state.container.height() - state.videoFullScreen.height;
} else {
return state.container.height();
}
@@ -889,8 +920,8 @@ function (Sjson, AsyncProcess) {
) {
// In case of html5 autoshowing subtitles, we adjust height of
// subs, by height of scrollbar.
height = state.videoControl.el.height() +
0.5 * state.videoControl.sliderEl.height();
height = state.el.find('.video-controls').height() +
0.5 * state.el.find('.slider').height();
// Height of videoControl does not contain height of slider.
// css is set to absolute, to avoid yanking when slider
// autochanges its height.

View File

@@ -1,11 +1,8 @@
(function(define) {
'use strict';
// VideoCommands module.
define('video/10_commands.js', [], function() {
var VideoCommands, Command, playCommand, pauseCommand, togglePlaybackCommand,
muteCommand, unmuteCommand, toggleMuteCommand, toggleFullScreenCommand,
setSpeedCommand;
toggleMuteCommand, toggleFullScreenCommand, setSpeedCommand, skipCommand;
/**
* Video commands module.
* @exports video/10_commands.js
@@ -19,6 +16,7 @@ define('video/10_commands.js', [], function() {
return new VideoCommands(state, i18n);
}
_.bindAll(this, 'destroy');
this.state = state;
this.state.videoCommands = this;
this.i18n = i18n;
@@ -29,9 +27,15 @@ define('video/10_commands.js', [], function() {
};
VideoCommands.prototype = {
destroy: function () {
this.state.el.off('destroy', this.destroy);
delete this.state.videoCommands;
},
/** Initializes the module. */
initialize: function() {
this.commands = this.getCommands();
this.state.el.on('destroy', this.destroy);
},
execute: function (command) {
@@ -48,7 +52,8 @@ define('video/10_commands.js', [], function() {
var commands = {},
commandsList = [
playCommand, pauseCommand, togglePlaybackCommand,
toggleMuteCommand, toggleFullScreenCommand, setSpeedCommand
toggleMuteCommand, toggleFullScreenCommand, setSpeedCommand,
skipCommand
];
_.each(commandsList, function(command) {
@@ -73,7 +78,7 @@ define('video/10_commands.js', [], function() {
});
togglePlaybackCommand = new Command('togglePlayback', function (state) {
if (state.videoControl.isPlaying) {
if (state.videoPlayer.isPlaying()) {
pauseCommand.execute(state);
} else {
playCommand.execute(state);
@@ -85,13 +90,21 @@ define('video/10_commands.js', [], function() {
});
toggleFullScreenCommand = new Command('toggleFullScreen', function (state) {
state.videoControl.toggleFullScreen();
state.videoFullScreen.toggle();
});
setSpeedCommand = new Command('speed', function (state, speed) {
state.videoSpeedControl.setSpeed(state.speedToString(speed));
});
skipCommand = new Command('skip', function (state, doNotShowAgain) {
if (doNotShowAgain) {
state.videoBumper.skipAndDoNotShowAgain();
} else {
state.videoBumper.skip();
}
});
return VideoCommands;
});
}(RequireJS.define));

View File

@@ -1,6 +1,5 @@
(function (require, $) {
'use strict';
// In the case when the Video constructor will be called before RequireJS finishes loading all of the Video
// dependencies, we will have a mock function that will collect all the elements that must be initialized as
// Video elements.
@@ -35,74 +34,122 @@
// Main module.
require(
[
'video/00_video_storage.js',
'video/01_initialize.js',
'video/025_focus_grabber.js',
'video/035_video_accessible_menu.js',
'video/04_video_control.js',
'video/04_video_full_screen.js',
'video/05_video_quality_control.js',
'video/06_video_progress_slider.js',
'video/07_video_volume_control.js',
'video/08_video_speed_control.js',
'video/09_video_caption.js',
'video/09_play_placeholder.js',
'video/09_play_pause_control.js',
'video/09_play_skip_control.js',
'video/09_skip_control.js',
'video/09_bumper.js',
'video/09_save_state_plugin.js',
'video/09_events_plugin.js',
'video/09_events_bumper_plugin.js',
'video/09_poster.js',
'video/10_commands.js',
'video/095_video_context_menu.js'
],
function (
initialize,
FocusGrabber,
VideoAccessibleMenu,
VideoControl,
VideoQualityControl,
VideoProgressSlider,
VideoVolumeControl,
VideoSpeedControl,
VideoCaption,
VideoCommands,
VideoStorage, initialize, FocusGrabber, VideoAccessibleMenu, VideoControl, VideoFullScreen,
VideoQualityControl, VideoProgressSlider, VideoVolumeControl, VideoSpeedControl, VideoCaption,
VideoPlayPlaceholder, VideoPlayPauseControl, VideoPlaySkipControl, VideoSkipControl, VideoBumper,
VideoSaveStatePlugin, VideoEventsPlugin, VideoEventsBumperPlugin, VideoPoster, VideoCommands,
VideoContextMenu
) {
var youtubeXhr = null,
oldVideo = window.Video;
window.Video = function (element) {
var previousState = window.Video.previousState,
state;
var el = $(element).find('.video'),
id = el.attr('id').replace(/video_/, ''),
storage = VideoStorage('VideoState', id),
bumperMetadata = el.data('bumper-metadata'),
mainVideoModules = [FocusGrabber, VideoControl, VideoPlayPlaceholder,
VideoPlayPauseControl, VideoProgressSlider, VideoSpeedControl, VideoVolumeControl,
VideoQualityControl, VideoFullScreen, VideoCaption, VideoCommands, VideoContextMenu,
VideoSaveStatePlugin, VideoEventsPlugin],
bumperVideoModules = [VideoControl, VideoPlaySkipControl, VideoSkipControl,
VideoVolumeControl, VideoCaption, VideoCommands, VideoSaveStatePlugin, VideoEventsBumperPlugin],
state = {
el: el,
id: id,
metadata: el.data('metadata'),
storage: storage,
options: {},
youtubeXhr: youtubeXhr,
modules: mainVideoModules
};
// Check for existance of previous state, uninitialize it if necessary, and create a new state. Store
// new state for future invocation of this module consturctor function.
if (previousState && previousState.videoPlayer) {
previousState.saveState(true);
$(window).off('unload', previousState.saveState);
var getBumperState = function (metadata) {
var bumperState = $.extend(true, {
el: el,
id: id,
storage: storage,
options: {},
youtubeXhr: youtubeXhr
}, {metadata: metadata});
bumperState.modules = bumperVideoModules;
bumperState.options = {
SaveStatePlugin: {events: ['language_menu:change']}
};
return bumperState;
};
var player = function (state) {
return function () {
_.extend(state.metadata, {autoplay: true, focusFirstControl: true});
initialize(state, element);
};
};
new VideoAccessibleMenu(el, {
storage: storage,
saveStateUrl: state.metadata.saveStateUrl
});
if (bumperMetadata) {
new VideoPoster(el, {
poster: el.data('poster'),
onClick: _.once(function () {
var mainVideoPlayer = player(state), bumper, bumperState;
if (storage.getItem('isBumperShown')) {
mainVideoPlayer();
} else {
bumperState = getBumperState(bumperMetadata);
bumper = new VideoBumper(player(bumperState), bumperState);
state.bumperState = bumperState;
bumper.getPromise().done(function () {
delete state.bumperState;
mainVideoPlayer();
});
}
})
});
} else {
initialize(state, element);
}
state = {};
// Because this constructor can be called multiple times on a single page (when the user switches
// verticals, the page doesn't reload, but the content changes), we must will check each time if there
// is a previous copy of 'state' object. If there is, we will make sure that copy exists cleanly. We
// have to do this because when verticals switch, the code does not handle any Xmodule JS code that is
// running - it simply removes DOM elements from the page. Any functions that were running during this,
// and that will run afterwards (expecting the DOM elements to be present) must be stopped by hand.
window.Video.previousState = state;
state.modules = [
FocusGrabber,
VideoAccessibleMenu,
VideoControl,
VideoQualityControl,
VideoProgressSlider,
VideoVolumeControl,
VideoSpeedControl,
VideoCaption,
VideoCommands,
VideoContextMenu
];
state.youtubeXhr = youtubeXhr;
initialize(state, element);
if (!youtubeXhr) {
youtubeXhr = state.youtubeXhr;
}
$(element).find('.video').data('video-player-state', state);
el.data('video-player-state', state);
var onSequenceChange = function onSequenceChange () {
if (state && state.videoPlayer) {
state.videoPlayer.destroy();
}
$('.sequence').off('sequence:change', onSequenceChange);
};
$('.sequence').on('sequence:change', onSequenceChange);
// Because the 'state' object is only available inside this closure, we will also make it available to
// the caller by returning it. This is necessary so that we can test Video with Jasmine.

View File

@@ -149,6 +149,18 @@ class InheritanceMixin(XBlockMixin):
default=True,
scope=Scope.settings
)
video_bumper = Dict(
display_name=_("Video Pre-Roll"),
help=_(
"""Identify a video, 5-10 seconds in length, to play before course videos. Enter the video ID from"""
""" the Video Uploads page and one or more transcript files in the following format:"""
""" {"video_id": "ID", "transcripts": {"language": "/static/filename.srt"}}."""
""" For example, an entry for a video with two transcripts looks like this:"""
""" {"video_id": "77cef264-d6f5-4cf2-ad9d-0178ab8c77be","""
""" "transcripts": {"en": "/static/DemoX-D01_1.srt", "uk": "/static/DemoX-D01_1_uk.srt"}}"""
),
scope=Scope.settings
)
reset_key = "DEFAULT_SHOW_RESET_BUTTON"
default_reset_button = getattr(settings, reset_key) if hasattr(settings, reset_key) else False

View File

@@ -8,3 +8,4 @@ Container for video module and it's utils.
from .transcripts_utils import *
from .video_utils import *
from .video_module import *
from .bumper_utils import *

View File

@@ -0,0 +1,142 @@
"""
Utils for video bumper
"""
import copy
import json
import pytz
import logging
from collections import OrderedDict
from datetime import datetime, timedelta
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([
getattr(video, 'bumper_do_not_show_again'),
(bumper_last_view_date and bumper_last_view_date + timedelta(seconds=periodicity) > utc_now)
])
is_studio = getattr(video.system, "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 = filter(None, [val_video_urls[p] for p in val_profiles])
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.system.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
),
})
return metadata

View File

@@ -15,6 +15,8 @@ from xmodule.exceptions import NotFoundError
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from .bumper_utils import get_bumper_settings
log = logging.getLogger(__name__)
@@ -408,20 +410,23 @@ def generate_sjson_for_all_speeds(item, user_filename, result_subs_dict, lang):
)
def get_or_create_sjson(item):
def get_or_create_sjson(item, transcripts):
"""
Get sjson if already exists, otherwise generate it.
Generate sjson with subs_id name, from user uploaded srt.
Subs_id is extracted from srt filename, which was set by user.
Args:
transcipts (dict): dictionary of (language: file) pairs.
Raises:
TranscriptException: when srt subtitles do not exist,
and exceptions from generate_subs_from_source.
`item` is module object.
"""
user_filename = item.transcripts[item.transcript_language]
user_filename = transcripts[item.transcript_language]
user_subs_id = os.path.splitext(user_filename)[0]
source_subs_id, result_subs_dict = user_subs_id, {1.0: user_subs_id}
try:
@@ -517,7 +522,7 @@ class VideoTranscriptsMixin(object):
This is necessary for both VideoModule and VideoDescriptor.
"""
def available_translations(self, verify_assets=True):
def available_translations(self, transcripts, verify_assets=True):
"""Return a list of language codes for which we have transcripts.
Args:
@@ -528,39 +533,51 @@ class VideoTranscriptsMixin(object):
when trying to make a listing of videos and their languages.
Defaults to True.
transcripts (dict): A dict with all transcripts and a sub.
Defaults to False
"""
translations = []
sub, other_lang = transcripts["sub"], transcripts["transcripts"]
# If we're not verifying the assets, we just trust our field values
if not verify_assets:
translations = list(self.transcripts)
if not translations or self.sub:
translations = list(other_lang)
if not translations or sub:
translations += ['en']
return set(translations)
# If we've gotten this far, we're going to verify that the transcripts
# being referenced are actually in the contentstore.
if self.sub: # check if sjson exists for 'en'.
if sub: # check if sjson exists for 'en'.
try:
Transcript.asset(self.location, self.sub, 'en')
Transcript.asset(self.location, sub, 'en')
except NotFoundError:
pass
try:
Transcript.asset(self.location, None, None, sub)
except NotFoundError:
pass
else:
translations = ['en']
else:
translations = ['en']
for lang in self.transcripts:
for lang in other_lang:
try:
Transcript.asset(self.location, None, None, self.transcripts[lang])
Transcript.asset(self.location, None, None, other_lang[lang])
except NotFoundError:
continue
translations.append(lang)
return translations
def get_transcript(self, transcript_format='srt', lang=None):
def get_transcript(self, transcripts, transcript_format='srt', lang=None):
"""
Returns transcript, filename and MIME type.
transcripts (dict): A dict with all transcripts and a sub.
Raises:
- NotFoundError if cannot find transcript file in storage.
- ValueError if transcript file is empty or incorrect JSON.
@@ -572,11 +589,12 @@ class VideoTranscriptsMixin(object):
If language is not 'en', give back transcript in proper language and format.
"""
if not lang:
lang = self.transcript_language
lang = self.get_default_transcript_language(transcripts)
sub, other_lang = transcripts["sub"], transcripts["transcripts"]
if lang == 'en':
if self.sub: # HTML5 case and (Youtube case for new style videos)
transcript_name = self.sub
if sub: # HTML5 case and (Youtube case for new style videos)
transcript_name = sub
elif self.youtube_id_1_0: # old courses
transcript_name = self.youtube_id_1_0
else:
@@ -587,8 +605,8 @@ class VideoTranscriptsMixin(object):
filename = u'{}.{}'.format(transcript_name, transcript_format)
content = Transcript.convert(data, 'sjson', transcript_format)
else:
data = Transcript.asset(self.location, None, None, self.transcripts[lang]).data
filename = u'{}.{}'.format(os.path.splitext(self.transcripts[lang])[0], transcript_format)
data = Transcript.asset(self.location, None, None, other_lang[lang]).data
filename = u'{}.{}'.format(os.path.splitext(other_lang[lang])[0], transcript_format)
content = Transcript.convert(data, 'srt', transcript_format)
if not content:
@@ -597,16 +615,36 @@ class VideoTranscriptsMixin(object):
return content, filename, Transcript.mime_types[transcript_format]
def get_default_transcript_language(self):
def get_default_transcript_language(self, transcripts):
"""
Returns the default transcript language for this video module.
Args:
transcripts (dict): A dict with all transcripts and a sub.
"""
if self.transcript_language in self.transcripts:
sub, other_lang = transcripts["sub"], transcripts["transcripts"]
if self.transcript_language in other_lang:
transcript_language = self.transcript_language
elif self.sub:
elif sub:
transcript_language = u'en'
elif len(self.transcripts) > 0:
transcript_language = sorted(self.transcripts)[0]
elif len(other_lang) > 0:
transcript_language = sorted(other_lang)[0]
else:
transcript_language = u'en'
return transcript_language
def get_transcripts_info(self, is_bumper=False):
"""
Returns a transcript dictionary for the video.
"""
if is_bumper:
transcripts = copy.deepcopy(get_bumper_settings(self).get('transcripts', {}))
return {
"sub": transcripts.pop("en", ""),
"transcripts": transcripts,
}
else:
return {
"sub": self.sub,
"transcripts": self.transcripts,
}

View File

@@ -7,6 +7,7 @@ StudioViewHandlers are handlers for video descriptor instance.
import json
import logging
from datetime import datetime
from webob import Response
from xblock.core import XBlock
@@ -44,7 +45,8 @@ class VideoStudentViewHandlers(object):
"""
accepted_keys = [
'speed', 'saved_video_position', 'transcript_language',
'transcript_download_format', 'youtube_is_available'
'transcript_download_format', 'youtube_is_available',
'bumper_last_view_date', 'bumper_do_not_show_again'
]
conversions = {
@@ -61,6 +63,9 @@ class VideoStudentViewHandlers(object):
else:
value = data[key]
if key == 'bumper_last_view_date':
value = datetime.utcnow()
setattr(self, key, value)
if key == 'speed':
@@ -73,16 +78,17 @@ class VideoStudentViewHandlers(object):
raise NotFoundError('Unexpected dispatch type')
def translation(self, youtube_id):
def translation(self, youtube_id, transcripts):
"""
This is called to get transcript file for specific language.
youtube_id: str: must be one of youtube_ids or None if HTML video
transcripts (dict): A dict with all transcripts and a sub.
Logic flow:
If youtube_id doesn't exist, we have a video in HTML5 mode. Otherwise,
video video in Youtube or Flash modes.
video in Youtube or Flash modes.
if youtube:
If english -> give back youtube_id subtitles:
@@ -106,6 +112,7 @@ class VideoStudentViewHandlers(object):
NotFoundError if for 'en' subtitles no asset is uploaded.
NotFoundError if youtube_id does not exist / invalid youtube_id
"""
sub, other_lang = transcripts["sub"], transcripts["transcripts"]
if youtube_id:
# Youtube case:
if self.transcript_language == 'en':
@@ -122,7 +129,7 @@ class VideoStudentViewHandlers(object):
log.info("Can't find content in storage for %s transcript: generating.", youtube_id)
generate_sjson_for_all_speeds(
self,
self.transcripts[self.transcript_language],
other_lang[self.transcript_language],
{speed: youtube_id for youtube_id, speed in youtube_ids.iteritems()},
self.transcript_language
)
@@ -132,11 +139,18 @@ class VideoStudentViewHandlers(object):
else:
# HTML5 case
if self.transcript_language == 'en':
return Transcript.asset(self.location, self.sub).data
else:
return get_or_create_sjson(self)
if '.srt' not in sub: # not bumper case
return Transcript.asset(self.location, sub).data
try:
return get_or_create_sjson(self, {'en': sub})
except TranscriptException:
pass # to raise NotFoundError and try to get data in get_static_transcript
elif other_lang:
return get_or_create_sjson(self, other_lang)
def get_static_transcript(self, request):
raise NotFoundError
def get_static_transcript(self, request, transcripts):
"""
Courses that are imported with the --nostatic flag do not show
transcripts/captions properly even if those captions are stored inside
@@ -144,6 +158,8 @@ class VideoStudentViewHandlers(object):
the static asset path of the course if the transcript can't be found
inside the contentstore and the course has the static_asset_path field
set.
transcripts (dict): A dict with all transcripts and a sub.
"""
response = Response(status=404)
# Only do redirect for English
@@ -154,7 +170,7 @@ class VideoStudentViewHandlers(object):
if video_id:
transcript_name = video_id
else:
transcript_name = self.sub
transcript_name = transcripts["sub"]
if transcript_name:
# Get the asset path for course
@@ -181,7 +197,9 @@ class VideoStudentViewHandlers(object):
"""
Entry point for transcript handlers for student_view.
Request GET may contain `videoId` for `translation` dispatch.
Request GET contains:
(optional) `videoId` for `translation` dispatch.
`is_bumper=1` flag for bumper case.
Dispatches, (HTTP GET):
/translation/[language_id]
@@ -197,15 +215,16 @@ class VideoStudentViewHandlers(object):
Returns list of languages, for which transcript files exist.
For 'en' check if SJSON exists. For non-`en` check if SRT file exists.
"""
is_bumper = request.GET.get('is_bumper', False)
transcripts = self.get_transcripts_info(is_bumper)
if dispatch.startswith('translation'):
language = dispatch.replace('translation', '').strip('/')
if not language:
log.info("Invalid /translation request: no language.")
return Response(status=400)
if language not in ['en'] + self.transcripts.keys():
if language not in ['en'] + transcripts["transcripts"].keys():
log.info("Video: transcript facilities are not available for given language.")
return Response(status=404)
@@ -213,12 +232,12 @@ class VideoStudentViewHandlers(object):
self.transcript_language = language
try:
transcript = self.translation(request.GET.get('videoId', None))
transcript = self.translation(request.GET.get('videoId', None), transcripts)
except (TypeError, NotFoundError) as ex:
log.info(ex.message)
# Try to return static URL redirection as last resort
# if no translation is required
return self.get_static_transcript(request)
return self.get_static_transcript(request, transcripts)
except (
TranscriptException,
UnicodeDecodeError,
@@ -232,7 +251,9 @@ class VideoStudentViewHandlers(object):
elif dispatch == 'download':
try:
transcript_content, transcript_filename, transcript_mime_type = self.get_transcript(self.transcript_download_format)
transcript_content, transcript_filename, transcript_mime_type = self.get_transcript(
transcripts, transcript_format=self.transcript_download_format
)
except (NotFoundError, ValueError, KeyError, UnicodeDecodeError):
log.debug("Video@download exception")
return Response(status=404)
@@ -246,8 +267,9 @@ class VideoStudentViewHandlers(object):
)
response.content_type = transcript_mime_type
elif dispatch == 'available_translations':
available_translations = self.available_translations()
elif dispatch.startswith('available_translations'):
available_translations = self.available_translations(transcripts)
if available_translations:
response = Response(json.dumps(available_translations))
response.content_type = 'application/json'

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
# pylint: disable=abstract-method
"""Video is ungraded Xmodule for support video content.
@@ -37,7 +38,8 @@ from xmodule.xml_module import is_pointer_tag, name_to_pathname, deserialize_fie
from xmodule.exceptions import NotFoundError
from .transcripts_utils import VideoTranscriptsMixin
from .video_utils import create_youtube_string, get_video_from_cdn
from .video_utils import create_youtube_string, get_video_from_cdn, get_poster
from .bumper_utils import bumperize
from .video_xfields import VideoFields
from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers
@@ -117,11 +119,21 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
resource_string(module, 'js/src/video/03_video_player.js'),
resource_string(module, 'js/src/video/035_video_accessible_menu.js'),
resource_string(module, 'js/src/video/04_video_control.js'),
resource_string(module, 'js/src/video/04_video_full_screen.js'),
resource_string(module, 'js/src/video/05_video_quality_control.js'),
resource_string(module, 'js/src/video/06_video_progress_slider.js'),
resource_string(module, 'js/src/video/07_video_volume_control.js'),
resource_string(module, 'js/src/video/08_video_speed_control.js'),
resource_string(module, 'js/src/video/09_video_caption.js'),
resource_string(module, 'js/src/video/09_play_placeholder.js'),
resource_string(module, 'js/src/video/09_play_pause_control.js'),
resource_string(module, 'js/src/video/09_play_skip_control.js'),
resource_string(module, 'js/src/video/09_skip_control.js'),
resource_string(module, 'js/src/video/09_bumper.js'),
resource_string(module, 'js/src/video/09_save_state_plugin.js'),
resource_string(module, 'js/src/video/09_events_plugin.js'),
resource_string(module, 'js/src/video/09_events_bumper_plugin.js'),
resource_string(module, 'js/src/video/09_poster.js'),
resource_string(module, 'js/src/video/095_video_context_menu.js'),
resource_string(module, 'js/src/video/10_commands.js'),
resource_string(module, 'js/src/video/10_main.js')
@@ -133,9 +145,13 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
]}
js_module_name = "Video"
def get_transcripts_for_student(self):
def get_transcripts_for_student(self, transcripts):
"""Return transcript information necessary for rendering the XModule student view.
This is more or less a direct extraction from `get_html`.
Args:
transcripts (dict): A dict with all transcripts and a sub.
Returns:
Tuple of (track_url, transcript_language, sorted_languages)
track_url -> subtitle download url
@@ -143,31 +159,27 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
sorted_languages -> dictionary of available transcript languages
"""
track_url = None
sub, other_lang = transcripts["sub"], transcripts["transcripts"]
if self.download_track:
if self.track:
track_url = self.track
elif self.sub or self.transcripts:
elif sub or other_lang:
track_url = self.runtime.handler_url(self, 'transcript', 'download').rstrip('/?')
if not self.transcripts:
transcript_language = u'en'
languages = {'en': 'English'}
else:
transcript_language = self.get_default_transcript_language()
transcript_language = self.get_default_transcript_language(transcripts)
native_languages = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2}
languages = {
lang: native_languages.get(lang, display)
for lang, display in settings.ALL_LANGUAGES
if lang in self.transcripts
}
if self.sub:
languages['en'] = 'English'
native_languages = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2}
languages = {
lang: native_languages.get(lang, display)
for lang, display in settings.ALL_LANGUAGES
if lang in other_lang
}
if not other_lang or (other_lang and sub):
languages['en'] = 'English'
# OrderedDict for easy testing of rendered context in tests
sorted_languages = sorted(languages.items(), key=itemgetter(1))
if 'table' in self.transcripts:
if 'table' in other_lang:
sorted_languages.insert(0, ('table', 'Table of Contents'))
sorted_languages = OrderedDict(sorted_languages)
@@ -233,7 +245,7 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
elif self.html5_sources:
download_video_link = self.html5_sources[0]
track_url, transcript_language, sorted_languages = self.get_transcripts_for_student()
track_url, transcript_language, sorted_languages = self.get_transcripts_for_student(self.get_transcripts_info())
# CDN_VIDEO_URLS is only to be used here and will be deleted
# TODO(ali@edx.org): Delete this after the CDN experiment has completed.
@@ -250,42 +262,73 @@ class VideoModule(VideoFields, VideoTranscriptsMixin, VideoStudentViewHandlers,
cdn_eval = False
cdn_exp_group = None
return self.system.render_template('video.html', {
'ajax_url': self.system.ajax_url + '/save_user_state',
self.youtube_streams = youtube_streams or create_youtube_string(self) # pylint: disable=W0201
metadata = {
'saveStateUrl': self.system.ajax_url + '/save_user_state',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
'streams': self.youtube_streams,
'sub': self.sub,
'sources': sources,
# This won't work when we move to data that
# isn't on the filesystem
'captionDataDir': getattr(self, 'data_dir', None),
'showCaptions': json.dumps(self.show_captions),
'generalSpeed': self.global_speed,
'speed': self.speed,
'savedVideoPosition': self.saved_video_position.total_seconds(),
'start': self.start_time.total_seconds(),
'end': self.end_time.total_seconds(),
'transcriptLanguage': transcript_language,
'transcriptLanguages': sorted_languages,
# TODO: Later on the value 1500 should be taken from some global
# configuration setting field.
'ytTestTimeout': 1500,
'ytApiUrl': settings.YOUTUBE['API'],
'ytTestUrl': settings.YOUTUBE['TEST_URL'],
'transcriptTranslationUrl': self.runtime.handler_url(
self, 'transcript', 'translation/__lang__'
).rstrip('/?'),
'transcriptAvailableTranslationsUrl': self.runtime.handler_url(
self, 'transcript', 'available_translations'
).rstrip('/?'),
## For now, the option "data-autohide-html5" is hard coded. This option
## either enables or disables autohiding of controls and captions on mouse
## inactivity. If set to true, controls and captions will autohide for
## HTML5 sources (non-YouTube) after a period of mouse inactivity over the
## whole video. When the mouse moves (or a key is pressed while any part of
## the video player is focused), the captions and controls will be shown
## once again.
##
## There is no option in the "Advanced Editor" to set this option. However,
## this option will have an effect if changed to "True". The code on
## front-end exists.
'autohideHtml5': False
}
bumperize(self)
context = {
'bumper_metadata': json.dumps(self.bumper['metadata']), # pylint: disable=E1101
'metadata': json.dumps(OrderedDict(metadata)),
'poster': json.dumps(get_poster(self)),
'branding_info': branding_info,
'cdn_eval': cdn_eval,
'cdn_exp_group': cdn_exp_group,
# This won't work when we move to data that
# isn't on the filesystem
'data_dir': getattr(self, 'data_dir', None),
'display_name': self.display_name_with_default,
'end': self.end_time.total_seconds(),
'handout': self.handout,
'id': self.location.html_id(),
'show_captions': json.dumps(self.show_captions),
'display_name': self.display_name_with_default,
'handout': self.handout,
'download_video_link': download_video_link,
'sources': json.dumps(sources),
'speed': json.dumps(self.speed),
'general_speed': self.global_speed,
'saved_video_position': self.saved_video_position.total_seconds(),
'start': self.start_time.total_seconds(),
'sub': self.sub,
'track': track_url,
'youtube_streams': youtube_streams or create_youtube_string(self),
# TODO: Later on the value 1500 should be taken from some global
# configuration setting field.
'yt_test_timeout': 1500,
'yt_api_url': settings.YOUTUBE['API'],
'yt_test_url': settings.YOUTUBE['TEST_URL'],
'transcript_download_format': transcript_download_format,
'transcript_download_formats_list': self.descriptor.fields['transcript_download_format'].values,
'transcript_language': transcript_language,
'transcript_languages': json.dumps(sorted_languages),
'transcript_translation_url': self.runtime.handler_url(self, 'transcript', 'translation').rstrip('/?'),
'transcript_available_translations_url': self.runtime.handler_url(self, 'transcript', 'available_translations').rstrip('/?'),
'license': getattr(self, "license", None),
})
}
return self.system.render_template('video.html', context)
@XBlock.wants("settings")
@@ -670,7 +713,10 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
def _update_transcript_for_index(language=None):
""" Find video transcript - if not found, don't update index """
try:
transcript = self.get_transcript(transcript_format='txt', lang=language)[0].replace("\n", " ")
transcripts = self.get_transcripts_info()
transcript = self.get_transcript(
transcripts, transcript_format='txt', lang=language
)[0].replace("\n", " ")
transcript_index_name = "transcript_{}".format(language if language else self.transcript_language)
video_body.update({transcript_index_name: transcript})
except NotFoundError:

View File

@@ -3,9 +3,14 @@
Module contains utils specific for video_module but not for transcripts.
"""
import json
from collections import OrderedDict
import logging
import urllib
import requests
from urllib import urlencode
from urlparse import parse_qs, urlsplit, urlunsplit
from django.conf import settings
from requests.exceptions import RequestException
@@ -71,3 +76,40 @@ def get_video_from_cdn(cdn_base_url, original_video_url):
return cdn_content['sources'][0]
else:
return None
def get_poster(video):
"""
Generate poster metadata.
youtube_streams is string that contains '1.00:youtube_id'
Poster metadata is dict of youtube url for image thumbnail and edx logo
"""
if not video.bumper.get("enabled"):
return
poster = OrderedDict({"url": "", "type": ""})
if video.youtube_streams:
youtube_id = video.youtube_streams.split('1.00:')[1].split(',')[0]
poster["url"] = settings.YOUTUBE['IMAGE_API'].format(youtube_id=youtube_id)
poster["type"] = "youtube"
else:
poster["url"] = "https://www.edx.org/sites/default/files/theme/edx-logo-header.png"
poster["type"] = "html5"
return poster
def set_query_parameter(url, param_name, param_value):
"""
Given a URL, set or replace a query parameter and return the
modified URL.
"""
scheme, netloc, path, query_string, fragment = urlsplit(url)
query_params = parse_qs(query_string)
query_params[param_name] = [param_value]
new_query_string = urlencode(query_params, doseq=True)
return urlunsplit((scheme, netloc, path, new_query_string, fragment))

View File

@@ -3,7 +3,7 @@ XFields for video module.
"""
import datetime
from xblock.fields import Scope, String, Float, Boolean, List, Dict
from xblock.fields import Scope, String, Float, Boolean, List, Dict, DateTime
from xmodule.fields import RelativeTime
from xmodule.mixin import LicenseMixin
@@ -142,7 +142,7 @@ class VideoFields(LicenseMixin):
)
speed = Float(
help=_("The last speed that the user specified for the video."),
scope=Scope.user_state,
scope=Scope.user_state
)
global_speed = Float(
help=_("The default speed for the video."),
@@ -174,3 +174,12 @@ class VideoFields(LicenseMixin):
scope=Scope.settings,
default="",
)
bumper_last_view_date = DateTime(
display_name=_("Date of the last view of the bumper"),
scope=Scope.preferences,
)
bumper_do_not_show_again = Boolean(
display_name=_("Do not show bumper again"),
scope=Scope.preferences,
default=False,
)

View File

@@ -3,6 +3,7 @@ Video player in the courseware.
"""
import time
import json
import requests
from selenium.webdriver.common.action_chains import ActionChains
from bok_choy.page_object import PageObject
@@ -21,10 +22,12 @@ VIDEO_BUTTONS = {
'download_transcript': '.video-tracks > a',
'speed': '.speeds',
'quality': '.quality-control',
'do_not_show_again': '.skip-control',
'skip_bumper': '.play-skip-control',
}
CSS_CLASS_NAMES = {
'closed_captions': '.closed .subtitles',
'closed_captions': '.video.closed',
'captions_rendered': '.video.is-captions-rendered',
'captions': '.subtitles',
'captions_text': '.subtitles > li',
@@ -37,7 +40,8 @@ CSS_CLASS_NAMES = {
'video_time': 'div.vidtime',
'video_display_name': '.vert h2',
'captions_lang_list': '.langs-list li',
'video_speed': '.speeds .value'
'video_speed': '.speeds .value',
'poster': '.poster',
}
VIDEO_MODES = {
@@ -79,7 +83,7 @@ class VideoPage(PageObject):
self.wait_for_element_presence(video_selector, 'Video is initialized')
@wait_for_js
def wait_for_video_player_render(self):
def wait_for_video_player_render(self, autoplay=False):
"""
Wait until Video Player Rendered Completely.
@@ -88,7 +92,12 @@ class VideoPage(PageObject):
self.wait_for_element_presence(CSS_CLASS_NAMES['video_init'], 'Video Player Initialized')
self.wait_for_element_presence(CSS_CLASS_NAMES['video_time'], 'Video Player Initialized')
video_player_buttons = ['volume', 'play', 'fullscreen', 'speed']
video_player_buttons = ['volume', 'fullscreen', 'speed']
if autoplay:
video_player_buttons.append('pause')
else:
video_player_buttons.append('play')
for button in video_player_buttons:
self.wait_for_element_visibility(VIDEO_BUTTONS[button], '{} button is visible'.format(button.title()))
@@ -106,6 +115,34 @@ class VideoPage(PageObject):
self.wait_for_ajax()
@wait_for_js
def wait_for_video_bumper_render(self):
"""
Wait until Poster, Video Pre-Roll and main Video Player are Rendered Completely.
"""
self.wait_for_video_class()
self.wait_for_element_presence(CSS_CLASS_NAMES['video_init'], 'Video Player Initialized')
self.wait_for_element_presence(CSS_CLASS_NAMES['video_time'], 'Video Player Initialized')
video_player_buttons = ['do_not_show_again', 'skip_bumper', 'volume']
for button in video_player_buttons:
self.wait_for_element_visibility(VIDEO_BUTTONS[button], '{} button is visible'.format(button.title()))
@property
def is_poster_shown(self):
"""
Check whether a poster is show.
"""
selector = self.get_element_selector(CSS_CLASS_NAMES['poster'])
return self.q(css=selector).visible
def click_on_poster(self):
"""
Click on the video poster.
"""
selector = self.get_element_selector(CSS_CLASS_NAMES['poster'])
self.q(css=selector).click()
def get_video_vertical_selector(self, video_display_name=None):
"""
Get selector for a video vertical with display name specified by `video_display_name`.
@@ -184,19 +221,14 @@ class VideoPage(PageObject):
@property
def is_autoplay_enabled(self):
"""
Extract `data-autoplay` attribute to check video autoplay is enabled or disabled.
Extract autoplay value of `data-metadata` attribute to check video autoplay is enabled or disabled.
Returns:
bool: Tells if autoplay enabled/disabled.
"""
selector = self.get_element_selector(CSS_CLASS_NAMES['video_container'])
auto_play = self.q(css=selector).attrs('data-autoplay')[0]
if auto_play.lower() == 'false':
return False
return True
auto_play = json.loads(self.q(css=selector).attrs('data-metadata')[0])['autoplay']
return auto_play
@property
def is_error_message_shown(self):
@@ -268,6 +300,7 @@ class VideoPage(PageObject):
bool: True means captions are visible, False means captions are not visible
"""
self.wait_for_ajax()
caption_state_selector = self.get_element_selector(CSS_CLASS_NAMES['closed_captions'])
return not self.q(css=caption_state_selector).present
@@ -515,6 +548,7 @@ class VideoPage(PageObject):
language_selector = VIDEO_MENUS["language"] + ' li[data-lang-code="{code}"]'.format(code=code)
language_selector = self.get_element_selector(language_selector)
self.wait_for_element_visibility(language_selector, 'language menu is visible')
self.q(css=language_selector).first.click()

View File

@@ -200,4 +200,5 @@ class AdvancedSettingsPage(CoursePage):
'social_sharing_url',
'teams_configuration',
'minimum_grade_credit',
'video_bumper',
]

View File

@@ -2,15 +2,62 @@
import datetime
import json
import ddt
from ..helpers import EventsTestMixin
from .test_video_module import VideoBaseTest
from ...pages.lms.video.video import _parse_time_str
from openedx.core.lib.tests.assertions.events import assert_event_matches, assert_events_equal
from opaque_keys.edx.keys import UsageKey, CourseKey
class VideoEventsTest(EventsTestMixin, VideoBaseTest):
class VideoEventsTestMixin(EventsTestMixin, VideoBaseTest):
"""
Useful helper methods to test video player event emission.
"""
def assert_payload_contains_ids(self, video_event):
"""
Video events should all contain "id" and "code" attributes in their payload.
This function asserts that those fields are present and have correct values.
"""
video_descriptors = self.course_fixture.get_nested_xblocks(category='video')
video_desc = video_descriptors[0]
video_locator = UsageKey.from_string(video_desc.locator)
expected_event = {
'event': {
'id': video_locator.html_id(),
'code': '3_yD_cEKoCk'
}
}
self.assert_events_match([expected_event], [video_event])
def assert_valid_control_event_at_time(self, video_event, time_in_seconds):
"""
Video control events should contain valid ID fields and a valid "currentTime" field.
This function asserts that those fields are present and have correct values.
"""
current_time = json.loads(video_event['event'])['currentTime']
self.assertAlmostEqual(current_time, time_in_seconds, delta=1)
def assert_field_type(self, event_dict, field, field_type):
"""Assert that a particular `field` in the `event_dict` has a particular type"""
self.assertIn(field, event_dict, '{0} not found in the root of the event'.format(field))
self.assertTrue(
isinstance(event_dict[field], field_type),
'Expected "{key}" to be a "{field_type}", but it has the value "{value}" of type "{t}"'.format(
key=field,
value=event_dict[field],
t=type(event_dict[field]),
field_type=field_type,
)
)
class VideoEventsTest(VideoEventsTestMixin):
""" Test video player event emission """
def test_video_control_events(self):
@@ -47,33 +94,6 @@ class VideoEventsTest(EventsTestMixin, VideoBaseTest):
assert_event_matches({'event_type': 'pause_video'}, video_event)
self.assert_valid_control_event_at_time(video_event, self.video.seconds)
def assert_payload_contains_ids(self, video_event):
"""
Video events should all contain "id" and "code" attributes in their payload.
This function asserts that those fields are present and have correct values.
"""
video_descriptors = self.course_fixture.get_nested_xblocks(category='video')
video_desc = video_descriptors[0]
video_locator = UsageKey.from_string(video_desc.locator)
expected_event = {
'event': {
'id': video_locator.html_id(),
'code': '3_yD_cEKoCk'
}
}
self.assert_events_match([expected_event], [video_event])
def assert_valid_control_event_at_time(self, video_event, time_in_seconds):
"""
Video control events should contain valid ID fields and a valid "currentTime" field.
This function asserts that those fields are present and have correct values.
"""
current_time = json.loads(video_event['event'])['currentTime']
self.assertAlmostEqual(current_time, time_in_seconds, delta=1)
def test_strict_event_format(self):
"""
This test makes a very strong assertion about the fields present in events. The goal of it is to ensure that new
@@ -127,15 +147,197 @@ class VideoEventsTest(EventsTestMixin, VideoBaseTest):
}
assert_events_equal(static_fields_pattern, load_video_event)
def assert_field_type(self, event_dict, field, field_type):
"""Assert that a particular `field` in the `event_dict` has a particular type"""
self.assertIn(field, event_dict, '{0} not found in the root of the event'.format(field))
self.assertTrue(
isinstance(event_dict[field], field_type),
'Expected "{key}" to be a "{field_type}", but it has the value "{value}" of type "{t}"'.format(
key=field,
value=event_dict[field],
t=type(event_dict[field]),
field_type=field_type,
)
@ddt.ddt
class VideoBumperEventsTest(VideoEventsTestMixin):
""" Test bumper video event emission """
# helper methods
def watch_video_and_skip(self):
"""
Wait 5 seconds and press "skip" button.
"""
self.video.wait_for_position('0:05')
self.video.click_player_button('skip_bumper')
def watch_video_and_dismiss(self):
"""
Wait 5 seconds and press "do not show again" button.
"""
self.video.wait_for_position('0:05')
self.video.click_player_button('do_not_show_again')
def wait_for_state(self, state='finished'):
"""
Wait until video will be in given state.
Finished state means that video is played to the end.
"""
self.video.wait_for_state(state)
def add_bumper(self):
"""
Add video bumper to the course.
"""
additional_data = {
u'video_bumper': {
u'value': {
"transcripts": {},
"video_id": "edx_video_id"
}
}
}
self.course_fixture.add_advanced_settings(additional_data)
@ddt.data(
('edx.video.bumper.skipped', watch_video_and_skip),
('edx.video.bumper.dismissed', watch_video_and_dismiss),
('edx.video.bumper.stopped', wait_for_state)
)
@ddt.unpack
def test_video_control_events(self, event_type, action):
"""
Scenario: Video component with pre-roll emits events correctly
Given the course has a Video component in "Youtube" mode with pre-roll enabled
And I click on the video poster
And the pre-roll video start playing
And I watch (5 seconds/5 seconds/to the end of) it
And I click (skip/do not show again) video button
Then a "edx.video.bumper.loaded" event is emitted
And a "edx.video.bumper.played" event is emitted
And a "edx.video.bumper.skipped/dismissed/stopped" event is emitted
And a "load_video" event is emitted
And a "play_video" event is emitted
"""
def is_video_event(event):
"""Filter out anything other than the video events of interest"""
return event['event_type'] in (
'edx.video.bumper.loaded',
'edx.video.bumper.played',
'edx.video.bumper.skipped',
'edx.video.bumper.dismissed',
'edx.video.bumper.stopped',
'load_video',
'play_video',
'pause_video'
) and self.video.state != 'buffering'
captured_events = []
self.add_bumper()
with self.capture_events(is_video_event, number_of_matches=5, captured_events=captured_events):
self.navigate_to_video_no_render()
self.video.click_on_poster()
self.video.wait_for_video_bumper_render()
sources, duration = self.video.sources[0], self.video.duration
action(self)
# Filter subsequent events that appear due to bufferisation: edx.video.bumper.played
# As bumper does not emit pause event, we filter subsequent edx.video.bumper.played events from
# the list, except first.
filtered_events = []
for video_event in captured_events:
is_played_event = video_event['event_type'] == 'edx.video.bumper.played'
appears_again = filtered_events and video_event['event_type'] == filtered_events[-1]['event_type']
if is_played_event and appears_again:
continue
filtered_events.append(video_event)
for idx, video_event in enumerate(filtered_events):
if idx < 3:
self.assert_bumper_payload_contains_ids(video_event, sources, duration)
else:
self.assert_payload_contains_ids(video_event)
if idx == 0:
assert_event_matches({'event_type': 'edx.video.bumper.loaded'}, video_event)
elif idx == 1:
assert_event_matches({'event_type': 'edx.video.bumper.played'}, video_event)
self.assert_valid_control_event_at_time(video_event, 0)
elif idx == 2:
assert_event_matches({'event_type': event_type}, video_event)
elif idx == 3:
assert_event_matches({'event_type': 'load_video'}, video_event)
elif idx == 4:
assert_event_matches({'event_type': 'play_video'}, video_event)
self.assert_valid_control_event_at_time(video_event, 0)
def assert_bumper_payload_contains_ids(self, video_event, sources, duration):
"""
Bumper video events should all contain "host_component_id", "bumper_id",
"duration", "code" attributes in their payload.
This function asserts that those fields are present and have correct values.
"""
self.add_bumper()
video_descriptors = self.course_fixture.get_nested_xblocks(category='video')
video_desc = video_descriptors[0]
video_locator = UsageKey.from_string(video_desc.locator)
expected_event = {
'event': {
'host_component_id': video_locator.html_id(),
'bumper_id': sources,
'duration': _parse_time_str(duration),
'code': 'html5'
}
}
self.assert_events_match([expected_event], [video_event])
def test_strict_event_format(self):
"""
This test makes a very strong assertion about the fields present in events. The goal of it is to ensure that new
fields are not added to all events mistakenly. It should be the only existing test that is updated when new top
level fields are added to all events.
"""
captured_events = []
self.add_bumper()
filter_event = lambda e: e['event_type'] == 'edx.video.bumper.loaded'
with self.capture_events(filter_event, captured_events=captured_events):
self.navigate_to_video_no_render()
self.video.click_on_poster()
load_video_event = captured_events[0]
# Validate the event payload
sources, duration = self.video.sources[0], self.video.duration
self.assert_bumper_payload_contains_ids(load_video_event, sources, duration)
# We cannot predict the value of these fields so we make weaker assertions about them
dynamic_string_fields = (
'accept_language',
'agent',
'host',
'ip',
'event',
'session'
)
for field in dynamic_string_fields:
self.assert_field_type(load_video_event, field, basestring)
self.assertIn(field, load_video_event, '{0} not found in the root of the event'.format(field))
del load_video_event[field]
# A weak assertion for the timestamp as well
self.assert_field_type(load_video_event, 'time', datetime.datetime)
del load_video_event['time']
# Note that all unpredictable fields have been deleted from the event at this point
course_key = CourseKey.from_string(self.course_id)
static_fields_pattern = {
'context': {
'course_id': unicode(course_key),
'org_id': course_key.org,
'path': '/event',
'user_id': self.user_info['user_id']
},
'event_source': 'browser',
'event_type': 'edx.video.bumper.loaded',
'username': self.user_info['username'],
'page': self.browser.current_url,
'referer': self.browser.current_url,
'name': 'edx.video.bumper.loaded',
}
assert_events_equal(static_fields_pattern, load_video_event)

View File

@@ -397,6 +397,7 @@ class YouTubeVideoTest(VideoBaseTest):
'time_to_response': 2.0,
'youtube_api_blocked': True,
})
self.metadata = self.metadata_for_mode('youtube_html5')
self.navigate_to_video()
@@ -711,6 +712,84 @@ class YouTubeVideoTest(VideoBaseTest):
self.assertEqual(self.video.caption_languages, {'zh_HANS': 'Simplified Chinese', 'zh_HANT': 'Traditional Chinese'})
def test_video_bumper_render(self):
"""
Scenario: Multiple videos with bumper in sequentials all load and work, switching between sequentials
Given it has videos "A,B" in "Youtube" and "HTML5" modes in position "1" of sequential
And video "C" in "Youtube" mode in position "2" of sequential
When I open sequential position "1"
Then I see video "B" has a poster
When I click on it
Then I see video bumper is playing
When I skip the bumper
Then I see the main video
When I click on video "A"
Then the main video starts playing
When I open sequential position "2"
And click on the poster
Then the main video starts playing
Then I see that the main video starts playing once I go back to position "2" of sequential
When I reload the page
Then I see that the main video starts playing when I click on the poster
"""
additional_data = {
u'video_bumper': {
u'value': {
"transcripts": {},
"video_id": "edx_video_id"
}
}
}
self.verticals = [
[{'display_name': 'A'}, {'display_name': 'B', 'metadata': self.metadata_for_mode('html5')}],
[{'display_name': 'C'}]
]
tab1_video_names = ['A', 'B']
tab2_video_names = ['C']
def execute_video_steps(video_names):
"""
Execute video steps
"""
for video_name in video_names:
self.video.use_video(video_name)
self.assertTrue(self.video.is_poster_shown)
self.video.click_on_poster()
self.video.wait_for_video_player_render(autoplay=True)
self.assertIn(self.video.state, ['playing', 'buffering', 'finished'])
self.course_fixture.add_advanced_settings(additional_data)
self.navigate_to_video_no_render()
self.video.use_video('B')
self.assertTrue(self.video.is_poster_shown)
self.video.click_on_poster()
self.video.wait_for_video_bumper_render()
self.assertIn(self.video.state, ['playing', 'buffering', 'finished'])
self.video.click_player_button('skip_bumper')
# no autoplay here, maybe video is too small, so pause is not switched
self.video.wait_for_video_player_render()
self.assertIn(self.video.state, ['playing', 'buffering', 'finished'])
self.video.use_video('A')
execute_video_steps(['A'])
# go to second sequential position
self.course_nav.go_to_sequential_position(2)
execute_video_steps(tab2_video_names)
# go back to first sequential position
# we are again playing tab 1 videos to ensure that switching didn't broke some video functionality.
self.course_nav.go_to_sequential_position(1)
execute_video_steps(tab1_video_names)
self.video.browser.refresh()
execute_video_steps(tab1_video_names)
class YouTubeHtml5VideoTest(VideoBaseTest):
""" Test YouTube HTML5 Video Player """

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,17 @@
# -*- coding: utf-8 -*-
"""Video xmodule tests in mongo."""
from mock import patch
from nose.plugins.attrib import attr
import os
import freezegun
import tempfile
import textwrap
import json
from datetime import timedelta
import ddt
from nose.plugins.attrib import attr
from datetime import timedelta, datetime
from webob import Request
from mock import MagicMock, Mock
from mock import MagicMock, Mock, patch
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
@@ -26,6 +28,9 @@ from xmodule.video_module.transcripts_utils import (
TranscriptsGenerationException,
)
TRANSCRIPT = {"start": [10], "end": [100], "text": ["Hi, welcome to Edx."]}
BUMPER_TRANSCRIPT = {"start": [1], "end": [10], "text": ["A bumper"]}
SRT_content = textwrap.dedent("""
0
00:00:00,12 --> 00:00:00,100
@@ -104,6 +109,20 @@ def _upload_file(subs_file, location, filename):
del_cached_content(content.location)
def attach_sub(item, filename):
"""
Attach `en` transcript.
"""
item.sub = filename
def attach_bumper_transcript(item, filename, lang="en"):
"""
Attach bumper transcript.
"""
item.video_bumper["transcripts"][lang] = filename
@attr('shard_1')
class TestVideo(BaseTestXmodule):
"""Integration tests: web client + mongo."""
@@ -129,6 +148,8 @@ class TestVideo(BaseTestXmodule):
{'speed': 2.0},
{'saved_video_position': "00:00:10"},
{'transcript_language': 'uk'},
{'bumper_do_not_show_again': True},
{'bumper_last_view_date': True},
{'demoo<EFBFBD>': 'sample'}
]
for sample in data:
@@ -151,6 +172,15 @@ class TestVideo(BaseTestXmodule):
self.item_descriptor.handle_ajax('save_user_state', {'transcript_language': "uk"})
self.assertEqual(self.item_descriptor.transcript_language, 'uk')
self.assertEqual(self.item_descriptor.bumper_do_not_show_again, False)
self.item_descriptor.handle_ajax('save_user_state', {'bumper_do_not_show_again': True})
self.assertEqual(self.item_descriptor.bumper_do_not_show_again, True)
with freezegun.freeze_time(datetime.now()):
self.assertEqual(self.item_descriptor.bumper_last_view_date, None)
self.item_descriptor.handle_ajax('save_user_state', {'bumper_last_view_date': True})
self.assertEqual(self.item_descriptor.bumper_last_view_date, datetime.utcnow())
response = self.item_descriptor.handle_ajax('save_user_state', {u'demoo<EFBFBD>': "sample"})
self.assertEqual(json.loads(response)['success'], True)
@@ -166,7 +196,7 @@ class TestTranscriptAvailableTranslationsDispatch(TestVideo):
Tests for `available_translations` dispatch.
"""
non_en_file = _create_srt_file()
srt_file = _create_srt_file()
DATA = """
<video show_captions="true"
display_name="A Name"
@@ -175,7 +205,7 @@ class TestTranscriptAvailableTranslationsDispatch(TestVideo):
<source src="example.webm"/>
<transcript language="uk" src="{}"/>
</video>
""".format(os.path.split(non_en_file.name)[1])
""".format(os.path.split(srt_file.name)[1])
MODEL_DATA = {
'data': DATA
@@ -197,7 +227,7 @@ class TestTranscriptAvailableTranslationsDispatch(TestVideo):
self.assertEqual(json.loads(response.body), ['en'])
def test_available_translation_non_en(self):
_upload_file(self.non_en_file, self.item_descriptor.location, os.path.split(self.non_en_file.name)[1])
_upload_file(self.srt_file, self.item_descriptor.location, os.path.split(self.srt_file.name)[1])
request = Request.blank('/available_translations')
response = self.item.transcript(request=request, dispatch='available_translations')
@@ -210,7 +240,7 @@ class TestTranscriptAvailableTranslationsDispatch(TestVideo):
_upload_sjson_file(good_sjson, self.item_descriptor.location)
# Upload non-english transcript.
_upload_file(self.non_en_file, self.item_descriptor.location, os.path.split(self.non_en_file.name)[1])
_upload_file(self.srt_file, self.item_descriptor.location, os.path.split(self.srt_file.name)[1])
self.item.sub = _get_subs_id(good_sjson.name)
@@ -220,6 +250,63 @@ class TestTranscriptAvailableTranslationsDispatch(TestVideo):
@attr('shard_1')
@ddt.ddt
class TestTranscriptAvailableTranslationsBumperDispatch(TestVideo):
"""
Test video handler that provide available translations info.
Tests for `available_translations_bumper` dispatch.
"""
srt_file = _create_srt_file()
DATA = """
<video show_captions="true"
display_name="A Name"
>
<source src="example.mp4"/>
<source src="example.webm"/>
<transcript language="uk" src="{}"/>
</video>
""".format(os.path.split(srt_file.name)[1])
MODEL_DATA = {
'data': DATA
}
def setUp(self):
super(TestTranscriptAvailableTranslationsBumperDispatch, self).setUp()
self.item_descriptor.render(STUDENT_VIEW)
self.item = self.item_descriptor.xmodule_runtime.xmodule_instance
self.dispatch = "available_translations/?is_bumper=1"
self.item.video_bumper = {"transcripts": {"en": ""}}
@ddt.data("en", "uk")
def test_available_translation_en_and_non_en(self, lang):
filename = os.path.split(self.srt_file.name)[1]
_upload_file(self.srt_file, self.item_descriptor.location, filename)
self.item.video_bumper["transcripts"][lang] = filename
request = Request.blank('/' + self.dispatch)
response = self.item.transcript(request=request, dispatch=self.dispatch)
self.assertEqual(json.loads(response.body), [lang])
def test_multiple_available_translations(self):
en_translation = _create_srt_file()
en_translation_filename = os.path.split(en_translation.name)[1]
uk_translation_filename = os.path.split(self.srt_file.name)[1]
# Upload english transcript.
_upload_file(en_translation, self.item_descriptor.location, en_translation_filename)
# Upload non-english transcript.
_upload_file(self.srt_file, self.item_descriptor.location, uk_translation_filename)
self.item.video_bumper["transcripts"]["en"] = en_translation_filename
self.item.video_bumper["transcripts"]["uk"] = uk_translation_filename
request = Request.blank('/' + self.dispatch)
response = self.item.transcript(request=request, dispatch=self.dispatch)
self.assertEqual(json.loads(response.body), ['en', 'uk'])
class TestTranscriptDownloadDispatch(TestVideo):
"""
Test video handler that provide translation transcripts.
@@ -272,8 +359,9 @@ class TestTranscriptDownloadDispatch(TestVideo):
request = Request.blank('/download')
response = self.item.transcript(request=request, dispatch='download')
self.assertEqual(response.status, '404 Not Found')
transcripts = self.item.get_transcripts_info()
with self.assertRaises(NotFoundError):
self.item.get_transcript()
self.item.get_transcript(transcripts)
@patch('xmodule.video_module.VideoModule.get_transcript', return_value=('Subs!', u"塞.srt", 'application/x-subrip; charset=utf-8'))
def test_download_non_en_non_ascii_filename(self, __):
@@ -285,14 +373,15 @@ class TestTranscriptDownloadDispatch(TestVideo):
@attr('shard_1')
@ddt.ddt
class TestTranscriptTranslationGetDispatch(TestVideo):
"""
Test video handler that provide translation transcripts.
Tests for `translation` dispatch.
Tests for `translation` and `translation_bumper` dispatches.
"""
non_en_file = _create_srt_file()
srt_file = _create_srt_file()
DATA = """
<video show_captions="true"
display_name="A Name"
@@ -301,7 +390,7 @@ class TestTranscriptTranslationGetDispatch(TestVideo):
<source src="example.webm"/>
<transcript language="uk" src="{}"/>
</video>
""".format(os.path.split(non_en_file.name)[1])
""".format(os.path.split(srt_file.name)[1])
MODEL_DATA = {
'data': DATA
@@ -311,37 +400,41 @@ class TestTranscriptTranslationGetDispatch(TestVideo):
super(TestTranscriptTranslationGetDispatch, self).setUp()
self.item_descriptor.render(STUDENT_VIEW)
self.item = self.item_descriptor.xmodule_runtime.xmodule_instance
self.item.video_bumper = {"transcripts": {"en": ""}}
def test_translation_fails(self):
@ddt.data(
# No language
request = Request.blank('/translation')
response = self.item.transcript(request=request, dispatch='translation')
self.assertEqual(response.status, '400 Bad Request')
('/translation', 'translation', '400 Bad Request'),
# No videoId - HTML5 video with language that is not in available languages
request = Request.blank('/translation/ru')
response = self.item.transcript(request=request, dispatch='translation/ru')
self.assertEqual(response.status, '404 Not Found')
('/translation/ru', 'translation/ru', '404 Not Found'),
# Language is not in available languages
request = Request.blank('/translation/ru?videoId=12345')
response = self.item.transcript(request=request, dispatch='translation/ru')
self.assertEqual(response.status, '404 Not Found')
('/translation/ru?videoId=12345', 'translation/ru', '404 Not Found'),
# Youtube_id is invalid or does not exist
request = Request.blank('/translation/uk?videoId=9855256955511225')
response = self.item.transcript(request=request, dispatch='translation/uk')
self.assertEqual(response.status, '404 Not Found')
('/translation/uk?videoId=9855256955511225', 'translation/uk', '404 Not Found'),
('/translation?is_bumper=1', 'translation', '400 Bad Request'),
('/translation/ru?is_bumper=1', 'translation/ru', '404 Not Found'),
('/translation/ru?videoId=12345&is_bumper=1', 'translation/ru', '404 Not Found'),
('/translation/uk?videoId=9855256955511225&is_bumper=1', 'translation/uk', '404 Not Found'),
)
@ddt.unpack
def test_translation_fails(self, url, dispatch, status_code):
request = Request.blank(url)
response = self.item.transcript(request=request, dispatch=dispatch)
self.assertEqual(response.status, status_code)
def test_translaton_en_youtube_success(self):
@ddt.data(
('translation/en?videoId={}', 'translation/en', attach_sub),
('translation/en?videoId={}&is_bumper=1', 'translation/en', attach_bumper_transcript))
@ddt.unpack
def test_translaton_en_youtube_success(self, url, dispatch, attach):
subs = {"start": [10], "end": [100], "text": ["Hi, welcome to Edx."]}
good_sjson = _create_file(json.dumps(subs))
_upload_sjson_file(good_sjson, self.item_descriptor.location)
subs_id = _get_subs_id(good_sjson.name)
self.item.sub = subs_id
request = Request.blank('/translation/en?videoId={}'.format(subs_id))
response = self.item.transcript(request=request, dispatch='translation/en')
attach(self.item, subs_id)
request = Request.blank(url.format(subs_id))
response = self.item.transcript(request=request, dispatch=dispatch)
self.assertDictEqual(json.loads(response.body), subs)
def test_translation_non_en_youtube_success(self):
@@ -352,9 +445,9 @@ class TestTranscriptTranslationGetDispatch(TestVideo):
u'\u041f\u0440\u0438\u0432\u0456\u0442, edX \u0432\u0456\u0442\u0430\u0454 \u0432\u0430\u0441.'
]
}
self.non_en_file.seek(0)
_upload_file(self.non_en_file, self.item_descriptor.location, os.path.split(self.non_en_file.name)[1])
subs_id = _get_subs_id(self.non_en_file.name)
self.srt_file.seek(0)
_upload_file(self.srt_file, self.item_descriptor.location, os.path.split(self.srt_file.name)[1])
subs_id = _get_subs_id(self.srt_file.name)
# youtube 1_0 request, will generate for all speeds for existing ids
self.item.youtube_id_1_0 = subs_id
@@ -387,16 +480,19 @@ class TestTranscriptTranslationGetDispatch(TestVideo):
}
self.assertDictEqual(json.loads(response.body), calculated_1_5)
def test_translaton_en_html5_success(self):
subs = {"start": [10], "end": [100], "text": ["Hi, welcome to Edx."]}
good_sjson = _create_file(json.dumps(subs))
@ddt.data(
('translation/en', 'translation/en', attach_sub),
('translation/en?is_bumper=1', 'translation/en', attach_bumper_transcript))
@ddt.unpack
def test_translaton_en_html5_success(self, url, dispatch, attach):
good_sjson = _create_file(json.dumps(TRANSCRIPT))
_upload_sjson_file(good_sjson, self.item_descriptor.location)
subs_id = _get_subs_id(good_sjson.name)
self.item.sub = subs_id
request = Request.blank('/translation/en')
response = self.item.transcript(request=request, dispatch='translation/en')
self.assertDictEqual(json.loads(response.body), subs)
attach(self.item, subs_id)
request = Request.blank(url)
response = self.item.transcript(request=request, dispatch=dispatch)
self.assertDictEqual(json.loads(response.body), TRANSCRIPT)
def test_translaton_non_en_html5_success(self):
subs = {
@@ -406,8 +502,8 @@ class TestTranscriptTranslationGetDispatch(TestVideo):
u'\u041f\u0440\u0438\u0432\u0456\u0442, edX \u0432\u0456\u0442\u0430\u0454 \u0432\u0430\u0441.'
]
}
self.non_en_file.seek(0)
_upload_file(self.non_en_file, self.item_descriptor.location, os.path.split(self.non_en_file.name)[1])
self.srt_file.seek(0)
_upload_file(self.srt_file, self.item_descriptor.location, os.path.split(self.srt_file.name)[1])
# manually clean youtube_id_1_0, as it has default value
self.item.youtube_id_1_0 = ""
@@ -453,7 +549,22 @@ class TestTranscriptTranslationGetDispatch(TestVideo):
response = self.item.transcript(request=request, dispatch='translation/uk')
self.assertEqual(response.status, '404 Not Found')
def test_translation_static_transcript(self):
@ddt.data(
# Test youtube style en
('/translation/en?videoId=12345', 'translation/en', '307 Temporary Redirect', '12345'),
# Test html5 style en
('/translation/en', 'translation/en', '307 Temporary Redirect', 'OEoXaMPEzfM', attach_sub),
# Test different language to ensure we are just ignoring it since we can't
# translate with static fallback
('/translation/uk', 'translation/uk', '404 Not Found'),
(
'/translation/en?is_bumper=1', 'translation/en', '307 Temporary Redirect', 'OEoXaMPEzfM',
attach_bumper_transcript
),
('/translation/uk?is_bumper=1', 'translation/uk', '404 Not Found'),
)
@ddt.unpack
def test_translation_static_transcript(self, url, dispatch, status_code, sub=None, attach=None):
"""
Set course static_asset_path and ensure we get redirected to that path
if it isn't found in the contentstore
@@ -464,30 +575,16 @@ class TestTranscriptTranslationGetDispatch(TestVideo):
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, self.course.id):
store.update_item(self.course, self.user.id)
# Test youtube style en
request = Request.blank('/translation/en?videoId=12345')
response = self.item.transcript(request=request, dispatch='translation/en')
self.assertEqual(response.status, '307 Temporary Redirect')
self.assertIn(
('Location', '/static/dummy/static/subs_12345.srt.sjson'),
response.headerlist
)
# Test HTML5 video style
self.item.sub = 'OEoXaMPEzfM'
request = Request.blank('/translation/en')
response = self.item.transcript(request=request, dispatch='translation/en')
self.assertEqual(response.status, '307 Temporary Redirect')
self.assertIn(
('Location', '/static/dummy/static/subs_OEoXaMPEzfM.srt.sjson'),
response.headerlist
)
# Test different language to ensure we are just ignoring it since we can't
# translate with static fallback
request = Request.blank('/translation/uk')
response = self.item.transcript(request=request, dispatch='translation/uk')
self.assertEqual(response.status, '404 Not Found')
if attach:
attach(self.item, sub)
request = Request.blank(url)
response = self.item.transcript(request=request, dispatch=dispatch)
self.assertEqual(response.status, status_code)
if sub:
self.assertIn(
('Location', '/static/dummy/static/subs_{}.srt.sjson'.format(sub)),
response.headerlist
)
@attr('shard_1')
@@ -497,7 +594,7 @@ class TestStudioTranscriptTranslationGetDispatch(TestVideo):
Tests for `translation` dispatch GET HTTP method.
"""
non_en_file = _create_srt_file()
srt_file = _create_srt_file()
DATA = """
<video show_captions="true"
display_name="A Name"
@@ -507,7 +604,7 @@ class TestStudioTranscriptTranslationGetDispatch(TestVideo):
<transcript language="uk" src="{}"/>
<transcript language="zh" src="{}"/>
</video>
""".format(os.path.split(non_en_file.name)[1], u"塞.srt".encode('utf8'))
""".format(os.path.split(srt_file.name)[1], u"塞.srt".encode('utf8'))
MODEL_DATA = {'data': DATA}
@@ -523,12 +620,12 @@ class TestStudioTranscriptTranslationGetDispatch(TestVideo):
self.assertEqual(response.status, '400 Bad Request')
# Correct case:
filename = os.path.split(self.non_en_file.name)[1]
_upload_file(self.non_en_file, self.item_descriptor.location, filename)
self.non_en_file.seek(0)
filename = os.path.split(self.srt_file.name)[1]
_upload_file(self.srt_file, self.item_descriptor.location, filename)
self.srt_file.seek(0)
request = Request.blank(u'translation/uk?filename={}'.format(filename))
response = self.item_descriptor.studio_transcript(request=request, dispatch='translation/uk')
self.assertEqual(response.body, self.non_en_file.read())
self.assertEqual(response.body, self.srt_file.read())
self.assertEqual(response.headers['Content-Type'], 'application/x-subrip; charset=utf-8')
self.assertEqual(
response.headers['Content-Disposition'],
@@ -537,12 +634,12 @@ class TestStudioTranscriptTranslationGetDispatch(TestVideo):
self.assertEqual(response.headers['Content-Language'], 'uk')
# Non ascii file name download:
self.non_en_file.seek(0)
_upload_file(self.non_en_file, self.item_descriptor.location, u'塞.srt')
self.non_en_file.seek(0)
self.srt_file.seek(0)
_upload_file(self.srt_file, self.item_descriptor.location, u'塞.srt')
self.srt_file.seek(0)
request = Request.blank('translation/zh?filename={}'.format(u'塞.srt'.encode('utf8')))
response = self.item_descriptor.studio_transcript(request=request, dispatch='translation/zh')
self.assertEqual(response.body, self.non_en_file.read())
self.assertEqual(response.body, self.srt_file.read())
self.assertEqual(response.headers['Content-Type'], 'application/x-subrip; charset=utf-8')
self.assertEqual(response.headers['Content-Disposition'], 'attachment; filename="塞.srt"')
self.assertEqual(response.headers['Content-Language'], 'zh')
@@ -614,7 +711,7 @@ class TestGetTranscript(TestVideo):
"""
Make sure that `get_transcript` method works correctly
"""
non_en_file = _create_srt_file()
srt_file = _create_srt_file()
DATA = """
<video show_captions="true"
display_name="A Name"
@@ -624,7 +721,7 @@ class TestGetTranscript(TestVideo):
<transcript language="uk" src="{}"/>
<transcript language="zh" src="{}"/>
</video>
""".format(os.path.split(non_en_file.name)[1], u"塞.srt".encode('utf8'))
""".format(os.path.split(srt_file.name)[1], u"塞.srt".encode('utf8'))
MODEL_DATA = {
'data': DATA
@@ -660,7 +757,8 @@ class TestGetTranscript(TestVideo):
_upload_sjson_file(good_sjson, self.item.location)
self.item.sub = _get_subs_id(good_sjson.name)
text, filename, mime_type = self.item.get_transcript()
transcripts = self.item.get_transcripts_info()
text, filename, mime_type = self.item.get_transcript(transcripts)
expected_text = textwrap.dedent("""\
0
@@ -697,7 +795,8 @@ class TestGetTranscript(TestVideo):
_upload_sjson_file(good_sjson, self.item.location)
self.item.sub = _get_subs_id(good_sjson.name)
text, filename, mime_type = self.item.get_transcript("txt")
transcripts = self.item.get_transcripts_info()
text, filename, mime_type = self.item.get_transcript(transcripts, transcript_format="txt")
expected_text = textwrap.dedent("""\
Hi, welcome to Edx.
Let's start with what is on your screen right now.""")
@@ -708,14 +807,15 @@ class TestGetTranscript(TestVideo):
def test_en_with_empty_sub(self):
transcripts = {"transcripts": {}, "sub": ""}
# no self.sub, self.youttube_1_0 exist, but no file in assets
with self.assertRaises(NotFoundError):
self.item.get_transcript()
self.item.get_transcript(transcripts)
# no self.sub and no self.youtube_1_0
# no self.sub and no self.youtube_1_0, no non-en transcritps
self.item.youtube_id_1_0 = None
with self.assertRaises(ValueError):
self.item.get_transcript()
self.item.get_transcript(transcripts)
# no self.sub but youtube_1_0 exists with file in assets
good_sjson = _create_file(content=textwrap.dedent("""\
@@ -737,7 +837,7 @@ class TestGetTranscript(TestVideo):
_upload_sjson_file(good_sjson, self.item.location)
self.item.youtube_id_1_0 = _get_subs_id(good_sjson.name)
text, filename, mime_type = self.item.get_transcript()
text, filename, mime_type = self.item.get_transcript(transcripts)
expected_text = textwrap.dedent("""\
0
00:00:00,270 --> 00:00:02,720
@@ -755,10 +855,11 @@ class TestGetTranscript(TestVideo):
def test_non_en_with_non_ascii_filename(self):
self.item.transcript_language = 'zh'
self.non_en_file.seek(0)
_upload_file(self.non_en_file, self.item_descriptor.location, u"塞.srt")
self.srt_file.seek(0)
_upload_file(self.srt_file, self.item_descriptor.location, u"塞.srt")
text, filename, mime_type = self.item.get_transcript()
transcripts = self.item.get_transcripts_info()
text, filename, mime_type = self.item.get_transcript(transcripts)
expected_text = textwrap.dedent("""
0
00:00:00,12 --> 00:00:00,100
@@ -774,8 +875,9 @@ class TestGetTranscript(TestVideo):
_upload_sjson_file(good_sjson, self.item.location)
self.item.sub = _get_subs_id(good_sjson.name)
transcripts = self.item.get_transcripts_info()
with self.assertRaises(ValueError):
self.item.get_transcript()
self.item.get_transcript(transcripts)
def test_key_error(self):
good_sjson = _create_file(content="""
@@ -794,5 +896,6 @@ class TestGetTranscript(TestVideo):
_upload_sjson_file(good_sjson, self.item.location)
self.item.sub = _get_subs_id(good_sjson.name)
transcripts = self.item.get_transcripts_info()
with self.assertRaises(KeyError):
self.item.get_transcript()
self.item.get_transcript(transcripts)

View File

@@ -9,8 +9,9 @@ from nose.plugins.attrib import attr
from django.conf import settings
from django.test import TestCase
from django.test.utils import override_settings
from xmodule.video_module import create_youtube_string, VideoDescriptor
from xmodule.video_module import VideoDescriptor, bumper_utils, video_utils
from xmodule.x_module import STUDENT_VIEW
from xmodule.tests.test_video import VideoDescriptorTestBase
from xmodule.tests.test_import import DummySystem
@@ -31,43 +32,51 @@ class TestVideoYouTube(TestVideo):
def test_video_constructor(self):
"""Make sure that all parameters extracted correctly from xml"""
context = self.item_descriptor.render(STUDENT_VIEW).content
sources = json.dumps([u'example.mp4', u'example.webm'])
sources = [u'example.mp4', u'example.webm']
expected_context = {
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', False),
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
'display_name': u'A Name',
'end': 3610.0,
'id': self.item_descriptor.location.html_id(),
'show_captions': 'true',
'handout': None,
'download_video_link': u'example.mp4',
'sources': sources,
'speed': 'null',
'general_speed': 1.0,
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'handout': None,
'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({
"saveStateUrl": self.item_descriptor.xmodule_runtime.ajax_url + "/save_user_state",
"autoplay": False,
"streams": "0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg",
"sub": "a_sub_file.srt.sjson",
"sources": sources,
"captionDataDir": None,
"showCaptions": "true",
"generalSpeed": 1.0,
"speed": None,
"savedVideoPosition": 0.0,
"start": 3603.0,
"end": 3610.0,
"transcriptLanguage": "en",
"transcriptLanguages": OrderedDict({"en": "English", "uk": u"Українська"}),
"ytTestTimeout": 1500,
"ytApiUrl": "www.youtube.com/iframe_api",
"ytTestUrl": "gdata.youtube.com/feeds/api/videos/",
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
"autohideHtml5": False,
})),
'track': None,
'youtube_streams': create_youtube_string(self.item_descriptor),
'yt_test_timeout': 1500,
'yt_api_url': 'www.youtube.com/iframe_api',
'yt_test_url': 'gdata.youtube.com/feeds/api/videos/',
'transcript_download_format': 'srt',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': u'en',
'transcript_languages': json.dumps(OrderedDict({"en": "English", "uk": u"Українська"})),
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation'
).rstrip('/?'),
'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
'transcript_download_formats_list': [
{'display_name': 'SubRip (.srt) file', 'value': 'srt'},
{'display_name': 'Text (.txt) file', 'value': 'txt'}
],
'poster': 'null',
}
self.assertEqual(
@@ -100,43 +109,51 @@ class TestVideoNonYouTube(TestVideo):
the template generates an empty string for the YouTube streams.
"""
context = self.item_descriptor.render(STUDENT_VIEW).content
sources = json.dumps([u'example.mp4', u'example.webm'])
sources = [u'example.mp4', u'example.webm']
expected_context = {
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
'display_name': u'A Name',
'download_video_link': u'example.mp4',
'end': 3610.0,
'handout': None,
'id': self.item_descriptor.location.html_id(),
'sources': sources,
'speed': 'null',
'general_speed': 1.0,
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'metadata': json.dumps(OrderedDict({
"saveStateUrl": self.item_descriptor.xmodule_runtime.ajax_url + "/save_user_state",
"autoplay": False,
"streams": "1.00:3_yD_cEKoCk",
"sub": "a_sub_file.srt.sjson",
"sources": sources,
"captionDataDir": None,
"showCaptions": "true",
"generalSpeed": 1.0,
"speed": None,
"savedVideoPosition": 0.0,
"start": 3603.0,
"end": 3610.0,
"transcriptLanguage": "en",
"transcriptLanguages": OrderedDict({"en": "English"}),
"ytTestTimeout": 1500,
"ytApiUrl": "www.youtube.com/iframe_api",
"ytTestUrl": "gdata.youtube.com/feeds/api/videos/",
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
"autohideHtml5": False,
})),
'track': None,
'youtube_streams': '1.00:3_yD_cEKoCk',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_api_url': 'www.youtube.com/iframe_api',
'yt_test_url': 'gdata.youtube.com/feeds/api/videos/',
'transcript_download_format': 'srt',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': u'en',
'transcript_languages': '{"en": "English"}',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation'
).rstrip('/?'),
'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?')
'transcript_download_formats_list': [
{'display_name': 'SubRip (.srt) file', 'value': 'srt'},
{'display_name': 'Text (.txt) file', 'value': 'txt'}
],
'poster': 'null',
}
self.assertEqual(
@@ -157,6 +174,32 @@ class TestGetHtmlMethod(BaseTestXmodule):
def setUp(self):
super(TestGetHtmlMethod, self).setUp()
self.setup_course()
self.default_metadata_dict = OrderedDict({
"saveStateUrl": "",
"autoplay": settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
"streams": "1.00:3_yD_cEKoCk",
"sub": "a_sub_file.srt.sjson",
"sources": '[]',
"captionDataDir": None,
"showCaptions": "true",
"generalSpeed": 1.0,
"speed": None,
"savedVideoPosition": 0.0,
"start": 3603.0,
"end": 3610.0,
"transcriptLanguage": "en",
"transcriptLanguages": OrderedDict({"en": "English"}),
"ytTestTimeout": 1500,
"ytApiUrl": "www.youtube.com/iframe_api",
"ytTestUrl": "gdata.youtube.com/feeds/api/videos/",
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
"autohideHtml5": False,
})
def test_get_html_track(self):
SOURCE_XML = """
@@ -209,36 +252,31 @@ class TestGetHtmlMethod(BaseTestXmodule):
'transcripts': '<transcript language="uk" src="ukrainian.srt" />',
},
]
sources = json.dumps([u'example.mp4', u'example.webm'])
sources = [u'example.mp4', u'example.webm']
expected_context = {
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
'display_name': u'A Name',
'download_video_link': u'example.mp4',
'end': 3610.0,
'id': None,
'sources': sources,
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'speed': 'null',
'general_speed': 1.0,
'track': u'http://www.example.com/track',
'youtube_streams': '1.00:3_yD_cEKoCk',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_api_url': 'www.youtube.com/iframe_api',
'yt_test_url': 'gdata.youtube.com/feeds/api/videos/',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'handout': None,
'id': self.item_descriptor.location.html_id(),
'metadata': '',
'track': None,
'transcript_download_format': 'srt',
'transcript_download_formats_list': [
{'display_name': 'SubRip (.srt) file', 'value': 'srt'},
{'display_name': 'Text (.txt) file', 'value': 'txt'}
],
'poster': 'null',
}
for data in cases:
metadata = self.default_metadata_dict
metadata['sources'] = sources
DATA = SOURCE_XML.format(
download_track=data['download_track'],
track=data['track'],
@@ -252,22 +290,29 @@ class TestGetHtmlMethod(BaseTestXmodule):
).rstrip('/?')
context = self.item_descriptor.render(STUDENT_VIEW).content
expected_context.update({
'transcript_download_format': None if self.item_descriptor.track and self.item_descriptor.download_track else 'srt',
'transcript_languages': '{"en": "English"}' if not data['transcripts'] else json.dumps({"uk": u'Українська'}),
'transcript_language': u'en' if not data['transcripts'] or data.get('sub') else u'uk',
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation'
metadata.update({
'transcriptLanguages': {"en": "English"} if not data['transcripts'] else {"uk": u'Українська'},
'transcriptLanguage': u'en' if not data['transcripts'] or data.get('sub') else u'uk',
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url(
'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'track': track_url if data['expected_track_url'] == u'a_sub_file.srt.sjson' else data['expected_track_url'],
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'sub': data['sub'],
'id': self.item_descriptor.location.html_id(),
})
expected_context.update({
'transcript_download_format': (
None if self.item_descriptor.track and self.item_descriptor.download_track else 'srt'
),
'track': (
track_url if data['expected_track_url'] == u'a_sub_file.srt.sjson' else data['expected_track_url']
),
'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(metadata)
})
self.assertEqual(
context,
self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context),
@@ -295,7 +340,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
""",
'result': {
'download_video_link': u'example_source.mp4',
'sources': json.dumps([u'example.mp4', u'example.webm']),
'sources': [u'example.mp4', u'example.webm'],
},
},
{
@@ -307,7 +352,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
""",
'result': {
'download_video_link': u'example.mp4',
'sources': json.dumps([u'example.mp4', u'example.webm']),
'sources': [u'example.mp4', u'example.webm'],
},
},
{
@@ -326,7 +371,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
<source src="example.webm"/>
""",
'result': {
'sources': json.dumps([u'example.mp4', u'example.webm']),
'sources': [u'example.mp4', u'example.webm'],
},
},
]
@@ -334,31 +379,21 @@ class TestGetHtmlMethod(BaseTestXmodule):
initial_context = {
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
'display_name': u'A Name',
'download_video_link': None,
'end': 3610.0,
'id': None,
'sources': '[]',
'speed': 'null',
'general_speed': 1.0,
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'download_video_link': u'example.mp4',
'handout': None,
'id': self.item_descriptor.location.html_id(),
'metadata': self.default_metadata_dict,
'track': None,
'youtube_streams': '1.00:3_yD_cEKoCk',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_api_url': 'www.youtube.com/iframe_api',
'yt_test_url': 'gdata.youtube.com/feeds/api/videos/',
'transcript_download_format': 'srt',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': u'en',
'transcript_languages': '{"en": "English"}',
'transcript_download_formats_list': [
{'display_name': 'SubRip (.srt) file', 'value': 'srt'},
{'display_name': 'Text (.txt) file', 'value': 'txt'}
],
'poster': 'null',
}
for data in cases:
@@ -371,17 +406,21 @@ class TestGetHtmlMethod(BaseTestXmodule):
context = self.item_descriptor.render(STUDENT_VIEW).content
expected_context = dict(initial_context)
expected_context.update({
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation'
expected_context['metadata'].update({
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url(
'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'id': self.item_descriptor.location.html_id(),
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'sources': data['result'].get('sources', []),
})
expected_context.update({
'id': self.item_descriptor.location.html_id(),
'download_video_link': data['result'].get('download_video_link'),
'metadata': json.dumps(expected_context['metadata'])
})
expected_context.update(data['result'])
self.assertEqual(
context,
@@ -413,7 +452,7 @@ class TestGetHtmlMethod(BaseTestXmodule):
'edx_video_id': "meow",
'result': {
'download_video_link': u'example_source.mp4',
'sources': json.dumps([u'example.mp4', u'example.webm']),
'sources': [u'example.mp4', u'example.webm'],
}
}
DATA = SOURCE_XML.format(
@@ -469,39 +508,32 @@ class TestGetHtmlMethod(BaseTestXmodule):
'result': {
'download_video_link': None,
# make sure the desktop_mp4 url is included as part of the alternative sources.
'sources': json.dumps([u'example.mp4', u'example.webm', u'http://www.meowmix.com']),
'sources': [u'example.mp4', u'example.webm', u'http://www.meowmix.com'],
}
}
# Video found for edx_video_id
metadata = self.default_metadata_dict
metadata['autoplay'] = False
metadata['sources'] = ""
initial_context = {
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
'display_name': u'A Name',
'download_video_link': None,
'end': 3610.0,
'id': None,
'sources': '[]',
'speed': 'null',
'general_speed': 1.0,
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'download_video_link': u'example.mp4',
'handout': None,
'id': self.item_descriptor.location.html_id(),
'track': None,
'youtube_streams': '1.00:3_yD_cEKoCk',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_api_url': 'www.youtube.com/iframe_api',
'yt_test_url': 'gdata.youtube.com/feeds/api/videos/',
'transcript_download_format': 'srt',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': u'en',
'transcript_languages': '{"en": "English"}',
'transcript_download_formats_list': [
{'display_name': 'SubRip (.srt) file', 'value': 'srt'},
{'display_name': 'Text (.txt) file', 'value': 'txt'}
],
'poster': 'null',
'metadata': metadata
}
DATA = SOURCE_XML.format(
@@ -514,17 +546,21 @@ class TestGetHtmlMethod(BaseTestXmodule):
context = self.item_descriptor.render(STUDENT_VIEW).content
expected_context = dict(initial_context)
expected_context.update({
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation'
expected_context['metadata'].update({
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url(
'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'id': self.item_descriptor.location.html_id(),
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'sources': data['result']['sources'],
})
expected_context.update({
'id': self.item_descriptor.location.html_id(),
'download_video_link': data['result']['download_video_link'],
'metadata': json.dumps(expected_context['metadata'])
})
expected_context.update(data['result'])
self.assertEqual(
context,
@@ -579,42 +615,32 @@ class TestGetHtmlMethod(BaseTestXmodule):
'result': {
'download_video_link': u'http://fake-video.edx.org/thundercats.mp4',
# make sure the urls for the various encodings are included as part of the alternative sources.
'sources': json.dumps(
[u'example.mp4', u'example.webm'] +
[video['url'] for video in encoded_videos]
),
'sources': [u'example.mp4', u'example.webm'] +
[video['url'] for video in encoded_videos],
}
}
# Video found for edx_video_id
metadata = self.default_metadata_dict
metadata['sources'] = ""
initial_context = {
'branding_info': None,
'license': None,
'bumper_metadata': 'null',
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
'display_name': u'A Name',
'download_video_link': None,
'end': 3610.0,
'id': None,
'sources': '[]',
'speed': 'null',
'general_speed': 1.0,
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'download_video_link': u'example.mp4',
'handout': None,
'id': self.item_descriptor.location.html_id(),
'track': None,
'youtube_streams': '1.00:3_yD_cEKoCk',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_api_url': 'www.youtube.com/iframe_api',
'yt_test_url': 'gdata.youtube.com/feeds/api/videos/',
'transcript_download_format': 'srt',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': u'en',
'transcript_languages': '{"en": "English"}',
'transcript_download_formats_list': [
{'display_name': 'SubRip (.srt) file', 'value': 'srt'},
{'display_name': 'Text (.txt) file', 'value': 'txt'}
],
'poster': 'null',
'metadata': metadata,
}
DATA = SOURCE_XML.format(
@@ -627,17 +653,21 @@ class TestGetHtmlMethod(BaseTestXmodule):
context = self.item_descriptor.render(STUDENT_VIEW).content
expected_context = dict(initial_context)
expected_context.update({
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation'
expected_context['metadata'].update({
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url(
'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'id': self.item_descriptor.location.html_id(),
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'sources': data['result']['sources'],
})
expected_context.update({
'id': self.item_descriptor.location.html_id(),
'download_video_link': data['result']['download_video_link'],
'metadata': json.dumps(expected_context['metadata'])
})
expected_context.update(data['result'])
self.assertEqual(
context,
@@ -690,12 +720,10 @@ class TestGetHtmlMethod(BaseTestXmodule):
""",
'result': {
'download_video_link': u'example_source.mp4',
'sources': json.dumps(
[
u'http://cdn_example.com/example.mp4',
u'http://cdn_example.com/example.webm'
]
),
'sources': [
u'http://cdn_example.com/example.mp4',
u'http://cdn_example.com/example.webm'
],
},
}
@@ -712,31 +740,21 @@ class TestGetHtmlMethod(BaseTestXmodule):
'url': 'http://www.xuetangx.com'
},
'license': None,
'bumper_metadata': 'null',
'cdn_eval': False,
'cdn_exp_group': None,
'data_dir': getattr(self, 'data_dir', None),
'show_captions': 'true',
'handout': None,
'display_name': u'A Name',
'download_video_link': None,
'end': 3610.0,
'handout': None,
'id': None,
'sources': '[]',
'speed': 'null',
'general_speed': 1.0,
'start': 3603.0,
'saved_video_position': 0.0,
'sub': u'a_sub_file.srt.sjson',
'metadata': self.default_metadata_dict,
'track': None,
'youtube_streams': '1.00:3_yD_cEKoCk',
'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True),
'yt_test_timeout': 1500,
'yt_api_url': 'www.youtube.com/iframe_api',
'yt_test_url': 'gdata.youtube.com/feeds/api/videos/',
'transcript_download_format': 'srt',
'transcript_download_formats_list': [{'display_name': 'SubRip (.srt) file', 'value': 'srt'}, {'display_name': 'Text (.txt) file', 'value': 'txt'}],
'transcript_language': u'en',
'transcript_languages': '{"en": "English"}',
'transcript_download_formats_list': [
{'display_name': 'SubRip (.srt) file', 'value': 'srt'},
{'display_name': 'Text (.txt) file', 'value': 'txt'}
],
'poster': 'null',
}
for data in cases:
@@ -748,21 +766,23 @@ class TestGetHtmlMethod(BaseTestXmodule):
)
self.initialize_module(data=DATA)
self.item_descriptor.xmodule_runtime.user_location = 'CN'
context = self.item_descriptor.render('student_view').content
expected_context = dict(initial_context)
expected_context.update({
'transcript_translation_url': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation'
expected_context['metadata'].update({
'transcriptTranslationUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
'transcript_available_translations_url': self.item_descriptor.xmodule_runtime.handler_url(
'transcriptAvailableTranslationsUrl': self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
'ajax_url': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'id': self.item_descriptor.location.html_id(),
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
'sources': data['result'].get('sources', []),
})
expected_context.update({
'id': self.item_descriptor.location.html_id(),
'download_video_link': data['result'].get('download_video_link'),
'metadata': json.dumps(expected_context['metadata'])
})
expected_context.update(data['result'])
self.assertEqual(
context,
@@ -948,3 +968,125 @@ class VideoDescriptorTest(TestCase, VideoDescriptorTestBase):
VideoDescriptor.from_xml(xml_data, module_system, id_generator=Mock())
with self.assertRaises(ValVideoNotFoundError):
get_video_info("test_edx_video_id")
class TestVideoWithBumper(TestVideo):
"""
Tests rendered content in presence of video bumper.
"""
CATEGORY = "video"
METADATA = {}
FEATURES = settings.FEATURES
@patch('xmodule.video_module.bumper_utils.get_bumper_settings')
def test_is_bumper_enabled(self, get_bumper_settings):
"""
Check that bumper is (not)shown if ENABLE_VIDEO_BUMPER is (False)True
Assume that bumper settings are correct.
"""
self.FEATURES.update({
"SHOW_BUMPER_PERIODICITY": 1,
"ENABLE_VIDEO_BUMPER": True,
})
get_bumper_settings.return_value = {
"video_id": "edx_video_id",
"transcripts": {},
}
with override_settings(FEATURES=self.FEATURES):
self.assertTrue(bumper_utils.is_bumper_enabled(self.item_descriptor))
self.FEATURES.update({"ENABLE_VIDEO_BUMPER": False})
with override_settings(FEATURES=self.FEATURES):
self.assertFalse(bumper_utils.is_bumper_enabled(self.item_descriptor))
@patch('xmodule.video_module.bumper_utils.is_bumper_enabled')
@patch('xmodule.video_module.bumper_utils.get_bumper_settings')
@patch('edxval.api.get_urls_for_profiles')
def test_bumper_metadata(self, get_url_for_profiles, get_bumper_settings, is_bumper_enabled):
"""
Test content with rendered bumper metadata.
"""
get_url_for_profiles.return_value = {
"desktop_mp4": "http://test_bumper.mp4",
"desktop_webm": "",
}
get_bumper_settings.return_value = {
"video_id": "edx_video_id",
"transcripts": {},
}
is_bumper_enabled.return_value = True
content = self.item_descriptor.render(STUDENT_VIEW).content
sources = [u'example.mp4', u'example.webm']
expected_context = {
'branding_info': None,
'license': None,
'bumper_metadata': json.dumps(OrderedDict({
'saveStateUrl': self.item_descriptor.xmodule_runtime.ajax_url + '/save_user_state',
"showCaptions": "true",
"sources": ["http://test_bumper.mp4"],
'streams': '',
"transcriptLanguage": "en",
"transcriptLanguages": {"en": "English"},
"transcriptTranslationUrl": video_utils.set_query_parameter(
self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'), 'is_bumper', 1
),
"transcriptAvailableTranslationsUrl": video_utils.set_query_parameter(
self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'), 'is_bumper', 1
),
})),
'cdn_eval': False,
'cdn_exp_group': None,
'display_name': u'A Name',
'download_video_link': u'example.mp4',
'handout': None,
'id': self.item_descriptor.location.html_id(),
'metadata': json.dumps(OrderedDict({
"saveStateUrl": self.item_descriptor.xmodule_runtime.ajax_url + "/save_user_state",
"autoplay": False,
"streams": "0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg",
"sub": "a_sub_file.srt.sjson",
"sources": sources,
"captionDataDir": None,
"showCaptions": "true",
"generalSpeed": 1.0,
"speed": None,
"savedVideoPosition": 0.0,
"start": 3603.0,
"end": 3610.0,
"transcriptLanguage": "en",
"transcriptLanguages": OrderedDict({"en": "English", "uk": u"Українська"}),
"ytTestTimeout": 1500,
"ytApiUrl": "www.youtube.com/iframe_api",
"ytTestUrl": "gdata.youtube.com/feeds/api/videos/",
"transcriptTranslationUrl": self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'translation/__lang__'
).rstrip('/?'),
"transcriptAvailableTranslationsUrl": self.item_descriptor.xmodule_runtime.handler_url(
self.item_descriptor, 'transcript', 'available_translations'
).rstrip('/?'),
"autohideHtml5": False,
})),
'track': None,
'transcript_download_format': 'srt',
'transcript_download_formats_list': [
{'display_name': 'SubRip (.srt) file', 'value': 'srt'},
{'display_name': 'Text (.txt) file', 'value': 'txt'}
],
'poster': json.dumps(OrderedDict({
"url": "http://img.youtube.com/vi/ZwkTiUPN0mg/0.jpg",
"type": "youtube"
}))
}
expected_content = self.item_descriptor.xmodule_runtime.render_template('video.html', expected_context)
self.assertEqual(content, expected_content)

View File

@@ -206,7 +206,8 @@ def video_summary(video_profiles, course_id, video_descriptor, request, local_ca
size = default_encoded_video.get('file_size', 0)
# Transcripts...
transcript_langs = video_descriptor.available_translations(verify_assets=False)
transcripts_info = video_descriptor.get_transcripts_info()
transcript_langs = video_descriptor.available_translations(transcripts_info, verify_assets=False)
transcripts = {
lang: reverse(
@@ -227,7 +228,7 @@ def video_summary(video_profiles, course_id, video_descriptor, request, local_ca
"duration": duration,
"size": size,
"transcripts": transcripts,
"language": video_descriptor.get_default_transcript_language(),
"language": video_descriptor.get_default_transcript_language(transcripts_info),
"encoded_videos": video_data.get('profiles')
}
ret.update(always_available_data)

View File

@@ -119,7 +119,8 @@ class VideoTranscripts(generics.RetrieveAPIView):
)
try:
video_descriptor = modulestore().get_item(usage_key)
content, filename, mimetype = video_descriptor.get_transcript(lang=lang)
transcripts = video_descriptor.get_transcripts_info()
content, filename, mimetype = video_descriptor.get_transcript(transcripts, lang=lang)
except (NotFoundError, ValueError, KeyError):
raise Http404(u"Transcript not found for {}, lang: {}".format(block_id, lang))

View File

@@ -123,6 +123,11 @@ FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS'] = False
FEATURES['SQUELCH_PII_IN_LOGS'] = False
FEATURES['PREVENT_CONCURRENT_LOGINS'] = False
FEATURES['ADVANCED_SECURITY'] = False
FEATURES['ENABLE_MOBILE_REST_API'] = True # Show video bumper in LMS
FEATURES['ENABLE_VIDEO_BUMPER'] = True # Show video bumper in LMS
FEATURES['SHOW_BUMPER_PERIODICITY'] = 1
PASSWORD_MIN_LENGTH = None
PASSWORD_COMPLEXITY = {}

View File

@@ -392,6 +392,13 @@ FEATURES = {
# Teams feature
'ENABLE_TEAMS': False,
# Show video bumper in LMS
'ENABLE_VIDEO_BUMPER': False,
# How many seconds to show the bumper again, default is 7 days:
'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600,
}
# Ignore static asset files on import which match this pattern
@@ -1665,6 +1672,8 @@ YOUTUBE = {
'v': 'set_youtube_id_of_11_symbols_here',
},
},
'IMAGE_API': 'http://img.youtube.com/vi/{youtube_id}/0.jpg', # /maxresdefault.jpg for 1920*1080
}
################################### APPS ######################################

View File

@@ -7,47 +7,9 @@
<div
id="video_${id}"
class="video closed"
data-streams="${youtube_streams}"
% if sub:
data-sub="${sub}"
% endif
% if autoplay:
data-autoplay="${autoplay}"
% endif
data-sources='${sources}'
data-save-state-url="${ajax_url}"
data-caption-data-dir="${data_dir}"
data-show-captions="${show_captions}"
data-general-speed="${general_speed}"
data-speed="${speed}"
data-saved-video-position="${saved_video_position}"
data-start="${start}"
data-end="${end}"
data-transcript-language="${transcript_language}"
data-transcript-languages='${transcript_languages}'
data-autoplay="${autoplay}"
data-yt-test-timeout="${yt_test_timeout}"
data-yt-api-url="${yt_api_url}"
data-yt-test-url="${yt_test_url}"
data-transcript-translation-url="${transcript_translation_url}"
data-transcript-available-translations-url="${transcript_available_translations_url}"
## For now, the option "data-autohide-html5" is hard coded. This option
## either enables or disables autohiding of controls and captions on mouse
## inactivity. If set to true, controls and captions will autohide for
## HTML5 sources (non-YouTube) after a period of mouse inactivity over the
## whole video. When the mouse moves (or a key is pressed while any part of
## the video player is focused), the captions and controls will be shown
## once again.
##
## There is no option in the "Advanced Editor" to set this option. However,
## this option will have an effect if changed to "True". The code on
## front-end exists.
data-autohide-html5="False"
data-metadata='${metadata}'
data-bumper-metadata='${bumper_metadata}'
data-poster='${poster}'
tabindex="-1"
>
<div class="focus_grabber first"></div>
@@ -65,41 +27,13 @@
</section>
<div class="video-player-post"></div>
<section class="video-controls is-hidden">
<div class="slider" title="${_('Video position')}"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#" title="${_('Play')}" role="button" aria-disabled="false"></a></li>
<li><div class="vidtime">0:00 / 0:00</div></li>
</ul>
<div class="secondary-controls">
<div class="speeds menu-container">
<a class="speed-button" href="#" title="${_('Speeds')}" role="button" aria-disabled="false">
<span class="label">${_('Speed')}</span>
<span class="value"></span>
</a>
<ol class="video-speeds menu" role="menu"></ol>
</div>
<div class="volume">
<a href="#" role="button" aria-disabled="false" title="${_('Volume')}" aria-label="${_('Click on this button to mute or unmute this video or press UP or DOWN buttons to increase or decrease volume level.')}"></a>
<div role="presentation" class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
<a href="#" class="add-fullscreen" title="${_('Fill browser')}" role="button" aria-disabled="false">${_('Fill browser')}</a>
<a href="#" class="quality-control is-hidden" title="${_('HD off')}" role="button" aria-disabled="false">${_('HD off')}</a>
<div class="lang menu-container">
<a href="#" class="hide-subtitles" title="${_('Turn off captions')}" role="button" aria-disabled="false">${_('Turn off captions')}</a>
</div>
</div>
<div class="vcr"><div class="vidtime">0:00 / 0:00</div></div>
<div class="secondary-controls"></div>
</div>
</section>
<a class="nav-skip sr" id="before-transcript_${id}" href="#after-transcript_${id}">${_('Skip to end of transcript.')}</a>
</article>
<ol id="transcript-captions" class="subtitles" tabindex="0" role="group" aria-label="${_('Activating an item in this group will spool the video to the corresponding time point. To skip transcript, go to previous item.')}">
<li></li>
</ol>
</div>
<a class="nav-skip sr" id="after-transcript_${id}" href="#before-transcript_${id}">${_('Go back to start of transcript.')}</a>
@@ -116,8 +50,8 @@
% if transcript_download_format:
<a href="${track}">${_('Download transcript')}</a>
<div class="a11y-menu-container">
<a class="a11y-menu-button" href="#" title="${'.' + transcript_download_format}">${'.' + transcript_download_format}</a>
<ol class="a11y-menu-list">
<a class="a11y-menu-button" href="#" title="${'.' + transcript_download_format}" role="button" aria-disabled="false">${'.' + transcript_download_format}</a>
<ol class="a11y-menu-list" role="menu">
% for item in transcript_download_formats_list:
% if item['value'] == transcript_download_format:
<li class="a11y-menu-item active">
@@ -126,7 +60,7 @@
% endif
## This is necessary so we don't scrape 'display_name' as a string.
<% dname = item['display_name'] %>
<a class="a11y-menu-item-link" href="#${item['value']}" title="${_(dname)}" data-value="${item['value']}">
<a class="a11y-menu-item-link" href="#${item['value']}" title="${_(dname)}" data-value="${item['value']}" role="menuitem" aria-disabled="false">
${_(dname)}
</a>
</li>