I've got this use case:
I've studied the Google Cast API v3 and it seems really hard. While with v2 it was possible since the sender app controls 90% of the process, i.e. connection with device and load content, with v3 the session is totally managed by the framework and a session is started only whit user intervention. The only method that it can be worth for my use case is the SessionManager.startSession(Intent intent)
doc here, however it's totally undocumented how to use the intent, extra parameters, action and so on. Is there anyone with some knowledge about this method and intent?
TLDR; Skip to Step 3 - Option 1 (SessionManager.startSession
) or Step 3 - Option 2 (MediaRouter.selectRoute
)
Set up CastOptionsProvider like normal.
Here are the main objects that we will use:
MediaRouter mediaRouter = MediaRouter.getInstance(activity);
CastContext context = CastContext.getSharedInstance(activity);
SessionManager sessionManager = context.getSessionManager();
Get the route/device Ids
Just get the current cached routes:
for (RouteInfo route : mediaRouter.getRoutes()) {
// Save route.getId(); however you want (it's a string)
}
Drawback: The returned routes may be well out of date. MediaRouter's cache of routes is only updated when a scan is triggered (by you manually, or by the cast library).
Active scan for the most accurate list of routes:
MediaRouter.Callback callback = new MediaRouter.Callback() {
private void updateMyRouteList() {
for (RouteInfo route : mediaRouter.getRoutes()) {
// Save route.getId() however you want (it's a string)
}
}
@Override
public void onRouteAdded(MediaRouter router, RouteInfo route) {
updateMyRouteList();
}
@Override
public void onRouteRemoved(MediaRouter router, RouteInfo route) {
updateMyRouteList();
}
@Override
public void onRouteChanged(MediaRouter router, RouteInfo route) {
updateMyRouteList();
}
};
mediaRouter.addCallback(new MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(appId))
.build(),
callback,
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
NOTE! It is important that you stop the active scan or the battery will drain quickly! You stop the scan with
mediaRouter.removeCallback(callback);
Same as Option 2 but, omit the flags
argument of mediaRouter.addCallback
.
This should (I think) listen for route changes passively. (Though you may not have much better results than in Option 1). For example:
mediaRouter.addCallback(new MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(appId))
.build(),
callback);
How to programmatically join a route (device). There are 2 main options.
Both options will either creating a new session, or joining an existing session on the device you are trying to join (if the appId is the same).
First, the prep:
// Optional - if your app changes receiverApplicationId on the fly you should change that here
context.setReceiverApplicationId(appId);
// Most people would just set this as a constant in their CastOptionsProvider
// Listen for a successful join
sessionManager.addSessionManagerListener(new SessionManagerListener<Session>() {
@Override
public void onSessionStarted(CastSession castSession, String sessionId) {
// We successfully joined a route(device)!
}
});
Now, how to actually join a route, given a routeId
that we got from Step 2
NOTE: I found this method did not work on my Android 4.4 device. I was getting SessionManagerListener.onSessionStartFailed
with error 15 (timeout).
It did work on my Android 7.0 device though.
// Create the intent
Intent castIntent = new Intent();
// Mandatory, if null, nothing will happen
castIntent.putExtra("CAST_INTENT_TO_CAST_ROUTE_ID_KEY", routeId);
// (Optional) Uses this name in the toast
castIntent.putExtra("CAST_INTENT_TO_CAST_DEVICE_NAME_KEY", route.getName());
// Optional - false = displays "Connecting to <devicename>..."
castIntent.putExtra("CAST_INTENT_TO_CAST_NO_TOAST_KEY", true);
sessionManager.startSession(castIntent);
To use this option you have to have the full Route
object, not just the id string.
If you already have the object, great!
If not, you can use the method in Step 2 - Option2 - Active Scan to get the Route
object by looking for a matching id.
mediaRouter.selectRoute(routeObject);
Once you have the session from step 3 prep, the hard work is done.
You can use RemoteMediaClient to control what is casted.
RemoteMediaClient remoteMediaClient = castSession.getRemoteMediaClient();
remoteMediaClient.load(...);
I'm going to include this because I have spent ridiculous amounts of hours fighting with session issues and hopefully it can benefit someone else. (Including intermittent timing and crash issues on Android 4.4/Slow device [not sure which one is the source of the problems]).
There is probably some extra stuff in there (especially if you use a constant appId, initialize
will be irrelevant), so please use what you need.
The method of most relevance is selectRoute
which accepts a routeId string and will actively scan for the matching for up to 15 seconds. It also handles some errors where a retry may work.
You can see the true full code here.
[The code below is probably out of date. The true full code is written for use in a Cordova plugin. It is trivial to remove the Cordova dependency if you want to use the code in your app though.]
public class ChromecastConnection {
/** Lifetime variable. */
private Activity activity;
/** settings object. */
private SharedPreferences settings;
/** Lifetime variable. */
private SessionListener newConnectionListener;
/** The Listener callback. */
private Listener listener;
/** Initialize lifetime variable. */
private String appId;
/**
* Constructor. Call this in activity start.
* @param act the current context
* @param connectionListener client callbacks for specific events
*/
ChromecastConnection(Activity act, Listener connectionListener) {
this.activity = act;
this.settings = activity.getSharedPreferences("CORDOVA-PLUGIN-CHROMECAST_ChromecastConnection", 0);
this.appId = settings.getString("appId", CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID);
this.listener = connectionListener;
// Set the initial appId
CastOptionsProvider.setAppId(appId);
// This is the first call to getContext which will start up the
// CastContext and prep it for searching for a session to rejoin
// Also adds the receiver update callback
getContext().addCastStateListener(listener);
}
/**
* Must be called each time the appId changes and at least once before any other method is called.
* @param applicationId the app id to use
* @param callback called when initialization is complete
*/
public void initialize(String applicationId, CallbackContext callback) {
activity.runOnUiThread(new Runnable() {
public void run() {
// If the app Id changed, set it again
if (!applicationId.equals(appId)) {
setAppId(applicationId);
}
// Tell the client that initialization was a success
callback.success();
// Check if there is any available receivers for 5 seconds
startRouteScan(5000L, new ScanCallback() {
@Override
void onRouteUpdate(List<RouteInfo> routes) {
// if the routes have changed, we may have an available device
// If there is at least one device available
if (getContext().getCastState() != CastState.NO_DEVICES_AVAILABLE) {
// Stop the scan
stopRouteScan(this);
// Let the client know a receiver is available
listener.onReceiverAvailableUpdate(true);
// Since we have a receiver we may also have an active session
CastSession session = getSessionManager().getCurrentCastSession();
// If we do have a session
if (session != null) {
// Let the client know
listener.onSessionRejoin(session);
}
}
}
}, null);
}
});
}
private MediaRouter getMediaRouter() {
return MediaRouter.getInstance(activity);
}
private CastContext getContext() {
return CastContext.getSharedInstance(activity);
}
private SessionManager getSessionManager() {
return getContext().getSessionManager();
}
private CastSession getSession() {
return getSessionManager().getCurrentCastSession();
}
private void setAppId(String applicationId) {
this.appId = applicationId;
this.settings.edit().putString("appId", appId).apply();
getContext().setReceiverApplicationId(appId);
}
/**
* This will create a new session or seamlessly selectRoute an existing one if we created it.
* @param routeId the id of the route to selectRoute
* @param callback calls callback.onJoin when we have joined a session,
* or callback.onError if an error occurred
*/
public void selectRoute(final String routeId, SelectRouteCallback callback) {
activity.runOnUiThread(new Runnable() {
public void run() {
if (getSession() != null && getSession().isConnected()) {
callback.onError(ChromecastUtilities.createError("session_error",
"Leave or stop current session before attempting to join new session."));
}
// We need this hack so that we can access these values in callbacks without having
// to store it as a global variable, just always access first element
final boolean[] foundRoute = {false};
final boolean[] sentResult = {false};
final int[] retries = {0};
// We need to start an active scan because getMediaRouter().getRoutes() may be out
// of date. Also, maintaining a list of known routes doesn't work. It is possible
// to have a route in your "known" routes list, but is not in
// getMediaRouter().getRoutes() which will result in "Ignoring attempt to select
// removed route: ", even if that route *should* be available. This state could
// happen because routes are periodically "removed" and "added", and if the last
// time media router was scanning ended when the route was temporarily removed the
// getRoutes() fn will have no record of the route. We need the active scan to
// avoid this situation as well. PS. Just running the scan non-stop is a poor idea
// since it will drain battery power quickly.
ScanCallback scan = new ScanCallback() {
@Override
void onRouteUpdate(List<RouteInfo> routes) {
// Look for the matching route
for (RouteInfo route : routes) {
if (!foundRoute[0] && route.getId().equals(routeId)) {
// Found the route!
foundRoute[0] = true;
// try-catch for issue:
// https://github.com/jellyfin/cordova-plugin-chromecast/issues/48
try {
// Try selecting the route!
getMediaRouter().selectRoute(route);
} catch (NullPointerException e) {
// Let it try to find the route again
foundRoute[0] = false;
}
}
}
}
};
Runnable retry = new Runnable() {
@Override
public void run() {
// Reset foundRoute
foundRoute[0] = false;
// Feed current routes into scan so that it can retry.
// If route is there, it will try to join,
// if not, it should wait for the scan to find the route
scan.onRouteUpdate(getMediaRouter().getRoutes());
}
};
Function<JSONObject, Void> sendErrorResult = new Function<JSONObject, Void>() {
@Override
public Void apply(JSONObject message) {
if (!sentResult[0]) {
sentResult[0] = true;
stopRouteScan(scan);
callback.onError(message);
}
return null;
}
};
listenForConnection(new ConnectionCallback() {
@Override
public void onJoin(CastSession session) {
sentResult[0] = true;
stopRouteScan(scan);
callback.onJoin(session);
}
@Override
public boolean onSessionStartFailed(int errorCode) {
if (errorCode == 7 || errorCode == 15) {
// It network or timeout error retry
retry.run();
return false;
} else {
sendErrorResult.apply(ChromecastUtilities.createError("session_error",
"Failed to start session with error code: " + errorCode));
return true;
}
}
@Override
public boolean onSessionEndedBeforeStart(int errorCode) {
if (retries[0] < 10) {
retries[0]++;
retry.run();
return false;
} else {
sendErrorResult.apply(ChromecastUtilities.createError("session_error",
"Failed to to join existing route (" + routeId + ") " + retries[0] + 1 + " times before giving up."));
return true;
}
}
});
startRouteScan(15000L, scan, new Runnable() {
@Override
public void run() {
sendErrorResult.apply(ChromecastUtilities.createError("timeout",
"Failed to to join route (" + routeId + ") after 15s and " + retries[0] + 1 + " trys."));
}
});
}
});
}
/**
* Must be called from the main thread.
* @param callback calls callback.success when we have joined, or callback.error if an error occurred
*/
private void listenForConnection(ConnectionCallback callback) {
// We should only ever have one of these listeners active at a time, so remove previous
getSessionManager().removeSessionManagerListener(newConnectionListener, CastSession.class);
newConnectionListener = new SessionListener() {
@Override
public void onSessionStarted(CastSession castSession, String sessionId) {
getSessionManager().removeSessionManagerListener(this, CastSession.class);
callback.onJoin(castSession);
}
@Override
public void onSessionStartFailed(CastSession castSession, int errCode) {
if (callback.onSessionStartFailed(errCode)) {
getSessionManager().removeSessionManagerListener(this, CastSession.class);
}
}
@Override
public void onSessionEnded(CastSession castSession, int errCode) {
if (callback.onSessionEndedBeforeStart(errCode)) {
getSessionManager().removeSessionManagerListener(this, CastSession.class);
}
}
};
getSessionManager().addSessionManagerListener(newConnectionListener, CastSession.class);
}
/**
* Starts listening for receiver updates.
* Must call stopRouteScan(callback) or the battery will drain with non-stop active scanning.
* @param timeout ms until the scan automatically stops,
* if 0 only calls callback.onRouteUpdate once with the currently known routes
* if null, will scan until stopRouteScan is called
* @param callback the callback to receive route updates on
* @param onTimeout called when the timeout hits
*/
public void startRouteScan(Long timeout, ScanCallback callback, Runnable onTimeout) {
// Add the callback in active scan mode
activity.runOnUiThread(new Runnable() {
public void run() {
callback.setMediaRouter(getMediaRouter());
if (timeout != null && timeout == 0) {
// Send out the one time routes
callback.onFilteredRouteUpdate();
return;
}
// Add the callback in active scan mode
getMediaRouter().addCallback(new MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(appId))
.build(),
callback,
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN);
// Send out the initial routes after the callback has been added.
// This is important because if the callback calls stopRouteScan only once, and it
// happens during this call of "onFilterRouteUpdate", there must actually be an
// added callback to remove to stop the scan.
callback.onFilteredRouteUpdate();
if (timeout != null) {
// remove the callback after timeout ms, and notify caller
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
// And stop the scan for routes
getMediaRouter().removeCallback(callback);
// Notify
if (onTimeout != null) {
onTimeout.run();
}
}
}, timeout);
}
}
});
}
/**
* Call to stop the active scan if any exist.
* @param callback the callback to stop and remove
*/
public void stopRouteScan(ScanCallback callback) {
activity.runOnUiThread(new Runnable() {
public void run() {
callback.stop();
getMediaRouter().removeCallback(callback);
}
});
}
/**
* Create this empty class so that we don't have to override every function
* each time we need a SessionManagerListener.
*/
private class SessionListener implements SessionManagerListener<CastSession> {
@Override
public void onSessionStarting(CastSession castSession) { }
@Override
public void onSessionStarted(CastSession castSession, String sessionId) { }
@Override
public void onSessionStartFailed(CastSession castSession, int error) { }
@Override
public void onSessionEnding(CastSession castSession) { }
@Override
public void onSessionEnded(CastSession castSession, int error) { }
@Override
public void onSessionResuming(CastSession castSession, String sessionId) { }
@Override
public void onSessionResumed(CastSession castSession, boolean wasSuspended) { }
@Override
public void onSessionResumeFailed(CastSession castSession, int error) { }
@Override
public void onSessionSuspended(CastSession castSession, int reason) { }
}
interface SelectRouteCallback {
void onJoin(CastSession session);
void onError(JSONObject message);
}
interface ConnectionCallback {
/**
* Successfully joined a session on a route.
* @param session the session we joined
*/
void onJoin(CastSession session);
/**
* Called if we received an error.
* @param errorCode You can find the error meaning here:
* https://developers.google.com/android/reference/com/google/android/gms/cast/CastStatusCodes
* @return true if we are done listening for join, false, if we to keep listening
*/
boolean onSessionStartFailed(int errorCode);
/**
* Called when we detect a session ended event before session started.
* See issues:
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/49
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/48
* @param errorCode error to output
* @return true if we are done listening for join, false, if we to keep listening
*/
boolean onSessionEndedBeforeStart(int errorCode);
}
public abstract static class ScanCallback extends MediaRouter.Callback {
/**
* Called whenever a route is updated.
* @param routes the currently available routes
*/
abstract void onRouteUpdate(List<RouteInfo> routes);
/** records whether we have been stopped or not. */
private boolean stopped = false;
/** Global mediaRouter object. */
private MediaRouter mediaRouter;
/**
* Sets the mediaRouter object.
* @param router mediaRouter object
*/
void setMediaRouter(MediaRouter router) {
this.mediaRouter = router;
}
/**
* Call this method when you wish to stop scanning.
* It is important that it is called, otherwise battery
* life will drain more quickly.
*/
void stop() {
stopped = true;
}
private void onFilteredRouteUpdate() {
if (stopped || mediaRouter == null) {
return;
}
List<RouteInfo> outRoutes = new ArrayList<>();
// Filter the routes
for (RouteInfo route : mediaRouter.getRoutes()) {
// We don't want default routes, or duplicate active routes
// or multizone duplicates https://github.com/jellyfin/cordova-plugin-chromecast/issues/32
Bundle extras = route.getExtras();
if (extras != null) {
CastDevice.getFromBundle(extras);
if (extras.getString("com.google.android.gms.cast.EXTRA_SESSION_ID") != null) {
continue;
}
}
if (!route.isDefault()
&& !route.getDescription().equals("Google Cast Multizone Member")
&& route.getPlaybackType() == RouteInfo.PLAYBACK_TYPE_REMOTE
) {
outRoutes.add(route);
}
}
onRouteUpdate(outRoutes);
}
@Override
public final void onRouteAdded(MediaRouter router, RouteInfo route) {
onFilteredRouteUpdate();
}
@Override
public final void onRouteChanged(MediaRouter router, RouteInfo route) {
onFilteredRouteUpdate();
}
@Override
public final void onRouteRemoved(MediaRouter router, RouteInfo route) {
onFilteredRouteUpdate();
}
}
abstract static class Listener implements CastStateListener {
abstract void onReceiverAvailableUpdate(boolean available);
abstract void onSessionRejoin(CastSession session);
/** CastStateListener functions. */
@Override
public void onCastStateChanged(int state) {
onReceiverAvailableUpdate(state != CastState.NO_DEVICES_AVAILABLE);
}
}
}
Working with chromecast is so fun...