<commit_before>var Backbone   = require('backbone')
,   config     = require('mbc-common').config.Mosto.General
,   path       = require('path')
,   uuid       = require('node-uuid')
,   _          = require('underscore')
,   moment     = require('moment')
,   mvcp       = require('../drivers/mvcp/mvcp-driver')
,   semaphore  = require('semaphore')
,   utils      = require('../utils')
,   Q          = require('q')
;

var Mosto = {};

function bubbleEvents(self, name) {
    return (function(event) {
        var args = [].slice(arguments, 1);
        var event_name = event.split(':');
        event_name.splice(1,0,name);
        event_name = event_name.join(':');
        this.trigger(event_name, args);
    }).bind(self)
}

Mosto.Media = Backbone.Model.extend({
    defaults: {
        playlist_order: undefined,
        actual_order: undefined,
        name: '',
        type: 'default',
        file: undefined,
        length: undefined,
        fps: undefined,
        start: undefined,
        end: undefined,
    },

    initialize: function(attributes, options) {
        if( !attributes.fps ){
            this.set('fps', config.fps);
        }
        if( typeof attributes.length == 'string' ) {
            var m = moment(attributes.length, 'HH:mm:ss.SSS');
            attributes.length = moment.duration({ hours: m.hour(), minutes: m.minute(), seconds: m.second(), milliseconds: m.millisecond() });
        }
        if( moment.isDuration(attributes.length) ) {
            this.set('length', parseInt(attributes.length.asSeconds() * this.get('fps')));
        }

        var toMoment = function(model, value, options) {
            if( !moment.isMoment(value) )
                model.set('start', moment(value), { silent: true });
        };

        this.on('change:start', toMoment);
        this.on('change:end', toMoment);
    },
});

Mosto.BlankClip = new Mosto.Media({
    name: path.basename(config.black).replace(path.extname(config.black, '')),
    file: config.black,
    fps: config.fps,
});

Mosto.MediaCollection = Backbone.Collection.extend({
    /* this is a playlist's list of clips, it sorts by playlist_order */
    model: Mosto.Media,
    comparator: 'playlist_order',
});

Mosto.MeltedCollection = Backbone.Collection.extend({
    model: Mosto.Media,
    comparator: 'start',
    initialize: function() {
        this.driver = new mvcp('melted');
        console.log("MeltedCollection: [INFO] MVCP Server instantiated: " + this.driver.uuid);
        this.semaphore = semaphore(1);
        this.take = this.semaphore.take;
        this.leave = this.semaphore.leave;

        var self = this;
        this.on('add', function(model, collection, options){
            self.take(function() {
                var index = collection.indexOf(model);
                self.driver.insertClip(model.toJSON(), index).then(self.driver.getServerStatus.bind(self.driver)).then(function(status) {
                    // statuses: offline|not_loaded|playing|stopped|paused|disconnected|unknown
                    if( status.status != 'playing' ) {
                        return self.driver.play();
                    }
                }).fail(function(err) {
                    throw err
                }).fin(self.leave);
            });
        });

        this.on('remove', function(model, collection, options) {
            self.take(function() {
                var index = options.index;
                self.driver.removeClip(index).fail(function(err) {
                    console.error("ERROR: [Mosto.MeltedCollection] could not remove clip from melted", err);
                    throw err;
                }).fin(self.leave);
            });
        });
        this.on('sort', function(collection, options) {
            self.take(function() {
                self.driver.getServerPlaylist().then(function(clips) {
                    var ids = collection.pluck('id');

                    var add = [];
                    var diff = _.difference(ids, _.pluck(clips, 'id'))
                    if(diff.length) {
                        console.error("ERROR [Mosto.MeltedCollection]: There shouldn't be clips in the collection that aren't loaded in melted");
                        diff.forEach(function(id) {
                            add.push({ item: collection.get(id), index: ids.indexOf(id) });
                        });
                    }

                    var ret = Q.resolve();
                    add.forEach(function(toAdd) {
                        ret = ret.then(function() {
                            return self.driver.insertClip(toAdd.item, toAdd.index);
                        });
                        clips.splice(toAdd.index, 0, toAdd.item);
                    });

                    var move = [];
                    var remove = [];
                    clips.forEach(function(clip, i) {
                        var j = ids.indexOf(clip.id);
                        i -= remove.length;
                        if( j < 0 ) {
                            console.error("ERROR [Mosto.MeltedCollection]: There shouldn't be clips in melted that aren't in the collection");
                            remove.push(i);
                            return;
                        }
                        if( i != j ) {
                            move.push({ from: i, to: j});
                        }
                    });
                    remove.forEach(function(i) {
                        ret = ret.then(function() { return self.driver.removeClip(i) });
                    });
                    move.forEach(function(move) {
                        ret = ret.then(function() { return self.driver.moveClip(move.from, move.to) });
                    });
                    return ret;
                }).fin(self.leave);
            });
        });
    },
    sync: function(method, model, options) {
        /*
         * override this collection's sync method so it reads and writes from / to
         * melted
         */
        var self = this;
        self.take(function() {
            if( method == 'read' ) {
                var promise = self.driver.getServerPlaylist().then(self.loadFromMelted.bind(self));
                promise = promise.then(function() { return self.driver.getServerStatus() }).then(
                    function(status) {
                        /*
                         * Now I represent the playlist on the melted driver exactly. But I don't
                         * know the start and end times for each of the clips (because they weren't
                         * loaded from the database), so I need to build it.
                         *
                         * if I'm empty, I shouldn't do anything. If I'm not empty and the player is
                         * stopped, I should start it on the first clip. If I'm not empty and the
                         * player is running, I should sync the start / end for each of my Medias
                         */
                        if( !self.length )
                            return;
                        // statuses: offline|not_loaded|playing|stopped|paused|disconnected|unknown
                        if( status.status == 'stopped' ) {
                            self.adjustTimes(0, 0);
                            return self.driver.play();
                        }
                        var current = self.findWhere({id: status.currentClip.id})
                        var index = self.indexOf(current);
                        self.adjustTimes(index, status.currentClip.currentFrame);
                    });
                promise.fin(self.leave);
            }
        });
    },

    adjustTimes: function(index, currentFrame) {
        var ftms = function(f, fps) {
            return utils.convertFramesToSeconds(f, fps) * 1000;
        };

        var current = this.at(index);

        var elapsedTime = ftms(currentFrame, current.get('fps'));
        var now = moment();
        current.set({
            start: now - elapsedTime,
            end: (now - elapsedTime) + ftms(current.get('totalFrames'), current.get('fps')),
        });
        for(var i = index - 1 ; i >= 0 ; i--) {
            var clip = this.at(i);
            var next = this.at(i+1);
            clip.set({
                end: next.get('start'),
                start: next.get('start') - ftms(clip.get('length'), clip.get('fps')),
            });
        }
        for(var i = index + 1 ; i < this.length ; i++) {
            var clip = this.at(i);
            var prev = this.at(i-1);
            clip.set({
                start: prev.get('end'),
                end: prev.get('end') + ftms(clip.get('length'), clip.get('fps')),
            });
        }
    },

    loadFromMelted: function(clips) {
        var toAdd = [];
        clips.forEach(function(clip) {
            toAdd.push(new Mosto.Media(clip));
        });
        this.add(toAdd, { merge: true, silent: true });
    },
});

Mosto.Playlist = Backbone.Model.extend({
    defaults: {
        name: null,
        start: null,   // moment
        end: null,     // moment
        mode: "snap",
        loaded: false,
        medias: null,
    },
    initialize: function (attributes, options) {
        console.log ('creating new Mosto.Playlist', attributes, options);
        var self = this;
        this.set('name', this.get('name') || this.get('_id'));

        if( !attributes.start )
            throw new Error("Must provide a start date");
        this.set('start', moment(this.get('start')));
        if (!attributes.end )
            throw new Error("Must provide an end date");
        this.set('end', moment(this.get('end')));

        if (!attributes.medias)
            this.set('medias', new Mosto.MediaCollection());
        this.get('medias').on('all', bubbleEvents(this, 'medias'));
        /*
          this is important: 'add' events are triggered AFTER every model has been
          added to the collection, and the collection's been SORTED. So we can trust
          this to be right.

          Also important: a second 'set' on a model MAKES IT FORGET THE PREVIOUS MODIFICATION.
          i.e.: model.changedAttributes() does not compare with the server.
        */
        this.get('medias').on('add', function(model, collection, options) {
            model.playlist = self;
            var index = collection.indexOf(model);
            self.adjustMediaTimes(index);
        });
    },
    getMedias: function() {
        return this.get('medias').toArray();
    },
    adjustMediaTimes: function(fromIndex) {
        var collection = this.get('medias');
        var timewalk = moment(fromIndex > 0 ?
                              collection.at(fromIndex-1).get('start') :
                              this.get('start'));
        for(var i = fromIndex ; i < collection.length ; i++) {
            var media = collection.at(i);
            media.set('start', moment(timewalk));
            timewalk.add((media.get('length') / media.get('fps')) * 1000);
            media.set('end', moment(timewalk));
        }
    },
});

Mosto.PlaylistCollection = Backbone.Collection.extend({
    model: Mosto.Playlist,
    comparator: 'start',
    initialize: function() {
        Backbone.Collection.prototype.initialize.apply(this, arguments);
        this.on('sort', this.addBlanks.bind(this));
    },
    sort: function(options) {
        this.removeBlanks({ silent: true });
        Backbone.Collection.prototype.sort.apply(this);
    },
    removeBlanks: function(options) {
        /*
         * forwards `options` to `remove`
         */
        this.remove(this.where({ name: Mosto.BlankClip.get('name') }),
                    options);
    },
    addBlanks: function(collection, options) {
        for(var i=1 ; i < this.length ; i++) {
            var prev = this.at(i-1);
            var curr = this.at(i);
            var end = prev.get('end');
            var start = curr.get('start');
            if( end < start ) {
                this.addBlankPlaylist(end, start, i);
            }
        }
    },
    addBlankPlaylist: function(from, to, at) {
        var options = at && { at: at } || {};
        var blank = Mosto.BlankClip.clone();
        var duration = to - from;
        var playlist = new Playlist({
            name: blank.get('name'),
            start: from,
            end: to,
        });
        blank.set({ 'id': uuid.v4(), duration: duration * blank.get('fps') });
        playlist.get('medias').add(blank);
        this.add(playlist, options);
    },
    getMedias: function() {
        var medias = this.map(function(playlist) { return playlist.getMedias() })
        return _.flatten(medias);
    },
});

Mosto.LoadedPlaylists = Backbone.Model.extend({
    defaults: {
        playlists: null,
        melted_medias: null,
    },

    initialize: function(attributes, options) {
        attributes = attributes || {};
        if (!attributes.playlists)
            this.set('playlists', new Mosto.PlaylistCollection());
        this.get('playlists').on('all', bubbleEvents(this, 'playlists'));
        if (!attributes.melted_medias)
            this.set('melted_medias', new Mosto.MeltedCollection());
        this.get('melted_medias').on('all', bubbleEvents(this, 'melted_medias'));
    },

    save: function() {
        // set fires add, remove, change and sort
        this.get('melted_medias').set(this.get('playlists').getMedias());
    },

    addPlaylist: function(playlist) {
        this.get('playlists').add(playlist, { merge: true });
    },
    removePlaylist: function(playlist) {
        this.get('playlists').remove(playlist);
    },
});

Mosto.Playlists = new Mosto.LoadedPlaylists();

exports = module.exports = Mosto;
<commit_msg>less verbose call<commit_after>var Backbone   = require('backbone')
,   config     = require('mbc-common').config.Mosto.General
,   path       = require('path')
,   uuid       = require('node-uuid')
,   _          = require('underscore')
,   moment     = require('moment')
,   mvcp       = require('../drivers/mvcp/mvcp-driver')
,   semaphore  = require('semaphore')
,   utils      = require('../utils')
,   Q          = require('q')
;

var Mosto = {};

function bubbleEvents(self, name) {
    return (function(event) {
        var args = [].slice(arguments, 1);
        var event_name = event.split(':');
        event_name.splice(1,0,name);
        event_name = event_name.join(':');
        this.trigger(event_name, args);
    }).bind(self)
}

Mosto.Media = Backbone.Model.extend({
    defaults: {
        playlist_order: undefined,
        actual_order: undefined,
        name: '',
        type: 'default',
        file: undefined,
        length: undefined,
        fps: undefined,
        start: undefined,
        end: undefined,
    },

    initialize: function(attributes, options) {
        if( !attributes.fps ){
            this.set('fps', config.fps);
        }
        if( typeof attributes.length == 'string' ) {
            var m = moment(attributes.length, 'HH:mm:ss.SSS');
            attributes.length = moment.duration({ hours: m.hour(), minutes: m.minute(), seconds: m.second(), milliseconds: m.millisecond() });
        }
        if( moment.isDuration(attributes.length) ) {
            this.set('length', parseInt(attributes.length.asSeconds() * this.get('fps')));
        }

        var toMoment = function(model, value, options) {
            if( !moment.isMoment(value) )
                model.set('start', moment(value), { silent: true });
        };

        this.on('change:start', toMoment);
        this.on('change:end', toMoment);
    },
});

Mosto.BlankClip = new Mosto.Media({
    name: path.basename(config.black).replace(path.extname(config.black, '')),
    file: config.black,
    fps: config.fps,
});

Mosto.MediaCollection = Backbone.Collection.extend({
    /* this is a playlist's list of clips, it sorts by playlist_order */
    model: Mosto.Media,
    comparator: 'playlist_order',
});

Mosto.MeltedCollection = Backbone.Collection.extend({
    model: Mosto.Media,
    comparator: 'start',
    initialize: function() {
        this.driver = new mvcp('melted');
        console.log("MeltedCollection: [INFO] MVCP Server instantiated: " + this.driver.uuid);
        this.semaphore = semaphore(1);
        this.take = this.semaphore.take;
        this.leave = this.semaphore.leave;

        var self = this;
        this.on('add', function(model, collection, options){
            self.take(function() {
                var index = collection.indexOf(model);
                self.driver.insertClip(model.toJSON(), index).then(self.driver.getServerStatus.bind(self.driver)).then(function(status) {
                    // statuses: offline|not_loaded|playing|stopped|paused|disconnected|unknown
                    if( status.status != 'playing' ) {
                        return self.driver.play();
                    }
                }).fail(function(err) {
                    throw err
                }).fin(self.leave);
            });
        });

        this.on('remove', function(model, collection, options) {
            self.take(function() {
                var index = options.index;
                self.driver.removeClip(index).fail(function(err) {
                    console.error("ERROR: [Mosto.MeltedCollection] could not remove clip from melted", err);
                    throw err;
                }).fin(self.leave);
            });
        });
        this.on('sort', function(collection, options) {
            self.take(function() {
                self.driver.getServerPlaylist().then(function(clips) {
                    var ids = collection.pluck('id');

                    var add = [];
                    var diff = _.difference(ids, _.pluck(clips, 'id'))
                    if(diff.length) {
                        console.error("ERROR [Mosto.MeltedCollection]: There shouldn't be clips in the collection that aren't loaded in melted");
                        diff.forEach(function(id) {
                            add.push({ item: collection.get(id), index: ids.indexOf(id) });
                        });
                    }

                    var ret = Q.resolve();
                    add.forEach(function(toAdd) {
                        ret = ret.then(function() {
                            return self.driver.insertClip(toAdd.item, toAdd.index);
                        });
                        clips.splice(toAdd.index, 0, toAdd.item);
                    });

                    var move = [];
                    var remove = [];
                    clips.forEach(function(clip, i) {
                        var j = ids.indexOf(clip.id);
                        i -= remove.length;
                        if( j < 0 ) {
                            console.error("ERROR [Mosto.MeltedCollection]: There shouldn't be clips in melted that aren't in the collection");
                            remove.push(i);
                            return;
                        }
                        if( i != j ) {
                            move.push({ from: i, to: j});
                        }
                    });
                    remove.forEach(function(i) {
                        ret = ret.then(function() { return self.driver.removeClip(i) });
                    });
                    move.forEach(function(move) {
                        ret = ret.then(function() { return self.driver.moveClip(move.from, move.to) });
                    });
                    return ret;
                }).fin(self.leave);
            });
        });
    },
    sync: function(method, model, options) {
        /*
         * override this collection's sync method so it reads and writes from / to
         * melted
         */
        var self = this;
        self.take(function() {
            if( method == 'read' ) {
                var promise = self.driver.getServerPlaylist().then(self.loadFromMelted.bind(self));
                promise = promise.then(self.driver.getServerStatus.bind(self.driver)).then(
                    function(status) {
                        /*
                         * Now I represent the playlist on the melted driver exactly. But I don't
                         * know the start and end times for each of the clips (because they weren't
                         * loaded from the database), so I need to build it.
                         *
                         * if I'm empty, I shouldn't do anything. If I'm not empty and the player is
                         * stopped, I should start it on the first clip. If I'm not empty and the
                         * player is running, I should sync the start / end for each of my Medias
                         */
                        if( !self.length )
                            return;
                        // statuses: offline|not_loaded|playing|stopped|paused|disconnected|unknown
                        if( status.status == 'stopped' ) {
                            self.adjustTimes(0, 0);
                            return self.driver.play();
                        }
                        var current = self.findWhere({id: status.currentClip.id})
                        var index = self.indexOf(current);
                        self.adjustTimes(index, status.currentClip.currentFrame);
                    });
                promise.fin(self.leave);
            }
        });
    },

    adjustTimes: function(index, currentFrame) {
        var ftms = function(f, fps) {
            return utils.convertFramesToSeconds(f, fps) * 1000;
        };

        var current = this.at(index);

        var elapsedTime = ftms(currentFrame, current.get('fps'));
        var now = moment();
        current.set({
            start: now - elapsedTime,
            end: (now - elapsedTime) + ftms(current.get('totalFrames'), current.get('fps')),
        });
        for(var i = index - 1 ; i >= 0 ; i--) {
            var clip = this.at(i);
            var next = this.at(i+1);
            clip.set({
                end: next.get('start'),
                start: next.get('start') - ftms(clip.get('length'), clip.get('fps')),
            });
        }
        for(var i = index + 1 ; i < this.length ; i++) {
            var clip = this.at(i);
            var prev = this.at(i-1);
            clip.set({
                start: prev.get('end'),
                end: prev.get('end') + ftms(clip.get('length'), clip.get('fps')),
            });
        }
    },

    loadFromMelted: function(clips) {
        var toAdd = [];
        clips.forEach(function(clip) {
            toAdd.push(new Mosto.Media(clip));
        });
        this.add(toAdd, { merge: true, silent: true });
    },
});

Mosto.Playlist = Backbone.Model.extend({
    defaults: {
        name: null,
        start: null,   // moment
        end: null,     // moment
        mode: "snap",
        loaded: false,
        medias: null,
    },
    initialize: function (attributes, options) {
        console.log ('creating new Mosto.Playlist', attributes, options);
        var self = this;
        this.set('name', this.get('name') || this.get('_id'));

        if( !attributes.start )
            throw new Error("Must provide a start date");
        this.set('start', moment(this.get('start')));
        if (!attributes.end )
            throw new Error("Must provide an end date");
        this.set('end', moment(this.get('end')));

        if (!attributes.medias)
            this.set('medias', new Mosto.MediaCollection());
        this.get('medias').on('all', bubbleEvents(this, 'medias'));
        /*
          this is important: 'add' events are triggered AFTER every model has been
          added to the collection, and the collection's been SORTED. So we can trust
          this to be right.

          Also important: a second 'set' on a model MAKES IT FORGET THE PREVIOUS MODIFICATION.
          i.e.: model.changedAttributes() does not compare with the server.
        */
        this.get('medias').on('add', function(model, collection, options) {
            model.playlist = self;
            var index = collection.indexOf(model);
            self.adjustMediaTimes(index);
        });
    },
    getMedias: function() {
        return this.get('medias').toArray();
    },
    adjustMediaTimes: function(fromIndex) {
        var collection = this.get('medias');
        var timewalk = moment(fromIndex > 0 ?
                              collection.at(fromIndex-1).get('start') :
                              this.get('start'));
        for(var i = fromIndex ; i < collection.length ; i++) {
            var media = collection.at(i);
            media.set('start', moment(timewalk));
            timewalk.add((media.get('length') / media.get('fps')) * 1000);
            media.set('end', moment(timewalk));
        }
    },
});

Mosto.PlaylistCollection = Backbone.Collection.extend({
    model: Mosto.Playlist,
    comparator: 'start',
    initialize: function() {
        Backbone.Collection.prototype.initialize.apply(this, arguments);
        this.on('sort', this.addBlanks.bind(this));
    },
    sort: function(options) {
        this.removeBlanks({ silent: true });
        Backbone.Collection.prototype.sort.apply(this);
    },
    removeBlanks: function(options) {
        /*
         * forwards `options` to `remove`
         */
        this.remove(this.where({ name: Mosto.BlankClip.get('name') }),
                    options);
    },
    addBlanks: function(collection, options) {
        for(var i=1 ; i < this.length ; i++) {
            var prev = this.at(i-1);
            var curr = this.at(i);
            var end = prev.get('end');
            var start = curr.get('start');
            if( end < start ) {
                this.addBlankPlaylist(end, start, i);
            }
        }
    },
    addBlankPlaylist: function(from, to, at) {
        var options = at && { at: at } || {};
        var blank = Mosto.BlankClip.clone();
        var duration = to - from;
        var playlist = new Playlist({
            name: blank.get('name'),
            start: from,
            end: to,
        });
        blank.set({ 'id': uuid.v4(), duration: duration * blank.get('fps') });
        playlist.get('medias').add(blank);
        this.add(playlist, options);
    },
    getMedias: function() {
        var medias = this.map(function(playlist) { return playlist.getMedias() })
        return _.flatten(medias);
    },
});

Mosto.LoadedPlaylists = Backbone.Model.extend({
    defaults: {
        playlists: null,
        melted_medias: null,
    },

    initialize: function(attributes, options) {
        attributes = attributes || {};
        if (!attributes.playlists)
            this.set('playlists', new Mosto.PlaylistCollection());
        this.get('playlists').on('all', bubbleEvents(this, 'playlists'));
        if (!attributes.melted_medias)
            this.set('melted_medias', new Mosto.MeltedCollection());
        this.get('melted_medias').on('all', bubbleEvents(this, 'melted_medias'));
    },

    save: function() {
        // set fires add, remove, change and sort
        this.get('melted_medias').set(this.get('playlists').getMedias());
    },

    addPlaylist: function(playlist) {
        this.get('playlists').add(playlist, { merge: true });
    },
    removePlaylist: function(playlist) {
        this.get('playlists').remove(playlist);
    },
});

Mosto.Playlists = new Mosto.LoadedPlaylists();

exports = module.exports = Mosto;
<|endoftext|>


<commit_before>/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// @flow
import * as React from 'react';
import { Provider } from 'react-redux';

// This module is mocked.
import copy from 'copy-to-clipboard';

import {
  render,
  screen,
  fireEvent,
} from 'firefox-profiler/test/fixtures/testing-library';
import {
  TimelineMarkersOverview,
  MIN_MARKER_WIDTH,
} from '../../components/timeline/Markers';
import { MaybeMarkerContextMenu } from '../../components/shared/MarkerContextMenu';
import { overlayFills } from '../../profile-logic/marker-styles';
import { ensureExists } from '../../utils/flow';

import {
  autoMockCanvasContext,
  flushDrawLog,
} from '../fixtures/mocks/canvas-context';
import { storeWithProfile } from '../fixtures/stores';
import { getProfileWithMarkers } from '../fixtures/profiles/processed-profile';
import {
  getMouseEvent,
  addRootOverlayElement,
  removeRootOverlayElement,
  fireFullClick,
  fireFullContextMenu,
} from '../fixtures/utils';
import { mockRaf } from '../fixtures/mocks/request-animation-frame';
import { autoMockElementSize } from '../fixtures/mocks/element-size';
import { autoMockIntersectionObserver } from '../fixtures/mocks/intersection-observer';

import type { CssPixels } from 'firefox-profiler/types';

function setupWithMarkers({ rangeStart, rangeEnd }, ...markersPerThread) {
  const flushRafCalls = mockRaf();

  const profile = getProfileWithMarkers(...markersPerThread);

  const renderResult = render(
    <Provider store={storeWithProfile(profile)}>
      <>
        <TimelineMarkersOverview
          rangeStart={rangeStart}
          rangeEnd={rangeEnd}
          threadsKey={0}
          onSelect={() => {}}
        />
        <MaybeMarkerContextMenu />
      </>
    </Provider>
  );

  flushRafCalls();

  function getContextMenu() {
    return ensureExists(
      document.querySelector('.react-contextmenu'),
      `Couldn't find the context menu.`
    );
  }

  function tryToGetMarkerTooltip() {
    return document.querySelector('.tooltipMarker');
  }

  function clickOnMenuItem(stringOrRegexp) {
    fireFullClick(screen.getByText(stringOrRegexp));
  }

  function fireMouseEvent(eventName, options) {
    fireEvent(
      ensureExists(
        document.querySelector('canvas'),
        `Couldn't find the canvas element`
      ),
      getMouseEvent(eventName, options)
    );
  }

  function getPositioningOptions({ x, y }) {
    // These positioning options will be sent to all our mouse events. Note
    // that the values aren't really consistent, especially offsetY and
    // pageY shouldn't be the same, but in the context of our test this will
    // be good enough.
    // pageX/Y values control the position of the tooltip so it's not super
    // important.
    // offsetX/Y are more important as they're used to find which node is
    // actually clicked.
    const positioningOptions = {
      offsetX: x,
      offsetY: y,
      clientX: x,
      clientY: y,
      pageX: x,
      pageY: y,
    };

    return positioningOptions;
  }

  // Note to a future developer: the x/y values can be derived from the
  // array returned by ctx.__flushDrawLog().
  function rightClick(where: { x: CssPixels, y: CssPixels }) {
    const positioningOptions = getPositioningOptions(where);
    const canvas = ensureExists(
      document.querySelector('canvas'),
      `Couldn't find the canvas element`
    );

    // Because different components listen to different events, we trigger
    // all the right events, to be as close as possible to the real stuff.
    fireMouseEvent('mousemove', positioningOptions);
    fireFullContextMenu(canvas, positioningOptions);

    flushRafCalls();
  }

  function mouseOver(where: { x: CssPixels, y: CssPixels }) {
    const positioningOptions = getPositioningOptions(where);
    fireMouseEvent('mousemove', positioningOptions);
    flushRafCalls();
  }

  return {
    rightClick,
    mouseOver,
    getContextMenu,
    tryToGetMarkerTooltip,
    clickOnMenuItem,
    ...renderResult,
  };
}

describe('TimelineMarkers', function () {
  autoMockCanvasContext();
  autoMockElementSize({ width: 200, height: 300 });
  autoMockIntersectionObserver();
  // We will be hovering over element with a tooltip. It requires root overlay
  // element to be present in DOM.
  beforeEach(addRootOverlayElement);
  afterEach(removeRootOverlayElement);

  it('renders correctly', () => {
    window.devicePixelRatio = 1;

    const { container } = setupWithMarkers({ rangeStart: 0, rangeEnd: 15 }, [
      ['DOMEvent', 0, 10],
      ['DOMEvent', 0, 10],
      ['DOMEvent', 5, 15],
      ['Paint', 2, 13],
      ['Navigation', 2, 6],
      ['Layout', 6, 8],
    ]);

    const drawCalls = flushDrawLog();

    expect(container.firstChild).toMatchSnapshot();
    expect(drawCalls).toMatchSnapshot();

    delete window.devicePixelRatio;
  });

  it('does not render several dot markers in the same position', () => {
    window.devicePixelRatio = 2;

    setupWithMarkers({ rangeStart: 0, rangeEnd: 15000 }, [
      // 2 very close dot markers. They shouldn't be drawn both together.
      ['DOMEvent', 5000],
      ['DOMEvent', 5001],
      // This is a longer marker starting at the same place, it should always be drawn
      ['DOMEvent', 5001, 7000],
    ]);

    const drawCalls = flushDrawLog();

    // We filter on height to get only 1 relevant fillRect operation for each marker.
    const fillRectOperations = drawCalls.filter(
      ([operation, , , , height]) => operation === 'fillRect' && height > 1
    );

    // Here 2 markers should be drawn: the first dot, and the long marker.
    expect(fillRectOperations).toHaveLength(2);
    expect(
      fillRectOperations.every(
        ([, , , width]) => width >= MIN_MARKER_WIDTH / window.devicePixelRatio
      )
    ).toBe(true);

    delete window.devicePixelRatio;
  });

  it('renders the first marker tooltip when hovered', () => {
    const { tryToGetMarkerTooltip, mouseOver } = setupWithMarkers(
      { rangeStart: 0, rangeEnd: 10 },
      [
        ['DOMEvent', 0, 3],
        ['Navigation', 6, 10],
      ]
    );

    // Tooltip should not be visible yet.
    expect(tryToGetMarkerTooltip()).not.toBeInTheDocument();

    // The "DOMEvent" marker is drawn from 0,0 to 5,60.
    mouseOver({ x: 30, y: 2 });

    // It should be visible after hovering the first marker.
    expect(tryToGetMarkerTooltip()).toBeInTheDocument();
  });

  describe('displays context menus', () => {
    beforeEach(() => {
      // Always use fake timers when dealing with context menus.
      jest.useFakeTimers();
    });

    it('when right clicking on a marker', () => {
      const { rightClick, getContextMenu, clickOnMenuItem } = setupWithMarkers(
        { rangeStart: 0, rangeEnd: 10 },
        [['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'mousedown' }]]
      );

      // The "DOMEvent" marker is drawn from 0,0 to 5,200.
      rightClick({ x: 50, y: 2 });

      jest.runAllTimers();

      expect(getContextMenu()).toHaveClass('react-contextmenu--visible');

      clickOnMenuItem('Copy description');
      expect(copy).toHaveBeenLastCalledWith('mousedown');
      expect(getContextMenu()).not.toHaveClass('react-contextmenu--visible');

      jest.runAllTimers();

      expect(document.querySelector('react-contextmenu')).toBeFalsy();
    });

    it('when right clicking on markers in a sequence', () => {
      const { rightClick, getContextMenu, clickOnMenuItem } = setupWithMarkers(
        { rangeStart: 0, rangeEnd: 10 },
        [
          ['DOMEvent', 0, 3],
          ['Navigation', 6, 10],
        ]
      );

      // The "DOMEvent" marker is drawn from 0,0 to 5,60.
      rightClick({ x: 30, y: 2 });
      jest.runAllTimers();

      // The "Navigation" marker is drawn from 0,120 to 5,200.
      rightClick({ x: 160, y: 2 });
      jest.runAllTimers();

      expect(getContextMenu()).toHaveClass('react-contextmenu--visible');

      clickOnMenuItem('Copy description');
      expect(copy).toHaveBeenLastCalledWith('Navigation');

      expect(getContextMenu()).not.toHaveClass('react-contextmenu--visible');

      jest.runAllTimers();
      expect(document.querySelector('react-contextmenu')).toBeFalsy();
    });

    it('and still highlights other markers when hovering them', () => {
      const { rightClick, mouseOver, getContextMenu } = setupWithMarkers(
        { rangeStart: 0, rangeEnd: 10 },
        [
          ['DOMEvent', 0, 3],
          ['DOMEvent', 6, 10],
        ]
      );

      // The "DOMEvent" marker is drawn from 0,0 to 5,60.
      rightClick({ x: 30, y: 2 });
      expect(getContextMenu()).toHaveClass('react-contextmenu--visible');

      flushDrawLog();
      // The "Marker B" marker is drawn from 0,120 to 5,200.
      mouseOver({ x: 160, y: 1 });

      const drawCalls = flushDrawLog();

      // Expect that we have one marker with hovered color
      const callsWithHoveredColor = drawCalls.filter(
        ([, argument]) => argument === overlayFills.HOVERED
      );
      expect(callsWithHoveredColor).toHaveLength(1);
    });
  });
});
<commit_msg>Add some tests to check if intersection observer working correctly for markers<commit_after>/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// @flow
import * as React from 'react';
import { Provider } from 'react-redux';

// This module is mocked.
import copy from 'copy-to-clipboard';

import {
  render,
  screen,
  fireEvent,
} from 'firefox-profiler/test/fixtures/testing-library';
import {
  TimelineMarkersOverview,
  MIN_MARKER_WIDTH,
} from '../../components/timeline/Markers';
import { MaybeMarkerContextMenu } from '../../components/shared/MarkerContextMenu';
import { overlayFills } from '../../profile-logic/marker-styles';
import { ensureExists } from '../../utils/flow';

import {
  autoMockCanvasContext,
  flushDrawLog,
} from '../fixtures/mocks/canvas-context';
import { storeWithProfile } from '../fixtures/stores';
import { getProfileWithMarkers } from '../fixtures/profiles/processed-profile';
import {
  getMouseEvent,
  addRootOverlayElement,
  removeRootOverlayElement,
  fireFullClick,
  fireFullContextMenu,
} from '../fixtures/utils';
import { mockRaf } from '../fixtures/mocks/request-animation-frame';
import {
  autoMockElementSize,
  setMockedElementSize,
} from '../fixtures/mocks/element-size';
import {
  autoMockIntersectionObserver,
  triggerIntersectionObservers,
} from '../fixtures/mocks/intersection-observer';

import type { CssPixels } from 'firefox-profiler/types';

const GRAPH_WIDTH = 200;
const GRAPH_HEIGHT = 300;

function setupWithMarkers({ rangeStart, rangeEnd }, ...markersPerThread) {
  const flushRafCalls = mockRaf();

  const profile = getProfileWithMarkers(...markersPerThread);

  const renderResult = render(
    <Provider store={storeWithProfile(profile)}>
      <>
        <TimelineMarkersOverview
          rangeStart={rangeStart}
          rangeEnd={rangeEnd}
          threadsKey={0}
          onSelect={() => {}}
        />
        <MaybeMarkerContextMenu />
      </>
    </Provider>
  );

  flushRafCalls();

  function getContextMenu() {
    return ensureExists(
      document.querySelector('.react-contextmenu'),
      `Couldn't find the context menu.`
    );
  }

  function tryToGetMarkerTooltip() {
    return document.querySelector('.tooltipMarker');
  }

  function clickOnMenuItem(stringOrRegexp) {
    fireFullClick(screen.getByText(stringOrRegexp));
  }

  function fireMouseEvent(eventName, options) {
    fireEvent(
      ensureExists(
        document.querySelector('canvas'),
        `Couldn't find the canvas element`
      ),
      getMouseEvent(eventName, options)
    );
  }

  function getPositioningOptions({ x, y }) {
    // These positioning options will be sent to all our mouse events. Note
    // that the values aren't really consistent, especially offsetY and
    // pageY shouldn't be the same, but in the context of our test this will
    // be good enough.
    // pageX/Y values control the position of the tooltip so it's not super
    // important.
    // offsetX/Y are more important as they're used to find which node is
    // actually clicked.
    const positioningOptions = {
      offsetX: x,
      offsetY: y,
      clientX: x,
      clientY: y,
      pageX: x,
      pageY: y,
    };

    return positioningOptions;
  }

  // Note to a future developer: the x/y values can be derived from the
  // array returned by ctx.__flushDrawLog().
  function rightClick(where: { x: CssPixels, y: CssPixels }) {
    const positioningOptions = getPositioningOptions(where);
    const canvas = ensureExists(
      document.querySelector('canvas'),
      `Couldn't find the canvas element`
    );

    // Because different components listen to different events, we trigger
    // all the right events, to be as close as possible to the real stuff.
    fireMouseEvent('mousemove', positioningOptions);
    fireFullContextMenu(canvas, positioningOptions);

    flushRafCalls();
  }

  function mouseOver(where: { x: CssPixels, y: CssPixels }) {
    const positioningOptions = getPositioningOptions(where);
    fireMouseEvent('mousemove', positioningOptions);
    flushRafCalls();
  }

  return {
    rightClick,
    mouseOver,
    getContextMenu,
    tryToGetMarkerTooltip,
    clickOnMenuItem,
    flushRafCalls,
    ...renderResult,
  };
}

describe('TimelineMarkers', function () {
  autoMockCanvasContext();
  autoMockElementSize({ width: GRAPH_WIDTH, height: GRAPH_HEIGHT });
  autoMockIntersectionObserver();
  // We will be hovering over element with a tooltip. It requires root overlay
  // element to be present in DOM.
  beforeEach(addRootOverlayElement);
  afterEach(removeRootOverlayElement);

  it('renders correctly', () => {
    window.devicePixelRatio = 1;

    const { container } = setupWithMarkers({ rangeStart: 0, rangeEnd: 15 }, [
      ['DOMEvent', 0, 10],
      ['DOMEvent', 0, 10],
      ['DOMEvent', 5, 15],
      ['Paint', 2, 13],
      ['Navigation', 2, 6],
      ['Layout', 6, 8],
    ]);

    const drawCalls = flushDrawLog();

    expect(container.firstChild).toMatchSnapshot();
    expect(drawCalls).toMatchSnapshot();

    delete window.devicePixelRatio;
  });

  it('does not render several dot markers in the same position', () => {
    window.devicePixelRatio = 2;

    setupWithMarkers({ rangeStart: 0, rangeEnd: 15000 }, [
      // 2 very close dot markers. They shouldn't be drawn both together.
      ['DOMEvent', 5000],
      ['DOMEvent', 5001],
      // This is a longer marker starting at the same place, it should always be drawn
      ['DOMEvent', 5001, 7000],
    ]);

    const drawCalls = flushDrawLog();

    // We filter on height to get only 1 relevant fillRect operation for each marker.
    const fillRectOperations = drawCalls.filter(
      ([operation, , , , height]) => operation === 'fillRect' && height > 1
    );

    // Here 2 markers should be drawn: the first dot, and the long marker.
    expect(fillRectOperations).toHaveLength(2);
    expect(
      fillRectOperations.every(
        ([, , , width]) => width >= MIN_MARKER_WIDTH / window.devicePixelRatio
      )
    ).toBe(true);

    delete window.devicePixelRatio;
  });

  it('renders the first marker tooltip when hovered', () => {
    const { tryToGetMarkerTooltip, mouseOver } = setupWithMarkers(
      { rangeStart: 0, rangeEnd: 10 },
      [
        ['DOMEvent', 0, 3],
        ['Navigation', 6, 10],
      ]
    );

    // Tooltip should not be visible yet.
    expect(tryToGetMarkerTooltip()).not.toBeInTheDocument();

    // The "DOMEvent" marker is drawn from 0,0 to 5,60.
    mouseOver({ x: 30, y: 2 });

    // It should be visible after hovering the first marker.
    expect(tryToGetMarkerTooltip()).toBeInTheDocument();
  });

  describe('displays context menus', () => {
    beforeEach(() => {
      // Always use fake timers when dealing with context menus.
      jest.useFakeTimers();
    });

    it('when right clicking on a marker', () => {
      const { rightClick, getContextMenu, clickOnMenuItem } = setupWithMarkers(
        { rangeStart: 0, rangeEnd: 10 },
        [['DOMEvent', 0, 10, { type: 'DOMEvent', eventType: 'mousedown' }]]
      );

      // The "DOMEvent" marker is drawn from 0,0 to 5,200.
      rightClick({ x: 50, y: 2 });

      jest.runAllTimers();

      expect(getContextMenu()).toHaveClass('react-contextmenu--visible');

      clickOnMenuItem('Copy description');
      expect(copy).toHaveBeenLastCalledWith('mousedown');
      expect(getContextMenu()).not.toHaveClass('react-contextmenu--visible');

      jest.runAllTimers();

      expect(document.querySelector('react-contextmenu')).toBeFalsy();
    });

    it('when right clicking on markers in a sequence', () => {
      const { rightClick, getContextMenu, clickOnMenuItem } = setupWithMarkers(
        { rangeStart: 0, rangeEnd: 10 },
        [
          ['DOMEvent', 0, 3],
          ['Navigation', 6, 10],
        ]
      );

      // The "DOMEvent" marker is drawn from 0,0 to 5,60.
      rightClick({ x: 30, y: 2 });
      jest.runAllTimers();

      // The "Navigation" marker is drawn from 0,120 to 5,200.
      rightClick({ x: 160, y: 2 });
      jest.runAllTimers();

      expect(getContextMenu()).toHaveClass('react-contextmenu--visible');

      clickOnMenuItem('Copy description');
      expect(copy).toHaveBeenLastCalledWith('Navigation');

      expect(getContextMenu()).not.toHaveClass('react-contextmenu--visible');

      jest.runAllTimers();
      expect(document.querySelector('react-contextmenu')).toBeFalsy();
    });

    it('and still highlights other markers when hovering them', () => {
      const { rightClick, mouseOver, getContextMenu } = setupWithMarkers(
        { rangeStart: 0, rangeEnd: 10 },
        [
          ['DOMEvent', 0, 3],
          ['DOMEvent', 6, 10],
        ]
      );

      // The "DOMEvent" marker is drawn from 0,0 to 5,60.
      rightClick({ x: 30, y: 2 });
      expect(getContextMenu()).toHaveClass('react-contextmenu--visible');

      flushDrawLog();
      // The "Marker B" marker is drawn from 0,120 to 5,200.
      mouseOver({ x: 160, y: 1 });

      const drawCalls = flushDrawLog();

      // Expect that we have one marker with hovered color
      const callsWithHoveredColor = drawCalls.filter(
        ([, argument]) => argument === overlayFills.HOVERED
      );
      expect(callsWithHoveredColor).toHaveLength(1);
    });
  });
});

describe('TimelineMarkers with intersection observer', function () {
  autoMockCanvasContext();
  autoMockElementSize({ width: GRAPH_WIDTH, height: GRAPH_HEIGHT });
  autoMockIntersectionObserver(false);

  function setup() {
    const setupResults = setupWithMarkers({ rangeStart: 0, rangeEnd: 15 }, [
      ['DOMEvent', 0, 10],
      ['DOMEvent', 0, 10],
    ]);

    /**
     * Coordinate the flushing of the requestAnimationFrame and the draw calls.
     */
    function getContextDrawCalls() {
      setupResults.flushRafCalls();
      return flushDrawLog();
    }

    return { ...setupResults, getContextDrawCalls };
  }

  it('will not draw before the intersection observer', () => {
    const { getContextDrawCalls } = setup();
    const drawCalls = getContextDrawCalls();
    // Make sure that marker graph is not drawn yet.
    expect(drawCalls.some(([operation]) => operation === 'fillRect')).toBe(
      false
    );
  });

  it('will not draw after the intersection observer if it is not intersecting', () => {
    const { getContextDrawCalls } = setup();
    let drawCalls = getContextDrawCalls();

    // Make sure that marker graph is not drawn yet.
    expect(drawCalls.some(([operation]) => operation === 'beginPath')).toBe(
      false
    );

    // Now let's trigger the intersection observer and make sure that it still
    // doesn't draw it.
    triggerIntersectionObservers({ isIntersecting: false });
    drawCalls = getContextDrawCalls();
    expect(drawCalls.some(([operation]) => operation === 'fillRect')).toBe(
      false
    );
  });

  it('will draw after the intersection observer if it is intersecting', () => {
    const { getContextDrawCalls } = setup();
    let drawCalls = getContextDrawCalls();

    // Make sure that marker graph is not drawn yet.
    expect(drawCalls.some(([operation]) => operation === 'fillRect')).toBe(
      false
    );

    // Now let's trigger the intersection observer and make sure that it draws it.
    triggerIntersectionObservers({ isIntersecting: true });
    drawCalls = getContextDrawCalls();
    expect(drawCalls.some(([operation]) => operation === 'fillRect')).toBe(
      true
    );
  });

  it('will redraw after it becomes visible again', () => {
    const { getContextDrawCalls } = setup();
    let drawCalls = getContextDrawCalls();

    // Make sure that marker graph is not drawn yet.
    expect(drawCalls.some(([operation]) => operation === 'fillRect')).toBe(
      false
    );

    // Now let's trigger the intersection observer and make sure that it draws it.
    triggerIntersectionObservers({ isIntersecting: true });
    drawCalls = getContextDrawCalls();
    expect(drawCalls.some(([operation]) => operation === 'fillRect')).toBe(
      true
    );

    // Now it goes out of view again. Make sure that we don't redraw.
    triggerIntersectionObservers({ isIntersecting: false });
    drawCalls = getContextDrawCalls();
    expect(drawCalls.some(([operation]) => operation === 'fillRect')).toBe(
      false
    );

    // Send out the resize with a width change.
    // By changing the "fake" result of getBoundingClientRect, we ensure that
    // the pure components rerender because their `width` props change.
    setMockedElementSize({ width: GRAPH_WIDTH * 2, height: GRAPH_HEIGHT });
    window.dispatchEvent(new Event('resize'));
    drawCalls = getContextDrawCalls();
    // It should still be not drawn yet.
    expect(drawCalls.some(([operation]) => operation === 'fillRect')).toBe(
      false
    );

    // Now let's trigger the intersection observer again and make sure that it redraws.
    triggerIntersectionObservers({ isIntersecting: true });
    drawCalls = getContextDrawCalls();
    expect(drawCalls.some(([operation]) => operation === 'fillRect')).toBe(
      true
    );
  });
});
<|endoftext|>


<commit_before>
( function( mw, $ ) {

mw.AdLoader = {
	/**
	 * Get ad display configuration object from a url
	 *
	 * @param {string} adUrl
	 * 		The url which contains the xml ad payload
	 * @param {function} callback
	 * 		Function called with ad payload once ad content is loaded.
	 */
	load: function( adUrl, callback ){
		var _this = this;
		// See if we should even try to request via xhr:
		if ( !('withCredentials' in new XMLHttpRequest()) && !(typeof XDomainRequest !== "undefined")){
			_this.loadFromProxy( adUrl, callback );
			return ;
		}
		// First try to directly load the ad url:
		try {
			$.ajax({
				url: adUrl,
				success: function( data ) {
					_this.handleResult( data, callback );
				},
				error: function( jqXHR, textStatus, errorThrown ){
					// try to load the file with the proxy:
					_this.loadFromProxy( adUrl, callback );
				}
			});
		} catch ( e ){
			mw.log( "AdLodaer:: first cross domain request failed, trying with proxy" );
		}
	},
	loadFromProxy: function( adUrl, callback ){
		var _this = this;
		// We use a xml proxy ( passing on the clients ip for geo lookup )
		// since the ad server is almost never on the same domain as the api.
		// @@todo also we should explore html5 based cross domain request to avoid the proxy
		var proxyUrl = mw.getConfig( 'Mw.XmlProxyUrl' );
		if( !proxyUrl ){
			mw.log( "Error: mw.KAds : missing kaltura proxy url ( can't load ad )");
			return ;
		}
		$.getJSON( proxyUrl + '?url=' + encodeURIComponent( adUrl ) + '&callback=?', function( result ){
			var adDisplayConf = {};
			if( result['http_code'] == 'ERROR' || result['http_code'] == 0 ){
				mw.log("Error: loadAdXml error with http response");
				callback(false);
				return ;
			}
			try{
				var resultXML = $.parseXML( result['contents'] );
			} catch (e){
				mw.log("Error: AdLoader could not parse:" + resultXML);
				callback({});
				return ;
			}
			// get the xml document:
			_this.handleResult( resultXML, callback );
		});
	},
	handleResult: function(data, callback ){
		var _this = this;
		switch( _this.getAdFormat( data) ){
			case 'vast':
				// If we have lots of ad formats we could conditionally load them here:
				// ( normally we load VastAdParser before we get here but just in-case )
				mw.load( 'mw.VastAdParser', function(){
					callback(
						mw.VastAdParser.parse( data )
					);
				});
				return ;
			break;
		}
		mw.log("Error: could not parse adFormat from add content: \n" + data);
		callback( {} );
	},
	/**
	 * Get ad Format
	 * @param {string}
	 * 		The xml string of the ad contents
	 * @return
	 * @type {string}
	 * 		The type of string
	 */
	getAdFormat: function( xmlObject ){
		if( xmlObject.childNodes ){
			var rootNodeName = xmlObject.childNodes[0].nodeName;
		}
		if( rootNodeName && (
				rootNodeName.toLowerCase() == 'vast' ||
				rootNodeName.toLowerCase() == 'videoadservingtemplate' )
		){
			return 'vast';
		}
		return 'unknown';
	}
};
} )( window.mw, jQuery );
<commit_msg>fixed XML parsing issue<commit_after>
( function( mw, $ ) {

mw.AdLoader = {
	/**
	 * Get ad display configuration object from a url
	 *
	 * @param {string} adUrl
	 * 		The url which contains the xml ad payload
	 * @param {function} callback
	 * 		Function called with ad payload once ad content is loaded.
	 */
	load: function( adUrl, callback ){
		var _this = this;
		// See if we should even try to request via xhr:
		if ( !('withCredentials' in new XMLHttpRequest()) && !(typeof XDomainRequest !== "undefined")){
			_this.loadFromProxy( adUrl, callback );
			return ;
		}
		// First try to directly load the ad url:
		try {
			$.ajax({
				url: adUrl,
				success: function( data ) {
					_this.handleResult( data, callback );
				},
				error: function( jqXHR, textStatus, errorThrown ){
					// try to load the file with the proxy:
					_this.loadFromProxy( adUrl, callback );
				}
			});
		} catch ( e ){
			mw.log( "AdLodaer:: first cross domain request failed, trying with proxy" );
		}
	},
	loadFromProxy: function( adUrl, callback ){
		var _this = this;
		// We use a xml proxy ( passing on the clients ip for geo lookup )
		// since the ad server is almost never on the same domain as the api.
		// @@todo also we should explore html5 based cross domain request to avoid the proxy
		var proxyUrl = mw.getConfig( 'Mw.XmlProxyUrl' );
		if( !proxyUrl ){
			mw.log( "Error: mw.KAds : missing kaltura proxy url ( can't load ad )");
			return ;
		}
		$.getJSON( proxyUrl + '?url=' + encodeURIComponent( adUrl ) + '&callback=?', function( result ){
			var adDisplayConf = {};
			if( result['http_code'] == 'ERROR' || result['http_code'] == 0 ){
				mw.log("Error: loadAdXml error with http response");
				callback(false);
				return ;
			}
			try{
				var resultXML = $.parseXML( result['contents'] );
			} catch (e){
				mw.log("Error: AdLoader could not parse:" + resultXML);
				callback({});
				return ;
			}
			// get the xml document:
			_this.handleResult( resultXML, callback );
		});
	},
	handleResult: function(data, callback ){
		var _this = this;

		// If our data is a string we need to parse it as XML
		if( typeof data === 'string' ) {
			// Clean everything before <?xml?> tag
			var xmlPosition = data.indexOf("<?xml");
			if( xmlPosition > 0 ) {
				var junk = data.substr(0,xmlPosition);
				data = data.replace(junk, '');
			}
			data = $.parseXML( data );
		}

		switch( _this.getAdFormat( data) ){
			case 'vast':
				// If we have lots of ad formats we could conditionally load them here:
				// ( normally we load VastAdParser before we get here but just in-case )
				mw.load( 'mw.VastAdParser', function(){
					callback(
						mw.VastAdParser.parse( data )
					);
				});
				return ;
			break;
		}
		mw.log("Error: could not parse adFormat from add content: \n" + data);
		callback( {} );
	},
	/**
	 * Get ad Format
	 * @param {string}
	 * 		The xml string of the ad contents
	 * @return
	 * @type {string}
	 * 		The type of string
	 */
	getAdFormat: function( xmlObject ){
		if( xmlObject.childNodes ){
			var rootNodeName = xmlObject.childNodes[0].nodeName;
		}
		if( rootNodeName && (
				rootNodeName.toLowerCase() == 'vast' ||
				rootNodeName.toLowerCase() == 'videoadservingtemplate' )
		){
			return 'vast';
		}
		return 'unknown';
	}
};
} )( window.mw, jQuery );
<|endoftext|>


<commit_before>import React from 'react'
import axios from 'axios';
import { Link } from 'react-router'

var getInternalEndpoints = function(service) {
  var endpoints = '';
  var internalEndpoint = service.internalEndpoint;
  if (service.type == 'NodePort') {
    endpoints = internalEndpoint.host + ':' + internalEndpoint.ports[0].nodePort + ' ' + internalEndpoint.ports[0].protocol;
  } else {
    endpoints = internalEndpoint.host + ':' + internalEndpoint.ports[0].port + ' ' + internalEndpoint.ports[0].protocol;
  }

  return endpoints;
};

// TODO: Check structure of service.externalEndpoints and display properly
var getExternalEndpoints = function(service) {
  return JSON.stringify(service.externalEndpoints);
};

export default React.createClass({
  getInitialState: function() {
    return {
      services: []
    }
  },

  componentDidMount: function() {
    // TODO: Change url to:
    // /api/v1/proxy/namespaces/kube-system/services/kubernetes-dashboard/api/v1/service

    // const url = '/api/v1/namespaces/' + this.props.params.namespace + '/services';

    var url = '/api/v1/proxy/namespaces/kube-system/services/kubernetes-dashboard/api/v1/service';
    if (this.props.params.namespace) {
      url = url + '/' + this.props.params.namespace;
    }

    axios.get(url)
      .then(res => {
        var services = [];
        if (res.data.services) {
          services = res.data.services.sort(function(a, b) {
            if(a.objectMeta.name  b.objectMeta.name) return 1;
            return 0;
          });
        }
        this.setState({ services });
      });
  },

  render() {
    return (

Services: {this.props.params.namespace}

          <Link to={"/namespaces/"+ this.props.params.namespace +"/events"}>Events</Link> |
          <Link to={"/namespaces/"+ this.props.params.namespace +"/pods"}>Pods</Link>

            Name
            Namespace
            Type
            Cluster IP
            Internal Endpoints
            External Endpoints

          {this.state.services.map(service =>

              {service.objectMeta.name}
              {service.objectMeta.namespace}
              {service.type}
              {service.clusterIP}
              {getInternalEndpoints(service)}
              {getExternalEndpoints(service)}

          )}

    )
  }
})<commit_msg>Updated display of external endpoints<commit_after>import React from 'react'
import axios from 'axios';
import { Link } from 'react-router'

var getInternalEndpoints = function(service) {
  const endpoints = [];
  const internalEndpoint = service.internalEndpoint;
  if (service.type == 'NodePort') {
    endpoints.push(
{internalEndpoint.host}:{internalEndpoint.ports[0].nodePort} ({internalEndpoint.ports[0].protocol})
);
  } else {
    endpoints.push(
{internalEndpoint.host}:{internalEndpoint.ports[0].port} ({internalEndpoint.ports[0].protocol})
);
  }
  return endpoints;
};

var getExternalEndpoints = function(service) {
  const endpoints = [];
  if (service.externalEndpoints && service.externalEndpoints.length > 0) {
    for (var i = 0; i < service.externalEndpoints.length; i++) {
      var endpoint = service.externalEndpoints[i];
      endpoints.push(
{endpoint.host}:{endpoint.ports[0].port} ({endpoint.ports[0].protocol})
);
    }
  }
  return endpoints;
};

export default React.createClass({
  getInitialState: function() {
    return {
      services: []
    }
  },

  componentDidMount: function() {
    // TODO: Change url to:
    // /api/v1/proxy/namespaces/kube-system/services/kubernetes-dashboard/api/v1/service

    // const url = '/api/v1/namespaces/' + this.props.params.namespace + '/services';

    var url = '/api/v1/proxy/namespaces/kube-system/services/kubernetes-dashboard/api/v1/service';
    if (this.props.params.namespace) {
      url = url + '/' + this.props.params.namespace;
    }

    axios.get(url)
      .then(res => {
        var services = [];
        if (res.data.services) {
          services = res.data.services.sort(function(a, b) {
            if(a.objectMeta.name  b.objectMeta.name) return 1;
            return 0;
          });
        }
        this.setState({ services });
      });
  },

  render() {
    return (

Services: {this.props.params.namespace}

          <Link to={"/namespaces/"+ this.props.params.namespace +"/events"}>Events</Link> |
          <Link to={"/namespaces/"+ this.props.params.namespace +"/pods"}>Pods</Link>

            Name
            Namespace
            Type
            Cluster IP
            Internal Endpoints
            External Endpoints

          {this.state.services.map(service =>

              {service.objectMeta.name}
              {service.objectMeta.namespace}
              {service.type}
              {service.clusterIP}
              {getInternalEndpoints(service)}
              {getExternalEndpoints(service)}

          )}

    )
  }
})<|endoftext|>


<commit_before>var Backbone   = require('backbone')
,   config     = require('mbc-common').config.Mosto.General
,   path       = require('path')
,   uuid       = require('node-uuid')
,   _          = require('underscore')
,   moment     = require('moment')
,   mvcp       = require('../drivers/mvcp/mvcp-driver')
,   semaphore  = require('semaphore')
,   utils      = require('../utils')
,   Q          = require('q')
;

var Mosto = {};

function bubbleEvents(self, name) {
    return (function(event) {
        var args = [].slice(arguments, 1);
        var event_name = event.split(':');
        event_name.splice(1,0,name);
        event_name = event_name.join(':');
        this.trigger(event_name, args);
    }).bind(self)
}

Mosto.Media = Backbone.Model.extend({
    defaults: {
        // playlist_order: undefined,
        // actual_order: undefined,
        name: '',
        type: 'default',
        // file: undefined,
        // length: undefined,
        // fps: undefined,
        // start: undefined,
        // end: undefined,
        in: undefined,
        out: undefined,
        blank: false,
    },

    constructor: function(attributes, options) {
        attributes = _.defaults(attributes || {}, { id: uuid.v4() });
        options = _.defaults(options || {}, { override_id: true });
        attributes._id = attributes.id;
        if( options.override_id )
            attributes.id = attributes.playlist_id + '-' + attributes.id;
        Backbone.Model.apply(this, arguments);
    },

    initialize: function(attributes, options) {
        attributes = attributes || {};
        if( !attributes.fps ){
            this.set('fps', config.fps);
        }
        if( typeof attributes.length == 'string' ) {
            var m = moment(attributes.length, 'HH:mm:ss.SSS');
            attributes.length = moment.duration({ hours: m.hour(), minutes: m.minute(), seconds: m.second(), milliseconds: m.millisecond() });
        }
        if( moment.isDuration(attributes.length) ) {
            attributes.length = parseInt(attributes.length.asSeconds() * this.get('fps'));
        }

        if( attributes.in == undefined ) {
            attributes.in = 0;
        }
        if( attributes.out == undefined ) {
            attributes.out = attributes.in + attributes.length - 1;
        }
        this.set({ in: attributes.in,
                   out: attributes.out,
                   length: attributes.out - attributes.in + 1 });

        var toMoment = function(model, value, options) {
            if( !moment.isMoment(value) )
                model.set('start', moment(value), { silent: true });
        };

        this.on('change:start', toMoment);
        this.on('change:end', toMoment);
    },
});

Mosto.BlankClip = new Mosto.Media({
    //name: path.basename(config.black).replace(path.extname(config.black, '')),
    name: "InfiniteBlankClip",
    //file: config.black,
    file: process.cwd() + '/test/videos/blank.xml',
    //fps: config.fps,
    fps: 25,
});

Mosto.MediaCollection = Backbone.Collection.extend({
    /* this is a playlist's list of clips, it sorts by playlist_order */
    model: Mosto.Media,
    comparator: 'playlist_order',
});

Mosto.MeltedCollection = Backbone.Collection.extend({
    model: Mosto.Media,
    comparator: 'start',
    initialize: function() {
        this.driver = new mvcp('melted');
        console.log("MeltedCollection: [INFO] MVCP Server instantiated: " + this.driver.uuid);
        this.semaphore = semaphore(1);
        this.take = this.semaphore.take;
        this.leave = this.semaphore.leave;

        var self = this;
        this.on('allx', function(event) {
            console.log(self, event, arguments);
        });

        this.fetch();
    },

    set: function(models, options) {
        var self = this
        options = _.defaults(options || {}, { set_melted: true });
        Backbone.Collection.prototype.set.call(this, models, _.extend(options, { silent: true }));
        if(! options.set_melted )
            return;
        self.take(function() {
            return self.driver.getServerStatus().then(function(status) {

                /* remove everything but the current clip */
                var ret = Q.resolve().then(function() {
                    return self.driver.cleanPlaylist();
                });

                var expected = self.getExpectedMedia();
                var wholeList = true;
                if( expected.media ) {
                    if(status.currentClip && ( expected.media.id.toString() == status.currentClip.id.toString() )) {
                        wholeList = false;
                        /* since I don't need to jump, and I can't insert clips before
                           the current one without getting a jump, I'll strip myself of
                           any clips before this */
                        var ids = self.pluck('id');
                        var cur_i = ids.indexOf(status.currentClip.id);
                        self.remove(ids.slice(0, cur_i));

                        /* and then put everything after into melted */
                        _.range(1, self.length).forEach(function(i) {
                            ret = ret.then(function() {
                                return self.driver.appendClip(self.at(i).toJSON());
                            });
                        });
                    }
                }
                if( wholeList ) {
                    /* since I've got to jump anyways, I'll add everything at the end
                       of the list */
                    self.forEach(function(c, i) {
                        ret = ret.then(function() {
                            return self.driver.appendClip(c.toJSON());
                        });
                    });

                    if( status.currentClip ) {
                        /* I leave the current clip at the top of the melted playlist, it won't do any harm */
                        ret = ret.then(function() {
                            return self.add(status.currentClip, { at: 0, set_melted: false });
                        });
                    }
                }

                self.forEach(function(c, i) {
                    c.set('actual_order', i);
                });
                return ret;
            }).fin(function(){
                self.leave();
            });
        });
    },
    sync: function(method, model, options) {
        /*
         * override this collection's sync method so it reads and writes from / to
         * melted
         */
        var self = this;
        self.take(function() {
            if( method == 'read' ) {
                var promise = self.driver.getServerPlaylist().then(self.loadFromMelted.bind(self));
                promise = promise.then(self.driver.getServerStatus.bind(self.driver)).then(
                    function(status) {
                        /*
                         * Now I represent the playlist on the melted driver exactly. But I don't
                         * know the start and end times for each of the clips (because they weren't
                         * loaded from the database), so I need to build it.
                         *
                         * if I'm empty, I shouldn't do anything. If I'm not empty and the player is
                         * stopped, I should start it on the first clip. If I'm not empty and the
                         * player is running, I should sync the start / end for each of my Medias
                         */
                        if( !self.length )
                            return;
                        // statuses: offline|not_loaded|playing|stopped|paused|disconnected|unknown

                        if( status.status == 'stopped' ) {
                            self.adjustTimes(0, 0);
                            // LET HEARTBEATS HANDLE THIS...
//                            return self.driver.play();
                        } else {
                            var current = self.findWhere({id: status.currentClip.id})
                            var index = self.indexOf(current);
                            self.adjustTimes(index, status.currentClip.currentFrame);
                        }
                    });
                promise.fin(self.leave);
            }
        });
    },

    getExpectedMedia: function(time) {
        time = time || moment();
        var expected = {
            media: undefined,
            frame: undefined,
        };
        var media = this.find(function(media) {
            return media.get('start') <= time && media.get('end') >= time;
        });
        if( media ) {
            var elapsed = moment.duration(time - media.get('start')).asSeconds();
            var frame = parseInt(elapsed * media.get('fps'));
            expected.media = media;
            expected.frame = frame;
        }
        return expected;
    },

    adjustTimes: function(index, currentFrame) {
        var ftms = function(f, fps) {
            return utils.convertFramesToSeconds(f, fps) * 1000;
        };

        var current = this.at(index);

        var elapsedTime = ftms(currentFrame, current.get('fps'));
        var now = moment();
        current.set({
            start: now - elapsedTime,
            end: (now - elapsedTime) + ftms(current.get('totalFrames'), current.get('fps')),
        });
        for(var i = index - 1 ; i >= 0 ; i--) {
            var clip = this.at(i);
            var next = this.at(i+1);
            clip.set({
                end: next.get('start'),
                start: next.get('start') - ftms(clip.get('length'), clip.get('fps')),
            });
        }
        for(var i = index + 1 ; i < this.length ; i++) {
            var clip = this.at(i);
            var prev = this.at(i-1);
            clip.set({
                start: prev.get('end'),
                end: prev.get('end') + ftms(clip.get('length'), clip.get('fps')),
            });
        }
    },

    loadFromMelted: function(clips) {
        var toAdd = [];
        clips.forEach(function(clip) {
            toAdd.push(new Mosto.Media(clip, { override_id: false }));
        });
        this.add(toAdd, { merge: true, silent: true });
    },
});

Mosto.Playlist = Backbone.Model.extend({
    defaults: {
        name: null,
        start: null,   // moment
        end: null,     // moment
        mode: "snap",
        loaded: false,
        medias: null,
    },
    initialize: function (attributes, options) {
        console.log ('creating new Mosto.Playlist', attributes, options);
        var self = this;

        this.set('name', this.get('name') || this.get('_id'));

        if( !attributes.start )
            throw new Error("Must provide a start date");
        this.set('start', moment(this.get('start')));
        if (!attributes.end )
            throw new Error("Must provide an end date");
        this.set('end', moment(this.get('end')));

        if ( !(attributes.medias instanceof Mosto.MediaCollection) )
            this.set('medias', new Mosto.MediaCollection());

        /*
          this is important: 'add' events are triggered AFTER every model has been
          added to the collection, and the collection's been SORTED. So we can trust
          this to be right.

          Also important: a second 'set' on a model MAKES IT FORGET THE PREVIOUS MODIFICATION.
          i.e.: model.changedAttributes() does not compare with the server.
        */
        this.get('medias').on('add', function(model, collection, options) {
            model.playlist = self;
            var index = collection.indexOf(model);
            self.adjustMediaTimes(index);
        });
        this.on('change:start', function(model, value, options) {
            self.adjustMediaTimes(0);
        });

        if ( attributes.medias instanceof Array )
            this.get('medias').set(attributes.medias);
        this.get('medias').on('all', bubbleEvents(this, 'medias'));
    },
    getMedias: function() {
        return this.get('medias').toArray();
    },
    adjustMediaTimes: function(fromIndex) {
        var collection = this.get('medias');
        var timewalk = moment(fromIndex > 0 ?
                              collection.at(fromIndex-1).get('end') :
                              this.get('start'));
        for(var i = fromIndex ; i < collection.length ; i++) {
            var media = collection.at(i);
            media.set('start', moment(timewalk));
            timewalk.add((media.get('length') / media.get('fps')) * 1000);
            media.set('end', moment(timewalk));
        }
    },
});

Mosto.PlaylistCollection = Backbone.Collection.extend({
    model: Mosto.Playlist,
    comparator: 'start',
    initialize: function() {
        Backbone.Collection.prototype.initialize.apply(this, arguments);
        this.on('sort', this.addBlanks.bind(this));
    },
    sort: function(options) {
        this.removeBlanks({ silent: true });
        Backbone.Collection.prototype.sort.apply(this);
    },
    removeBlanks: function(options) {
        /*
         * forwards `options` to `remove`
         */
        this.remove(this.where({ name: Mosto.BlankClip.get('name') }),
                    options);
    },
    addBlanks: function(collection, options) {
        var now = moment();
        var first = this.at(0);
        if( first && first.get('start') > now ) {
            this.addBlankPlaylist(now, first.get('start'), 0);
        }
        for(var i=1 ; i < this.length ; i++) {
            var prev = this.at(i-1);
            var curr = this.at(i);
            var end = prev.get('end');
            var start = curr.get('start');
            if( end < start ) {
                this.addBlankPlaylist(end, start, i);
            }
        }
    },
    addBlankPlaylist: function(from, to, at) {
        var options = { at: at };
        var blank = Mosto.BlankClip.clone();
        var length = parseInt(((to - from) / 1000) * blank.get('fps'));
        var playlist = new Mosto.Playlist({
            name: blank.get('name'),
            start: from,
            end: to,
        });
        blank.set({ 'id': to.valueOf() + '-blank',
                    length: length,
                    in: 0,
                    out: length });
        playlist.get('medias').add(blank);
        this.add(playlist, options);
    },
    getMedias: function() {
        var medias = this.map(function(playlist) { return playlist.getMedias() })
        return _.flatten(medias);
    },
});

Mosto.LoadedPlaylists = Backbone.Model.extend({
    defaults: {
        playlists: null,
        melted_medias: null,
    },

    initialize: function(attributes, options) {
        attributes = attributes || {};
        if (!attributes.playlists)
            this.set('playlists', new Mosto.PlaylistCollection());
        this.get('playlists').on('all', bubbleEvents(this, 'playlists'));
        if (!attributes.melted_medias)
            this.set('melted_medias', new Mosto.MeltedCollection());
        this.get('melted_medias').on('all', bubbleEvents(this, 'melted_medias'));
    },

    save: function() {
        // set fires add, remove, change and sort
        var mm = this.get('melted_medias');
        var pl = this.get('playlists');
        mm.take(function() {
            mm.set(pl.getMedias());
            mm.leave()
        });
    },

    addPlaylist: function(playlist) {
        this.get('playlists').add(playlist, { merge: true });
        this.save();
    },
    removePlaylist: function(playlist) {
        this.get('playlists').remove(playlist);
        this.save();
    },
});

Mosto._globals = {}

Mosto.Playlists = function() {
    return Mosto._globals.playlists || (Mosto._globals.playlists = new Mosto.LoadedPlaylists());
}

exports = module.exports = Mosto;
<commit_msg>BlankClip should just be a set of attributes<commit_after>var Backbone   = require('backbone')
,   config     = require('mbc-common').config.Mosto.General
,   path       = require('path')
,   uuid       = require('node-uuid')
,   _          = require('underscore')
,   moment     = require('moment')
,   mvcp       = require('../drivers/mvcp/mvcp-driver')
,   semaphore  = require('semaphore')
,   utils      = require('../utils')
,   Q          = require('q')
;

var Mosto = {};

function bubbleEvents(self, name) {
    return (function(event) {
        var args = [].slice(arguments, 1);
        var event_name = event.split(':');
        event_name.splice(1,0,name);
        event_name = event_name.join(':');
        this.trigger(event_name, args);
    }).bind(self)
}

Mosto.Media = Backbone.Model.extend({
    defaults: {
        // playlist_order: undefined,
        // actual_order: undefined,
        name: '',
        type: 'default',
        // file: undefined,
        // length: undefined,
        // fps: undefined,
        // start: undefined,
        // end: undefined,
        in: undefined,
        out: undefined,
        blank: false,
    },

    constructor: function(attributes, options) {
        attributes = _.defaults(attributes || {}, { id: uuid.v4() });
        options = _.defaults(options || {}, { override_id: true });
        attributes._id = attributes.id;
        if( options.override_id )
            attributes.id = attributes.playlist_id + '-' + attributes.id;
        Backbone.Model.apply(this, arguments);
    },

    initialize: function(attributes, options) {
        attributes = attributes || {};
        if( !attributes.fps ){
            this.set('fps', config.fps);
        }
        if( typeof attributes.length == 'string' ) {
            var m = moment(attributes.length, 'HH:mm:ss.SSS');
            attributes.length = moment.duration({ hours: m.hour(), minutes: m.minute(), seconds: m.second(), milliseconds: m.millisecond() });
        }
        if( moment.isDuration(attributes.length) ) {
            attributes.length = parseInt(attributes.length.asSeconds() * this.get('fps'));
        }

        if( attributes.in == undefined ) {
            attributes.in = 0;
        }
        if( attributes.out == undefined ) {
            attributes.out = attributes.in + attributes.length - 1;
        }
        this.set({ in: attributes.in,
                   out: attributes.out,
                   length: attributes.out - attributes.in + 1 });

        var toMoment = function(model, value, options) {
            if( !moment.isMoment(value) )
                model.set('start', moment(value), { silent: true });
        };

        this.on('change:start', toMoment);
        this.on('change:end', toMoment);
    },
});

Mosto.BlankClip = {
    blank: true,
    //name: path.basename(config.black).replace(path.extname(config.black, '')),
    name: "InfiniteBlankClip",
    //file: config.black,
    file: process.cwd() + '/test/videos/blank.xml',
    //fps: config.fps,
    fps: 25,
    in: 0,
    out: 14999,
    length: 15000,
};

Mosto.MediaCollection = Backbone.Collection.extend({
    /* this is a playlist's list of clips, it sorts by playlist_order */
    model: Mosto.Media,
    comparator: 'playlist_order',
});

Mosto.MeltedCollection = Backbone.Collection.extend({
    model: Mosto.Media,
    comparator: 'start',
    initialize: function() {
        this.driver = new mvcp('melted');
        console.log("MeltedCollection: [INFO] MVCP Server instantiated: " + this.driver.uuid);
        this.semaphore = semaphore(1);
        this.take = this.semaphore.take;
        this.leave = this.semaphore.leave;

        var self = this;
        this.on('allx', function(event) {
            console.log(self, event, arguments);
        });

        this.fetch();
    },

    set: function(models, options) {
        var self = this
        options = _.defaults(options || {}, { set_melted: true });
        Backbone.Collection.prototype.set.call(this, models, _.extend(options, { silent: true }));
        if(! options.set_melted )
            return;
        self.take(function() {
            return self.driver.getServerStatus().then(function(status) {

                /* remove everything but the current clip */
                var ret = Q.resolve().then(function() {
                    return self.driver.cleanPlaylist();
                });

                var expected = self.getExpectedMedia();
                var wholeList = true;
                if( expected.media ) {
                    if(status.currentClip && ( expected.media.id.toString() == status.currentClip.id.toString() )) {
                        wholeList = false;
                        /* since I don't need to jump, and I can't insert clips before
                           the current one without getting a jump, I'll strip myself of
                           any clips before this */
                        var ids = self.pluck('id');
                        var cur_i = ids.indexOf(status.currentClip.id);
                        self.remove(ids.slice(0, cur_i));

                        /* and then put everything after into melted */
                        _.range(1, self.length).forEach(function(i) {
                            ret = ret.then(function() {
                                return self.driver.appendClip(self.at(i).toJSON());
                            });
                        });
                    }
                }
                if( wholeList ) {
                    /* since I've got to jump anyways, I'll add everything at the end
                       of the list */
                    self.forEach(function(c, i) {
                        ret = ret.then(function() {
                            return self.driver.appendClip(c.toJSON());
                        });
                    });

                    if( status.currentClip ) {
                        /* I leave the current clip at the top of the melted playlist, it won't do any harm */
                        ret = ret.then(function() {
                            return self.add(status.currentClip, { at: 0, set_melted: false });
                        });
                    }
                }

                self.forEach(function(c, i) {
                    c.set('actual_order', i);
                });
                return ret;
            }).fin(function(){
                self.leave();
            });
        });
    },
    sync: function(method, model, options) {
        /*
         * override this collection's sync method so it reads and writes from / to
         * melted
         */
        var self = this;
        self.take(function() {
            if( method == 'read' ) {
                var promise = self.driver.getServerPlaylist().then(self.loadFromMelted.bind(self));
                promise = promise.then(self.driver.getServerStatus.bind(self.driver)).then(
                    function(status) {
                        /*
                         * Now I represent the playlist on the melted driver exactly. But I don't
                         * know the start and end times for each of the clips (because they weren't
                         * loaded from the database), so I need to build it.
                         *
                         * if I'm empty, I shouldn't do anything. If I'm not empty and the player is
                         * stopped, I should start it on the first clip. If I'm not empty and the
                         * player is running, I should sync the start / end for each of my Medias
                         */
                        if( !self.length )
                            return;
                        // statuses: offline|not_loaded|playing|stopped|paused|disconnected|unknown

                        if( status.status == 'stopped' ) {
                            self.adjustTimes(0, 0);
                            // LET HEARTBEATS HANDLE THIS...
//                            return self.driver.play();
                        } else {
                            var current = self.findWhere({id: status.currentClip.id})
                            var index = self.indexOf(current);
                            self.adjustTimes(index, status.currentClip.currentFrame);
                        }
                    });
                promise.fin(self.leave);
            }
        });
    },

    getExpectedMedia: function(time) {
        time = time || moment();
        var expected = {
            media: undefined,
            frame: undefined,
        };
        var media = this.find(function(media) {
            return media.get('start') <= time && media.get('end') >= time;
        });
        if( media ) {
            var elapsed = moment.duration(time - media.get('start')).asSeconds();
            var frame = parseInt(elapsed * media.get('fps'));
            expected.media = media;
            expected.frame = frame;
        }
        return expected;
    },

    adjustTimes: function(index, currentFrame) {
        var ftms = function(f, fps) {
            return utils.convertFramesToSeconds(f, fps) * 1000;
        };

        var current = this.at(index);

        var elapsedTime = ftms(currentFrame, current.get('fps'));
        var now = moment();
        current.set({
            start: now - elapsedTime,
            end: (now - elapsedTime) + ftms(current.get('totalFrames'), current.get('fps')),
        });
        for(var i = index - 1 ; i >= 0 ; i--) {
            var clip = this.at(i);
            var next = this.at(i+1);
            clip.set({
                end: next.get('start'),
                start: next.get('start') - ftms(clip.get('length'), clip.get('fps')),
            });
        }
        for(var i = index + 1 ; i < this.length ; i++) {
            var clip = this.at(i);
            var prev = this.at(i-1);
            clip.set({
                start: prev.get('end'),
                end: prev.get('end') + ftms(clip.get('length'), clip.get('fps')),
            });
        }
    },

    loadFromMelted: function(clips) {
        var toAdd = [];
        clips.forEach(function(clip) {
            toAdd.push(new Mosto.Media(clip, { override_id: false }));
        });
        this.add(toAdd, { merge: true, silent: true });
    },
});

Mosto.Playlist = Backbone.Model.extend({
    defaults: {
        name: null,
        start: null,   // moment
        end: null,     // moment
        mode: "snap",
        loaded: false,
        medias: null,
    },
    initialize: function (attributes, options) {
        console.log ('creating new Mosto.Playlist', attributes, options);
        var self = this;

        this.set('name', this.get('name') || this.get('_id'));

        if( !attributes.start )
            throw new Error("Must provide a start date");
        this.set('start', moment(this.get('start')));
        if (!attributes.end )
            throw new Error("Must provide an end date");
        this.set('end', moment(this.get('end')));

        if ( !(attributes.medias instanceof Mosto.MediaCollection) )
            this.set('medias', new Mosto.MediaCollection());

        /*
          this is important: 'add' events are triggered AFTER every model has been
          added to the collection, and the collection's been SORTED. So we can trust
          this to be right.

          Also important: a second 'set' on a model MAKES IT FORGET THE PREVIOUS MODIFICATION.
          i.e.: model.changedAttributes() does not compare with the server.
        */
        this.get('medias').on('add', function(model, collection, options) {
            model.playlist = self;
            var index = collection.indexOf(model);
            self.adjustMediaTimes(index);
        });
        this.on('change:start', function(model, value, options) {
            self.adjustMediaTimes(0);
        });

        if ( attributes.medias instanceof Array )
            this.get('medias').set(attributes.medias);
        this.get('medias').on('all', bubbleEvents(this, 'medias'));
    },
    getMedias: function() {
        return this.get('medias').toArray();
    },
    adjustMediaTimes: function(fromIndex) {
        var collection = this.get('medias');
        var timewalk = moment(fromIndex > 0 ?
                              collection.at(fromIndex-1).get('end') :
                              this.get('start'));
        for(var i = fromIndex ; i < collection.length ; i++) {
            var media = collection.at(i);
            media.set('start', moment(timewalk));
            timewalk.add((media.get('length') / media.get('fps')) * 1000);
            media.set('end', moment(timewalk));
        }
    },
});

Mosto.PlaylistCollection = Backbone.Collection.extend({
    model: Mosto.Playlist,
    comparator: 'start',
    initialize: function() {
        Backbone.Collection.prototype.initialize.apply(this, arguments);
        this.on('sort', this.addBlanks.bind(this));
    },
    sort: function(options) {
        this.removeBlanks({ silent: true });
        Backbone.Collection.prototype.sort.apply(this);
    },
    removeBlanks: function(options) {
        /*
         * forwards `options` to `remove`
         */
        this.remove(this.where({ blank: true }),
                    options);
    },
    addBlanks: function(collection, options) {
        var now = moment();
        var first = this.at(0);
        if( first && first.get('start') > now ) {
            this.addBlankPlaylist(now, first.get('start'), 0);
        }
        for(var i=1 ; i < this.length ; i++) {
            var prev = this.at(i-1);
            var curr = this.at(i);
            var end = prev.get('end');
            var start = curr.get('start');
            if( end < start ) {
                this.addBlankPlaylist(end, start, i);
            }
        }
    },
    addBlankPlaylist: function(from, to, at) {
        var options = { at: at };
        var blank = _.clone(Mosto.BlankClip);
        var length = parseInt(((to - from) / 1000) * blank.fps);
        var playlist = new Mosto.Playlist({
            name: blank.name,
            start: from,
            end: to,
            blank: true,
        });
        _.extend(blank, { 'id': to.valueOf() + '-blank',
                          length: length,
                          in: 0,
                          out: length });
        playlist.get('medias').add(blank);
        this.add(playlist, options);
    },
    getMedias: function() {
        var medias = this.map(function(playlist) { return playlist.getMedias() })
        return _.flatten(medias);
    },
});

Mosto.LoadedPlaylists = Backbone.Model.extend({
    defaults: {
        playlists: null,
        melted_medias: null,
    },

    initialize: function(attributes, options) {
        attributes = attributes || {};
        if (!attributes.playlists)
            this.set('playlists', new Mosto.PlaylistCollection());
        this.get('playlists').on('all', bubbleEvents(this, 'playlists'));
        if (!attributes.melted_medias)
            this.set('melted_medias', new Mosto.MeltedCollection());
        this.get('melted_medias').on('all', bubbleEvents(this, 'melted_medias'));
    },

    save: function() {
        // set fires add, remove, change and sort
        var mm = this.get('melted_medias');
        var pl = this.get('playlists');
        mm.take(function() {
            mm.set(pl.getMedias());
            mm.leave()
        });
    },

    addPlaylist: function(playlist) {
        this.get('playlists').add(playlist, { merge: true });
        this.save();
    },
    removePlaylist: function(playlist) {
        this.get('playlists').remove(playlist);
        this.save();
    },
});

Mosto._globals = {}

Mosto.Playlists = function() {
    return Mosto._globals.playlists || (Mosto._globals.playlists = new Mosto.LoadedPlaylists());
}

exports = module.exports = Mosto;
<|endoftext|>


<commit_before>function makeRandomData(numPoints, scaleFactor) {
    if (typeof scaleFactor === "undefined") { scaleFactor = 1; }
    var data = [];
    for (var i = 0; i < numPoints; i++) {
        var x = Math.random();
        var r = { x: x, y: (x + x * Math.random()) * scaleFactor };
        data.push(r);
    }
    data.sort(function (a, b) {
        return a.x - b.x;
    });
    return { data: data, metadata: { cssClass: "random-data" } };
}

function makeNormallyDistributedData(n, xMean, xStdDev, yMean, yStdDev) {
    if (typeof n === "undefined") { n = 100; }
    var results = [];
    var x = d3.random.normal(xMean, xStdDev);
    var y = d3.random.normal(yMean, yStdDev);
    for (var i = 0; i < n; i++) {
        var r = { x: x(), y: y() };
        results.push(r);
    }
    return results;
}
function makeBinFunction(accessor, range, nBins) {
    return function (d) {
        return binByVal(d, accessor, range, nBins);
    };
}

function binByVal(data, accessor, range, nBins) {
    if (typeof range === "undefined") { range = [0, 100]; }
    if (typeof nBins === "undefined") { nBins = 10; }
    if (accessor == null) {
        accessor = function (d) {
            return d.x;
        };
    }
    ;
    var min = range[0];
    var max = range[1];
    var spread = max - min;
    var binBeginnings = d3.range(nBins).map(function (n) {
        return min + n * spread / nBins;
    });
    var binEndings = d3.range(nBins).map(function (n) {
        return min + (n + 1) * spread / nBins;
    });
    var counts = new Array(nBins);
    d3.range(nBins).forEach(function (b, i) {
        return counts[i] = 0;
    });
    data.forEach(function (d) {
        var v = accessor(d);
        var found = false;
        for (var i = 0; i < nBins; i++) {
            if (v <= binEndings[i]) {
                counts[i]++;
                found = true;
                break;
            }
        }
        if (!found) {
            counts[counts.length - 1]++;
        }
        ;
    });
    var bins = counts.map(function (count, i) {
        var bin = {};
        bin.x = binBeginnings[i];
        bin.dx = spread / nBins;
        bin.y = count;
        return bin;
    });
    return bins;
}
function makeRandomBucketData(numBuckets, bucketWidth, maxValue) {
    if (typeof maxValue === "undefined") { maxValue = 10; }
    var data = [];
    for (var i = 0; i < numBuckets; i++) {
        data.push({
            x: i * bucketWidth,
            dx: bucketWidth,
            y: Math.round(Math.random() * maxValue)
        });
    }
    return {
        "data": data,
        metadata: { cssClass: "random-buckets" }
    };
}
<commit_msg>Update plottable.js, plottable.d.ts, and other build artefacts<commit_after>function makeRandomData(numPoints, scaleFactor) {
    if (typeof scaleFactor === "undefined") { scaleFactor = 1; }
    var data = [];
    for (var i = 0; i < numPoints; i++) {
        var x = Math.random();
        var r = { x: x, y: (x + x * Math.random()) * scaleFactor };
        data.push(r);
    }
    data.sort(function (a, b) {
        return a.x - b.x;
    });
    return { data: data, metadata: { cssClass: "random-data" } };
}

function makeNormallyDistributedData(n, xMean, xStdDev, yMean, yStdDev) {
    if (typeof n === "undefined") { n = 100; }
    var results = [];
    var x = d3.random.normal(xMean, xStdDev);
    var y = d3.random.normal(yMean, yStdDev);
    for (var i = 0; i < n; i++) {
        var r = { x: x(), y: y() };
        results.push(r);
    }
    return results;
}
function makeBinFunction(accessor, range, nBins) {
    return function (d) {
        return binByVal(d, accessor, range, nBins);
    };
}

function binByVal(data, accessor, range, nBins) {
    if (typeof range === "undefined") { range = [0, 100]; }
    if (typeof nBins === "undefined") { nBins = 10; }
    if (accessor == null) {
        accessor = function (d) {
            return d.x;
        };
    }
    ;
    var min = range[0];
    var max = range[1];
    var spread = max - min;
    var binBeginnings = d3.range(nBins).map(function (n) {
        return min + n * spread / nBins;
    });
    var binEndings = d3.range(nBins).map(function (n) {
        return min + (n + 1) * spread / nBins;
    });
    var counts = new Array(nBins);
    d3.range(nBins).forEach(function (b, i) {
        return counts[i] = 0;
    });
    data.forEach(function (d) {
        var v = accessor(d);
        var found = false;
        for (var i = 0; i < nBins; i++) {
            if (v <= binEndings[i]) {
                counts[i]++;
                found = true;
                break;
            }
        }
        if (!found) {
            counts[counts.length - 1]++;
        }
        ;
    });
    var bins = counts.map(function (count, i) {
        var bin = {};
        bin.x = binBeginnings[i];
        bin.dx = spread / nBins;
        bin.y = count;
        return bin;
    });
    return bins;
}
function makeRandomBucketData(numBuckets, bucketWidth, maxValue) {
    if (typeof maxValue === "undefined") { maxValue = 10; }
    var data = [];
    for (var i = 0; i < numBuckets; i++) {
        data.push({
            x: i * bucketWidth,
            dx: bucketWidth,
            y: Math.round(Math.random() * maxValue)
        });
    }
    return {
        "data": data,
        metadata: { cssClass: "random-buckets" }
    };
}

function generateHeightWeightData(n) {
    var data = [];
    var heightGen = d3.random.normal(180, 2.5);
    var weightGen = d3.random.normal(170, 30);
    for (var i = 0; i < n; i++) {
        data.push({
            age: Math.random() * 50 + 20,
            gender: Math.random() > 0.5 ? "female" : "male",
            height: heightGen(),
            weight: weightGen()
        });
    }

    return {
        data: data,
        metadata: { cssClass: "height-weight-data" }
    };
}
<|endoftext|>