Add Timed Transcripts Editor.

This commit is contained in:
Anton Stupak
2013-08-13 18:10:44 +03:00
committed by polesye
parent 1f7bb112bf
commit aecc20af6b
75 changed files with 8762 additions and 279 deletions

View File

@@ -0,0 +1,92 @@
define(["backbone", "underscore"], function(Backbone, _) {
var AbstractEditor = Backbone.View.extend({
// Model is MetadataModel
initialize : function() {
var self = this;
var templateName = _.result(this, 'templateName');
// Backbone model cid is only unique within the collection.
this.uniqueId = _.uniqueId(templateName + "_");
var tpl = document.getElementById(templateName).text;
if(!tpl) {
console.error("Couldn't load template: " + templateName);
}
this.template = _.template(tpl);
this.$el.html(this.template({model: this.model, uniqueId: this.uniqueId}));
this.listenTo(this.model, 'change', this.render);
this.render();
},
/**
* The ID/name of the template. Subclasses must override this.
*/
templateName: '',
/**
* Returns the value currently displayed in the editor/view. Subclasses should implement this method.
*/
getValueFromEditor : function () {},
/**
* Sets the value currently displayed in the editor/view. Subclasses should implement this method.
*/
setValueInEditor : function (value) {},
/**
* Sets the value in the model, using the value currently displayed in the view.
*/
updateModel: function () {
this.model.setValue(this.getValueFromEditor());
},
/**
* Clears the value currently set in the model (reverting to the default).
*/
clear: function () {
this.model.clear();
},
/**
* Shows the clear button, if it is not already showing.
*/
showClearButton: function() {
if (!this.$el.hasClass('is-set')) {
this.$el.addClass('is-set');
this.getClearButton().removeClass('inactive');
this.getClearButton().addClass('active');
}
},
/**
* Returns the clear button.
*/
getClearButton: function () {
return this.$el.find('.setting-clear');
},
/**
* Renders the editor, updating the value displayed in the view, as well as the state of
* the clear button.
*/
render: function () {
if (!this.template) return;
this.setValueInEditor(this.model.getDisplayValue());
if (this.model.isExplicitlySet()) {
this.showClearButton();
}
else {
this.$el.removeClass('is-set');
this.getClearButton().addClass('inactive');
this.getClearButton().removeClass('active');
}
return this;
}
});
return AbstractEditor;
});

View File

