gecko-dev/mobile/android/modules/MatchstickApp.jsm

376 строки
10 KiB
JavaScript

/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
/* 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";
this.EXPORTED_SYMBOLS = ["MatchstickApp"];
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/Services.jsm");
function log(msg) {
Services.console.logStringMessage(msg);
}
const MATCHSTICK_PLAYER_URL = "http://openflint.github.io/flint-player/player.html";
const STATUS_RETRY_COUNT = 5; // Number of times we retry a partial status
const STATUS_RETRY_WAIT = 1000; // Delay between attempts in milliseconds
/* MatchstickApp is a wrapper for interacting with a DIAL server.
* The basic interactions all use a REST API.
* See: https://github.com/openflint/openflint.github.io/wiki/Flint%20Protocol%20Docs
*/
function MatchstickApp(aServer) {
this.server = aServer;
this.app = "~flintplayer";
this.resourceURL = this.server.appsURL + this.app;
this.token = null;
this.statusTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
this.statusRetry = 0;
}
MatchstickApp.prototype = {
status: function status(aCallback) {
// Query the server to see if an application is already running
let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
xhr.open("GET", this.resourceURL, true);
xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
xhr.setRequestHeader("Accept", "application/xml; charset=utf8");
xhr.setRequestHeader("Authorization", this.token);
xhr.addEventListener("load", (function() {
if (xhr.status == 200) {
let doc = xhr.responseXML;
let state = doc.querySelector("state").textContent;
// The serviceURL can be missing if the player is not completely loaded
let serviceURL = null;
let serviceNode = doc.querySelector("channelBaseUrl");
if (serviceNode) {
serviceURL = serviceNode.textContent + "/senders/" + this.token;
}
if (aCallback)
aCallback({ state: state, serviceURL: serviceURL });
} else {
if (aCallback)
aCallback({ state: "error" });
}
}).bind(this), false);
xhr.addEventListener("error", (function() {
if (aCallback)
aCallback({ state: "error" });
}).bind(this), false);
xhr.send(null);
},
start: function start(aCallback) {
// Start a given app with any extra query data. Each app uses it's own data scheme.
let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
xhr.open("POST", this.resourceURL, true);
xhr.overrideMimeType("text/xml");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.addEventListener("load", (function() {
if (xhr.status == 200 || xhr.status == 201) {
this.statusRetry = 0;
let response = JSON.parse(xhr.responseText);
this.token = response.token;
this.pingInterval = response.interval;
if (aCallback)
aCallback(true);
} else {
if (aCallback)
aCallback(false);
}
}).bind(this), false);
xhr.addEventListener("error", (function() {
if (aCallback)
aCallback(false);
}).bind(this), false);
let data = {
type: "launch",
app_info: {
url: MATCHSTICK_PLAYER_URL,
useIpc: true,
maxInactive: -1
}
};
xhr.send(JSON.stringify(data));
},
stop: function stop(aCallback) {
// Send command to kill an app, if it's already running.
let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
xhr.open("DELETE", this.resourceURL + "/run", true);
xhr.overrideMimeType("text/plain");
xhr.setRequestHeader("Accept", "application/xml; charset=utf8");
xhr.setRequestHeader("Authorization", this.token);
xhr.addEventListener("load", (function() {
if (xhr.status == 200) {
if (aCallback)
aCallback(true);
} else {
if (aCallback)
aCallback(false);
}
}).bind(this), false);
xhr.addEventListener("error", (function() {
if (aCallback)
aCallback(false);
}).bind(this), false);
xhr.send(null);
},
remoteMedia: function remoteMedia(aCallback, aListener) {
this.status((aStatus) => {
if (aStatus.serviceURL) {
if (aCallback) {
aCallback(new RemoteMedia(aStatus.serviceURL, aListener, this));
}
return;
}
// It can take a few moments for the player app to load. Let's use a small delay
// and retry a few times.
if (this.statusRetry < STATUS_RETRY_COUNT) {
this.statusRetry++;
this.statusTimer.initWithCallback(() => {
this.remoteMedia(aCallback, aListener);
}, STATUS_RETRY_WAIT, Ci.nsITimer.TYPE_ONE_SHOT);
} else {
// Fail
if (aCallback) {
aCallback();
}
}
});
}
}
/* RemoteMedia provides a wrapper for using WebSockets and Flint protocol to control
* the Matchstick media player
* See: https://github.com/openflint/openflint.github.io/wiki/Flint%20Protocol%20Docs
* See: https://github.com/openflint/flint-receiver-sdk/blob/gh-pages/v1/libs/mediaplayer.js
*/
function RemoteMedia(aURL, aListener, aApp) {
this._active = false;
this._status = "uninitialized";
this.app = aApp;
this.listener = aListener;
this.pingTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
let uri = Services.io.newURI(aURL, null, null);
this.ws = Cc["@mozilla.org/network/protocol;1?name=ws"].createInstance(Ci.nsIWebSocketChannel);
this.ws.initLoadInfo(null, // aLoadingNode
Services.scriptSecurityManager.getSystemPrincipal(),
null, // aTriggeringPrincipal
Ci.nsILoadInfo.SEC_NORMAL,
Ci.nsIContentPolicy.TYPE_WEBSOCKET);
this.ws.asyncOpen(uri, aURL, this, null);
}
// Used to give us a small gap between not pinging too often and pinging too late
const PING_INTERVAL_BACKOFF = 200;
RemoteMedia.prototype = {
_ping: function _ping() {
if (this.app.pingInterval == -1) {
return;
}
let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
xhr.open("GET", this.app.resourceURL, true);
xhr.setRequestHeader("Accept", "application/xml; charset=utf8");
xhr.setRequestHeader("Authorization", this.app.token);
xhr.addEventListener("load", () => {
if (xhr.status == 200) {
this.pingTimer.initWithCallback(() => {
this._ping();
}, this.app.pingInterval - PING_INTERVAL_BACKOFF, Ci.nsITimer.TYPE_ONE_SHOT);
}
});
xhr.send(null);
},
_changeStatus: function _changeStatus(status) {
if (this._status != status) {
this._status = status;
if ("onRemoteMediaStatus" in this.listener) {
this.listener.onRemoteMediaStatus(this);
}
}
},
_teardown: function _teardown() {
if (!this._active) {
return;
}
// Stop any queued ping event
this.pingTimer.cancel();
// Let the listener know we are finished
this._active = false;
if (this.listener && "onRemoteMediaStop" in this.listener) {
this.listener.onRemoteMediaStop(this);
}
},
_sendMsg: function _sendMsg(params) {
// Convert payload to a string
params.payload = JSON.stringify(params.payload);
try {
this.ws.sendMsg(JSON.stringify(params));
} catch (e if e.result == Cr.NS_ERROR_NOT_CONNECTED) {
// This shouldn't happen unless something gets out of sync with the
// connection. Let's make sure we try to cleanup.
this._teardown();
} catch (e) {
log("Send Error: " + e)
}
},
get active() {
return this._active;
},
get status() {
return this._status;
},
shutdown: function shutdown() {
this.ws.close(Ci.nsIWebSocketChannel.CLOSE_NORMAL, "shutdown");
},
play: function play() {
if (!this._active) {
return;
}
let params = {
namespace: "urn:flint:org.openflint.fling.media",
payload: {
type: "PLAY",
requestId: "requestId-5",
}
};
this._sendMsg(params);
},
pause: function pause() {
if (!this._active) {
return;
}
let params = {
namespace: "urn:flint:org.openflint.fling.media",
payload: {
type: "PAUSE",
requestId: "requestId-4",
}
};
this._sendMsg(params);
},
load: function load(aData) {
if (!this._active) {
return;
}
let params = {
namespace: "urn:flint:org.openflint.fling.media",
payload: {
type: "LOAD",
requestId: "requestId-2",
media: {
contentId: aData.source,
contentType: "video/mp4",
metadata: {
title: "",
subtitle: ""
}
}
}
};
this._sendMsg(params);
},
onStart: function(aContext) {
this._active = true;
if (this.listener && "onRemoteMediaStart" in this.listener) {
this.listener.onRemoteMediaStart(this);
}
this._ping();
},
onStop: function(aContext, aStatusCode) {
// This will be called for internal socket failures and timeouts. Make
// sure we cleanup.
this._teardown();
},
onAcknowledge: function(aContext, aSize) {},
onBinaryMessageAvailable: function(aContext, aMessage) {},
onMessageAvailable: function(aContext, aMessage) {
let msg = JSON.parse(aMessage);
if (!msg) {
return;
}
let payload = JSON.parse(msg.payload);
if (!payload) {
return;
}
// Handle state changes using the player notifications
if (payload.type == "MEDIA_STATUS") {
let status = payload.status[0];
let state = status.playerState.toLowerCase();
if (state == "playing") {
this._changeStatus("started");
} else if (state == "paused") {
this._changeStatus("paused");
} else if (state == "idle" && "idleReason" in status) {
// Make sure we are really finished. IDLE can be sent at other times.
let reason = status.idleReason.toLowerCase();
if (reason == "finished") {
this._changeStatus("completed");
}
}
}
},
onServerClose: function(aContext, aStatusCode, aReason) {
// This will be fired from _teardown when we close the websocket, but it
// can also be called for other internal socket failures and timeouts. We
// make sure the _teardown bails on reentry.
this._teardown();
}
}