bug 1037015 - support tab casting to chromecast r=mfinkle

This commit is contained in:
Brad Lassey 2014-07-29 13:59:22 -04:00
parent f7bea96e82
commit 828e42e81c
8 changed files with 472 additions and 60 deletions

View File

@ -1,4 +1,5 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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/. */
@ -10,6 +11,8 @@ import org.mozilla.gecko.util.EventCallback;
import org.json.JSONObject;
import org.json.JSONException;
import com.google.android.gms.cast.Cast.MessageReceivedCallback;
import com.google.android.gms.cast.ApplicationMetadata;
import com.google.android.gms.cast.Cast;
import com.google.android.gms.cast.Cast.ApplicationConnectionResult;
import com.google.android.gms.cast.CastDevice;
@ -33,10 +36,13 @@ import android.util.Log;
class ChromeCast implements GeckoMediaPlayer {
private static final boolean SHOW_DEBUG = false;
static final String MIRROR_RECIEVER_APP_ID = "D40D28D6";
private final Context context;
private final RouteInfo route;
private GoogleApiClient apiClient;
private RemoteMediaPlayer remoteMediaPlayer;
private boolean canMirror;
// Callback to start playback of a url on a remote device
private class VideoPlayCallback implements ResultCallback<ApplicationConnectionResult>,
@ -133,6 +139,7 @@ class ChromeCast implements GeckoMediaPlayer {
public ChromeCast(Context context, RouteInfo route) {
this.context = context;
this.route = route;
this.canMirror = route.supportsControlCategory(CastMediaControlIntent.categoryForCast(MIRROR_RECIEVER_APP_ID));
}
// This dumps everything we can find about the device into JSON. This will hopefully make it
@ -146,6 +153,7 @@ class ChromeCast implements GeckoMediaPlayer {
obj.put("friendlyName", device.getFriendlyName());
obj.put("location", device.getIpAddress().toString());
obj.put("modelName", device.getModelName());
obj.put("mirror", canMirror);
// For now we just assume all of these are Google devices
obj.put("manufacturer", "Google Inc.");
} catch(JSONException ex) {
@ -263,6 +271,127 @@ class ChromeCast implements GeckoMediaPlayer {
});
}
private String mSessionId;
MirrorChannel mMirrorChannel;
boolean mApplicationStarted = false;
class MirrorChannel implements MessageReceivedCallback {
/**
* @return custom namespace
*/
public String getNamespace() {
return "urn:x-cast:org.mozilla.mirror";
}
/*
* Receive message from the receiver app
*/
@Override
public void onMessageReceived(CastDevice castDevice, String namespace,
String message) {
GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("MediaPlayer:Response", message));
}
public void sendMessage(String message) {
if (apiClient != null && mMirrorChannel != null) {
try {
Cast.CastApi.sendMessage(apiClient, mMirrorChannel.getNamespace(), message)
.setResultCallback(
new ResultCallback<Status>() {
@Override
public void onResult(Status result) {
}
});
} catch (Exception e) {
Log.e(LOGTAG, "Exception while sending message", e);
}
}
}
}
private class MirrorCallback implements ResultCallback<ApplicationConnectionResult> {
final EventCallback callback;
MirrorCallback(final EventCallback callback) {
this.callback = callback;
}
public void onResult(ApplicationConnectionResult result) {
Status status = result.getStatus();
if (status.isSuccess()) {
ApplicationMetadata applicationMetadata = result.getApplicationMetadata();
mSessionId = result.getSessionId();
String applicationStatus = result.getApplicationStatus();
boolean wasLaunched = result.getWasLaunched();
mApplicationStarted = true;
// Create the custom message
// channel
mMirrorChannel = new MirrorChannel();
try {
Cast.CastApi.setMessageReceivedCallbacks(apiClient,
mMirrorChannel
.getNamespace(),
mMirrorChannel);
callback.sendSuccess(null);
} catch (IOException e) {
Log.e(LOGTAG, "Exception while creating channel", e);
}
GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Casting:Mirror", route.getId()));
} else {
callback.sendError(null);
}
}
}
public void message(String msg, final EventCallback callback) {
if (mMirrorChannel != null) {
mMirrorChannel.sendMessage(msg);
}
}
public void mirror(final EventCallback callback) {
final CastDevice device = CastDevice.getFromBundle(route.getExtras());
Cast.CastOptions.Builder apiOptionsBuilder = Cast.CastOptions.builder(device, new Cast.Listener() {
@Override
public void onApplicationStatusChanged() { }
@Override
public void onVolumeChanged() { }
@Override
public void onApplicationDisconnected(int errorCode) { }
});
apiClient = new GoogleApiClient.Builder(context)
.addApi(Cast.API, apiOptionsBuilder.build())
.addConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() {
@Override
public void onConnected(Bundle connectionHint) {
if (!apiClient.isConnected()) {
return;
}
// Launch the media player app and launch this url once its loaded
try {
Cast.CastApi.launchApplication(apiClient, MIRROR_RECIEVER_APP_ID, true)
.setResultCallback(new MirrorCallback(callback));
} catch (Exception e) {
debug("Failed to launch application", e);
}
}
@Override
public void onConnectionSuspended(int cause) {
debug("suspended");
}
}).build();
apiClient.connect();
}
private static final String LOGTAG = "GeckoChromeCast";
private void debug(String msg, Exception e) {
if (SHOW_DEBUG) {

View File

@ -1,4 +1,5 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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/. */
@ -19,6 +20,8 @@ import android.support.v7.media.MediaRouter;
import android.support.v7.media.MediaRouter.RouteInfo;
import android.util.Log;
import com.google.android.gms.cast.CastMediaControlIntent;
import java.util.HashMap;
/* Wraper for different MediaRouter types supproted by Android. i.e. Chromecast, Miracast, etc. */
@ -30,6 +33,8 @@ interface GeckoMediaPlayer {
public void stop(EventCallback callback);
public void start(EventCallback callback);
public void end(EventCallback callback);
public void mirror(EventCallback callback);
public void message(String message, EventCallback callback);
}
/* Manages a list of GeckoMediaPlayers methods (i.e. Chromecast/Miracast). Routes messages
@ -74,14 +79,17 @@ class MediaPlayerManager implements NativeEventListener,
app.addAppStateListener(this);
}
mediaRouter = MediaRouter.getInstance(context);
EventDispatcher.getInstance().registerGeckoThreadListener(this, "MediaPlayer:Load",
"MediaPlayer:Start",
"MediaPlayer:Stop",
"MediaPlayer:Play",
"MediaPlayer:Pause",
"MediaPlayer:Get",
"MediaPlayer:End");
mediaRouter = MediaRouter.getInstance(context);
EventDispatcher.getInstance().registerGeckoThreadListener(this,
"MediaPlayer:Load",
"MediaPlayer:Start",
"MediaPlayer:Stop",
"MediaPlayer:Play",
"MediaPlayer:Pause",
"MediaPlayer:Get",
"MediaPlayer:End",
"MediaPlayer:Mirror",
"MediaPlayer:Message");
}
public static void onDestroy() {
@ -89,13 +97,16 @@ class MediaPlayerManager implements NativeEventListener,
return;
}
EventDispatcher.getInstance().unregisterGeckoThreadListener(instance, "MediaPlayer:Load",
"MediaPlayer:Start",
"MediaPlayer:Stop",
"MediaPlayer:Play",
"MediaPlayer:Pause",
"MediaPlayer:Get",
"MediaPlayer:End");
EventDispatcher.getInstance().unregisterGeckoThreadListener(instance,
"MediaPlayer:Load",
"MediaPlayer:Start",
"MediaPlayer:Stop",
"MediaPlayer:Play",
"MediaPlayer:Pause",
"MediaPlayer:Get",
"MediaPlayer:End",
"MediaPlayer:Mirror",
"MediaPlayer:Message");
if (instance.context instanceof GeckoApp) {
GeckoApp app = (GeckoApp) instance.context;
app.removeAppStateListener(instance);
@ -132,8 +143,10 @@ class MediaPlayerManager implements NativeEventListener,
final GeckoMediaPlayer display = displays.get(message.getString("id"));
if (display == null) {
Log.e(LOGTAG, "Couldn't find a display for this id");
callback.sendError(null);
Log.e(LOGTAG, "Couldn't find a display for this id: " + message.getString("id") + " for message: " + event);
if (callback != null) {
callback.sendError(null);
}
return;
}
@ -147,6 +160,10 @@ class MediaPlayerManager implements NativeEventListener,
display.pause(callback);
} else if ("MediaPlayer:End".equals(event)) {
display.end(callback);
} else if ("MediaPlayer:Mirror".equals(event)) {
display.mirror(callback);
} else if ("MediaPlayer:Message".equals(event) && message.has("data")) {
display.message(message.getString("data"), callback);
} else if ("MediaPlayer:Load".equals(event)) {
final String url = message.optString("source", "");
final String type = message.optString("type", "video/mp4");
@ -155,48 +172,49 @@ class MediaPlayerManager implements NativeEventListener,
}
}
private final MediaRouter.Callback callback = new MediaRouter.Callback() {
@Override
public void onRouteRemoved(MediaRouter router, RouteInfo route) {
debug("onRouteRemoved: route=" + route);
displays.remove(route.getId());
}
@SuppressWarnings("unused")
public void onRouteSelected(MediaRouter router, int type, MediaRouter.RouteInfo route) {
}
// These methods aren't used by the support version Media Router
@SuppressWarnings("unused")
public void onRouteUnselected(MediaRouter router, int type, RouteInfo route) {
}
@Override
public void onRoutePresentationDisplayChanged(MediaRouter router, RouteInfo route) {
}
@Override
public void onRouteVolumeChanged(MediaRouter router, RouteInfo route) {
}
@Override
public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo route) {
debug("onRouteAdded: route=" + route);
GeckoMediaPlayer display = getMediaPlayerForRoute(route);
if (display != null) {
displays.put(route.getId(), display);
private final MediaRouter.Callback callback =
new MediaRouter.Callback() {
@Override
public void onRouteRemoved(MediaRouter router, RouteInfo route) {
debug("onRouteRemoved: route=" + route);
displays.remove(route.getId());
}
}
@Override
public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo route) {
debug("onRouteChanged: route=" + route);
GeckoMediaPlayer display = displays.get(route.getId());
if (display != null) {
displays.put(route.getId(), display);
@SuppressWarnings("unused")
public void onRouteSelected(MediaRouter router, int type, MediaRouter.RouteInfo route) {
}
}
};
// These methods aren't used by the support version Media Router
@SuppressWarnings("unused")
public void onRouteUnselected(MediaRouter router, int type, RouteInfo route) {
}
@Override
public void onRoutePresentationDisplayChanged(MediaRouter router, RouteInfo route) {
}
@Override
public void onRouteVolumeChanged(MediaRouter router, RouteInfo route) {
}
@Override
public void onRouteAdded(MediaRouter router, MediaRouter.RouteInfo route) {
debug("onRouteAdded: route=" + route);
GeckoMediaPlayer display = getMediaPlayerForRoute(route);
if (display != null) {
displays.put(route.getId(), display);
}
}
@Override
public void onRouteChanged(MediaRouter router, MediaRouter.RouteInfo route) {
debug("onRouteChanged: route=" + route);
GeckoMediaPlayer display = displays.get(route.getId());
if (display != null) {
displays.put(route.getId(), display);
}
}
};
private GeckoMediaPlayer getMediaPlayerForRoute(MediaRouter.RouteInfo route) {
try {
@ -221,6 +239,7 @@ class MediaPlayerManager implements NativeEventListener,
MediaRouteSelector selectorBuilder = new MediaRouteSelector.Builder()
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_VIDEO)
.addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
.addControlCategory(CastMediaControlIntent.categoryForCast(ChromeCast.MIRROR_RECIEVER_APP_ID))
.build();
mediaRouter.addCallback(selectorBuilder, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY);
}

View File

@ -65,11 +65,30 @@ var CastingApps = {
Services.obs.addObserver(this, "Casting:Play", false);
Services.obs.addObserver(this, "Casting:Pause", false);
Services.obs.addObserver(this, "Casting:Stop", false);
Services.obs.addObserver(this, "Casting:Mirror", false);
BrowserApp.deck.addEventListener("TabSelect", this, true);
BrowserApp.deck.addEventListener("pageshow", this, true);
BrowserApp.deck.addEventListener("playing", this, true);
BrowserApp.deck.addEventListener("ended", this, true);
NativeWindow.menu.add(
Strings.browser.GetStringFromName("casting.mirrorTab"),
"drawable://casting",
function() {
function callbackFunc(aService) {
let app = SimpleServiceDiscovery.findAppForService(aService);
if (app)
app.mirror(function() {
});
}
function filterFunc(aService) {
Cu.reportError("testing: " + aService);
return aService.mirror == true;
}
this.prompt(callbackFunc, filterFunc);
}.bind(this));
},
uninit: function ca_uninit() {
@ -81,6 +100,7 @@ var CastingApps = {
Services.obs.removeObserver(this, "Casting:Play");
Services.obs.removeObserver(this, "Casting:Pause");
Services.obs.removeObserver(this, "Casting:Stop");
Services.obs.removeObserver(this, "Casting:Mirror");
NativeWindow.contextmenus.remove(this._castMenuId);
},
@ -106,6 +126,12 @@ var CastingApps = {
this.closeExternal();
}
break;
case "Casting:Mirror":
{
Cu.import("resource://gre/modules/TabMirror.jsm");
this.tabMirror = new TabMirror(aData, window);
}
break;
}
},

View File

@ -176,6 +176,7 @@ selectionHelper.textCopied=Text copied to clipboard
# Casting
casting.prompt=Cast to Device
casting.mirrorTab=Mirror Tab
# Context menu
contextmenu.openInNewTab=Open Link in New Tab

View File

@ -13,7 +13,7 @@ Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Messaging.jsm");
let log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.d.bind(null, "MediaPlayerApp");
// Helper function for sending commands to Java.
// Helper function for sending commands to Java.
function send(type, data, callback) {
let msg = {
type: type
@ -52,6 +52,12 @@ MediaPlayerApp.prototype = {
callback(new RemoteMedia(this.id, listener));
}
},
mirror: function mirror(callback) {
send("MediaPlayer:Mirror", { id: this.id }, (result) => {
if (callback) callback(true);
});
}
}
/* RemoteMedia provides a proxy to a native media player session.

View File

@ -187,7 +187,8 @@ var SimpleServiceDiscovery = {
friendlyName: display.friendlyName,
uuid: display.uuid,
manufacturer: display.manufacturer,
modelName: display.modelName
modelName: display.modelName,
mirror: display.mirror
};
this._addService(service);

View File

@ -0,0 +1,229 @@
/* -*- Mode: js; js-indent-level: 2; indent-tabs-mode: nil; tab-width: 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";
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
Cu.import("resource://gre/modules/Services.jsm");
let TabMirror = function(deviceId, window) {
let out_queue = [];
let in_queue = [];
let DEBUG = false;
let log = Cu.import("resource://gre/modules/AndroidLog.jsm",
{}).AndroidLog.d.bind(null, "TabMirror");
let RTCPeerConnection = window.mozRTCPeerConnection;
let RTCSessionDescription = window.mozRTCSessionDescription;
let RTCIceCandidate = window.mozRTCIceCandidate;
let getUserMedia = window.navigator.mozGetUserMedia.bind(window.navigator);
Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
in_queue.push(aData);
}, "MediaPlayer:Response", false);
let poll_timeout = 1000; // ms
let audio_stream = undefined;
let video_stream = undefined;
let pc = undefined;
let poll_success = function(msg) {
if (!msg) {
poll_error();
return;
}
let js;
try {
js = JSON.parse(msg);
} catch(ex) {
log("ex: " + ex);
}
let sdp = js.body;
if (sdp) {
if (sdp.sdp) {
if (sdp.type === "offer") {
process_offer(sdp);
} else if (sdp.type === "answer") {
process_answer(sdp);
}
} else {
process_ice_candidate(sdp);
}
}
window.setTimeout(poll, poll_timeout);
};
let poll_error = function (msg) {
window.setTimeout(poll, poll_timeout);
};
let poll = function () {
if (in_queue) {
poll_success(in_queue.pop());
}
};
let failure = function(x) {
log("ERROR: " + JSON.stringify(x));
};
// Signaling methods
let send_sdpdescription= function(sdp) {
let msg = {
body: sdp
};
sendMessage(JSON.stringify(msg));
};
let deobjify = function(x) {
return JSON.parse(JSON.stringify(x));
};
let process_offer = function(sdp) {
pc.setRemoteDescription(new RTCSessionDescription(sdp),
set_remote_offer_success, failure);
};
let process_answer = function(sdp) {
pc.setRemoteDescription(new RTCSessionDescription(sdp),
set_remote_answer_success, failure);
};
let process_ice_candidate = function(msg) {
pc.addIceCandidate(new RTCIceCandidate(msg));
};
let set_remote_offer_success = function() {
pc.createAnswer(create_answer_success, failure);
};
let set_remote_answer_success= function() {
};
let set_local_success_offer = function(sdp) {
send_sdpdescription(sdp);
};
let set_local_success_answer = function(sdp) {
send_sdpdescription(sdp);
};
let filter_nonrelay_candidates = function(sdp) {
let lines = sdp.sdp.split("\r\n");
let lines2 = lines.filter(function(x) {
if (!/candidate/.exec(x))
return true;
if (/relay/.exec(x))
return true;
return false;
});
sdp.sdp = lines2.join("\r\n");
};
let create_offer_success = function(sdp) {
pc.setLocalDescription(sdp,
function() {
set_local_success_offer(sdp);
},
failure);
};
let create_answer_success = function(sdp) {
pc.setLocalDescription(sdp,
function() {
set_local_success_answer(sdp);
},
failure);
};
let on_ice_candidate = function (candidate) {
send_sdpdescription(candidate.candidate);
};
let ready = function() {
pc.createOffer(create_offer_success, failure);
poll();
};
let config = {
iceServers: [{ "url": "stun:stun.services.mozilla.com" }]
};
let pc = new RTCPeerConnection(config, {});
if (!pc) {
log("Failure creating Webrtc object");
return;
}
pc.onicecandidate = on_ice_candidate;
let windowId = window.BrowserApp.selectedBrowser.contentWindow.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils).outerWindowID;
let viewport = window.BrowserApp.selectedTab.getViewport();
let maxWidth = Math.max(viewport.cssWidth, viewport.width);
let maxHeight = Math.max(viewport.cssHeight, viewport.height);
let minRatio = Math.sqrt((maxWidth * maxHeight) / (640 * 480));
let screenWidth = 640;
let screenHeight = 480;
let videoWidth = 0;
let videoHeight = 0;
if (screenWidth/screenHeight > maxWidth / maxHeight) {
videoWidth = screenWidth;
videoHeight = Math.ceil(videoWidth * maxHeight / maxWidth);
} else {
videoHeight = screenHeight;
videoWidth = Math.ceil(videoHeight * maxWidth / maxHeight);
}
let constraints = {
video: {
mediaSource: "browser",
advanced: [
{ width: { min: videoWidth, max: videoWidth },
height: { min: videoHeight, max: videoHeight }
},
{ aspectRatio: maxWidth / maxHeight }
]
}
};
let gUM_success = function(stream){
pc.addStream(stream);
ready();
};
let gUM_failure = function() {
log("Could not get video stream");
};
getUserMedia( constraints, gUM_success, gUM_failure);
function sendMessage(msg) {
let obj = {
type: "MediaPlayer:Message",
id: deviceId,
data: msg
};
if (deviceId) {
Services.androidBridge.handleGeckoMessage(obj);
}
}
};
this.EXPORTED_SYMBOLS = ["TabMirror"];

View File

@ -24,6 +24,7 @@ EXTRA_JS_MODULES += [
'SharedPreferences.jsm',
'SimpleServiceDiscovery.jsm',
'SSLExceptions.jsm',
'TabMirror.jsm',
'WebappManagerWorker.js',
]