Commit 3874145a by Carlos Andrés Rocha

Initial version of HTML5 video player.

This patch replaces the youTube player with a new HTML 5 video player
that can plays content hosted elsewhere.

On the backend it changes the specification of the video elements in
the course description xml file to accept the following structure:

<video> <source src="video1.mp4" type="video/mp4"/> <source
  src="video1.webm" type="video/webm"/> <source src="video1.ogg"
  type="video/ogg"/> </video>

Multiple sources are required since support from different video
containers and video format is not standarized yet among the major web
browsers.

On the frontend it replaces the previous video module with a leaner
version, controlled by modified versions of the video control
elements. Instead of interacting with the youTube API, the new
controls work directly with the HTML5 media (video and audio) API
element specification.

The new version is compatible with the previous css stylesheets.

Known Issues:
- On fullscreen the video element is not resized
- No support for subtitles yet (in the works)
- No support for multiple speeds yet (in the works)
- No support for youTube or other external video providers yet.
- No fallback to flash based video players.
parent 7b06d912
......@@ -48,32 +48,52 @@ class Module(XModule):
'''Tags in the courseware file guaranteed to correspond to the module'''
return ["video"]
def video_list(self):
return self.youtube
def get_html(self):
return self.system.render_template('video.html', {
'streams': self.video_list(),
'id': self.item_id,
'position': self.position,
'name': self.name,
'position': self.position,
'sources': self.sources,
'annotations': self.annotations,
})
def __init__(self, system, xml, item_id, state=None):
XModule.__init__(self, system, xml, item_id, state)
xmltree = etree.fromstring(xml)
self.youtube = xmltree.get('youtube')
self.name = xmltree.get('name')
self.position = 0
self.position = self._init_position(state)
self.sources = self._init_sources(xmltree)
self.annotations = self._init_annotations(xmltree)
def _init_position(self, state):
position = 0 if state is None else json.loads(state).get('position', 0)
return position
def _init_sources(self, xmltree):
valid_attrs = ['src', 'type' ,'codecs']
def get_attrib(el):
return {k:v for k,v in el.attrib.iteritems() if k in valid_attrs}
sources = []
elements = [xmltree]
elements.extend(xmltree.findall('source'))
for el in elements:
attrb = get_attrib(el)
if attrb:
sources.append(attrb)
if state is not None:
state = json.loads(state)
if 'position' in state:
self.position = int(float(state['position']))
return sources
self.annotations=[(e.get("name"),self.render_function(e)) \
for e in xmltree]
def _init_annotations(self, xmltree):
# TODO: [rocha] make anotations similar to html5 media
# tracks, which can be used for subtitles, alternative
# languages, and alternative streams (sign language for
# example)
return []
class VideoSegmentDescriptor(XModuleDescriptor):
......
class @MediaControl
constructor: (@container, args...) ->
@media = @container.media
@controls = @container.controls
@initialize(args...)
@render()
@bind()
get_vcr_controls: ->
if not @_vcr_controls
@_vcr_controls = @controls.find('.vcr').first()
if @_vcr_controls.length == 0
@_vcr_controls = $('<ul class="vcr">')
@controls.append($('<div>').append(@_vcr_controls))
return @_vcr_controls
get_secondary_controls: ->
if not @_secondary_controls
@_secondary_controls = @controls.find('.secondary-controls').first()
if @_secondary_controls.length == 0
@_secondary_controls = $('<div class="secondary-controls">')
@controls.append(@_secondary_controls)
return @_secondary_controls
initialize:->
bind: ->
render:->
class @MediaPlayControl extends @MediaControl
render: ->
@element = $('<a class="video_control" href="#"></a>')
@get_vcr_controls().append($('<li>').append(@element))
unless onTouchBasedDevice()
@element.addClass('play').html('Play')
bind: ->
@media.bind('play', @onPlay)
.bind('pause', @onPause)
.bind('ended', @onPause)
@element.click(@togglePlayback)
onPlay: =>
@element.removeClass('play').addClass('pause').html('Pause')
onPause: =>
@element.removeClass('pause').addClass('play').html('Play')
togglePlayback: (event) =>
event.preventDefault()
if @element.hasClass('play') || @element.hasClass('pause')
if @media[0].paused
@media.trigger('play')
else
@media.trigger('pause')
class @MediaTimeDisplay extends @MediaControl
render: ->
@element = $('<div class="vidtime">0:00 / 0:00</div>')
@get_vcr_controls().append($('<li>').append(@element))
bind: ->
@media.bind('timeupdate', @onTimeUpdate)
onTimeUpdate: (event) =>
media = @media[0]
progress = Time.format(media.currentTime) + ' / ' + Time.format(media.duration)
@element.html(progress)
class @MediaFullscreenControl extends @MediaControl
initialize: () ->
@parent = @container.element
render: ->
@element = $('<a href="#" class="add-fullscreen" title="Fullscreen">Fullscreen</a>')
@get_secondary_controls().append(@element)
bind: ->
$(document).keyup(@exitFullScreen)
@element.click(@toggleFullScreen)
toggleFullScreen: (event) =>
event.preventDefault()
if not @container.element.hasClass('fullscreen')
@element.attr('title', 'Exit fill browser')
@parent.append('<a href="#" class="exit">Exit</a>').click(@exitFullScreen)
@parent.addClass('fullscreen')
else
@element.attr('title', 'Fullscreen')
@parent.find('.exit').remove()
@parent.removeClass('fullscreen')
@parent.resize()
exitFullScreen: (event) =>
if @parent.hasClass('fullscreen') && event.keyCode == 27
@toggleFullScreen(event)
class @MediaSliderControl extends @MediaControl
initialize: ->
# slider scale factor. used to make the motion of the slider's
# handle smoother for short movies.
@_ssf = 100
render: ->
@element = $('<div class="slider"></div>')
@controls.append(@element)
@render_slider() unless onTouchBasedDevice()
render_slider: ->
@slider = @element.slider
range: 'min'
change: @onChange
slide: @onSlide
stop: @onStop
@handle = @slider.find('.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
bind: ->
@media.bind('loadedmetadata', @onLoadedMetadata)
.bind('play', @onPlay)
.bind('timeupdate', @onTimeUpdate)
seek: (value) ->
time = value / @_ssf
@media[0].currentTime = time
updateTooltip: (value)->
@handle.qtip('option', 'content.text', "#{Time.format(value)}")
onLoadedMetadata: =>
max_value = @_ssf * @media[0].duration
@slider.slider('option', 'max', max_value) if @slider
onPlay: =>
if not @slider
render_slider
onTimeUpdate: =>
if @slider and not @sliding
max_value = @_ssf * @media[0].duration
@slider.slider('option', 'max', max_value)
pos_value = @_ssf * @media[0].currentTime
@slider.slider('value', pos_value)
onChange: (event, ui) =>
@updateTooltip(ui.value)
onSlide: (event, ui) =>
@sliding = true
@seek(ui.value)
@updateTooltip(ui.value)
onStop: (event, ui) =>
@sliding = false
@seek(ui.value)
class @MediaVolumeControl extends @MediaControl
initialize: ->
@previousVolume = 100
render: ->
@element = $('<div class="volume"><a href="#"></a></div>')
@volume = $('<div class="volume-slider">')
@element.append(
$('<div class="volume-slider-container">').append(@volume))
@get_secondary_controls().append(@element)
@render_slider() unless onTouchBasedDevice()
render_slider: ->
@slider = @volume.slider
orientation: "vertical"
range: "min"
min: 0
max: 100
value: 100
change: @onChange
slide: @onChange
bind: ->
@media.bind('canplay', @onCanPlay)
@element.mouseenter =>
@element.addClass('open')
@element.mouseleave =>
@element.removeClass('open')
@element.find('>a').click(@toggleMute)
onCanPlay: =>
@slider.slider('option', 'max', 100 * @media[0].volume)
onChange:(event, ui) =>
@media[0].volume = ui.value / 100
@element.toggleClass('muted', ui.value == 0)
toggleMute: =>
media = @media[0]
if media.volume > 0
@previousVolume = 100 * media.volume
@slider.slider('option', 'value', 0)
else
@slider.slider('option', 'value', @previousVolume)
class @Video
constructor: (@id, videos) ->
window.player = null
@element = $("#video_#{@id}")
@parseVideos videos
@fetchMetadata()
@parseSpeed()
$("#video_#{@id}").data('video', this)
if YT.Player
@embed()
else
window.onYouTubePlayerAPIReady = =>
$('.course-content .video').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'
@element = $("#video_#{@id}")
@element.data('video', this)
embed: ->
@player = new VideoPlayer(this)
@media = @element.find('video').first()
@sources = @media.find('sources')
@controls = @element.find('.video-controls')
fetchMetadata: (url) ->
@metadata = {}
$.each @videos, (speed, url) =>
$.get "http://gdata.youtube.com/feeds/api/videos/#{url}?v=2&alt=jsonc", ((data) => @metadata[data.data.id] = data.data) , 'jsonp'
@render()
getDuration: ->
@metadata[@youtubeId()].duration
render: ->
new MediaSliderControl @
new MediaPlayControl @
new MediaTimeDisplay @
new MediaVolumeControl @ unless onTouchBasedDevice()
new MediaFullscreenControl @
class @VideoCaption
constructor: (@player, @youtubeId) ->
@render()
@bind()
$: (selector) ->
@player.$(selector)
bind: ->
$(window).bind('resize', @onWindowResize)
$(@player).bind('resize', @onWindowResize)
$(@player).bind('updatePlayTime', @onUpdatePlayTime)
$(@player).bind('seek', @onUpdatePlayTime)
$(@player).bind('play', @onPlay)
@$('.hide-subtitles').click @toggle
@$('.subtitles').mouseenter(@onMouseEnter).mouseleave(@onMouseLeave)
.mousemove(@onMovement).bind('mousewheel', @onMovement)
.bind('DOMMouseScroll', @onMovement)
captionURL: ->
"/static/subs/#{@youtubeId}.srt.sjson"
render: ->
@$('.video-wrapper').after """
<ol class="subtitles"><li>Attempting to load captions...</li></ol>
"""
@$('.video-controls .secondary-controls').append """
<a href="#" class="hide-subtitles" title="Turn off captions">Captions</a>
"""
@$('.subtitles').css maxHeight: @$('.video-wrapper').height() - 5
@fetchCaption()
fetchCaption: ->
$.getWithPrefix @captionURL(), (captions) =>
@captions = captions.text
@start = captions.start
if onTouchBasedDevice()
$('.subtitles li').html "Caption will be displayed when you start playing the video."
else
@renderCaption()
renderCaption: ->
container = $('<ol>')
$.each @captions, (index, text) =>
container.append $('<li>').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 <li> for cosmatic reason
@$('.subtitles').prepend($('<li class="spacing">').height(@topSpacingHeight()))
.append($('<li class="spacing">').height(@bottomSpacingHeight()))
@rendered = true
search: (time) ->
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
onPlay: =>
@renderCaption() unless @rendered
onUpdatePlayTime: (event, time) =>
# This 250ms offset is required to match the video speed
time = Math.round(Time.convert(time, @player.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()
onWindowResize: =>
@$('.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 @player.isPlaying()
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', @player.currentSpeed()) / 1000)
$(@player).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 @player.element.hasClass('closed')
@$('.hide-subtitles').attr('title', 'Turn off captions')
@player.element.removeClass('closed')
@scrollCaption()
else
@$('.hide-subtitles').attr('title', 'Turn on captions')
@player.element.addClass('closed')
captionHeight: ->
if @player.element.hasClass('fullscreen')
$(window).height() - @$('.video-controls').height()
else
@$('.video-wrapper').height()
class @VideoControl
constructor: (@player) ->
@render()
@bind()
$: (selector) ->
@player.$(selector)
bind: ->
$(@player).bind('play', @onPlay)
.bind('pause', @onPause)
.bind('ended', @onPause)
@$('.video_control').click @togglePlayback
render: ->
@$('.video-controls').append """
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#"></a></li>
<li>
<div class="vidtime">0:00 / 0:00</div>
</li>
</ul>
<div class="secondary-controls">
<a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a>
</div>
</div>
"""
unless onTouchBasedDevice()
@$('.video_control').addClass('play').html('Play')
onPlay: =>
@$('.video_control').removeClass('play').addClass('pause').html('Pause')
onPause: =>
@$('.video_control').removeClass('pause').addClass('play').html('Play')
togglePlayback: (event) =>
event.preventDefault()
if $('.video_control').hasClass('play') || $('.video_control').hasClass('pause')
if @player.isPlaying()
$(@player).trigger('pause')
else
$(@player).trigger('play')
class @VideoPlayer
constructor: (@video) ->
# Define a missing constant of Youtube API
YT.PlayerState.UNSTARTED = -1
@currentTime = 0
@element = $("#video_#{@video.id}")
@render()
@bind()
$: (selector) ->
$(selector, @element)
bind: ->
$(@).bind('seek', @onSeek)
.bind('updatePlayTime', @onUpdatePlayTime)
.bind('speedChange', @onSpeedChange)
.bind('play', @onPlay)
.bind('pause', @onPause)
.bind('ended', @onPause)
$(document).keyup @bindExitFullScreen
@$('.add-fullscreen').click @toggleFullScreen
@addToolTip() unless onTouchBasedDevice()
bindExitFullScreen: (event) =>
if @element.hasClass('fullscreen') && event.keyCode == 27
@toggleFullScreen(event)
render: ->
new VideoControl @
new VideoCaption @, @video.youtubeId('1.0')
new VideoVolumeControl @ unless onTouchBasedDevice()
new VideoSpeedControl @, @video.speeds
new VideoProgressSlider @
@player = new YT.Player @video.id,
playerVars:
controls: 0
wmode: 'transparent'
rel: 0
showinfo: 0
enablejsapi: 1
videoId: @video.youtubeId()
events:
onReady: @onReady
onStateChange: @onStateChange
addToolTip: ->
@$('.add-fullscreen, .hide-subtitles').qtip
position:
my: 'top right'
at: 'top center'
onReady: =>
$(@).trigger('ready')
$(@).trigger('updatePlayTime', 0)
unless onTouchBasedDevice()
$('.course-content .video:first').data('video').player.play()
onStateChange: (event) =>
switch event.data
when YT.PlayerState.PLAYING
$(@).trigger('play')
when YT.PlayerState.PAUSED, YT.PlayerState.UNSTARTED
$(@).trigger('pause')
when YT.PlayerState.ENDED
$(@).trigger('ended')
onPlay: =>
Logger.log 'play_video', id: @currentTime, code: @player.getVideoEmbedCode()
window.player.pauseVideo() if window.player && window.player != @player
window.player = @player
unless @player.interval
@player.interval = setInterval(@update, 200)
onPause: =>
Logger.log 'pause_video', id: @currentTime, code: @player.getVideoEmbedCode()
window.player = null if window.player == @player
clearInterval(@player.interval)
@player.interval = null
onSeek: (event, time) ->
@player.seekTo(time, true)
if @isPlaying()
clearInterval(@player.interval)
@player.interval = setInterval(@update, 200)
else
@currentTime = time
$(@).trigger('updatePlayTime', time)
onSpeedChange: (event, newSpeed) =>
@currentTime = Time.convert(@currentTime, parseFloat(@currentSpeed()), newSpeed)
@video.setSpeed(parseFloat(newSpeed).toFixed(2).replace /\.00$/, '.0')
if @isPlaying()
@player.loadVideoById(@video.youtubeId(), @currentTime)
else
@player.cueVideoById(@video.youtubeId(), @currentTime)
$(@).trigger('updatePlayTime', @currentTime)
update: =>
if @currentTime = @player.getCurrentTime()
$(@).trigger('updatePlayTime', @currentTime)
onUpdatePlayTime: (event, time) =>
progress = Time.format(time) + ' / ' + Time.format(@duration())
@$(".vidtime").html(progress)
toggleFullScreen: (event) =>
event.preventDefault()
if @element.hasClass('fullscreen')
@$('.exit').remove()
@$('.add-fullscreen').attr('title', 'Fill browser')
@element.removeClass('fullscreen')
else
@element.append('<a href="#" class="exit">Exit</a>').addClass('fullscreen')
@$('.add-fullscreen').attr('title', 'Exit fill browser')
@$('.exit').click @toggleFullScreen
$(@).trigger('resize')
# Delegates
play: ->
@player.playVideo() if @player.playVideo
isPlaying: ->
@player.getPlayerState() == YT.PlayerState.PLAYING
pause: ->
@player.pauseVideo()
duration: ->
@video.getDuration()
currentSpeed: ->
@video.speed
volume: (value) ->
if value?
@player.setVolume value
else
@player.getVolume()
class @VideoProgressSlider
constructor: (@player) ->
@buildSlider() unless onTouchBasedDevice()
$(@player).bind('updatePlayTime', @onUpdatePlayTime)
$(@player).bind('ready', @onReady)
$(@player).bind('play', @onPlay)
$: (selector) ->
@player.$(selector)
buildSlider: ->
@slider = @$('.slider').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
onReady: =>
@slider.slider('option', 'max', @player.duration()) if @slider
onPlay: =>
@buildSlider() unless @slider
onUpdatePlayTime: (event, currentTime) =>
if @slider && !@frozen
@slider.slider('option', 'max', @player.duration())
@slider.slider('value', currentTime)
onSlide: (event, ui) =>
@frozen = true
@updateTooltip(ui.value)
$(@player).trigger('seek', ui.value)
onChange: (event, ui) =>
@updateTooltip(ui.value)
onStop: (event, ui) =>
@frozen = true
$(@player).trigger('seek', ui.value)
setTimeout (=> @frozen = false), 200
updateTooltip: (value)->
@handle.qtip('option', 'content.text', "#{Time.format(value)}")
class @VideoSpeedControl
constructor: (@player, @speeds) ->
@render()
@bind()
$: (selector) ->
@player.$(selector)
bind: ->
$(@player).bind('speedChange', @onSpeedChange)
@$('.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: ->
@$('.secondary-controls').prepend """
<div class="speeds">
<a href="#">
<h3>Speed</h3>
<p class="active"></p>
</a>
<ol class="video_speeds"></ol>
</div>
"""
$.each @speeds, (index, speed) =>
link = $('<a>').attr(href: "#").html("#{speed}x")
@$('.video_speeds').prepend($('<li>').attr('data-speed', speed).html(link))
@setSpeed(@player.currentSpeed())
changeVideoSpeed: (event) =>
event.preventDefault()
unless $(event.target).parent().hasClass('active')
$(@player).trigger 'speedChange', $(event.target).parent().data('speed')
onSpeedChange: (event, speed) =>
@setSpeed(parseFloat(speed).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")
class @VideoVolumeControl
constructor: (@player) ->
@previousVolume = 100
@render()
@bind()
$: (selector) ->
@player.$(selector)
bind: ->
$(@player).bind('ready', @onReady)
@$('.volume').mouseenter ->
$(this).addClass('open')
@$('.volume').mouseleave ->
$(this).removeClass('open')
@$('.volume>a').click(@toggleMute)
render: ->
@$('.secondary-controls').prepend """
<div class="volume">
<a href="#"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
"""
@slider = @$('.volume-slider').slider
orientation: "vertical"
range: "min"
min: 0
max: 100
value: 100
change: @onChange
slide: @onChange
onReady: =>
@slider.slider 'option', 'max', @player.volume()
onChange: (event, ui) =>
@player.volume ui.value
@$('.secondary-controls .volume').toggleClass 'muted', ui.value == 0
toggleMute: =>
if @player.volume() > 0
@previousVolume = @player.volume()
@slider.slider 'option', 'value', 0
else
@slider.slider 'option', 'value', @previousVolume
......@@ -2,11 +2,17 @@
<h1> ${name} </h1>
% endif
<div id="video_${id}" class="video" data-streams="${streams}">
<div id="video_${id}" class="video">
<div class="tc-wrapper">
<article class="video-wrapper">
<section class="video-player">
<div id="${id}"></div>
<div id="${id}">
<video>
% for s in sources:
<source ${" ".join("{0}=\"{1}\"".format(*i) for i in s.items())}/>
% endfor
</video>
</div>
</section>
<section class="video-controls"></section>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment