forked from Open-CT/openct-tasks
591 lines
19 KiB
JavaScript
591 lines
19 KiB
JavaScript
(function($) {
|
|
|
|
var player;
|
|
var ready = false;
|
|
var state_cache;
|
|
|
|
|
|
|
|
// time formatter and parser
|
|
var time_string = {
|
|
|
|
parse: function(value) {
|
|
if(typeof value === 'string') {
|
|
var mult = 1,
|
|
parts = value.split(':'),
|
|
res = 0;
|
|
while(parts.length) {
|
|
res += mult * parseFloat(parts.pop());
|
|
mult *= 60;
|
|
}
|
|
return res;
|
|
}
|
|
return value || 0;
|
|
},
|
|
|
|
format: function(value) {
|
|
var v = parseInt(value, 10),
|
|
h = Math.floor(v / 3600);
|
|
m = Math.floor((v - (h * 3600)) / 60),
|
|
s = v - (h * 3600) - (m * 60);
|
|
|
|
function zero(v) {
|
|
return v < 10 ? '0' + v : v;
|
|
}
|
|
return (h > 0 ? h + ':' : '') + zero(m) + ':' + zero(s);
|
|
}
|
|
}
|
|
|
|
|
|
// load youtube IFrame Player API
|
|
var apiLoader = {
|
|
callbacks: [],
|
|
loaded: false,
|
|
loading: false,
|
|
|
|
fetch: function() {
|
|
window.onYouTubePlayerAPIReady = function() {
|
|
delete window.onYouTubePlayerAPIReady;
|
|
apiLoader.loaded = true;
|
|
var cb;
|
|
while(cb = apiLoader.callbacks.pop()) {
|
|
cb();
|
|
}
|
|
}
|
|
var script = document.createElement("script");
|
|
script.src = "https://www.youtube.com/player_api";
|
|
script.onerror = function() {
|
|
console.error('Error loading IFrame Player API');
|
|
}
|
|
document.body.appendChild(script);
|
|
},
|
|
|
|
|
|
load: function(callback) {
|
|
if(this.loaded) {
|
|
return callback();
|
|
}
|
|
this.callbacks.push(callback);
|
|
if(!this.loading) {
|
|
this.loading = true;
|
|
this.fetch();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// sections nav
|
|
var sections = {
|
|
|
|
data: [],
|
|
active: null,
|
|
visible: true,
|
|
show_viewed: true,
|
|
callback: null,
|
|
|
|
generateSections: function(amount, start, end) {
|
|
if(!start) start = 0;
|
|
if(!end) end = player.getDuration();
|
|
var duration = (end - start) / amount;
|
|
var res = [];
|
|
for(var i=0; i<amount; i++) {
|
|
res.push({
|
|
start: i * duration,
|
|
end: (i + 1) * duration,
|
|
title: 'Section ' + (1 + i),
|
|
viewed: false,
|
|
parts: [
|
|
{
|
|
viewed: false,
|
|
start: i * duration,
|
|
end: (i + 1) * duration
|
|
}
|
|
],
|
|
description: null
|
|
});
|
|
}
|
|
return res;
|
|
},
|
|
|
|
|
|
prepareSectionsArray: function(sections) {
|
|
for(var i=0; i<sections.length; i++) {
|
|
if('start' in sections[i]) {
|
|
sections[i].start = time_string.parse(sections[i].start);
|
|
}
|
|
if('end' in sections[i]) {
|
|
sections[i].end = time_string.parse(sections[i].end);
|
|
}
|
|
}
|
|
for(var i=0; i<sections.length; i++) {
|
|
if('start' in sections[i]) continue;
|
|
if(i == 0) {
|
|
sections[i].start = 0;
|
|
} else if('end' in sections[i - 1]) {
|
|
sections[i].start = sections[i - 1].end;
|
|
} else {
|
|
console.error('Section #' + i + ' start time not computable');
|
|
}
|
|
}
|
|
for(var i=0; i<sections.length; i++) {
|
|
if('end' in sections[i]) continue;
|
|
if(i == sections.length - 1) {
|
|
sections[i].end = player.getDuration();
|
|
} else if('start' in sections[i + 1]) {
|
|
sections[i].end = sections[i + 1].start;
|
|
} else {
|
|
console.error('Section #' + i + ' end time not computable');
|
|
}
|
|
}
|
|
return sections;
|
|
},
|
|
|
|
|
|
init: function(config, parent) {
|
|
this.active = null;
|
|
this.show_viewed = !!config.show_viewed;
|
|
this.callback = config.callback;
|
|
if(!config.sections) {
|
|
this.visible = false;
|
|
this.data = this.generateSections(1);
|
|
} else if(Number.isInteger(config.sections)) {
|
|
this.visible = false;
|
|
this.data = this.generateSections(config.sections);
|
|
} else {
|
|
this.visible = true;
|
|
this.data = this.prepareSectionsArray(config.sections.slice());
|
|
for(var i=0,section; section = this.data[i]; i++) {
|
|
section.viewed = false;
|
|
var parts_amount = Number.isInteger(section.parts) ? section.parts : 1;
|
|
var part_duraton = (section.end - section.start) / parts_amount;
|
|
if(config.layout.enumerate_sections) {
|
|
section.number = 1 + i;
|
|
}
|
|
section.parts = [];
|
|
for(var j=0; j<parts_amount; j++) {
|
|
section.parts[j] = {
|
|
viewed: false,
|
|
start: section.start + j * part_duraton,
|
|
end: section.start + (j + 1) * part_duraton
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.render(parent, function(time) {
|
|
player.seekTo(time);
|
|
player.playVideo();
|
|
});
|
|
},
|
|
|
|
|
|
renderTitle: function(parent, idx, section, onClick) {
|
|
var that = this;
|
|
function makeClickCallback(idx) {
|
|
return function() {
|
|
onClick(that.data[idx].start);
|
|
}
|
|
}
|
|
var el = $(
|
|
'<div class="title">' + ('number' in section ? section.number + '. ' : '') + section.title +
|
|
'<div class="duration">' + time_string.format(section.start) + '</div>' +
|
|
'</div>'
|
|
);
|
|
el.click(makeClickCallback(idx));
|
|
parent.append(el);
|
|
},
|
|
|
|
renderDescription: function(parent, idx, section) {
|
|
if(!section.image && !section.description) {
|
|
return;
|
|
}
|
|
var description = section.description.replace(/\{[^\}]+\}/g, function(m) {
|
|
m = m.substr(1, m.length - 2).split('|');
|
|
if(m.length > 1) {
|
|
var time = time_string.parse(m[1]);
|
|
var title = m[0];
|
|
} else {
|
|
var time = time_string.parse(m[0]);
|
|
var title = time_string.format(time);
|
|
}
|
|
|
|
return '<span class="time-link" data-time="' + time + '">' + title + '</span>';
|
|
});
|
|
if(section.image) {
|
|
// section.image is either an URL to an image, or a #id
|
|
// identifier for an image tag to fetch the URL from
|
|
var html = '<div class="description hasImage">';
|
|
var imgSrc = section.image;
|
|
if(imgSrc[0] == '#') {
|
|
imgSrc = $('img' + imgSrc).attr('src');
|
|
}
|
|
html += '<div class="image"><img src="' + imgSrc + '"></img></div>';
|
|
html += '<div>' + description + '</div>';
|
|
html += '</div>';
|
|
} else {
|
|
var html = '<div class="description">';
|
|
html += description;
|
|
html += '</div>';
|
|
}
|
|
var el = $(html);
|
|
|
|
function makeClickCallback(link) {
|
|
var time = parseFloat($(link).data('time'));
|
|
return function() {
|
|
player.seekTo(time);
|
|
player.playVideo();
|
|
}
|
|
}
|
|
el.find('span.time-link').each(function() {
|
|
$(this).click(makeClickCallback(this));
|
|
});
|
|
parent.append(el);
|
|
},
|
|
|
|
|
|
render: function(parent, onClick) {
|
|
if(!this.visible) return;
|
|
|
|
for(var i=0,section; section = this.data[i]; i++) {
|
|
section.element = $('<div class="section"></div>');
|
|
this.renderTitle(section.element, i, section, onClick);
|
|
this.renderDescription(section.element, i, section);
|
|
parent.append(section.element);
|
|
}
|
|
},
|
|
|
|
|
|
refresh: function() {
|
|
if(!this.visible) return;
|
|
for(var i=0,section; section = this.data[i]; i++) {
|
|
section.element.toggleClass('active', i === this.active);
|
|
section.element.toggleClass('viewed', this.show_viewed && section.viewed);
|
|
}
|
|
},
|
|
|
|
|
|
track: function(time) {
|
|
for(var i=0,section; section=this.data[i]; i++) {
|
|
if(time >= section.start && time < section.end) {
|
|
this.setActive(i);
|
|
var cnt = 0;
|
|
var refresh = false;
|
|
var scoreUpdate = false;
|
|
for(var j=0,part; part=this.data[i].parts[j]; j++) {
|
|
if(!part.viewed && time >= part.start && time < part.end) {
|
|
part.viewed = true;
|
|
refresh = true;
|
|
}
|
|
if(part.viewed) cnt++;
|
|
}
|
|
if(cnt && cnt > Math.floor(section.parts.length * 0.5)) {
|
|
var wasViewed = section.viewed;
|
|
section.viewed = true;
|
|
refresh = refresh || !wasViewed;
|
|
scoreUpdate = !wasViewed
|
|
}
|
|
if(refresh) {
|
|
this.refresh();
|
|
}
|
|
if(scoreUpdate && this.callback) {
|
|
this.callback();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
|
|
|
|
setActive: function(idx) {
|
|
if(this.active === idx) return;
|
|
this.active = idx;
|
|
this.refresh();
|
|
},
|
|
|
|
|
|
getViewed: function() {
|
|
var res = [];
|
|
for(var i=0,section; section=this.data[i]; i++) {
|
|
res.push({viewed: !!section.viewed, parts: section.parts});
|
|
}
|
|
return res;
|
|
},
|
|
|
|
|
|
setViewed: function(viewed) {
|
|
for(var i=0,section; section=this.data[i]; i++) {
|
|
var v = viewed[i];
|
|
if(!v) { continue; }
|
|
section.viewed = v.viewed;
|
|
if(section.parts && v.parts) {
|
|
for(var j=0, part; part=section.parts[j]; j++) {
|
|
if(!v.parts[j]) { continue; }
|
|
part.viewed = !!v.parts[j].viewed;
|
|
}
|
|
}
|
|
}
|
|
this.refresh();
|
|
},
|
|
|
|
|
|
destroy: function() {
|
|
$.each(this.data, function(i, section) {
|
|
section.element.remove();
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// tpl
|
|
var template = {
|
|
|
|
elements: {},
|
|
|
|
init: function(parent, config) {
|
|
this.render(parent);
|
|
var refreshLayout = this.getRefreshLayoutFunc(config);
|
|
$(window).scroll(refreshLayout);
|
|
$(window).resize(refreshLayout);
|
|
refreshLayout();
|
|
},
|
|
|
|
|
|
render: function(parent) {
|
|
this.elements.root = $(
|
|
'<div class="task-video">\
|
|
<div class="task-video-content" data-key="content">\
|
|
<div class="player" data-key="player"><div></div></div>\
|
|
<div class="sections" data-key="sections"></div>\
|
|
</div>\
|
|
</div>'
|
|
);
|
|
var self = this;
|
|
this.elements.root.find('[data-key]').each(function() {
|
|
self.elements[$(this).data('key')] = $(this);
|
|
});
|
|
parent.html('').append(this.elements.root);
|
|
},
|
|
|
|
|
|
getRefreshLayoutFunc: function(config) {
|
|
var elements = this.elements,
|
|
win = $(window),
|
|
doc = $(window.document),
|
|
is_wide_mode_old = null;
|
|
|
|
return function() {
|
|
var is_fixed_content = win.scrollTop() > elements.root.position().top;
|
|
var is_wide_mode = win.width() >= config.layout.wide_mode_min_width;
|
|
|
|
elements.root.toggleClass('task-video-wide-mode', is_wide_mode);
|
|
elements.root.toggleClass('task-video-narrow-mode', !is_wide_mode);
|
|
|
|
var scroll = Math.max(0, win.scrollTop() - elements.root.position().top);
|
|
|
|
if(is_wide_mode_old !== is_wide_mode) {
|
|
is_wide_mode_old = is_wide_mode;
|
|
elements.root.height('');
|
|
elements.content.width('');
|
|
elements.content.height('');
|
|
elements.player.height('');
|
|
elements.player.width('');
|
|
elements.sections.height('');
|
|
elements.sections.width('');
|
|
elements.sections.css('margin-top', '');
|
|
}
|
|
|
|
if(is_wide_mode) {
|
|
var video_width = config.layout.wide_mode_video_width * elements.root.width();
|
|
elements.player.width(video_width);
|
|
elements.sections.width(elements.root.width() - video_width);
|
|
elements.content.width(elements.root.width());
|
|
|
|
var max_height = video_width / config.layout.video_aspect_ratio;
|
|
var height = Math.floor(Math.max(0.5 * max_height, max_height - scroll));
|
|
elements.root.height(height);
|
|
elements.content.height(height);
|
|
} else {
|
|
var max_height = elements.root.width() / config.layout.video_aspect_ratio;
|
|
elements.sections.height(max_height);
|
|
var player_height = Math.max(0.5 * max_height, max_height - scroll);
|
|
elements.player.height(player_height);
|
|
elements.sections.css('margin-top', is_fixed_content ? max_height : '');
|
|
}
|
|
|
|
elements.content.toggleClass('fixed-content', is_fixed_content);
|
|
}
|
|
},
|
|
|
|
|
|
get: function(name) {
|
|
return this.elements[name];
|
|
},
|
|
|
|
|
|
|
|
|
|
destroy: function() {
|
|
$(window).unbind('scroll');
|
|
$(window).unbind('resize');
|
|
this.elements.root && this.elements.root.remove();
|
|
this.elements = {};
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// player watcher
|
|
var watchDog = {
|
|
|
|
interval: null,
|
|
|
|
watch: function() {
|
|
if(this.interval !== null) return;
|
|
this.interval = setInterval(function() {
|
|
sections.track(player.getCurrentTime());
|
|
}, 100);
|
|
},
|
|
|
|
|
|
stop: function() {
|
|
clearInterval(this.interval);
|
|
this.interval = null;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// player init
|
|
|
|
function createPlayer(parent, config, events) {
|
|
ready = false;
|
|
var defaults = {}
|
|
if(window.stringsLanguage) {
|
|
defaults.hl = window.stringsLanguage;
|
|
}
|
|
var youtube = Object.assign(defaults, config.youtube);
|
|
|
|
|
|
return new YT.Player(parent, {
|
|
videoId: config.video_id,
|
|
height: '100%',
|
|
width: '100%',
|
|
enablejsapi: 1,
|
|
origin: null, // ??
|
|
host: 'https://www.youtube.com',
|
|
playerVars: youtube,
|
|
events: {
|
|
'onReady': function(e) {
|
|
sections.init(config, template.get('sections'));
|
|
ready = true;
|
|
if(state_cache) {
|
|
stateHandler(state_cache);
|
|
delete(state_cache);
|
|
}
|
|
},
|
|
'onStateChange': function(e) {
|
|
if(e.data === YT.PlayerState.PLAYING) {
|
|
watchDog.watch();
|
|
} else {
|
|
watchDog.stop();
|
|
}
|
|
if(e.data === YT.PlayerState.ENDED && events.onPlaybackEnd) {
|
|
events.onPlaybackEnd();
|
|
}
|
|
},
|
|
'onError': function(e) {
|
|
console.log('onError', e.data)
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
function makeConfig(params, callback) {
|
|
var defaults = {
|
|
layout: {
|
|
video_aspect_ratio: 16/9,
|
|
wide_mode_min_width: 1024,
|
|
wide_mode_video_width: 0.6,
|
|
enumerate_sections: true
|
|
},
|
|
callback: callback
|
|
}
|
|
return Object.assign(defaults, params);
|
|
}
|
|
|
|
|
|
function stateHandler(state) {
|
|
|
|
if(!ready) {
|
|
//console.error('Player not ready');
|
|
return null;
|
|
}
|
|
|
|
if(state) {
|
|
if('sections' in state) {
|
|
sections.setViewed(state.sections);
|
|
}
|
|
if('timestamp' in state) {
|
|
player.seekTo(state.timestamp);
|
|
//player.playVideo();
|
|
}
|
|
} else {
|
|
var sectionsData = sections.getViewed();
|
|
var nbViewed = 0;
|
|
for(var i=0, section; section=sectionsData[i]; i++) {
|
|
if(section.viewed) { nbViewed += 1; }
|
|
}
|
|
return {
|
|
timestamp: player.getCurrentTime(),
|
|
playing: player.getPlayerState() === YT.PlayerState.PLAYING,
|
|
viewed: nbViewed,
|
|
total: sectionsData.length,
|
|
sections: sectionsData
|
|
}
|
|
}
|
|
}
|
|
|
|
// jQuery plugin interface
|
|
|
|
$.fn.taskVideo = function(params, callback, events) {
|
|
var that = this;
|
|
var config = makeConfig(params, callback);
|
|
if(!events) { events = {}; }
|
|
if(callback) { events.onPlaybackEnd = callback; }
|
|
apiLoader.load(function() {
|
|
template.init(that, config);
|
|
player = createPlayer(template.get('player').find('div')[0], config, events);
|
|
});
|
|
return this;
|
|
}
|
|
|
|
|
|
$.fn.taskVideo.ready = function() {
|
|
return ready;
|
|
}
|
|
|
|
|
|
$.fn.taskVideo.state = function(state) {
|
|
if(ready) {
|
|
return stateHandler(state);
|
|
} else if(state !== undefined) {
|
|
state_cache = state;
|
|
} else {
|
|
return state_cache;
|
|
}
|
|
}
|
|
|
|
|
|
$.fn.taskVideo.destroy = function() {
|
|
delete(state_cache);
|
|
watchDog.stop();
|
|
player && player.destroy();
|
|
player = null;
|
|
template.destroy();
|
|
}
|
|
|
|
})(jQuery);
|