Начать сеанс каста для устройства каста
У меня есть этот вариант использования:
- Обнаружение кастовых устройств и сохранение их идентификатора, имен и информации;
- Автоматически подключитесь к предопределенному устройству и запустите сеанс приведения с некоторым содержимым.
Я изучил 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);
}
});
}