summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/secondscreen/RokuApp.jsm
blob: 7c458ebe347fa366cc7fa48d8effa9c476ac7f97 (plain)
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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
/* 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/. */

"use strict";

var EXPORTED_SYMBOLS = ["RokuApp"];

const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { AppConstants } = ChromeUtils.import(
  "resource://gre/modules/AppConstants.jsm"
);
const { XPCOMUtils } = ChromeUtils.import(
  "resource://gre/modules/XPCOMUtils.jsm"
);

XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest"]);

// function log(msg) {
//   Services.console.logStringMessage(msg);
// }

const PROTOCOL_VERSION = 1;

/* RokuApp is a wrapper for interacting with a Roku channel.
 * The basic interactions all use a REST API.
 * spec: http://sdkdocs.roku.com/display/sdkdoc/External+Control+Guide
 */
function RokuApp(service) {
  this.service = service;
  this.resourceURL = this.service.location;
  this.app = AppConstants.RELEASE_OR_BETA ? "Firefox" : "Firefox Nightly";
  this.mediaAppID = -1;
}

RokuApp.prototype = {
  status: function status(callback) {
    // We have no way to know if the app is running, so just return "unknown"
    // but we use this call to fetch the mediaAppID for the given app name
    let url = this.resourceURL + "query/apps";
    let xhr = new XMLHttpRequest();
    xhr.open("GET", url, true);
    xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
    xhr.overrideMimeType("text/xml");

    xhr.addEventListener("load", () => {
      if (xhr.status == 200) {
        let doc = xhr.responseXML;
        let apps = doc.querySelectorAll("app");
        for (let app of apps) {
          if (app.textContent == this.app) {
            this.mediaAppID = app.id;
          }
        }
      }

      // Since ECP has no way of telling us if an app is running, we always return "unknown"
      if (callback) {
        callback({ state: "unknown" });
      }
    });

    xhr.addEventListener("error", function() {
      if (callback) {
        callback({ state: "unknown" });
      }
    });

    xhr.send(null);
  },

  start: function start(callback) {
    // We need to make sure we have cached the mediaAppID
    if (this.mediaAppID == -1) {
      this.status(() => {
        // If we found the mediaAppID, use it to make a new start call
        if (this.mediaAppID != -1) {
          this.start(callback);
        } else {
          // We failed to start the app, so let the caller know
          callback(false);
        }
      });
      return;
    }

    // Start a given app with any extra query data. Each app uses it's own data scheme.
    // NOTE: Roku will also pass "source=external-control" as a param
    let url =
      this.resourceURL +
      "launch/" +
      this.mediaAppID +
      "?version=" +
      parseInt(PROTOCOL_VERSION);
    let xhr = new XMLHttpRequest();
    xhr.open("POST", url, true);
    xhr.overrideMimeType("text/plain");

    xhr.addEventListener("load", function() {
      if (callback) {
        callback(xhr.status === 200);
      }
    });

    xhr.addEventListener("error", function() {
      if (callback) {
        callback(false);
      }
    });

    xhr.send(null);
  },

  stop: function stop(callback) {
    // Roku doesn't seem to support stopping an app, so let's just go back to
    // the Home screen
    let url = this.resourceURL + "keypress/Home";
    let xhr = new XMLHttpRequest();
    xhr.open("POST", url, true);
    xhr.overrideMimeType("text/plain");

    xhr.addEventListener("load", function() {
      if (callback) {
        callback(xhr.status === 200);
      }
    });

    xhr.addEventListener("error", function() {
      if (callback) {
        callback(false);
      }
    });

    xhr.send(null);
  },

  remoteMedia: function remoteMedia(callback, listener) {
    if (this.mediaAppID != -1) {
      if (callback) {
        callback(new RemoteMedia(this.resourceURL, listener));
      }
    } else if (callback) {
      callback();
    }
  },
};

/* RemoteMedia provides a wrapper for using TCP socket to control Roku apps.
 * The server implementation must be built into the Roku receiver app.
 */
function RemoteMedia(url, listener) {
  this._url = url;
  this._listener = listener;
  this._status = "uninitialized";

  let serverURI = Services.io.newURI(this._url);
  this._socket = Cc["@mozilla.org/network/socket-transport-service;1"]
    .getService(Ci.nsISocketTransportService)
    .createTransport([], serverURI.host, 9191, null);
  this._outputStream = this._socket.openOutputStream(0, 0, 0);

  this._scriptableStream = Cc[
    "@mozilla.org/scriptableinputstream;1"
  ].createInstance(Ci.nsIScriptableInputStream);

  this._inputStream = this._socket.openInputStream(0, 0, 0);
  this._pump = Cc["@mozilla.org/network/input-stream-pump;1"].createInstance(
    Ci.nsIInputStreamPump
  );
  this._pump.init(this._inputStream, 0, 0, true);
  this._pump.asyncRead(this);
}

RemoteMedia.prototype = {
  onStartRequest(request) {},

  onDataAvailable(request, stream, offset, count) {
    this._scriptableStream.init(stream);
    let data = this._scriptableStream.read(count);
    if (!data) {
      return;
    }

    let msg = JSON.parse(data);
    if (this._status === msg._s) {
      return;
    }

    this._status = msg._s;

    if (this._listener) {
      // Check to see if we are getting the initial "connected" message
      if (
        this._status == "connected" &&
        "onRemoteMediaStart" in this._listener
      ) {
        this._listener.onRemoteMediaStart(this);
      }

      if ("onRemoteMediaStatus" in this._listener) {
        this._listener.onRemoteMediaStatus(this);
      }
    }
  },

  onStopRequest(request, result) {
    if (this._listener && "onRemoteMediaStop" in this._listener) {
      this._listener.onRemoteMediaStop(this);
    }
  },

  _sendMsg: function _sendMsg(data) {
    if (!data) {
      return;
    }

    // Add the protocol version
    data._v = PROTOCOL_VERSION;

    let raw = JSON.stringify(data);
    this._outputStream.write(raw, raw.length);
  },

  shutdown: function shutdown() {
    this._outputStream.close();
    this._inputStream.close();
  },

  get active() {
    return this._socket && this._socket.isAlive();
  },

  play: function play() {
    // TODO: add position support
    this._sendMsg({ type: "PLAY" });
  },

  pause: function pause() {
    this._sendMsg({ type: "STOP" });
  },

  load: function load(data) {
    this._sendMsg({
      type: "LOAD",
      title: data.title,
      source: data.source,
      poster: data.poster,
    });
  },

  get status() {
    return this._status;
  },
};