androidgoogle-castgoogle-cast-sdk

Start cast session for a cast device


I've got this use case:

  1. Detect cast devices and save their id, names and information;
  2. In automatic way, connect to predefined device and start cast session with some content.

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?


Solution

  • TLDR; Skip to Step 3 - Option 1 (SessionManager.startSession) or Step 3 - Option 2 (MediaRouter.selectRoute)

    Step 1 - Setup

    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();
    

    Step 2 - Retrieve Routes (Devices) for Saving/Using

    Get the route/device Ids

    Step 2 - Option 1 - Current Cached Routes

    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).

    Step 2 - Option 2 - Active Scan

    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);
    

    Step 2 - Option 3 - Passive Scan

    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);
    

    Step 3 - Join a Route (Device)

    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

    Step 3 - Option 1 - SessionManager.startSession

    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);
    

    Step 3 - Option 2 - MediaRouter.selectRoute

    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);
    

    Step 4 - Stream Content

    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(...);
    

    The Full Code

    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...