Navigation

Video Playback Plugin Tutorial

Estimated time to complete: ~20 minutes

Introduction

This tutorial creates a MongoDB Compass plugin to view WebM videos embedded within documents in a MongoDB collection. While these videos must fit within the maximum BSON document size of 16MB, this is enough space to store several minutes of modest-quality video.

Once complete, your plugin will look similar to the following:

../../_images/video-tutorial-complete.png

Prerequisites

The following are required to begin building MongoDB Compass plugins:

  • MongoDB Compass (version 1.11 or greater)
  • Node Version Manager (NVM)
  • NodeJS
  • Khaos

The following procedure outlines how to install these dependencies:

  1. Install the latest version of MongoDB Compass for your operating system from the downloads page.

  2. Install the Node Version Manager (NVM):

    For MacOS and Linux operating systems:

    Follow the installation instructions at https://github.com/creationix/nvm#install-script.

    For Windows operating systems:
    1. Download the nvm-setup.zip file from https://github.com/coreybutler/nvm-windows/releases.
    2. Decompress the downloaded .zip file and run nvm-setup.exe.
  3. Install NodeJS via NVM:

    nvm install stable
    
  4. Install the Khaos templating engine:

    npm install -g khaos
    
  5. Create the MongoDB Compass plugins directory. Compass looks for plugins in this directory:

    MongoDB Compass
    mkdir -p ~/.mongodb/compass/plugins
    
    MongoDB Compass Community Edition
    mkdir -p ~/.mongodb/compass-community/plugins
    

Creating the Plugin

Run the following commands to create an empty plugin called media-player.

MongoDB Compass
cd ~/.mongodb/compass/plugins
khaos create mongodb-js/compass-plugin ./media-player
MongoDB Compass Community Edition
cd ~/.mongodb/compass-community/plugins
khaos create mongodb-js/compass-plugin ./media-player

When prompted, enter the following values:

Field Description
Name media-player
Description Plays WebM video files embedded in a MongoDB collection.
Role Collection.Tab

This plugin is implemented with the Collection.Tab role, meaning it will reside in its own tab in the MongoDB Compass collection view.

Finally, run the following command to install the plugin’s dependencies:

cd media-player && npm install

Creating the Store

A store is responsible for storing and maintaining the state of the React/Flux application architecture used by MongoDB Compass plugins. The store responds to events and actions, resulting in state changes which are then reflected by the component’s view.

This data flow is shown in the following diagram:

../../_images/react-diagram.png

Stores listen to actions. Components subscribe to stores.

Note

For more information on stores, refer to the Redux documentation.

The media player plugin’s store is called MediaPlayerStore, and it keeps the following state variables:

videoURLs An array of strings where each string is a URL referring to a WebM video. These URLs are created using URL.createObjectURL, and must be deleted using URL.revokeObjectURL.
storeState

A string indicating the current state of the store. Must be one of the following:

  • initial
  • fetching
  • done
  • outdated
error An optional object containing error information.

Registering Actions

First you must register the actions that the store will respond to. MediaPlayerStore accepts the following two actions:

fetchVideos Fetch any documents matching the query bar’s parameters.
reset Clear the store’s query information and any stored state.

Update src/actions/actions.js to include these actions in the MediaPlayerActions object:

1
2
3
4
5
6
7
8
9
import Reflux from 'reflux';

const MediaPlayerActions = Reflux.createActions([
  'fetchVideos',
  'reset'
]);

export default MediaPlayerActions;
export { MediaPlayerActions };

Implementing the Store

Update src/stores/store.js to match the following:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
import Reflux from 'reflux';
import StateMixin from 'reflux-state-mixin';
import MediaPlayerActions from 'actions';
import toNS from 'mongodb-ns';

const debug = require('debug')('mongodb-compass:stores:media-player');

/**
 * Return true iff an input buffer has the magic number indicating an
 * EBML structure, which may be a WebM file. This is a quick-and-dirty
 * proxy to filter out a subset of ineligible fields.
 */
function isEBML(bin) {
  const view = new Uint8Array(bin);
  return view[0] === 0x1a &&
         view[1] === 0x45 &&
         view[2] === 0xdf &&
         view[3] === 0xa3;
}

/**
 * Media Player store.
 */
