/** @jsx React.DOM */ /* 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/. */ /* jshint newcap:false */ /* global loop:true, React */ var loop = loop || {}; loop.shared = loop.shared || {}; loop.shared.views = (function(_, OT, l10n) { "use strict"; var sharedModels = loop.shared.models; var __ = l10n.get; /** * L10n view. Translates resulting view DOM fragment once rendered. */ var L10nView = (function() { var L10nViewImpl = Backbone.View.extend(), // Original View constructor originalExtend = L10nViewImpl.extend; // Original static extend fn /** * Patches View extend() method so we can hook and patch any declared render * method. * * @return {Backbone.View} Extended view with patched render() method. */ L10nViewImpl.extend = function() { var ExtendedView = originalExtend.apply(this, arguments), originalRender = ExtendedView.prototype.render; /** * Wraps original render() method to translate contents once they're * rendered. * * @return {Backbone.View} Extended view instance. */ ExtendedView.prototype.render = function() { if (originalRender) { originalRender.apply(this, arguments); l10n.translate(this.el); } return this; }; return ExtendedView; }; return L10nViewImpl; })(); /** * Base view. */ var BaseView = L10nView.extend({ /** * Hides view element. * * @return {BaseView} */ hide: function() { this.$el.hide(); return this; }, /** * Shows view element. * * @return {BaseView} */ show: function() { this.$el.show(); return this; }, /** * Base render implementation: renders an attached template if available. * * Note: You need to override this if you want to do fancier stuff, eg. * rendering the template using model data. * * @return {BaseView} */ render: function() { if (this.template) { this.$el.html(this.template()); } return this; } }); /** * Media control button. * * Required props: * - {String} scope Media scope, can be "local" or "remote". * - {String} type Media type, can be "audio" or "video". * - {Function} action Function to be executed on click. * - {Enabled} enabled Stream activation status (default: true). */ var MediaControlButton = React.createClass({displayName: 'MediaControlButton', propTypes: { scope: React.PropTypes.string.isRequired, type: React.PropTypes.string.isRequired, action: React.PropTypes.func.isRequired, enabled: React.PropTypes.bool.isRequired }, getDefaultProps: function() { return {enabled: true}; }, handleClick: function() { this.props.action(); }, _getClasses: function() { var cx = React.addons.classSet; // classes var classesObj = { "btn": true, "media-control": true, "local-media": this.props.scope === "local", "muted": !this.props.enabled }; classesObj["btn-mute-" + this.props.type] = true; return cx(classesObj); }, _getTitle: function(enabled) { var prefix = this.props.enabled ? "mute" : "unmute"; var suffix = "button_title"; var msgId = [prefix, this.props.scope, this.props.type, suffix].join("_"); return __(msgId); }, render: function() { return ( React.DOM.button( {className:this._getClasses(), title:this._getTitle(), onClick:this.handleClick}) ); } }); /** * Conversation controls. */ var ConversationToolbar = React.createClass({displayName: 'ConversationToolbar', getDefaultProps: function() { return { video: {enabled: true}, audio: {enabled: true} }; }, propTypes: { video: React.PropTypes.object.isRequired, audio: React.PropTypes.object.isRequired, hangup: React.PropTypes.func.isRequired, publishStream: React.PropTypes.func.isRequired }, handleClickHangup: function() { this.props.hangup(); }, handleToggleVideo: function() { this.props.publishStream("video", !this.props.video.enabled); }, handleToggleAudio: function() { this.props.publishStream("audio", !this.props.audio.enabled); }, render: function() { return ( React.DOM.ul( {className:"controls"}, React.DOM.li(null, React.DOM.button( {className:"btn btn-hangup", onClick:this.handleClickHangup, title:__("hangup_button_title")})), React.DOM.li(null, MediaControlButton( {action:this.handleToggleVideo, enabled:this.props.video.enabled, scope:"local", type:"video"} )), React.DOM.li(null, MediaControlButton( {action:this.handleToggleAudio, enabled:this.props.audio.enabled, scope:"local", type:"audio"} )) ) ); } }); var ConversationView = React.createClass({displayName: 'ConversationView', mixins: [Backbone.Events], propTypes: { sdk: React.PropTypes.object.isRequired, model: React.PropTypes.object.isRequired }, // height set to "auto" to fix video layout on Google Chrome // @see https://bugzilla.mozilla.org/show_bug.cgi?id=991122 publisherConfig: { width: "100%", height: "auto", style: { bugDisplayMode: "off", buttonDisplayMode: "off" } }, getInitialState: function() { return { video: {enabled: false}, audio: {enabled: false} }; }, componentDidMount: function() { this.props.model.startSession(); }, componentWillMount: function() { this.listenTo(this.props.model, "session:connected", this.startPublishing); this.listenTo(this.props.model, "session:stream-created", this._streamCreated); this.listenTo(this.props.model, ["session:peer-hungup", "session:network-disconnected", "session:ended"].join(" "), this.stopPublishing); }, componentWillUnmount: function() { this.hangup(); }, hangup: function() { this.stopPublishing(); this.props.model.endSession(); }, /** * Subscribes and attaches each created stream to a DOM element. * * XXX: for now we only support a single remote stream, hence a single DOM * element. * * http://tokbox.com/opentok/libraries/client/js/reference/StreamEvent.html * * @param {StreamEvent} event */ _streamCreated: function(event) { var incoming = this.getDOMNode().querySelector(".incoming"); event.streams.forEach(function(stream) { if (stream.connection.connectionId !== this.props.model.session.connection.connectionId) { this.props.model.session.subscribe(stream, incoming, this.publisherConfig); } }, this); }, /** * Publishes remote streams available once a session is connected. * * http://tokbox.com/opentok/libraries/client/js/reference/SessionConnectEvent.html * * @param {SessionConnectEvent} event */ startPublishing: function(event) { var outgoing = this.getDOMNode().querySelector(".outgoing"); // XXX move this into its StreamingVideo component? this.publisher = this.props.sdk.initPublisher( outgoing, this.publisherConfig); // Suppress OT GuM custom dialog, see bug 1018875 function preventOpeningAccessDialog(event) { event.preventDefault(); } this.publisher.on("accessDialogOpened", preventOpeningAccessDialog); this.publisher.on("accessDenied", preventOpeningAccessDialog); this.publisher.on("streamCreated", function(event) { this.setState({ audio: {enabled: event.stream.hasAudio}, video: {enabled: event.stream.hasVideo} }); }.bind(this)); this.publisher.on("streamDestroyed", function() { this.setState({ audio: {enabled: false}, video: {enabled: false} }); }.bind(this)); this.props.model.session.publish(this.publisher); }, /** * Toggles streaming status for a given stream type. * * @param {String} type Stream type ("audio" or "video"). * @param {Boolean} enabled Enabled stream flag. */ publishStream: function(type, enabled) { if (type === "audio") { this.publisher.publishAudio(enabled); this.setState({audio: {enabled: enabled}}); } else { this.publisher.publishVideo(enabled); this.setState({video: {enabled: enabled}}); } }, /** * Unpublishes local stream. */ stopPublishing: function() { // Unregister access OT GuM custom dialog listeners, see bug 1018875 this.publisher.off("accessDialogOpened"); this.publisher.off("accessDenied"); this.props.model.session.unpublish(this.publisher); }, render: function() { return ( React.DOM.div( {className:"conversation"}, ConversationToolbar( {video:this.state.video, audio:this.state.audio, publishStream:this.publishStream, hangup:this.hangup} ), React.DOM.div( {className:"media nested"}, React.DOM.div( {className:"remote"}, React.DOM.div( {className:"incoming"})), React.DOM.div( {className:"local"}, React.DOM.div( {className:"outgoing"})) ) ) ); } }); /** * Notification view. */ var NotificationView = BaseView.extend({ template: _.template([ '
', ' ', '

<%- message %>

', '
' ].join("")), events: { "click .close": "dismiss" }, dismiss: function(event) { event.preventDefault(); this.$el.addClass("fade-out"); setTimeout(function() { this.collection.remove(this.model); this.remove(); }.bind(this), 500); // XXX make timeout value configurable }, render: function() { this.$el.html(this.template(this.model.toJSON())); return this; } }); /** * Notification list view. */ var NotificationListView = Backbone.View.extend({ /** * Constructor. * * Available options: * - {loop.shared.models.NotificationCollection} collection Notifications * collection * * @param {Object} options Options object */ initialize: function(options) { options = options || {}; if (!options.collection) { this.collection = new sharedModels.NotificationCollection(); } this.listenTo(this.collection, "reset add remove", this.render); }, /** * Clears the notification stack. */ clear: function() { this.collection.reset(); }, /** * Adds a new notification to the stack, triggering rendering of it. * * @param {Object|NotificationModel} notification Notification data. */ notify: function(notification) { this.collection.add(notification); }, /** * Adds a new notification to the stack using an l10n message identifier, * triggering rendering of it. * * @param {String} messageId L10n message id * @param {String} level Notification level */ notifyL10n: function(messageId, level) { this.notify({ message: l10n.get(messageId), level: level }); }, /** * Adds a warning notification to the stack and renders it. * * @return {String} message */ warn: function(message) { this.notify({level: "warning", message: message}); }, /** * Adds a l10n warning notification to the stack and renders it. * * @param {String} messageId L10n message id */ warnL10n: function(messageId) { this.warn(l10n.get(messageId)); }, /** * Adds an error notification to the stack and renders it. * * @return {String} message */ error: function(message) { this.notify({level: "error", message: message}); }, /** * Adds a l10n rror notification to the stack and renders it. * * @param {String} messageId L10n message id */ errorL10n: function(messageId) { this.error(l10n.get(messageId)); }, /** * Renders this view. * * @return {loop.shared.views.NotificationListView} */ render: function() { this.$el.html(this.collection.map(function(notification) { return new NotificationView({ model: notification, collection: this.collection }).render().$el; }.bind(this))); return this; } }); /** * Unsupported Browsers view. */ var UnsupportedBrowserView = BaseView.extend({ template: _.template([ '
', '

', '

', '

', '

', '
' ].join("")) }); /** * Unsupported Browsers view. */ var UnsupportedDeviceView = BaseView.extend({ template: _.template([ '
', '

', '

', '

', '
' ].join("")) }); return { L10nView: L10nView, BaseView: BaseView, ConversationView: ConversationView, ConversationToolbar: ConversationToolbar, MediaControlButton: MediaControlButton, NotificationListView: NotificationListView, NotificationView: NotificationView, UnsupportedBrowserView: UnsupportedBrowserView, UnsupportedDeviceView: UnsupportedDeviceView }; })(_, window.OT, document.webL10n || document.mozL10n);