1/**
2 * EGroupware - SmallParT - videobar widget
3 *
4 * @link https://www.egroupware.org
5 * @package smallpart
6 * @subpackage Ui
7 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
8 */
9
10import {et2_video} from "../../api/js/etemplate/et2_widget_video";
11import {et2_createWidget, et2_register_widget, WidgetConfig} from "../../api/js/etemplate/et2_core_widget";
12import {ClassWithAttributes} from '../../api/js/etemplate/et2_core_inheritance';
13import {CommentType} from './app';
14
15type CommentMarked = Array<{x: number; y: number; c: string}>;
16export class et2_smallpart_videobar extends et2_video implements et2_IResizeable
17{
18
19	static readonly _attributes : any = {
20		"marking_enabled": {
21			"name": "Marking",
22			"type": "boolean",
23			"description": "",
24			"default": false
25		},
26
27		"marking_readonly": {
28			"name": "Marking readonly",
29			"type": "boolean",
30			"description": "",
31			"default": true
32		},
33
34		"marking_color": {
35			"name": "Marking color",
36			"type": "string",
37			"description": "",
38			"default": "ffffff"
39		},
40
41		"marking_callback": {
42
43		},
44
45		"slider_callback": {
46			"name": "Slider on click callback",
47			"type":"js",
48			"default": et2_no_init,
49			"description": "Callback function to get executed after clicking om slider bar"
50		},
51
52		"slider_tags": {
53			"name": "slider tags",
54			"type": "any",
55			"description": "comment tags on slider",
56			"default": {}
57		},
58
59		"stop_contextmenu": {
60			"name": "stop contextmenu",
61			"type": "boolean",
62			"description": "This would prevent the browser native contextmenu on video tag",
63			"default": true
64		},
65		"ontimeupdate_callback": {
66			"name": "ontimeupdate callback",
67			"type":"js",
68			"default": et2_no_init,
69			"description": "Callback function to get executed while video is playing"
70		},
71		"onresize_callback": {
72			"name": "onresize callback",
73			'type': "js",
74			"default": et2_no_init,
75			"description": "Callback function called when video gets resized"
76		}
77	};
78
79	private container: JQuery = null;
80
81	private wrapper: JQuery = null;
82
83	private slider: JQuery = null;
84
85	private marking: JQuery = null;
86
87	private timer = null;
88
89	private slider_progressbar: JQuery = null;
90
91	private comments: Array<CommentType> = null;
92
93	private mark_ratio: number = 0;
94
95	private marking_color: string = 'ffffff';
96
97	private marks: CommentMarked = [];
98
99	private marking_readonly: boolean = true;
100
101	private _scrolled: Array = [];
102
103	/**
104	 *
105	 * @memberOf et2_DOMWidget
106	 */
107	constructor(_parent, _attrs? : WidgetConfig, _child? : object)
108	{
109		// Call the inherited constructor
110		super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_smallpart_videobar._attributes, _child || {}));
111
112		// wrapper DIV container for video tag and marking selector
113		this.wrapper = jQuery(document.createElement('div'))
114			.append(this.video)
115			.addClass('videobar_wrapper');
116
117		// widget container
118		this.container = jQuery(document.createElement('div'))
119			.append(this.wrapper)
120			.addClass('et2_smallpart_videobar videobar_container');
121
122		// slider div
123		this.slider = jQuery(document.createElement('div'))
124			.appendTo(this.container)
125			.addClass('videobar_slider');
126
127		// marking div
128		this.marking = jQuery(document.createElement('div'))
129			.addClass('videobar_marking container');
130		this.marking.append(jQuery(document.createElement('div'))
131			.addClass('markingMask maskOn'));
132		this.marking.append(jQuery(document.createElement('div'))
133			.addClass('marksContainer'));
134
135		// slider progressbar span
136		this.slider_progressbar = jQuery(document.createElement('span'))
137			.addClass('videobar_slider_progressbar')
138			.appendTo(this.slider);
139
140		this.wrapper.append(this.marking);
141
142		this._buildHandlers();
143
144		// timer span
145		this.timer = et2_createWidget('smallpart-videotime', {}, this);
146
147		//@TODO: this should not be necessary but for some reason attach to the dom
148		// not working on et2_creatWidget there manully attach it here.
149		jQuery(this.timer.getDOMNode()).attr('id',  this.id+"[timer]")
150		this.container.append(this.timer.getDOMNode());
151
152		if (this.options.stop_contextmenu) this.video.on('contextmenu', function(){return false;});
153
154		this.setDOMNode(this.container[0]);
155	}
156
157	private _buildHandlers()
158	{
159		var self = this;
160		this.slider.on('click', function(e){
161			self._slider_onclick.call(self ,e);
162		});
163
164	}
165
166	private _slider_onclick(e:JQueryMouseEventObject)
167	{
168		this.slider_progressbar.css({width:e.offsetX});
169		this._scrolled = [];
170		this.video[0]['previousTime'] = this.video[0]['currentTime'];
171		this.video[0]['currentTime'] = e.offsetX * this.video[0].duration / this.slider.width();
172		this.timer.set_value(this.video[0]['currentTime']);
173		if (typeof this.slider_callback == "function") this.slider_callback(this.video[0], this);
174	}
175
176	doLoadingFinished(): boolean
177	{
178		super.doLoadingFinished();
179		let self = this;
180
181		this.video[0].addEventListener("loadedmetadata", function(){
182			self._videoLoadnigIsFinished();
183		});
184		return false;
185	}
186
187	public _vtimeToSliderPosition(_vtime: string | number): number
188	{
189		return this.slider.width() / this.video[0]['duration']  * parseFloat(<string>_vtime);
190	}
191
192	public set_slider_tags(_comments: Array<CommentType>)
193	{
194		this.comments = _comments;
195		// need to wait video is loaded before setting tags
196		if (this.video.width() == 0) return;
197
198		this.slider.empty();
199		this.slider.append(this.slider_progressbar);
200		for (let i in this.comments)
201		{
202			if (!this.comments[i]) continue;
203			this.slider.append(jQuery(document.createElement('span'))
204				.offset({left: this._vtimeToSliderPosition(this.comments[i]['comment_starttime'])})
205				.css({'background-color': '#'+this.comments[i]['comment_color']})
206				.attr('data-id', this.comments[i]['comment_id'])
207				.addClass('commentOnSlider commentColor'+this.comments[i]['comment_color']));
208		}
209	}
210
211	public set_marking_readonly(_state)
212	{
213		this.marking_readonly = _state;
214	}
215
216	public set_marking_color (_color)
217	{
218		this.marking_color = _color;
219	}
220
221	public set_marking_enabled(_state: boolean, _callback)
222	{
223		let self= this;
224		let isDrawing = false;
225		this.marking.toggle(_state);
226		let drawing = function(e)
227		{
228			if (e.target.nodeName !== "SPAN" && !self.marking_readonly)
229					{
230						let pixelX = Math.floor(e.originalEvent.offsetX / self.mark_ratio) * self.mark_ratio;
231						let pixelY = Math.floor(e.originalEvent.offsetY /  self.mark_ratio) * self.mark_ratio;
232						let mark = {
233							x: self._convertMarkedPixelX2Percent(pixelX),
234							y: self._convertMarkedPixelY2Percent(pixelY),
235							c: self.marking_color
236						};
237						self._addMark(mark);
238						_callback(mark);
239			}
240		};
241		if (_state)
242		{
243			this.marking.find('.marksContainer')
244				.off()
245				.on('mousedown', function(e){
246					console.log('mousedown')
247					isDrawing = true;
248				})
249				.on('mouseup', function(e){
250					isDrawing = false;
251					drawing(e);
252				})
253				.on('mousemove', function(e){
254					if (isDrawing === true) {
255						drawing(e);
256					}
257				});
258		}
259	}
260
261	public setMarkingMask(_state: boolean)
262	{
263		if (_state)
264		{
265			this.marking.find('.markingMask').addClass('maskOn');
266		}
267		else
268		{
269			this.marking.find('.markingMask').removeClass('maskOn');
270		}
271	}
272
273	public setMarksState(_state: boolean)
274	{
275		this.marking.find('.marksContainer').toggle(_state);
276	}
277
278	public setMarks(_marks: CommentMarked)
279	{
280		let self = this;
281		// clone the array to avoid missing its original content
282		let $marksContainer = this.marking.find('.marksContainer').empty();
283		this.marks = _marks?.slice(0) || [];
284		this.mark_ratio = parseFloat((this.video.width() / 80).toPrecision(4));
285		for(let i in _marks)
286		{
287			$marksContainer.append(jQuery(document.createElement('span'))
288				.offset({left: this._convertMarkPercentX2Pixel(_marks[i]['x']), top: this._convertMarkPercentY2Pixel(_marks[i]['y'])})
289				.css({
290					"background-color":"#"+_marks[i]['c'],
291					"width": this.mark_ratio,
292					"height": this.mark_ratio
293				})
294				.attr('data-color', _marks[i]['c'])
295				.click(function(){
296					if (!self.marking_readonly)	self._removeMark(self._getMark(this), this);
297				})
298				.addClass('marks'));
299		}
300	}
301
302	public getMarks(): CommentMarked
303	{
304		if (this.marks) return this.marks;
305		let $marks = this.marking.find('.marksContainer').find('span.marks');
306		let marks = [];
307		let self =this;
308		$marks.each(function(){
309			marks.push({
310				x: self._convertMarkedPixelX2Percent(parseFloat(this.style.left)),
311				y: self._convertMarkedPixelY2Percent(parseFloat(this.style.top)),
312				c: this.dataset['color']
313			})
314		});
315		this.marks = marks;
316		return marks;
317	}
318
319	private _getMark(_node: HTMLElement): CommentMarked
320	{
321		return [{
322			x: this._convertMarkedPixelX2Percent(parseFloat(_node.style.left)),
323			y: this._convertMarkedPixelY2Percent(parseFloat(_node.style.top)),
324			c: _node.dataset['color']
325		}];
326	}
327
328	private _addMark(_mark)
329	{
330		this.marks.push(_mark);
331		this.setMarks(this.marks);
332	}
333
334	public removeMarks()
335	{
336		this.marks = [];
337		this.marking.find('.marksContainer').find('span.marks').remove();
338	}
339
340	private _removeMark(_mark: CommentMarked, _node: HTMLElement)
341	{
342		for (let i in this.marks)
343		{
344			if (this.marks[i]['x'] == _mark[0]['x'] && this.marks[i]['y'] == _mark[0]['y']) this.marks.splice(<number><unknown>i, 1);
345		}
346		if (_node) jQuery(_node).remove();
347	}
348
349	private _convertMarkedPixelX2Percent(_x: number): number
350	{
351		return parseFloat((_x / this.video.width() / 0.01).toPrecision(4));
352	}
353
354	private _convertMarkedPixelY2Percent(_y: number): number
355	{
356		return parseFloat((_y / this.video.height() / 0.01).toPrecision(4));
357	}
358
359	private _convertMarkPercentX2Pixel(_x: number): number
360	{
361		return _x * this.video.width() * 0.01;
362	}
363
364	private _convertMarkPercentY2Pixel(_y: number): number
365	{
366		return _y * this.video.height() * 0.01;
367	}
368
369	/**
370	 * Seek to a time / position
371	 *
372	 * @param _vtime in seconds
373	 */
374	public seek_video(_vtime : number)
375	{
376		super.seek_video(_vtime);
377		this._scrolled = [];
378		this.timer.set_value(this.video[0]['currentTime']);
379		this.slider_progressbar.css({width: this._vtimeToSliderPosition(_vtime)});
380	}
381
382	/**
383	 * Play video
384	 */
385	public play_video(_ended_callback, _onTagCallback) : Promise<void>
386	{
387		let self = this;
388		let ended_callback = _ended_callback;
389		this._scrolled = [];
390		return super.play_video().then(function(){
391			self.video[0].ontimeupdate = function(_event){
392				self.slider_progressbar.css({width: self._vtimeToSliderPosition(self.video[0].currentTime)});
393				self.timer.set_value(self.video[0]['currentTime']);
394				if (typeof ended_callback == "function" && self.video[0].ended)
395				{
396					ended_callback.call();
397					self.pause_video();
398				}
399				if (typeof _onTagCallback == "function") {
400					for (let i in self.comments)
401					{
402						if (Math.floor(self.video[0].currentTime) == parseInt(self.comments[i]['comment_starttime'])
403							&& (self._scrolled.length == 0 || self._scrolled.indexOf(parseInt(self.comments[i]['comment_id'])) == -1 ))
404						{
405							_onTagCallback.call(this, self.comments[i]['comment_id']);
406							self._scrolled.push(parseInt(self.comments[i]['comment_id']));
407						}
408					}
409				}
410				if (typeof self.ontimeupdate_callback == "function") {
411					self.ontimeupdate_callback.call(this, Math.floor(self.video[0].currentTime));
412				}
413			};
414		});
415	}
416
417	/**
418	 * Pause video
419	 */
420	public pause_video()
421	{
422		super.pause_video();
423	}
424
425	private _videoLoadnigIsFinished()
426	{
427		// this will make sure that slider and video are synced
428		this.slider.width(this.video.width());
429		this.set_slider_tags(this.comments);
430		this.marking.css({width: this.video.width(), height: this.video.height()});
431	}
432
433	resize (_height)
434	{
435		this.slider.width('auto');
436		this.marking.width('auto');
437		this.slider.width(this.video.width());
438		this.marking.css({width: this.video.width(), height: this.video.height()});
439		this.slider_progressbar.css({width: this._vtimeToSliderPosition(this.video[0].currentTime)});
440		//redraw marks and tags to get the right ratio
441		this.setMarks(this.getMarks());
442		this.set_slider_tags(this.comments);
443		if (typeof this.onresize_callback == 'function') this.onresize_callback.call(this, this.video.width(), this.video.height(), this._vtimeToSliderPosition(this.video[0].currentTime))
444	}
445
446	/**
447	 * return slider dom node as jquery object
448	 */
449	getSliderDOMNode()
450	{
451		return this.slider;
452	}
453}
454et2_register_widget(et2_smallpart_videobar, ["smallpart-videobar"]);