From 156da20c356b60bd184b6bf36e34b3f2ad905aea Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Wed, 30 Jan 2013 11:47:17 +0200 Subject: [PATCH] Renamed videox to videoalpha, as per Piotr naming scheme request. --- common/lib/xmodule/setup.py | 1 + .../xmodule/css/videoalpha/display.scss | 559 ++++++++++++++++++ .../xmodule/js/src/videoalpha/display.coffee | 63 ++ .../js/src/videoalpha/display/_subview.coffee | 14 + .../videoalpha/display/video_caption.coffee | 152 +++++ .../videoalpha/display/video_control.coffee | 35 ++ .../videoalpha/display/video_player.coffee | 180 ++++++ .../display/video_progress_slider.coffee | 49 ++ .../display/video_quality_control.coffee | 26 + .../display/video_speed_control.coffee | 43 ++ .../display/video_volume_control.coffee | 40 ++ .../xmodule/templates/videoalpha/default.yaml | 7 + .../lib/xmodule/xmodule/videoalpha_module.py | 150 +++++ lms/templates/videoalpha.html | 31 + 14 files changed, 1350 insertions(+) create mode 100644 common/lib/xmodule/xmodule/css/videoalpha/display.scss create mode 100644 common/lib/xmodule/xmodule/js/src/videoalpha/display.coffee create mode 100644 common/lib/xmodule/xmodule/js/src/videoalpha/display/_subview.coffee create mode 100644 common/lib/xmodule/xmodule/js/src/videoalpha/display/video_caption.coffee create mode 100644 common/lib/xmodule/xmodule/js/src/videoalpha/display/video_control.coffee create mode 100644 common/lib/xmodule/xmodule/js/src/videoalpha/display/video_player.coffee create mode 100644 common/lib/xmodule/xmodule/js/src/videoalpha/display/video_progress_slider.coffee create mode 100644 common/lib/xmodule/xmodule/js/src/videoalpha/display/video_quality_control.coffee create mode 100644 common/lib/xmodule/xmodule/js/src/videoalpha/display/video_speed_control.coffee create mode 100644 common/lib/xmodule/xmodule/js/src/videoalpha/display/video_volume_control.coffee create mode 100644 common/lib/xmodule/xmodule/templates/videoalpha/default.yaml create mode 100644 common/lib/xmodule/xmodule/videoalpha_module.py create mode 100644 lms/templates/videoalpha.html 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 """ + #
  1. 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 $('
    1. ').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
    2. for cosmetic reason + @$('.subtitles').prepend($('
    3. ').height(@topSpacingHeight())) + .append($('
    4. ').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 """ +
      +
      +
        +
      • +
      • +
        0:00 / 0:00
        +
      • +
      + +
      + """#" + + 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($('
    5. ').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: +
      +

      Download video here.

      +
      +% endif + +% if track: +
      +

      Download subtitles here.

      +
      +% endif