const MediaPlayerStore = Reflux.createStore({
  /**
   * adds a state to the store, similar to React.Component's state
   * @see https://github.com/yonatanmn/Super-Simple-Flux#reflux-state-mixin
   *
   * If you call `this.setState({...})` this will cause the store to trigger
   * and push down its state as props to connected components.
   */
  mixins: [StateMixin.store],

  /**
   * listen to all actions defined in ../actions/index.jsx
   */
  listenables: MediaPlayerActions,

  /**
   * Initialize everything that is not part of the store's state.
   */
  init() {
    this.filter = {};
    this.sort = null;
    this.project = null;
    this.skip = 0;
    this.limit = 0;

    this.indexes = [];
    this.ns = '';
  },

  /**
   * This method is called when all plugins are activated. You can register
   * listeners to other plugins' stores here.
   *
   * If this plugin does not depend on other stores, you can delete the method.
   *
   * @param {Object} appRegistry - app registry containing all stores and components
   */
  onActivated(appRegistry) {
    // Events emitted from the app registry:
    appRegistry.on('query-changed', this.onQueryChanged.bind(this));
    appRegistry.getStore('App.NamespaceStore').
                listen(this.onNamespaceChanged.bind(this));
  },

  /**
   * Initialize the Media Player store state. The returned object must
   * contain all keys that you might want to modify with this.setState().
   *
   * @return {Object} initial store state.
   */
  getInitialState() {
    return {
      storeState: 'initial',
      error: null,
      videoURLs: []
    };
  },

  /**
   * Event handler for query bar state changes.
   */
  onQueryChanged(state) {
    if (state.ns && toNS(state.ns).collection) {
      this.filter = state.filter;
      this.project = state.project;
      this.sort = state.sort;
      this.skip = state.skip;
      this.limit = state.limit;
      this.ns = state.ns;

      if (this.state.storeState === 'done') {
        this.setState({storeState: 'outdated'});
      }
    }
  },

  /**
   * Event handler for database/collection changes.
   */
  onNamespaceChanged() {
    this.reset();
  },

  /**
   * Action to release the current list of videos, and reset this
   * store's data to a pristine state.
   */
  reset() {
    for (const url of this.state.videoURLs) {
      if (url) {
        window.URL.revokeObjectURL(url);
      }
    }

    this.setState(this.getInitialState());
  },

  /**
   * Action to fetch any documents associated with the current query
   * information associated with this MediaPlayerStore.
   */
  fetchVideos() {
    if (this.state.storeState === 'fetching') {
      return;
    }

    this.reset();

    this.setState({storeState: 'fetching'});

    const findOptions = {
      sort: this.sort,
      fields: this.project,
      skip: this.skip,
      limit: Math.min(5, this.limit)
    };

    window.app.dataService.find(this.ns, this.filter, findOptions,
                                (findError, documents) => {
      if (findError) {
        this.setState({error: findError});
        return;
      }

      const urls = [];
      try {
        for (const doc of documents) {
          if (isEBML(doc.data.buffer)) {
            const blob = new Blob([doc.data.buffer], {type: 'video/webm'});
            urls.push(URL.createObjectURL(blob));
          } else {
            urls.push('');
          }
        }
      } catch (error) {
        this.setState({error: error});
      }

      this.setState({videoURLs: urls, storeState: 'done'});
    });
  },

  /**
   * log changes to the store as debug messages.
   * @param  {Object} prevState   previous state.
   */
  storeDidUpdate(prevState) {
    debug('MediaPlayer store changed from', prevState, 'to', this.state);
  }
});

export default MediaPlayerStore;
export { MediaPlayerStore };

Creating the Component

The media player contains a component, which is a view which renders based on the MediaPlayerStore store.

Update src/components/media-player/media-player.jsx to match the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import React, { Component } from 'react';

import PropTypes from 'prop-types';
import classnames from 'classnames';

import styles from './media-player.less';

class MediaPlayer extends Component {
  static displayName = 'MediaPlayerComponent';

  static propTypes = {
    videoURLs: PropTypes.array.isRequired,
    storeState: PropTypes.oneOf(['initial', 'fetching', 'done', 'outdated']),
    error: PropTypes.object
  };

  static defaultProps = {
    videoURLs: [],
    storeState: 'initial',
    error: null
  };

  constructor(props) {
    super(props);
  }

  componentWillMount() {
    this.queryBar = window.app.appRegistry.getComponent('Query.QueryBar');
  }

  onApplyClicked() {
    this.props.actions.fetchVideos();
  }

  onResetClicked() {
    this.props.actions.reset();
    this.props.actions.fetchVideos();
  }

  renderError() {
    if (this.props.error) {
      return (<div>{this.props.error}</div>);
    }

    return null;
  }

  renderContent() {
    if (!_.includes(['done', 'outdated'], this.props.storeState)) {
      return null;
    }

    return (
      <div className="column-container">
        {this.renderError()}
        <div className="column main">
          {this.props.videoURLs.map((url) => {
            if (url) {
              return <video className={classnames(styles.videoElement)} controls src={url}>No video support</video>;
            }

            return <div className={classnames(styles.videoElement)}>Does not contain a playable video</div>;
          })}
        </div>
      </div>
    );
  }

  /**
   * Render MediaPlayer component.
   *
   * @returns {React.Component} The rendered component.
   */
  render() {
    return (
      <div className={classnames(styles.root)}>
        <div className="controls-container">
          <this.queryBar
            buttonLabel="Find"
            onApply={this.onApplyClicked.bind(this)}
            onReset={this.onResetClicked.bind(this)} />
        </div>
        {this.renderContent()}
      </div>
    );
  }
}

export default MediaPlayer;
export { MediaPlayer };

Running the Plugin

Run the following command from your plugin’s root directory to build the plugin:

npm run compile

Launch Compass to view your plugin.