Начать сеанс каста для устройства каста

У меня есть этот вариант использования:

  1. Обнаружение кастовых устройств и сохранение их идентификатора, имен и информации;
  2. Автоматически подключитесь к предопределенному устройству и запустите сеанс приведения с некоторым содержимым.

Я изучил Google Cast API v3, и это кажется очень сложным. В то время как с v2 это было возможно, так как приложение отправителя контролирует 90% процесса, то есть соединение с устройством и загрузкой контента, с v3 сессия полностью управляется платформой, а сессия запускается только при вмешательстве пользователя. Единственный метод, который может быть полезен для моего случая использования, это SessionManager.startSession(Intent intent) здесь документ, однако совершенно недокументировано, как использовать намерение, дополнительные параметры, действие и так далее. Есть кто-нибудь с некоторыми знаниями об этом методе и намерении?

1 ответ

TL; DR; Переходите к шагу 3 - варианту 1 (SessionManager.startSession) или Шаг 3 - Вариант 2 (MediaRouter.selectRoute)

Шаг 1 - Настройка

Настройте CastOptionsProvider как обычно.

Вот основные объекты, которые мы будем использовать:

MediaRouter mediaRouter = MediaRouter.getInstance(activity);
CastContex context = CastContext.getSharedInstance(activity);
SessionManager sessionManager = context.getSessionManager();

Шаг 2 - Получение маршрутов (устройств) для сохранения / использования

Получить идентификаторы маршрута / устройства

Шаг 2 - Вариант 1 - Текущие кэшированные маршруты

Просто получите текущие кешированные маршруты:

for (RouteInfo route : mediaRouter.getRoutes()) {
    // Save route.getId(); however you want (it's a string)
}

Недостаток: возвращенные маршруты могут быть устаревшими. Кэш маршрутов MediaRouter обновляется только при запуске сканирования (вручную или библиотекой приведения).

Шаг 2 - Вариант 2 - Активное сканирование

Активное сканирование для наиболее точного списка маршрутов:

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

НОТА! Важно остановить активное сканирование, иначе батарея быстро разрядится! Вы останавливаете сканирование с помощью

mediaRouter.removeCallback(callback);

Шаг 2 - Вариант 3 - Пассивное сканирование

То же, что и вариант 2, но без символаflags аргумент mediaRouter.addCallback.
Это должно (я думаю) пассивно отслеживать изменения маршрута. (Хотя результаты могут быть не намного лучше, чем в варианте 1). Например:

mediaRouter.addCallback(new MediaRouteSelector.Builder()
                .addControlCategory(CastMediaControlIntent.categoryForCast(appId))
                .build(),
        callback);

Шаг 3 - Присоединяйтесь к маршруту (устройству)

Как программно присоединиться к маршруту (устройству). Есть 2 основных варианта.

Оба варианта либо создают новый сеанс, либо присоединяются к существующему сеансу на устройстве, к которому вы пытаетесь присоединиться (если appId тот же).

Во-первых, подготовка:

// 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)!
    }
});

Теперь, как на самом деле присоединиться к маршруту, учитывая routeIdчто мы получили на шаге 2

Шаг 3 - Вариант 1 - SessionManager.startSession

ПРИМЕЧАНИЕ. Я обнаружил, что этот метод не работает на моем устройстве Android 4.4. Я получалSessionManagerListener.onSessionStartFailedс ошибкой 15 (таймаут).
Тем не менее, он работал на моем устройстве Android 7.0.

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

Шаг 3 - Вариант 2 - MediaRouter.selectRoute

Чтобы использовать эту опцию, вы должны иметь полную Routeобъект, а не только строку идентификатора.
Если у вас уже есть объект - отлично!
Если нет, вы можете использовать метод, описанный в Шаге 2 - Вариант 2 - Активное сканирование, чтобы получитьRoute объект, ища соответствующий идентификатор.

mediaRouter.selectRoute(routeObject);

Шаг 4 - Потоковое содержимое

Как только у вас будет тренировка из шага 3, тяжелая работа сделана.
Вы можете использовать RemoteMediaClient для управления тем, что передается.

RemoteMediaClient remoteMediaClient = castSession.getRemoteMediaClient();
remoteMediaClient.load(...);

