diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py
index 0a9c05f3ec..6b7114c439 100644
--- a/common/lib/xmodule/setup.py
+++ b/common/lib/xmodule/setup.py
@@ -37,6 +37,7 @@ setup(
"timelimit = xmodule.timelimit_module:TimeLimitDescriptor",
"vertical = xmodule.vertical_module:VerticalDescriptor",
"video = xmodule.video_module:VideoDescriptor",
+ "videoalpha = xmodule.videoalpha_module:VideoAlphaDescriptor",
"videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"videosequence = xmodule.seq_module:SequenceDescriptor",
"discussion = xmodule.discussion_module:DiscussionDescriptor",
diff --git a/common/lib/xmodule/xmodule/css/videoalpha/display.scss b/common/lib/xmodule/xmodule/css/videoalpha/display.scss
new file mode 100644
index 0000000000..bf575e74a3
--- /dev/null
+++ b/common/lib/xmodule/xmodule/css/videoalpha/display.scss
@@ -0,0 +1,559 @@
+& {
+ margin-bottom: 30px;
+}
+
+div.video {
+ @include clearfix();
+ background: #f3f3f3;
+ display: block;
+ margin: 0 -12px;
+ padding: 12px;
+ border-radius: 5px;
+
+ article.video-wrapper {
+ float: left;
+ margin-right: flex-gutter(9);
+ width: flex-grid(6, 9);
+
+ section.video-player {
+ height: 0;
+ overflow: hidden;
+ padding-bottom: 56.25%;
+ position: relative;
+
+ object, iframe {
+ border: none;
+ height: 100%;
+ left: 0;
+ position: absolute;
+ top: 0;
+ width: 100%;
+ }
+ }
+
+ section.video-controls {
+ @include clearfix();
+ background: #333;
+ border: 1px solid #000;
+ border-top: 0;
+ color: #ccc;
+ position: relative;
+
+ &:hover {
+ ul, div {
+ opacity: 1;
+ }
+ }
+
+ div.slider {
+ @include clearfix();
+ background: #c2c2c2;
+ border: 1px solid #000;
+ @include border-radius(0);
+ border-top: 1px solid #000;
+ @include box-shadow(inset 0 1px 0 #eee, 0 1px 0 #555);
+ height: 7px;
+ margin-left: -1px;
+ margin-right: -1px;
+ @include transition(height 2.0s ease-in-out);
+
+ div.ui-widget-header {
+ background: #777;
+ @include box-shadow(inset 0 1px 0 #999);
+ }
+
+ a.ui-slider-handle {
+ background: $pink url(../images/slider-handle.png) center center no-repeat;
+ @include background-size(50%);
+ border: 1px solid darken($pink, 20%);
+ @include border-radius(15px);
+ @include box-shadow(inset 0 1px 0 lighten($pink, 10%));
+ cursor: pointer;
+ height: 15px;
+ margin-left: -7px;
+ top: -4px;
+ @include transition(height 2.0s ease-in-out, width 2.0s ease-in-out);
+ width: 15px;
+
+ &:focus, &:hover {
+ background-color: lighten($pink, 10%);
+ outline: none;
+ }
+ }
+ }
+
+ ul.vcr {
+ @extend .dullify;
+ float: left;
+ list-style: none;
+ margin: 0 lh() 0 0;
+ padding: 0;
+
+ li {
+ float: left;
+ margin-bottom: 0;
+
+ a {
+ border-bottom: none;
+ border-right: 1px solid #000;
+ @include box-shadow(1px 0 0 #555);
+ cursor: pointer;
+ display: block;
+ line-height: 46px;
+ padding: 0 lh(.75);
+ text-indent: -9999px;
+ @include transition(background-color, opacity);
+ width: 14px;
+ background: url('../images/vcr.png') 15px 15px no-repeat;
+ outline: 0;
+
+ &:focus {
+ outline: 0;
+ }
+
+ &:empty {
+ height: 46px;
+ background: url('../images/vcr.png') 15px 15px no-repeat;
+ }
+
+ &.play {
+ background-position: 17px -114px;
+
+ &:hover {
+ background-color: #444;
+ }
+ }
+
+ &.pause {
+ background-position: 16px -50px;
+
+ &:hover {
+ background-color: #444;
+ }
+ }
+ }
+
+ div.vidtime {
+ padding-left: lh(.75);
+ font-weight: bold;
+ line-height: 46px; //height of play pause buttons
+ padding-left: lh(.75);
+ -webkit-font-smoothing: antialiased;
+ }
+ }
+ }
+
+ div.secondary-controls {
+ @extend .dullify;
+ float: right;
+
+ div.speeds {
+ float: left;
+ position: relative;
+
+ &.open {
+ &>a {
+ background: url('../images/open-arrow.png') 10px center no-repeat;
+ }
+
+ ol.video_speeds {
+ display: block;
+ opacity: 1;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
+ }
+
+ &>a {
+ background: url('../images/closed-arrow.png') 10px center no-repeat;
+ border-left: 1px solid #000;
+ border-right: 1px solid #000;
+ @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555);
+ @include clearfix();
+ color: #fff;
+ cursor: pointer;
+ display: block;
+ line-height: 46px; //height of play pause buttons
+ margin-right: 0;
+ padding-left: 15px;
+ position: relative;
+ @include transition();
+ -webkit-font-smoothing: antialiased;
+ width: 116px;
+ outline: 0;
+
+ &:focus {
+ outline: 0;
+ }
+
+ h3 {
+ color: #999;
+ float: left;
+ font-size: em(14);
+ font-weight: normal;
+ letter-spacing: 1px;
+ padding: 0 lh(.25) 0 lh(.5);
+ line-height: 46px;
+ text-transform: uppercase;
+ }
+
+ p.active {
+ float: left;
+ font-weight: bold;
+ margin-bottom: 0;
+ padding: 0 lh(.5) 0 0;
+ line-height: 46px;
+ color: #fff;
+ }
+
+ &:hover, &:active, &:focus {
+ opacity: 1;
+ background-color: #444;
+ }
+ }
+
+ // fix for now
+ ol.video_speeds {
+ @include box-shadow(inset 1px 0 0 #555, 0 3px 0 #444);
+ @include transition();
+ background-color: #444;
+ border: 1px solid #000;
+ bottom: 46px;
+ display: none;
+ opacity: 0;
+ position: absolute;
+ width: 133px;
+ z-index: 10;
+
+ li {
+ @include box-shadow( 0 1px 0 #555);
+ border-bottom: 1px solid #000;
+ color: #fff;
+ cursor: pointer;
+
+ a {
+ border: 0;
+ color: #fff;
+ display: block;
+ padding: lh(.5);
+
+ &:hover {
+ background-color: #666;
+ color: #aaa;
+ }
+ }
+
+ &.active {
+ font-weight: bold;
+ }
+
+ &:last-child {
+ @include box-shadow(none);
+ border-bottom: 0;
+ margin-top: 0;
+ }
+ }
+ }
+ }
+
+ div.volume {
+ float: left;
+ position: relative;
+
+ &.open {
+ .volume-slider-container {
+ display: block;
+ opacity: 1;
+ }
+ }
+
+ &.muted {
+ &>a {
+ background: url('../images/mute.png') 10px center no-repeat;
+ }
+ }
+
+ > a {
+ background: url('../images/volume.png') 10px center no-repeat;
+ border-right: 1px solid #000;
+ @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555);
+ @include clearfix();
+ color: #fff;
+ cursor: pointer;
+ display: block;
+ height: 46px;
+ margin-right: 0;
+ padding-left: 15px;
+ position: relative;
+ @include transition();
+ -webkit-font-smoothing: antialiased;
+ width: 30px;
+
+ &:hover, &:active, &:focus {
+ background-color: #444;
+ }
+ }
+
+ .volume-slider-container {
+ @include box-shadow(inset 1px 0 0 #555, 0 3px 0 #444);
+ @include transition();
+ background-color: #444;
+ border: 1px solid #000;
+ bottom: 46px;
+ display: none;
+ opacity: 0;
+ position: absolute;
+ width: 45px;
+ height: 125px;
+ margin-left: -1px;
+ z-index: 10;
+
+ .volume-slider {
+ height: 100px;
+ border: 0;
+ width: 5px;
+ margin: 14px auto;
+ background: #666;
+ border: 1px solid #000;
+ @include box-shadow(0 1px 0 #333);
+
+ a.ui-slider-handle {
+ background: $pink url(../images/slider-handle.png) center center no-repeat;
+ @include background-size(50%);
+ border: 1px solid darken($pink, 20%);
+ @include border-radius(15px);
+ @include box-shadow(inset 0 1px 0 lighten($pink, 10%));
+ cursor: pointer;
+ height: 15px;
+ left: -6px;
+ @include transition(height 2.0s ease-in-out, width 2.0s ease-in-out);
+ width: 15px;
+ }
+
+ .ui-slider-range {
+ background: #ddd;
+ }
+ }
+ }
+ }
+
+ a.add-fullscreen {
+ background: url(../images/fullscreen.png) center no-repeat;
+ border-right: 1px solid #000;
+ @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555);
+ color: #797979;
+ display: block;
+ float: left;
+ line-height: 46px; //height of play pause buttons
+ margin-left: 0;
+ padding: 0 lh(.5);
+ text-indent: -9999px;
+ @include transition();
+ width: 30px;
+
+ &:hover {
+ background-color: #444;
+ color: #fff;
+ text-decoration: none;
+ }
+ }
+
+ a.quality_control {
+ background: url(../images/hd.png) center no-repeat;
+ border-right: 1px solid #000;
+ @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555);
+ color: #797979;
+ display: block;
+ float: left;
+ line-height: 46px; //height of play pause buttons
+ margin-left: 0;
+ padding: 0 lh(.5);
+ text-indent: -9999px;
+ @include transition();
+ width: 30px;
+
+ &:hover {
+ background-color: #444;
+ color: #fff;
+ text-decoration: none;
+ }
+
+ &.active {
+ background-color: #F44;
+ color: #0ff;
+ text-decoration: none;
+ }
+ }
+
+
+ a.hide-subtitles {
+ background: url('../images/cc.png') center no-repeat;
+ color: #797979;
+ display: block;
+ float: left;
+ font-weight: 800;
+ line-height: 46px; //height of play pause buttons
+ margin-left: 0;
+ opacity: 1;
+ padding: 0 lh(.5);
+ position: relative;
+ text-indent: -9999px;
+ @include transition();
+ -webkit-font-smoothing: antialiased;
+ width: 30px;
+
+ &:hover {
+ background-color: #444;
+ color: #fff;
+ text-decoration: none;
+ }
+
+ &.off {
+ opacity: .7;
+ }
+ }
+ }
+ }
+
+ &:hover section.video-controls {
+ ul, div {
+ opacity: 1;
+ }
+
+ div.slider {
+ height: 14px;
+ margin-top: -7px;
+
+ a.ui-slider-handle {
+ @include border-radius(20px);
+ height: 20px;
+ margin-left: -10px;
+ top: -4px;
+ width: 20px;
+ }
+ }
+ }
+ }
+
+ ol.subtitles {
+ padding-left: 0;
+ float: left;
+ max-height: 460px;
+ overflow: auto;
+ width: flex-grid(3, 9);
+ margin: 0;
+ font-size: 14px;
+ list-style: none;
+
+ li {
+ border: 0;
+ color: #666;
+ cursor: pointer;
+ margin-bottom: 8px;
+ padding: 0;
+ line-height: lh();
+
+ &.current {
+ color: #333;
+ font-weight: 700;
+ }
+
+ &:hover {
+ color: $blue;
+ }
+
+ &:empty {
+ margin-bottom: 0px;
+ }
+ }
+ }
+
+ &.closed {
+ @extend .trans;
+
+ article.video-wrapper {
+ width: flex-grid(9,9);
+ }
+
+ ol.subtitles {
+ width: 0;
+ height: 0;
+ }
+ }
+
+ &.fullscreen {
+ background: rgba(#000, .95);
+ border: 0;
+ bottom: 0;
+ height: 100%;
+ left: 0;
+ margin: 0;
+ overflow: hidden;
+ padding: 0;
+ position: fixed;
+ top: 0;
+ width: 100%;
+ z-index: 999;
+ vertical-align: middle;
+
+ &.closed {
+ ol.subtitles {
+ right: -(flex-grid(4));
+ width: auto;
+ }
+ }
+
+ div.tc-wrapper {
+ @include clearfix;
+ display: table;
+ width: 100%;
+ height: 100%;
+
+ article.video-wrapper {
+ width: 100%;
+ display: table-cell;
+ vertical-align: middle;
+ float: none;
+ }
+
+ object, iframe {
+ bottom: 0;
+ height: 100%;
+ left: 0;
+ overflow: hidden;
+ position: fixed;
+ top: 0;
+ }
+
+ section.video-controls {
+ bottom: 0;
+ left: 0;
+ position: absolute;
+ width: 100%;
+ z-index: 9999;
+ }
+ }
+
+ ol.subtitles {
+ background: rgba(#000, .8);
+ bottom: 0;
+ height: 100%;
+ max-height: 100%;
+ max-width: flex-grid(3);
+ padding: lh();
+ position: fixed;
+ right: 0;
+ top: 0;
+ @include transition();
+
+ li {
+ color: #aaa;
+
+ &.current {
+ color: #fff;
+ }
+ }
+ }
+ }
+}
diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display.coffee
new file mode 100644
index 0000000000..1876330340
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display.coffee
@@ -0,0 +1,63 @@
+class @Video
+ constructor: (element) ->
+ @el = $(element).find('.video')
+ @id = @el.attr('id').replace(/video_/, '')
+ @start = @el.data('start')
+ @end = @el.data('end')
+ @caption_data_dir = @el.data('caption-data-dir')
+ @caption_asset_path = @el.data('caption-asset-path')
+ @show_captions = @el.data('show-captions') == "true"
+ window.player = null
+ @el = $("#video_#{@id}")
+ @parseVideos @el.data('streams')
+ @fetchMetadata()
+ @parseSpeed()
+ $("#video_#{@id}").data('video', this).addClass('video-load-complete')
+
+ @hide_captions = $.cookie('hide_captions') == 'true'
+
+ if YT.Player
+ @embed()
+ else
+ window.onYouTubePlayerAPIReady = =>
+ @el.each ->
+ $(this).data('video').embed()
+
+ youtubeId: (speed)->
+ @videos[speed || @speed]
+
+ parseVideos: (videos) ->
+ @videos = {}
+ $.each videos.split(/,/), (index, video) =>
+ video = video.split(/:/)
+ speed = parseFloat(video[0]).toFixed(2).replace /\.00$/, '.0'
+ @videos[speed] = video[1]
+
+ parseSpeed: ->
+ @setSpeed($.cookie('video_speed'))
+ @speeds = ($.map @videos, (url, speed) -> speed).sort()
+
+ setSpeed: (newSpeed) ->
+ if @videos[newSpeed] != undefined
+ @speed = newSpeed
+ $.cookie('video_speed', "#{newSpeed}", expires: 3650, path: '/')
+ else
+ @speed = '1.0'
+
+ embed: ->
+ @player = new VideoPlayer video: this
+
+ fetchMetadata: (url) ->
+ @metadata = {}
+ $.each @videos, (speed, url) =>
+ $.get "https://gdata.youtube.com/feeds/api/videos/#{url}?v=2&alt=jsonc", ((data) => @metadata[data.data.id] = data.data) , 'jsonp'
+
+ getDuration: ->
+ @metadata[@youtubeId()].duration
+
+ log: (eventName) ->
+ Logger.log eventName,
+ id: @id
+ code: @youtubeId()
+ currentTime: @player.currentTime
+ speed: @speed
diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/_subview.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/_subview.coffee
new file mode 100644
index 0000000000..2e14289843
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/_subview.coffee
@@ -0,0 +1,14 @@
+class @Subview
+ constructor: (options) ->
+ $.each options, (key, value) =>
+ @[key] = value
+ @initialize()
+ @render()
+ @bind()
+
+ $: (selector) ->
+ $(selector, @el)
+
+ initialize: ->
+ render: ->
+ bind: ->
diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_caption.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_caption.coffee
new file mode 100644
index 0000000000..e840cd2a77
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_caption.coffee
@@ -0,0 +1,152 @@
+class @VideoCaption extends Subview
+ initialize: ->
+ @loaded = false
+
+ bind: ->
+ $(window).bind('resize', @resize)
+ @$('.hide-subtitles').click @toggle
+ @$('.subtitles').mouseenter(@onMouseEnter).mouseleave(@onMouseLeave)
+ .mousemove(@onMovement).bind('mousewheel', @onMovement)
+ .bind('DOMMouseScroll', @onMovement)
+
+ captionURL: ->
+ "#{@captionAssetPath}#{@youtubeId}.srt.sjson"
+
+ render: ->
+ # TODO: make it so you can have a video with no captions.
+ #@$('.video-wrapper').after """
+ #
- Attempting to load captions...
+ # """
+ @$('.video-wrapper').after """
+
+ """
+ @$('.video-controls .secondary-controls').append """
+ Captions
+ """#"
+ @$('.subtitles').css maxHeight: @$('.video-wrapper').height() - 5
+ @fetchCaption()
+
+ fetchCaption: ->
+ $.getWithPrefix @captionURL(), (captions) =>
+ @captions = captions.text
+ @start = captions.start
+
+ @loaded = true
+
+ if onTouchBasedDevice()
+ $('.subtitles li').html "Caption will be displayed when you start playing the video."
+ else
+ @renderCaption()
+
+ renderCaption: ->
+ container = $('')
+
+ $.each @captions, (index, text) =>
+ container.append $('- ').html(text).attr
+ 'data-index': index
+ 'data-start': @start[index]
+
+ @$('.subtitles').html(container.html())
+ @$('.subtitles li[data-index]').click @seekPlayer
+
+ # prepend and append an empty
- for cosmetic reason
+ @$('.subtitles').prepend($('
- ').height(@topSpacingHeight()))
+ .append($('
- ').height(@bottomSpacingHeight()))
+
+ @rendered = true
+
+ search: (time) ->
+ if @loaded
+ min = 0
+ max = @start.length - 1
+
+ while min < max
+ index = Math.ceil((max + min) / 2)
+ if time < @start[index]
+ max = index - 1
+ if time >= @start[index]
+ min = index
+ return min
+
+ play: ->
+ if @loaded
+ @renderCaption() unless @rendered
+ @playing = true
+
+ pause: ->
+ if @loaded
+ @playing = false
+
+ updatePlayTime: (time) ->
+ if @loaded
+ # This 250ms offset is required to match the video speed
+ time = Math.round(Time.convert(time, @currentSpeed, '1.0') * 1000 + 250)
+ newIndex = @search time
+
+ if newIndex != undefined && @currentIndex != newIndex
+ if @currentIndex
+ @$(".subtitles li.current").removeClass('current')
+ @$(".subtitles li[data-index='#{newIndex}']").addClass('current')
+
+ @currentIndex = newIndex
+ @scrollCaption()
+
+ resize: =>
+ @$('.subtitles').css maxHeight: @captionHeight()
+ @$('.subtitles .spacing:first').height(@topSpacingHeight())
+ @$('.subtitles .spacing:last').height(@bottomSpacingHeight())
+ @scrollCaption()
+
+ onMouseEnter: =>
+ clearTimeout @frozen if @frozen
+ @frozen = setTimeout @onMouseLeave, 10000
+
+ onMovement: =>
+ @onMouseEnter()
+
+ onMouseLeave: =>
+ clearTimeout @frozen if @frozen
+ @frozen = null
+ @scrollCaption() if @playing
+
+ scrollCaption: ->
+ if !@frozen && @$('.subtitles .current:first').length
+ @$('.subtitles').scrollTo @$('.subtitles .current:first'),
+ offset: - @calculateOffset(@$('.subtitles .current:first'))
+
+ seekPlayer: (event) =>
+ event.preventDefault()
+ time = Math.round(Time.convert($(event.target).data('start'), '1.0', @currentSpeed) / 1000)
+ $(@).trigger('seek', time)
+
+ calculateOffset: (element) ->
+ @captionHeight() / 2 - element.height() / 2
+
+ topSpacingHeight: ->
+ @calculateOffset(@$('.subtitles li:not(.spacing):first'))
+
+ bottomSpacingHeight: ->
+ @calculateOffset(@$('.subtitles li:not(.spacing):last'))
+
+ toggle: (event) =>
+ event.preventDefault()
+ if @el.hasClass('closed') # Captions are "closed" e.g. turned off
+ @hideCaptions(false)
+ else # Captions are on
+ @hideCaptions(true)
+
+ hideCaptions: (hide_captions) =>
+ if hide_captions
+ @$('.hide-subtitles').attr('title', 'Turn on captions')
+ @el.addClass('closed')
+ else
+ @$('.hide-subtitles').attr('title', 'Turn off captions')
+ @el.removeClass('closed')
+ @scrollCaption()
+ $.cookie('hide_captions', hide_captions, expires: 3650, path: '/')
+
+ captionHeight: ->
+ if @el.hasClass('fullscreen')
+ $(window).height() - @$('.video-controls').height()
+ else
+ @$('.video-wrapper').height()
diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_control.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_control.coffee
new file mode 100644
index 0000000000..856549c3e2
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_control.coffee
@@ -0,0 +1,35 @@
+class @VideoControl extends Subview
+ bind: ->
+ @$('.video_control').click @togglePlayback
+
+ render: ->
+ @el.append """
+
+
+ """#"
+
+ unless onTouchBasedDevice()
+ @$('.video_control').addClass('play').html('Play')
+
+ play: ->
+ @$('.video_control').removeClass('play').addClass('pause').html('Pause')
+
+ pause: ->
+ @$('.video_control').removeClass('pause').addClass('play').html('Play')
+
+ togglePlayback: (event) =>
+ event.preventDefault()
+ if @$('.video_control').hasClass('play')
+ $(@).trigger('play')
+ else if @$('.video_control').hasClass('pause')
+ $(@).trigger('pause')
diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_player.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_player.coffee
new file mode 100644
index 0000000000..22308a5568
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_player.coffee
@@ -0,0 +1,180 @@
+class @VideoPlayer extends Subview
+ initialize: ->
+ # Define a missing constant of Youtube API
+ YT.PlayerState.UNSTARTED = -1
+
+ @currentTime = 0
+ @el = $("#video_#{@video.id}")
+
+ bind: ->
+ $(@control).bind('play', @play)
+ .bind('pause', @pause)
+ $(@qualityControl).bind('changeQuality', @handlePlaybackQualityChange)
+ $(@caption).bind('seek', @onSeek)
+ $(@speedControl).bind('speedChange', @onSpeedChange)
+ $(@progressSlider).bind('seek', @onSeek)
+ if @volumeControl
+ $(@volumeControl).bind('volumeChange', @onVolumeChange)
+ $(document).keyup @bindExitFullScreen
+
+ @$('.add-fullscreen').click @toggleFullScreen
+ @addToolTip() unless onTouchBasedDevice()
+
+ bindExitFullScreen: (event) =>
+ if @el.hasClass('fullscreen') && event.keyCode == 27
+ @toggleFullScreen(event)
+
+ render: ->
+ @control = new VideoControl el: @$('.video-controls')
+ @qualityControl = new VideoQualityControl el: @$('.secondary-controls')
+ @caption = new VideoCaption
+ el: @el
+ youtubeId: @video.youtubeId('1.0')
+ currentSpeed: @currentSpeed()
+ captionAssetPath: @video.caption_asset_path
+ unless onTouchBasedDevice()
+ @volumeControl = new VideoVolumeControl el: @$('.secondary-controls')
+ @speedControl = new VideoSpeedControl el: @$('.secondary-controls'), speeds: @video.speeds, currentSpeed: @currentSpeed()
+ @progressSlider = new VideoProgressSlider el: @$('.slider')
+ @playerVars =
+ controls: 0
+ wmode: 'transparent'
+ rel: 0
+ showinfo: 0
+ enablejsapi: 1
+ modestbranding: 1
+ if @video.start
+ @playerVars.start = @video.start
+ @playerVars.wmode = 'window'
+ if @video.end
+ # work in AS3, not HMLT5. but iframe use AS3
+ @playerVars.end = @video.end
+
+ @player = new YT.Player @video.id,
+ playerVars: @playerVars
+ videoId: @video.youtubeId()
+ events:
+ onReady: @onReady
+ onStateChange: @onStateChange
+ onPlaybackQualityChange: @onPlaybackQualityChange
+ @caption.hideCaptions(@['video'].hide_captions)
+
+ addToolTip: ->
+ @$('.add-fullscreen, .hide-subtitles').qtip
+ position:
+ my: 'top right'
+ at: 'top center'
+
+ onReady: (event) =>
+ unless onTouchBasedDevice()
+ $('.video-load-complete:first').data('video').player.play()
+
+ onStateChange: (event) =>
+ switch event.data
+ when YT.PlayerState.UNSTARTED
+ @onUnstarted()
+ when YT.PlayerState.PLAYING
+ @onPlay()
+ when YT.PlayerState.PAUSED
+ @onPause()
+ when YT.PlayerState.ENDED
+ @onEnded()
+
+ onPlaybackQualityChange: (event, value) =>
+ quality = @player.getPlaybackQuality()
+ @qualityControl.onQualityChange(quality)
+
+ handlePlaybackQualityChange: (event, value) =>
+ @player.setPlaybackQuality(value)
+
+ onUnstarted: =>
+ @control.pause()
+ @caption.pause()
+
+ onPlay: =>
+ @video.log 'play_video'
+ window.player.pauseVideo() if window.player && window.player != @player
+ window.player = @player
+ unless @player.interval
+ @player.interval = setInterval(@update, 200)
+ @caption.play()
+ @control.play()
+ @progressSlider.play()
+
+ onPause: =>
+ @video.log 'pause_video'
+ window.player = null if window.player == @player
+ clearInterval(@player.interval)
+ @player.interval = null
+ @caption.pause()
+ @control.pause()
+
+ onEnded: =>
+ @control.pause()
+ @caption.pause()
+
+ onSeek: (event, time) =>
+ @player.seekTo(time, true)
+ if @isPlaying()
+ clearInterval(@player.interval)
+ @player.interval = setInterval(@update, 200)
+ else
+ @currentTime = time
+ @updatePlayTime time
+
+ onSpeedChange: (event, newSpeed) =>
+ @currentTime = Time.convert(@currentTime, parseFloat(@currentSpeed()), newSpeed)
+ newSpeed = parseFloat(newSpeed).toFixed(2).replace /\.00$/, '.0'
+ @video.setSpeed(newSpeed)
+ @caption.currentSpeed = newSpeed
+
+ if @isPlaying()
+ @player.loadVideoById(@video.youtubeId(), @currentTime)
+ else
+ @player.cueVideoById(@video.youtubeId(), @currentTime)
+ @updatePlayTime @currentTime
+
+ onVolumeChange: (event, volume) =>
+ @player.setVolume volume
+
+ update: =>
+ if @currentTime = @player.getCurrentTime()
+ @updatePlayTime @currentTime
+
+ updatePlayTime: (time) ->
+ progress = Time.format(time) + ' / ' + Time.format(@duration())
+ @$(".vidtime").html(progress)
+ @caption.updatePlayTime(time)
+ @progressSlider.updatePlayTime(time, @duration())
+
+ toggleFullScreen: (event) =>
+ event.preventDefault()
+ if @el.hasClass('fullscreen')
+ @$('.add-fullscreen').attr('title', 'Fill browser')
+ @el.removeClass('fullscreen')
+ else
+ @el.addClass('fullscreen')
+ @$('.add-fullscreen').attr('title', 'Exit fill browser')
+ @caption.resize()
+
+ # Delegates
+ play: =>
+ @player.playVideo() if @player.playVideo
+
+ isPlaying: ->
+ @player.getPlayerState() == YT.PlayerState.PLAYING
+
+ pause: =>
+ @player.pauseVideo() if @player.pauseVideo
+
+ duration: ->
+ @video.getDuration()
+
+ currentSpeed: ->
+ @video.speed
+
+ volume: (value) ->
+ if value?
+ @player.setVolume value
+ else
+ @player.getVolume()
diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_progress_slider.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_progress_slider.coffee
new file mode 100644
index 0000000000..874756cb71
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_progress_slider.coffee
@@ -0,0 +1,49 @@
+class @VideoProgressSlider extends Subview
+ initialize: ->
+ @buildSlider() unless onTouchBasedDevice()
+
+ buildSlider: ->
+ @slider = @el.slider
+ range: 'min'
+ change: @onChange
+ slide: @onSlide
+ stop: @onStop
+ @buildHandle()
+
+ buildHandle: ->
+ @handle = @$('.slider .ui-slider-handle')
+ @handle.qtip
+ content: "#{Time.format(@slider.slider('value'))}"
+ position:
+ my: 'bottom center'
+ at: 'top center'
+ container: @handle
+ hide:
+ delay: 700
+ style:
+ classes: 'ui-tooltip-slider'
+ widget: true
+
+ play: =>
+ @buildSlider() unless @slider
+
+ updatePlayTime: (currentTime, duration) ->
+ if @slider && !@frozen
+ @slider.slider('option', 'max', duration)
+ @slider.slider('value', currentTime)
+
+ onSlide: (event, ui) =>
+ @frozen = true
+ @updateTooltip(ui.value)
+ $(@).trigger('seek', ui.value)
+
+ onChange: (event, ui) =>
+ @updateTooltip(ui.value)
+
+ onStop: (event, ui) =>
+ @frozen = true
+ $(@).trigger('seek', ui.value)
+ setTimeout (=> @frozen = false), 200
+
+ updateTooltip: (value)->
+ @handle.qtip('option', 'content.text', "#{Time.format(value)}")
diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_quality_control.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_quality_control.coffee
new file mode 100644
index 0000000000..f8f6167075
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_quality_control.coffee
@@ -0,0 +1,26 @@
+class @VideoQualityControl extends Subview
+ initialize: ->
+ @quality = null;
+
+ bind: ->
+ @$('.quality_control').click @toggleQuality
+
+ render: ->
+ @el.append """
+ HD
+ """#"
+
+ onQualityChange: (value) ->
+ @quality = value
+ if @quality in ['hd720', 'hd1080', 'highres']
+ @el.addClass('active')
+ else
+ @el.removeClass('active')
+
+ toggleQuality: (event) =>
+ event.preventDefault()
+ if @quality in ['hd720', 'hd1080', 'highres']
+ newQuality = 'large'
+ else
+ newQuality = 'hd720'
+ $(@).trigger('changeQuality', newQuality)
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_speed_control.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_speed_control.coffee
new file mode 100644
index 0000000000..1d0d8b7d44
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_speed_control.coffee
@@ -0,0 +1,43 @@
+class @VideoSpeedControl extends Subview
+ bind: ->
+ @$('.video_speeds a').click @changeVideoSpeed
+ if onTouchBasedDevice()
+ @$('.speeds').click (event) ->
+ event.preventDefault()
+ $(this).toggleClass('open')
+ else
+ @$('.speeds').mouseenter ->
+ $(this).addClass('open')
+ @$('.speeds').mouseleave ->
+ $(this).removeClass('open')
+ @$('.speeds').click (event) ->
+ event.preventDefault()
+ $(this).removeClass('open')
+
+ render: ->
+ @el.prepend """
+
+ """
+
+ $.each @speeds, (index, speed) =>
+ link = $('').attr(href: "#").html("#{speed}x")
+ @$('.video_speeds').prepend($('
- ').attr('data-speed', speed).html(link))
+ @setSpeed(@currentSpeed)
+
+ changeVideoSpeed: (event) =>
+ event.preventDefault()
+ unless $(event.target).parent().hasClass('active')
+ @currentSpeed = $(event.target).parent().data('speed')
+ $(@).trigger 'speedChange', $(event.target).parent().data('speed')
+ @setSpeed(parseFloat(@currentSpeed).toFixed(2).replace /\.00$/, '.0')
+
+ setSpeed: (speed) ->
+ @$('.video_speeds li').removeClass('active')
+ @$(".video_speeds li[data-speed='#{speed}']").addClass('active')
+ @$('.speeds p.active').html("#{speed}x")
diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_volume_control.coffee b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_volume_control.coffee
new file mode 100644
index 0000000000..096b50042d
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/video_volume_control.coffee
@@ -0,0 +1,40 @@
+class @VideoVolumeControl extends Subview
+ initialize: ->
+ @currentVolume = 100
+
+ bind: ->
+ @$('.volume').mouseenter ->
+ $(this).addClass('open')
+ @$('.volume').mouseleave ->
+ $(this).removeClass('open')
+ @$('.volume>a').click(@toggleMute)
+
+ render: ->
+ @el.prepend """
+
+ """#"
+ @slider = @$('.volume-slider').slider
+ orientation: "vertical"
+ range: "min"
+ min: 0
+ max: 100
+ value: 100
+ change: @onChange
+ slide: @onChange
+
+ onChange: (event, ui) =>
+ @currentVolume = ui.value
+ $(@).trigger 'volumeChange', @currentVolume
+ @$('.volume').toggleClass 'muted', @currentVolume == 0
+
+ toggleMute: =>
+ if @currentVolume > 0
+ @previousVolume = @currentVolume
+ @slider.slider 'option', 'value', 0
+ else
+ @slider.slider 'option', 'value', @previousVolume
diff --git a/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml b/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml
new file mode 100644
index 0000000000..69ed22cc1e
--- /dev/null
+++ b/common/lib/xmodule/xmodule/templates/videoalpha/default.yaml
@@ -0,0 +1,7 @@
+---
+metadata:
+ display_name: default
+ data_dir: a_made_up_name
+data: |
+
+children: []
diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py
new file mode 100644
index 0000000000..e41f9783e4
--- /dev/null
+++ b/common/lib/xmodule/xmodule/videoalpha_module.py
@@ -0,0 +1,150 @@
+import json
+import logging
+
+from lxml import etree
+from pkg_resources import resource_string, resource_listdir
+
+from xmodule.x_module import XModule
+from xmodule.raw_module import RawDescriptor
+from xmodule.modulestore.mongo import MongoModuleStore
+from xmodule.modulestore.django import modulestore
+from xmodule.contentstore.content import StaticContent
+
+import datetime
+import time
+
+import datetime
+import time
+
+log = logging.getLogger(__name__)
+
+
+class VideoModule(XModule):
+ video_time = 0
+ icon_class = 'video'
+
+ js = {'coffee':
+ [resource_string(__name__, 'js/src/time.coffee'),
+ resource_string(__name__, 'js/src/videoalpha/display.coffee')] +
+ [resource_string(__name__, 'js/src/videoalpha/display/' + filename)
+ for filename
+ in sorted(resource_listdir(__name__, 'js/src/videoalpha/display'))
+ if filename.endswith('.coffee')]}
+ css = {'scss': [resource_string(__name__, 'css/videoalpha/display.scss')]}
+ js_module_name = "Video"
+
+ def __init__(self, system, location, definition, descriptor,
+ instance_state=None, shared_state=None, **kwargs):
+ XModule.__init__(self, system, location, definition, descriptor,
+ instance_state, shared_state, **kwargs)
+ xmltree = etree.fromstring(self.definition['data'])
+ self.youtube = xmltree.get('youtube')
+ self.position = 0
+ self.show_captions = xmltree.get('show_captions', 'true')
+ self.source = self._get_source(xmltree)
+ self.track = self._get_track(xmltree)
+ self.start_time, self.end_time = self._get_timeframe(xmltree)
+
+ if instance_state is not None:
+ state = json.loads(instance_state)
+ if 'position' in state:
+ self.position = int(float(state['position']))
+
+ def _get_source(self, xmltree):
+ # find the first valid source
+ return self._get_first_external(xmltree, 'source')
+
+ def _get_track(self, xmltree):
+ # find the first valid track
+ return self._get_first_external(xmltree, 'track')
+
+ def _get_first_external(self, xmltree, tag):
+ """
+ Will return the first valid element
+ of the given tag.
+ 'valid' means has a non-empty 'src' attribute
+ """
+ result = None
+ for element in xmltree.findall(tag):
+ src = element.get('src')
+ if src:
+ result = src
+ break
+ return result
+
+ def _get_timeframe(self, xmltree):
+ """ Converts 'from' and 'to' parameters in video tag to seconds.
+ If there are no parameters, returns empty string. """
+
+ def parse_time(s):
+ """Converts s in '12:34:45' format to seconds. If s is
+ None, returns empty string"""
+ if s is None:
+ return ''
+ else:
+ x = time.strptime(s, '%H:%M:%S')
+ return datetime.timedelta(hours=x.tm_hour,
+ minutes=x.tm_min,
+ seconds=x.tm_sec).total_seconds()
+
+ return parse_time(xmltree.get('from')), parse_time(xmltree.get('to'))
+
+ def handle_ajax(self, dispatch, get):
+ '''
+ Handle ajax calls to this video.
+ TODO (vshnayder): This is not being called right now, so the position
+ is not being saved.
+ '''
+ log.debug(u"GET {0}".format(get))
+ log.debug(u"DISPATCH {0}".format(dispatch))
+ if dispatch == 'goto_position':
+ self.position = int(float(get['position']))
+ log.info(u"NEW POSITION {0}".format(self.position))
+ return json.dumps({'success': True})
+ raise Http404()
+
+ def get_progress(self):
+ ''' TODO (vshnayder): Get and save duration of youtube video, then return
+ fraction watched.
+ (Be careful to notice when video link changes and update)
+
+ For now, we have no way of knowing if the video has even been watched, so
+ just return None.
+ '''
+ return None
+
+ def get_instance_state(self):
+ #log.debug(u"STATE POSITION {0}".format(self.position))
+ return json.dumps({'position': self.position})
+
+ def videoalpha_list(self):
+ return self.youtube
+
+ def get_html(self):
+ if isinstance(modulestore(), MongoModuleStore) :
+ caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_'
+ else:
+ # VS[compat]
+ # cdodge: filesystem static content support.
+ caption_asset_path = "/static/{0}/subs/".format(self.metadata['data_dir'])
+
+ return self.system.render_template('videoalpha.html', {
+ 'streams': self.videoalpha_list(),
+ 'id': self.location.html_id(),
+ 'position': self.position,
+ 'source': self.source,
+ 'track': self.track,
+ 'display_name': self.display_name,
+ # TODO (cpennington): This won't work when we move to data that isn't on the filesystem
+ 'data_dir': self.metadata['data_dir'],
+ 'caption_asset_path': caption_asset_path,
+ 'show_captions': self.show_captions,
+ 'start': self.start_time,
+ 'end': self.end_time
+ })
+
+
+class VideoAlphaDescriptor(RawDescriptor):
+ module_class = VideoModule
+ stores_state = True
+ template_dir_name = "videoalpha"
diff --git a/lms/templates/videoalpha.html b/lms/templates/videoalpha.html
new file mode 100644
index 0000000000..6cee9ed39b
--- /dev/null
+++ b/lms/templates/videoalpha.html
@@ -0,0 +1,31 @@
+% if display_name is not UNDEFINED and display_name is not None:
+
${display_name}
+% endif
+
+
+%if settings.MITX_FEATURES['STUB_VIDEO_FOR_TESTING']:
+
+%else:
+
+%endif
+
+% if source:
+
+% endif
+
+% if track:
+
+
Download subtitles here.
+
+% endif