@@ -1,5 +1,10 @@
define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, MetadataModel) {
define(
[
"backbone", "underscore", "js/models/metadata", "js/views/abstract_editor",
"js/views/transcripts/metadata_videolist"
],
function(Backbone, _, MetadataModel, AbstractEditor, VideoList) {
var Metadata = {};
Metadata.Editor = Backbone.View.extend({
@@ -32,6 +37,9 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
else if(model.getType() === MetadataModel.LIST_TYPE) {
new Metadata.List(data);
}
else if(model.getType() === MetadataModel.VIDEO_LIST_TYPE) {
new VideoList(data);
}
else {
// Everything else is treated as GENERIC_TYPE, which uses String editor.
new Metadata.String(data);
@@ -74,95 +82,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
}
});
Metadata.AbstractEditor = Backbone.View.extend({
// Model is MetadataModel
initialize : function() {
var self = this;
var templateName = _.result(this, 'templateName');
// Backbone model cid is only unique within the collection.
this.uniqueId = _.uniqueId(templateName + "_");
var tpl = document.getElementById(templateName).text;
if(!tpl) {
console.error("Couldn't load template: " + templateName);
}
this.template = _.template(tpl);
this.$el.html(this.template({model: this.model, uniqueId: this.uniqueId}));
this.listenTo(this.model, 'change', this.render);
this.render();
},
/**
* The ID/name of the template. Subclasses must override this.
*/
templateName: '',
/**
* Returns the value currently displayed in the editor/view. Subclasses should implement this method.
*/
getValueFromEditor : function () {},
/**
* Sets the value currently displayed in the editor/view. Subclasses should implement this method.
*/
setValueInEditor : function (value) {},
/**
* Sets the value in the model, using the value currently displayed in the view.
*/
updateModel: function () {
this.model.setValue(this.getValueFromEditor());
},
/**
* Clears the value currently set in the model (reverting to the default).
*/
clear: function () {
this.model.clear();
},
/**
* Shows the clear button, if it is not already showing.
*/
showClearButton: function() {
if (!this.$el.hasClass('is-set')) {
this.$el.addClass('is-set');
this.getClearButton().removeClass('inactive');
this.getClearButton().addClass('active');
}
},
/**
* Returns the clear button.
*/
getClearButton: function () {
return this.$el.find('.setting-clear');
},
/**
* Renders the editor, updating the value displayed in the view, as well as the state of
* the clear button.
*/
render: function () {
if (!this.template) return;
this.setValueInEditor(this.model.getDisplayValue());
if (this.model.isExplicitlySet()) {
this.showClearButton();
}
else {
this.$el.removeClass('is-set');
this.getClearButton().addClass('inactive');
this.getClearButton().removeClass('active');
}
return this;
}
});
Metadata.String = Metadata.AbstractEditor.extend({
Metadata.String = AbstractEditor.extend({
events : {
"change input" : "updateModel",
@@ -181,7 +101,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
}
});
Metadata.Number = Metadata.AbstractEditor.extend({
Metadata.Number = AbstractEditor.extend({
events : {
"change input" : "updateModel",
@@ -191,7 +111,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
},
render: function () {
Metadata.AbstractEditor.prototype.render.apply(this);
AbstractEditor.prototype.render.apply(this);
if (!this.initialized) {
var numToString = function (val) {
return val.toFixed(4);
@@ -279,7 +199,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
});
Metadata.Option = Metadata.AbstractEditor.extend({
Metadata.Option = AbstractEditor.extend({
events : {
"change select" : "updateModel",
@@ -316,7 +236,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
}
});
Metadata.List = Metadata.AbstractEditor.extend({
Metadata.List = AbstractEditor.extend({
events : {
"click .setting-clear" : "clear",
@@ -355,7 +275,7 @@ define(["backbone", "underscore", "js/models/metadata"], function(Backbone, _, M
// We don't call updateModel here since it's bound to the
// change event
var list = this.model.get('value') || [];
this.setValueInEditor(list.concat(['']))
this.setValueInEditor(list.concat(['']));
this.$el.find('.create-setting').addClass('is-disabled');
},

View File

@@ -0,0 +1,234 @@
define(
[
"jquery", "backbone", "underscore",
"js/views/transcripts/utils",
"js/views/metadata", "js/collections/metadata",
"js/views/transcripts/metadata_videolist"
],
function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
var Editor = Backbone.View.extend({
tagName: 'div',
initialize: function () {
// prepare data for MetadataView.Editor
var metadata = this.$el.data('metadata'),
models = this.toModels(metadata);
this.collection = new MetadataCollection(models);
// initialize MetadataView.Editor
this.metadataEditor = new MetadataView.Editor({
el: this.$el,
collection: this.collection
});
},
/**
* @function
*
* Convert JSON metadata to List of models
*
* @param {object|string} data Data containing information about metadata
* setting editors.
*
* @returns {array} Processed objects list.
*
* @example:
* var metadata = {
* field_1: {.1.},
* field_2: {.2.}
* };
*
* toModels(metadata) // => [{.1.}, {.2.}]
*
*/
toModels: function (data) {
var metadata = (_.isString(data)) ? JSON.parse(data) : data,
models = [];
for (var model in metadata) {
if (metadata.hasOwnProperty(model)) {
models.push(metadata[model]);
}
}
return models;
},
/**
* @function
*
* Synchronize data from `Advanced` tab of Video player with data in
* `Basic` tab. It is called when we go from `Advanced` to `Basic` tab.
*
* @param {object} metadataCollection Collection containing all models
* with information about metadata
* setting editors in `Advanced` tab.
*
*/
syncBasicTab: function (metadataCollection, metadataView) {
var result = [],
getField = Utils.getField,
component_id = this.$el.closest('.component').data('id'),
subs = getField(metadataCollection, 'sub'),
values = {},
videoUrl, metadata, modifiedValues;
// If metadataCollection is not passed, just exit.
if (!metadataCollection || !metadataView) {
return false;
}
// Get field that should be synchronized with `Advanced` tab fields.
videoUrl = getField(this.collection, 'video_url');
modifiedValues = metadataView.getModifiedMetadataValues();
var isSubsModified = (function (values) {
var isSubsChanged = subs.hasChanged("value");
return Boolean(isSubsChanged && _.isString(values.sub));
}(modifiedValues));
// When we change value of `sub` field in the `Advanced`,
// we update data on backend. That provides possibility to remove
// transcripts.
if (isSubsModified) {
metadata = $.extend(true, {}, modifiedValues);
// Save module state
Utils.command('save', component_id, null, {
metadata: metadata,
current_subs: _.pluck(
Utils.getVideoList(videoUrl.getDisplayValue()),
'video'
)
});
}
// Get values from `Advanced` tab fields (`html5_sources`,
// `youtube_id_1_0`) that should be synchronized.
values.html5Sources = getField(metadataCollection, 'html5_sources')
.getDisplayValue();
values.youtube = getField(metadataCollection, 'youtube_id_1_0')
.getDisplayValue();
// The length of youtube video_id should be 11 characters.
if (values.youtube.length === 11) {
// Just video id is retrieved from `Advanced` tab field and
// it should be transformed to appropriate format.
// OEoXaMPEzfM => http://youtu.be/OEoXaMPEzfM
values.youtube = Utils.getYoutubeLink(values.youtube);
} else {
values.youtube = '';
}
result.push(values.youtube);
result = result.concat(values.html5Sources);
videoUrl.setValue(result);
// Synchronize other fields that has the same `field_name` property.
Utils.syncCollections(metadataCollection, this.collection);
if (isSubsModified){
// When `sub` field is changed, clean Storage to avoid overwriting.
Utils.Storage.remove('sub');
// Trigger `change` event manually if `video_url` model
// isn't changed.
if (!videoUrl.hasChanged()) {
videoUrl.trigger('change');
}
}
},
/**
* @function
*
* Synchronize data from `Basic` tab of Video player with data in
* `Advanced` tab. It is called when we go from `Basic` to `Advanced` tab.
*
* @param {object} metadataCollection Collection containing all models
* with information about metadata
* setting editors in `Advanced` tab.
*
*/
syncAdvancedTab: function (metadataCollection, metadataView) {
var getField = Utils.getField,
subsValue = Utils.Storage.get('sub'),
subs = getField(metadataCollection, 'sub'),
html5Sources, youtube, videoUrlValue, result;
// if metadataCollection is not passed, just exit.
if (!metadataCollection) {
return false;
}
// Get fields from `Advenced` tab (`html5_sources`, `youtube_id_1_0`)
// that should be synchronized.
html5Sources = getField(metadataCollection, 'html5_sources');
youtube = getField(metadataCollection, 'youtube_id_1_0');
// Get value from `Basic` tab `VideoUrl` field that should be
// synchronized.
videoUrlValue = getField(this.collection, 'video_url')
.getDisplayValue();
// Change list representation format to more convenient and group
// them by mode (`youtube`, `html5`).
// Before:
// [
// 'http://youtu.be/OEoXaMPEzfM',
// 'video_name.mp4',
// 'video_name.webm'
// ]
// After:
// {
// youtube: [{mode: `youtube`, type: `youtube`, ...}],
// html5: [
// {mode: `html5`, type: `mp4`, ...},
// {mode: `html5`, type: `webm`, ...}
// ]
// }
result = _.groupBy(
videoUrlValue,
function (value) {
return Utils.parseLink(value).mode;
}
);
if (html5Sources) {
html5Sources.setValue(result.html5 || []);
}
if (youtube) {
if (result.youtube) {
result = Utils.parseLink(result.youtube[0]).video;
} else {
result = '';
}
youtube.setValue(result);
}
// If Utils.Storage contain some subtitles, update them.
if (_.isString(subsValue)) {
subs.setValue(subsValue);
// After updating should be removed, because it might overwrite
// subtitles added by user manually.
Utils.Storage.remove('sub');
}
// Synchronize other fields that has the same `field_name` property.
Utils.syncCollections(this.collection, metadataCollection);
}
});
return Editor;
});

View File

@@ -0,0 +1,201 @@
define(
[
"jquery", "backbone", "underscore",
"js/views/transcripts/utils"
],
function($, Backbone, _, Utils) {
var FileUploader = Backbone.View.extend({
invisibleClass: 'is-invisible',
// Pre-defined list of supported file formats.
validFileExtensions: ['srt'],
events: {
'change .file-input': 'changeHandler',
'click .setting-upload': 'clickHandler'
},
uploadTpl: '#file-upload',
initialize: function () {
_.bindAll(this);
this.file = false;
this.render();
},
render: function () {
var tpl = $(this.uploadTpl).text(),
tplContainer = this.$el.find('.transcripts-file-uploader'),
videoList = this.options.videoListObject.getVideoObjectsList();
if (tplContainer.length) {
if (!tpl) {
console.error('Couldn\'t load Transcripts File Upload template');
return;
}
this.template = _.template(tpl);
tplContainer.html(this.template({
ext: this.validFileExtensions,
component_id: this.options.component_id,
video_list: videoList
}));
this.$form = this.$el.find('.file-chooser');
this.$input = this.$form.find('.file-input');
this.$progress = this.$el.find('.progress-fill');
}
},
/**
* @function
*
* Uploads file to the server. Get file from the `file` property.
*
*/
upload: function () {
if (!this.file) {
return;
}
this.$form.ajaxSubmit({
beforeSend: this.xhrResetProgressBar,
uploadProgress: this.xhrProgressHandler,
complete: this.xhrCompleteHandler
});
},
/**
* @function
*
* Handle click event on `upload` button.
*
* @param {object} event Event object.
*
*/
clickHandler: function (event) {
event.preventDefault();
this.$input
.val(null)
// Show system upload window
.trigger('click');
},
/**
* @function
*
* Handle change event.
*
* @param {object} event Event object.
*
*/
changeHandler: function (event) {
event.preventDefault();
this.options.messenger.hideError();
this.file = this.$input.get(0).files[0];
// if file has valid file extension, than upload file.
// Otherwise, show error message.
if (this.checkExtValidity(this.file)) {
this.upload();
} else {
this.options.messenger
.showError('Please select a file in .srt format.');
}
},
/**
* @function
*
* Checks that file has supported extension.
*
* @param {object} file Object with information about file.
*
* @returns {boolean} Indicate that file has supported or unsupported
* extension.
*
*/
checkExtValidity: function (file) {
if (!file.name) {
return void(0);
}
var fileExtension = file.name
.split('.')
.pop()
.toLowerCase();
if ($.inArray(fileExtension, this.validFileExtensions) !== -1) {
return true;
}
return false;
},
/**
* @function
*
* Resets progress bar.
*
*/
xhrResetProgressBar: function () {
var percentVal = '0%';
this.$progress
.width(percentVal)
.html(percentVal)
.removeClass(this.invisibleClass);
},
/**
* @function
*
* Callback function to be invoked with upload progress information
* (if supported by the browser).
*
* @param {object} event Event object.
*
* @param {integer} position Amount of transmitted bytes.
* *
* @param {integer} total Total size of file.
* *
* @param {integer} percentComplete Object with information about file.
*
*/
xhrProgressHandler: function (event, position, total, percentComplete) {
var percentVal = percentComplete + '%';
this.$progress
.width(percentVal)
.html(percentVal);
},
/**
* @function
*
* Handle complete uploading.
*
*/
xhrCompleteHandler: function (xhr) {
var resp = JSON.parse(xhr.responseText),
err = resp.status || 'Error: Uploading failed.',
sub = resp.subs;
this.$progress
.addClass(this.invisibleClass);
if (xhr.status === 200) {
this.options.messenger.render('uploaded', resp);
Utils.Storage.set('sub', sub);
} else {
this.options.messenger.showError(err);
}
}
});
return FileUploader;
});

View File

@@ -0,0 +1,233 @@
define(
[
"jquery", "backbone", "underscore",
"js/views/transcripts/utils", "js/views/transcripts/file_uploader",
"gettext"
],
function($, Backbone, _, Utils, FileUploader, gettext) {
var MessageManager = Backbone.View.extend({
tagName: 'div',
elClass: '.wrapper-transcripts-message',
invisibleClass: 'is-invisible',
events: {
'click .setting-import': 'importHandler',
'click .setting-replace': 'replaceHandler',
'click .setting-choose': 'chooseHandler',
'click .setting-use-existing': 'useExistingHandler'
},
// Pre-defined dict with anchors to status templates.
templates: {
not_found: '#transcripts-not-found',
found: '#transcripts-found',
import: '#transcripts-import',
replace: '#transcripts-replace',
uploaded: '#transcripts-uploaded',
use_existing: '#transcripts-use-existing',
choose: '#transcripts-choose'
},
initialize: function () {
_.bindAll(this);
this.component_id = this.$el.closest('.component').data('id');
this.fileUploader = new FileUploader({
el: this.$el,
messenger: this,
component_id: this.component_id,
videoListObject: this.options.parent
});
},
render: function (template_id, params) {
var tplHtml = $(this.templates[template_id]).text(),
videoList = this.options.parent.getVideoObjectsList(),
// Change list representation format to more convenient and group
// them by video property.
// Before:
// [
// {mode: `html5`, type: `mp4`, video: `video_name_1`},
// {mode: `html5`, type: `webm`, video: `video_name_2`}
// ]
// After:
// {
// `video_name_1`: [{mode: `html5`, type: `webm`, ...}],
// `video_name_2`: [{mode: `html5`, type: `mp4`, ...}]
// }
groupedList = _.groupBy(
videoList,
function (value) {
return value.video;
}
),
html5List = (params) ? params.html5_local : [],
template;
if (!tplHtml) {
console.error('Couldn\'t load Transcripts status template');
return;
}
template = _.template(tplHtml);
this.$el.find('.transcripts-status')
.removeClass('is-invisible')
.find(this.elClass).html(template({
component_id: encodeURIComponent(this.component_id),
html5_list: html5List,
grouped_list: groupedList,
subs_id: (params) ? params.subs: ''
}));
this.fileUploader.render();
return this;
},
/**
* @function
*
* Shows error message.
*
* @param {string} err Error message that will be shown
*
* @param {boolean} hideButtons Hide buttons
*
*/
showError: function (err, hideButtons) {
var $error = this.$el.find('.transcripts-error-message');
if (err) {
// Hide any other error messages.
this.hideError();
$error
.html(gettext(err))
.removeClass(this.invisibleClass);
if (hideButtons) {
this.$el.find('.wrapper-transcripts-buttons')
.addClass(this.invisibleClass);
}
}
},
/**
* @function
*
* Hides error message.
*
*/
hideError: function () {
this.$el.find('.transcripts-error-message')
.addClass(this.invisibleClass);
this.$el.find('.wrapper-transcripts-buttons')
.removeClass(this.invisibleClass);
},
/**
* @function
*
* Handle import button.
*
* @params {object} event Event object.
*
*/
importHandler: function (event) {
event.preventDefault();
this.processCommand('replace', 'Error: Import failed.');
},
/**
* @function
*
* Handle replace button.
*
* @params {object} event Event object.
*
*/
replaceHandler: function (event) {
event.preventDefault();
this.processCommand('replace', 'Error: Replacing failed.');
},
/**
* @function
*
* Handle choose buttons.
*
* @params {object} event Event object.
*
*/
chooseHandler: function (event) {
event.preventDefault();
var videoId = $(event.currentTarget).data('video-id');
this.processCommand('choose', 'Error: Choosing failed.', videoId);
},
/**
* @function
*
* Handle `use existing` button.
*
* @params {object} event Event object.
*
*/
useExistingHandler: function (event) {
event.preventDefault();
this.processCommand('rename', 'Error: Choosing failed.');
},
/**
* @function
*
* Decorator for `command` function in the Utils.
*
* @params {string} action Action that will be invoked on server. Is a part
* of url.
*
* @params {string} errorMessage Error massage that will be shown if any
* connection error occurs
*
* @params {string} videoId Extra parameter that sometimes should be sent
* to the server
*
*/
processCommand: function (action, errorMessage, videoId) {
var self = this,
component_id = this.component_id,
videoList = this.options.parent.getVideoObjectsList(),
extraParam, xhr;
if (videoId) {
extraParam = { html5_id: videoId };
}
xhr = Utils.command(action, component_id, videoList, extraParam)
.done(function (resp) {
var sub = resp.subs;
self.render('found', resp);
Utils.Storage.set('sub', sub);
})
.fail(function (resp) {
var message = resp.status || errorMessage;
self.showError(message);
});
return xhr;
}
});
return MessageManager;
});

View File

@@ -0,0 +1,410 @@
define(
[
"jquery", "backbone", "underscore", "js/views/abstract_editor",
"js/views/transcripts/utils", "js/views/transcripts/message_manager",
"js/views/metadata"
],
function($, Backbone, _, AbstractEditor, Utils, MessageManager, MetadataView) {
VideoList = AbstractEditor.extend({
// Time that we wait since the last time user typed.
inputDelay: 300,
events : {
'click .setting-clear' : 'clear',
'keypress .setting-input' : 'showClearButton',
'click .collapse-setting' : 'toggleExtraVideosBar'
},
templateName: 'metadata-videolist-entry',
// Pre-defined dict of placeholders: "videoType - placeholder" pairs.
placeholders: {
'webm': '.webm',
'mp4': 'http://somesite.com/video.mp4',
'youtube': 'http://youtube.com/'
},
initialize: function () {
// Initialize MessageManager that is responsible for
// status messages and errors.
this.messenger = new MessageManager({
el: this.$el,
parent: this
});
// Call it after MessageManager. This is because
// MessageManager is used in `render` method that
// is called in `AbstractEditor.prototype.initialize`.
AbstractEditor.prototype.initialize
.apply(this, arguments);
this.$el.on(
'input', 'input',
_.debounce(_.bind(this.inputHandler, this), this.inputDelay)
);
this.component_id = this.$el.closest('.component').data('id');
},
render: function () {
// Call inherited `render` method.
AbstractEditor.prototype.render
.apply(this, arguments);
var self = this,
component_id = this.$el.closest('.component').data('id'),
videoList = this.getVideoObjectsList(),
showServerError = function (response) {
var errorMessage = response.status || 'Error: Connection with server failed.';
self.messenger
.render('not_found')
.showError(
errorMessage,
true // hide buttons
);
};
this.$extraVideosBar = this.$el.find('.videolist-extra-videos');
if (videoList.length === 0) {
this.messenger
.render('not_found')
.showError(
'No sources',
true // hide buttons
);
return void(0);
}
// Check current state of Timed Transcripts.
Utils.command('check', component_id, videoList)
.done(function (resp) {
var params = resp,
len = videoList.length,
mode = (len === 1) ? videoList[0].mode : false;
// If there are more than 1 video or just html5 source is
// passed, video sources box should expand
if (len > 1 || mode === 'html5') {
self.openExtraVideosBar();
} else {
self.closeExtraVideosBar();
}
self.messenger.render(resp.command, params);
self.checkIsUniqVideoTypes();
// Synchronize transcripts field in the `Advanced` tab.
Utils.Storage.set('sub', resp.subs);
})
.fail(showServerError);
},
/**
* @function
*
* Returns the values currently displayed in the editor/view.
*
* @returns {array} List of non-empty values.
*
*/
getValueFromEditor: function () {
return _.map(
this.$el.find('.input'),
function (ele) {
return ele.value.trim();
}
).filter(_.identity);
},
/**
* @function
*
* Returns list of objects with information about the values currently
* displayed in the editor/view.
*
* @returns {array} List of objects.
*
* @examples
* this.getValueFromEditor(); // =>
* [
* 'http://youtu.be/OEoXaMPEzfM',
* 'video_name.mp4',
* 'video_name.webm'
* ]
*
* this.getVideoObjectsList(); // =>
* [
* {mode: `youtube`, type: `youtube`, ...},
* {mode: `html5`, type: `mp4`, ...},
* {mode: `html5`, type: `webm`, ...}
* ]
*
*/
getVideoObjectsList: function () {
var links = this.getValueFromEditor();
return Utils.getVideoList(links);
},
/**
* @function
*
* Sets the values currently displayed in the editor/view.
*
* @params {array} value List of values.
*
*/
setValueInEditor: function (value) {
var parseLink = Utils.parseLink,
list = this.$el.find('.input'),
val = value.filter(_.identity),
placeholders = this.getPlaceholders(val);
list.each(function (index) {
$(this)
.val(val[index] || null)
.attr('placeholder', placeholders[index]);
});
},
/**
* @function
*
* Returns the placeholders for the values currently displayed in the
* editor/view.
*
* @returns {array} List of placeholders.
*
*/
getPlaceholders: function (value) {
var parseLink = Utils.parseLink,
placeholders = _.clone(this.placeholders);
// Returned list should have the same size as a count of editors/views.
return _.map(
this.$el.find('.input'),
function (element, index) {
var linkInfo = parseLink(value[index]),
type = (linkInfo) ? linkInfo.type : null,
label;
// If placeholder for current video type exist, retrieve it
// and remove from cloned list.
// Otherwise, we use the remaining placeholders.
if (placeholders[type]) {
label = placeholders[type];
delete placeholders[type];
} else {
if ( !($.isArray(placeholders)) ) {
placeholders = _.values(placeholders);
}
label = placeholders.pop();
}
return label;
}
);
},
/**
* @function
*
* Opens video sources box.
*
* @params {object} event Event object.
*
*/
openExtraVideosBar: function (event) {
if (event && event.preventDefault) {
event.preventDefault();
}
this.$extraVideosBar.addClass('is-visible');
},
/**
* @function
*
* Closes video sources box.
*
* @params {object} event Event object.
*
*/
closeExtraVideosBar: function (event) {
if (event && event.preventDefault) {
event.preventDefault();
}
this.$extraVideosBar.removeClass('is-visible');
},
/**
* @function
*
* Toggles video sources box.
*
* @params {object} event Event object.
*
*/
toggleExtraVideosBar: function (event) {
if (event && event.preventDefault) {
event.preventDefault();
}
if (this.$extraVideosBar.hasClass('is-visible')) {
this.closeExtraVideosBar.apply(this, arguments);
} else {
this.openExtraVideosBar.apply(this, arguments);
}
},
/**
* @function
*
* Handle `input` event.
*
* @params {object} event Event object.
*
*/
inputHandler: function (event) {
if (event && event.preventDefault) {
event.preventDefault();
}
var $el = $(event.currentTarget),
$inputs = this.$el.find('.input'),
entry = $el.val(),
data = Utils.parseLink(entry),
isNotEmpty = Boolean(entry);
// Empty value should not be validated
if (this.checkValidity(data, isNotEmpty)) {
var fieldsValue = this.getValueFromEditor(),
modelValue = this.model.getValue();
if (modelValue) {
// Remove empty values
modelValue = modelValue.filter(_.identity);
}
// When some correct value is adjusted (model is changed),
// then field changes to incorrect value (no changes to model),
// then back to previous correct value (that value is already
// in model). In this case Backbone doesn't trigger 'change'
// event on model. That's why render method will not be invoked
// and we should hide error here.
if (_.isEqual(fieldsValue, modelValue)) {
this.messenger.hideError();
} else {
this.updateModel();
}
// Enable inputs.
$inputs
.prop('disabled', false)
.removeClass('is-disabled');
} else {
// If any error occurs, disable all inputs except the current.
// User cannot change other inputs before putting valid value in
// the current input.
$inputs
.not($el)
.prop('disabled', true)
.addClass('is-disabled');
// If error occurs in the main video input, just close video
// sources box.
if ($el.hasClass('videolist-url')) {
this.closeExtraVideosBar();
}
}
},
/**
* @function
*
* Checks the values currently displayed in the editor/view have unique
* types (mp4 | webm | youtube).
*
* @param {object} videoList List of objects with information about the
* values currently displayed in the editor/view
*
* @returns {boolean} Boolean value that indicate if video types are unique.
*
*/
isUniqVideoTypes: function (videoList) {
// Extract a list of "type" property values.
var arr = _.pluck(videoList, 'type'), // => ex: ['youtube', 'mp4', 'mp4']
// Produces a duplicate-free version of the array.
uniqArr = _.uniq(arr); // => ex: ['youtube', 'mp4']
return arr.length === uniqArr.length;
},
/**
* @function
*
* Shows error message if the values currently displayed in the
* editor/view have duplicate types.
*
* @param {object} list List of objects with information about the
* values currently displayed in the editor/view
*
* @returns {boolean} Boolean value that indicate if video types are unique.
*
*/
checkIsUniqVideoTypes: function (list) {
var videoList = list || this.getVideoObjectsList(),
isUnique = true;
if (!this.isUniqVideoTypes(videoList)) {
this.messenger
.showError('Link types should be unique.', true);
isUnique = false;
}
return isUnique;
},
/**
* @function
*
* Checks if the values currently displayed in the editor/view have
* valid values and show error messages.
*
* @param {object} data Objects with information about the value
* currently displayed in the editor/view
*
* @param {boolean} showErrorModeMessage Disable mode validation
*
* @returns {boolean} Boolean value that indicate if value is valid.
*
*/
checkValidity: function (data, showErrorModeMessage) {
var self = this,
videoList = this.getVideoObjectsList();
if (!this.checkIsUniqVideoTypes(videoList)) {
return false;
}
if (data.mode === 'incorrect' && showErrorModeMessage) {
this.messenger
.showError('Incorrect url format.', true);
return false;
}
return true;
}
});
return VideoList;
});

View File

@@ -0,0 +1,365 @@
define(["jquery", "underscore", "jquery.ajaxQueue"], function($, _) {
var Utils = (function () {
var Storage = {};
/**
* @function
*
* Adds some data to the Storage object. If data with existent `data_id`
* is added, nothing happens.
*
* @param {string} data_id Unique identifier for the data.
* @param {any} data Data that should be stored.
*
* @returns {object} Object itself for chaining.
*/
Storage.set = function (data_id, data) {
Storage[data_id] = data;
return this;
};
/**
* @function
*
* Return data from the Storage object by identifier.
*
* @param {string} data_id Unique identifier of the data.
*
* @returns {any} Stored data.
*/
Storage.get= function (data_id) {
return Storage[data_id];
};
/**
* @function
*
* Deletes data from the Storage object by identifier.
*
* @param {string} data_id Unique identifier of the data.
*
* @returns {boolean} Boolean value that indicate if data is removed.
*/
Storage.remove = function (data_id) {
return (delete Storage[data_id]);
};
/**
* @function
*
* Returns model from collection by 'field_name' property.
*
* @param {object} collection The model (CMS.Models.Metadata) containing
* information about metadata setting editors.
* @param {string} field_name Name of field that should be found.
*
* @returns {
* object: when model exist,
* undefined: when model doesn't exist.
* }
*/
var _getField = function (collection, field_name) {
var model;
if (collection && field_name) {
model = collection.findWhere({
field_name: field_name
});
}
return model;
};
/**
* @function
*
* Parses Youtube link and return video id.
*
* These are the types of URLs supported:
* http://www.youtube.com/watch?v=OEoXaMPEzfM&feature=feedrec_grec_index
* http://www.youtube.com/user/IngridMichaelsonVEVO#p/a/u/1/OEoXaMPEzfM
* http://www.youtube.com/v/OEoXaMPEzfM?fs=1&hl=en_US&rel=0
* http://www.youtube.com/watch?v=OEoXaMPEzfM#t=0m10s
* http://www.youtube.com/embed/OEoXaMPEzfM?rel=0
* http://www.youtube.com/watch?v=OEoXaMPEzfM
* http://youtu.be/OEoXaMPEzfM
*
* @param {string} url Url that should be parsed.
*
* @returns {
* string: Video Id,
* undefined: when url has incorrect format or argument is
* non-string, video id's length is not equal 11.
* }
*/
var _youtubeParser = (function () {
var cache = {};
return function (url) {
if (typeof url !== 'string') {
return void(0);
}
if (cache[url]) {
return cache[url];
}
var regExp = /.*(?:youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=)([^#\&\?]*).*/;
var match = url.match(regExp);
cache[url] = (match && match[1].length === 11) ? match[1] : void(0);
return cache[url];
};
}());
/**
* @function
*
* Parses links with html5 video sources in mp4 or webm formats.
*
* @param {string} url Url that should be parsed.
*
* @returns {
* object: Object with information about the video
* (file name, video type),
* undefined: when url has incorrect format or argument is
* non-string.
* }
*/
var _videoLinkParser = (function () {
var cache = {};
return function (url) {
if (typeof url !== 'string') {
return void(0);
}
if (cache[url]) {
return cache[url];
}
var link = document.createElement('a'),
match;
link.href = url;
match = link.pathname
.split('/')
.pop()
.match(/(.+)\.(mp4|webm)$/);
if (match) {
cache[url] = {
video: match[1],
type: match[2]
};
}
return cache[url];
};
}());
/**
* @function
*
* Facade function that parses html5 and youtube links.
*
* @param {string} url Url that should be parsed.
*
* @returns {
* object: Object with information about the video:
* {
* mode: "youtube|html5|incorrect",
* video: "file_name|youtube_id",
* type: "youtube|mp4|webm"
* },
* undefined: when argument is non-string.
* }
*/
var _linkParser = function (url) {
var result;
if (typeof url !== 'string') {
return void(0);
}
if (_youtubeParser(url)) {
result = {
mode: 'youtube',
video: _youtubeParser(url),
type: 'youtube'
};
} else if (_videoLinkParser(url)) {
result = $.extend({mode: 'html5'}, _videoLinkParser(url));
} else {
result = {
mode: 'incorrect'
};
}
return result;
};
/**
* @function
*
* Returns short-hand youtube url.
*
* @param {string} video_id Youtube Video Id that will be added to the link.
*
* @returns {string} Short-hand Youtube url.
*
* @example
* _getYoutubeLink('OEoXaMPEzfM'); => 'http://youtu.be/OEoXaMPEzfM'
*/
var _getYoutubeLink = function (video_id) {
return 'http://youtu.be/' + video_id;
};
/**
* @function
*
* Returns list of objects with information about the passed links.
*
* @param {array} links List of links that will be processed.
*
* @returns {array} List of objects.
*
* @examples
* var links = [
* 'http://youtu.be/OEoXaMPEzfM',
* 'video_name.mp4',
* 'video_name.webm'
* ]
*
* _getVideoList(links); // =>
* [
* {mode: `youtube`, type: `youtube`, ...},
* {mode: `html5`, type: `mp4`, ...},
* {mode: `html5`, type: `webm`, ...}
* ]
*
*/
var _getVideoList = function (links) {
if ($.isArray(links)) {
var arr = [],
data;
for (var i = 0, len = links.length; i < len; i += 1) {
data = _linkParser(links[i]);
if (data.mode !== 'incorrect') {
arr.push(data);
}
}
return arr;
}
};
/**
* @function
*
* Synchronizes 2 Backbone collections by 'field_name' property.
*
* @param {object} fromCollection Collection with which synchronization
* will happens.
* @param {object} toCollection Collection which will synchronized.
*
*/
var _syncCollections = function (fromCollection, toCollection) {
fromCollection.each(function (m) {
var model = toCollection.findWhere({
field_name: m.getFieldName()
});
if (model) {
model.setValue(m.getDisplayValue());
}
});
};
/**
* @function
*
* Sends Ajax requests in appropriate format.
*
* @param {string} action Action that will be invoked on server. Is a part
* of url.
* @param {string} component_id Id of component.
* @param {array} videoList List of object with information about inserted
* urls.
* @param {object} extraParams Extra parameters that can be send to the
* server
*
* @returns {object} XMLHttpRequest object. Using this object, we can attach
* callbacks to AJAX request events (for example on 'done', 'fail',
* etc.).
*/
var _command = (function () {
// We will store the XMLHttpRequest object that $.ajax() function
// returns, to abort an ongoing AJAX request (if necessary) upon
// subsequent invocations of _command() function.
//
// A new AJAX request will be made on each invocation of the
// _command() function.
var xhr = null;
return function (action, component_id, videoList, extraParams) {
var params, data;
console.log('[_command]: arguments = ', arguments);
if (extraParams) {
if ($.isPlainObject(extraParams)) {
params = extraParams;
} else {
params = {params: extraParams};
}
}
data = $.extend(
{ id: component_id },
{ videos: videoList },
params
);
xhr = $.ajaxQueue({
url: '/transcripts/' + action,
data: { data: JSON.stringify(data) },
notifyOnError: false,
type: 'get'
});
return xhr;
};
}());
return {
getField: _getField,
parseYoutubeLink: _youtubeParser,
parseHTML5Link: _videoLinkParser,
parseLink: _linkParser,
getYoutubeLink: _getYoutubeLink,
syncCollections: _syncCollections,
command: _command,
getVideoList: _getVideoList,
Storage: {
set: Storage.set,
get: Storage.get,
remove: Storage.remove
}
};
}());
return Utils;
});