Полный код

Я собираюсь включить это, потому что я потратил невероятное количество часов на борьбу с проблемами сеанса, и, надеюсь, это принесет пользу кому-то другому. (Включая прерывистую синхронизацию и проблемы со сбоями на Android 4.4/ медленном устройстве [не уверен, какой из них является источником проблем]).

Вероятно, там есть какие-то лишние вещи (особенно если вы используете постоянный appId, initialize не имеет значения), поэтому используйте то, что вам нужно.

Наиболее актуален метод selectRouteкоторый принимает строку routeId и будет активно сканировать соответствие в течение 15 секунд. Он также обрабатывает некоторые ошибки при повторной попытке моей работы.

Здесь вы можете увидеть полный код.

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

}

Работать с Chromecast так весело...

Я нашел другой способ подключения к устройству приведения без использования намерения, но с маршрутами.

Итак, первый шаг - инициализация приведения класса CastOptionsProvider и контекст приведения также. 2-й шаг для получения устройств и последний шаг для подключения к устройству приведения, передав маршрут выбранного устройства, которое вы получили на 2-м шаге:

MediaRouter.getInstance(activity).selectRoute(route);

У меня недавно было такое же требование.

Вы можете обнаружить кастовые устройства, используя MediaRouter.

MediaRouter mMediaRouter = MediaRouter.getInstance(this);
MediaRouteSelector mMediaRouteSelector = new MediaRouteSelector.Builder()
            .addControlCategory(CastMediaControlIntent.categoryForCast(getString(R.string.cast_app_id)))
            .build();
mMediaRouter.addCallback(mMediaRouterCallback, mMediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY);

// Then get your media routes using 
List<RouteInfo> routes = mMediaRouter.getRoutes()

// Get cast devices for your media routes.
// Save these for future use as per your use case
List<CastDevice> castDevices = routes.stream()
    .map(route -> CastDevice.getFromBundle(route.getExtras()))
    .collect(Collectors.toCollection())

Для автоматического подключения к устройству передачи и потоковой передачи некоторого контента используйте этот фрагмент. Обратите внимание, что вы не сможете использовать RemoteMediaPlayer в зависимости от приложения получателя. Этот фрагмент работал для меня, потому что мое приложение получателя использует MediaManager

// Connect to the cast device you want to stream the content to
private void connectToCastDevice(CastDevice castDevice) {
    Cast.CastOptions apiOptions = Cast.CastOptions.builder(castDevice, mCastListener).build();
    mApiClient = new GoogleApiClient.Builder(this)
            .addApi(Cast.API, apiOptions)
            .addConnectionCallbacks(mConnectionCallback)
            .addOnConnectionFailedListener(mConnectionFailedListener)
            .build();
    mApiClient.connect();
}

// After you are connected to the cast device. Load your media to it
// In my case using RemoteMediaPlayer
private void loadMediaItem(final MediaInfo mediaInfo) {
    LaunchOptions launchOptions = new LaunchOptions();
    launchOptions.setRelaunchIfRunning(false);

    PendingResult<Cast.ApplicationConnectionResult> result = Cast.CastApi.launchApplication(mApiClient, getString(R.string.cast_app_id), launchOptions);

    result.then(new ResultTransform<Cast.ApplicationConnectionResult, RemoteMediaPlayer.MediaChannelResult>() {

        @Nullable @Override
        public PendingResult<RemoteMediaPlayer.MediaChannelResult> onSuccess(@NonNull Cast.ApplicationConnectionResult applicationConnectionResult) {
            Log.d(TAG, "Application launch result: " + applicationConnectionResult);
            return mRemoteMediaPlayer.load(mApiClient, mediaInfo);
        }

    }).andFinally(new ResultCallbacks<RemoteMediaPlayer.MediaChannelResult>() {

        @Override
        public void onSuccess(@NonNull RemoteMediaPlayer.MediaChannelResult mediaChannelResult) {
            Log.d(TAG, "Media channel result: " + mediaChannelResult);
        }

        @Override
        public void onFailure(@NonNull Status status) {
            Log.d(TAG, "Media channel status: " + status);
        }

    });
}
Другие вопросы по тегам