diff --git a/src/android/Chromecast.java b/src/android/Chromecast.java index b76d911..48bc898 100644 --- a/src/android/Chromecast.java +++ b/src/android/Chromecast.java @@ -407,7 +407,7 @@ void onRouteUpdate(List routes) { callbackContext.sendPluginResult(pluginResult); } }; - connection.scanForRoutes(clientScan); + connection.scanForRoutes(null, clientScan, null); return true; } @@ -438,6 +438,12 @@ private JSONArray routesToJSON(List routes) { JSONObject obj = new JSONObject(); obj.put("name", route.getName()); obj.put("id", route.getId()); + + CastDevice device = CastDevice.getFromBundle(route.getExtras()); + if (device != null) { + obj.put("isNearbyDevice", !device.isOnLocalNetwork()); + } + routesArray.put(obj); } catch (JSONException e) { } diff --git a/src/android/ChromecastConnection.java b/src/android/ChromecastConnection.java index 7b3fe0a..2f7417f 100644 --- a/src/android/ChromecastConnection.java +++ b/src/android/ChromecastConnection.java @@ -3,7 +3,6 @@ import android.app.Activity; import android.app.AlertDialog; import android.content.DialogInterface; -import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.os.Handler; @@ -201,15 +200,51 @@ public void run() { callback.onJoin(session); return; } - listenForConnection(callback); - Intent castIntent = new Intent(); - castIntent.putExtra("CAST_INTENT_TO_CAST_ROUTE_ID_KEY", routeId); - // RouteName and toast are just for display - castIntent.putExtra("CAST_INTENT_TO_CAST_DEVICE_NAME_KEY", routeName); - castIntent.putExtra("CAST_INTENT_TO_CAST_NO_TOAST_KEY", false); + // We need this hack so that we can access the foundRoute value + // Without having to store it as a global variable. + // Just always access first element + final boolean[] foundRoute = {false}; + + listenForConnection(callback); - getSessionManager().startSession(castIntent); + // 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 routes) { + // Look for the matching route + for (RouteInfo route : routes) { + if (!foundRoute[0] && route.getId().equals(routeId)) { + // Found the route! + foundRoute[0] = true; + // So stop the scan + stopScan(this); + // And select it! + getMediaRouter().selectRoute(route); + } + } + } + }; + scanForRoutes(5000L, scan, new Runnable() { + @Override + public void run() { + // If we were not able to find the route + if (!foundRoute[0]) { + stopScan(scan); + callback.onError("TIMEOUT Could not find active route with id: " + + routeId + " after 5s."); + } + } + }); } }); } @@ -308,9 +343,13 @@ public void onSessionStartFailed(CastSession castSession, int errCode) { /** * Starts listening for receiver updates. * Must call stopScan(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 stopScan is called * @param callback the callback to receive route updates on + * @param onTimeout called when the timeout hits */ - public void scanForRoutes(ScanCallback callback) { + public void scanForRoutes(Long timeout, ScanCallback callback, Runnable onTimeout) { // Add the callback in active scan mode activity.runOnUiThread(new Runnable() { public void run() { @@ -319,12 +358,31 @@ public void run() { // Send out the initial routes callback.onFilteredRouteUpdate(); + if (timeout != null && timeout == 0) { + 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); + + 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); + } } }); } @@ -440,10 +498,9 @@ private void onFilteredRouteUpdate() { if (stopped || mediaRouter == null) { return; } - List routes = mediaRouter.getRoutes(); List outRoutes = new ArrayList<>(); // Filter the routes - for (RouteInfo route : 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(); @@ -453,7 +510,10 @@ private void onFilteredRouteUpdate() { continue; } } - if (!route.isDefault() && !route.getDescription().equals("Google Cast Multizone Member")) { + if (!route.isDefault() + && !route.getDescription().equals("Google Cast Multizone Member") + && route.getPlaybackType() == RouteInfo.PLAYBACK_TYPE_REMOTE + ) { outRoutes.add(route); } } diff --git a/tests/tests.js b/tests/tests.js index 14addd5..4188c0b 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -2,8 +2,15 @@ * The order of the tests is very important! * Unfortunately using nested describes and beforeAll does not work correctly. * So just be careful with the order of tests! + * Edit: TODO We should really switch to mocha. */ +// We need a promise polyfill for Android < 4.4.3 +// from https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js +/*eslint-disable */ +!(function (e, n) { typeof exports === 'object' && typeof module !== 'undefined' ? n() : typeof define === 'function' && define.amd ? define(n) : n(); }(0, function () { 'use strict'; function e (e) { var n = this.constructor; return this.then(function (t) { return n.resolve(e()).then(function () { return t; }); }, function (t) { return n.resolve(e()).then(function () { return n.reject(t); }); }); } function n (e) { return !(!e || typeof e.length === 'undefined'); } function t () {} function o (e) { if (!(this instanceof o)) throw new TypeError('Promises must be constructed via new'); if (typeof e !== 'function') throw new TypeError('not a function'); this._state = 0, this._handled = !1, this._value = undefined, this._deferreds = [], c(e, this); } function r (e, n) { for (;e._state === 3;)e = e._value; e._state !== 0 ? (e._handled = !0, o._immediateFn(function () { var t = e._state === 1 ? n.onFulfilled : n.onRejected; if (t !== null) { var o; try { o = t(e._value); } catch (r) { return void f(n.promise, r); }i(n.promise, o); } else (e._state === 1 ? i : f)(n.promise, e._value); })) : e._deferreds.push(n); } function i (e, n) { try { if (n === e) throw new TypeError('A promise cannot be resolved with itself.'); if (n && (typeof n === 'object' || typeof n === 'function')) { var t = n.then; if (n instanceof o) return e._state = 3, e._value = n, void u(e); if (typeof t === 'function') return void c((function (e, n) { return function () { e.apply(n, arguments); }; }(t, n)), e); }e._state = 1, e._value = n, u(e); } catch (r) { f(e, r); } } function f (e, n) { e._state = 2, e._value = n, u(e); } function u (e) { e._state === 2 && e._deferreds.length === 0 && o._immediateFn(function () { e._handled || o._unhandledRejectionFn(e._value); }); for (var n = 0, t = e._deferreds.length; t > n; n++)r(e, e._deferreds[n]); e._deferreds = null; } function c (e, n) { var t = !1; try { e(function (e) { t || (t = !0, i(n, e)); }, function (e) { t || (t = !0, f(n, e)); }); } catch (o) { if (t) return; t = !0, f(n, o); } } var a = setTimeout; o.prototype['catch'] = function (e) { return this.then(null, e); }, o.prototype.then = function (e, n) { var o = new this.constructor(t); return r(this, new function (e, n, t) { this.onFulfilled = typeof e === 'function' ? e : null, this.onRejected = typeof n === 'function' ? n : null, this.promise = t; }(e, n, o)), o; }, o.prototype['finally'] = e, o.all = function (e) { return new o(function (t, o) { function r (e, n) { try { if (n && (typeof n === 'object' || typeof n === 'function')) { var u = n.then; if (typeof u === 'function') return void u.call(n, function (n) { r(e, n); }, o); }i[e] = n, --f == 0 && t(i); } catch (c) { o(c); } } if (!n(e)) return o(new TypeError('Promise.all accepts an array')); var i = Array.prototype.slice.call(e); if (i.length === 0) return t([]); for (var f = i.length, u = 0; i.length > u; u++)r(u, i[u]); }); }, o.resolve = function (e) { return e && typeof e === 'object' && e.constructor === o ? e : new o(function (n) { n(e); }); }, o.reject = function (e) { return new o(function (n, t) { t(e); }); }, o.race = function (e) { return new o(function (t, r) { if (!n(e)) return r(new TypeError('Promise.race accepts an array')); for (var i = 0, f = e.length; f > i; i++)o.resolve(e[i]).then(t, r); }); }, o._immediateFn = typeof setImmediate === 'function' && function (e) { setImmediate(e); } || function (e) { a(e, 0); }, o._unhandledRejectionFn = function (e) { void 0 !== console && console && console.warn('Possible Unhandled Promise Rejection:', e); }; var l = (function () { if (typeof self !== 'undefined') return self; if (typeof window !== 'undefined') return window; if (typeof global !== 'undefined') return global; throw Error('unable to locate global object'); }()); 'Promise' in l ? l.Promise.prototype['finally'] || (l.Promise.prototype['finally'] = e) : l.Promise = o; })); +/*eslint-enable */ + /* eslint-env jasmine */ exports.defineAutoTests = function () { /* eslint-disable no-undef */ @@ -11,7 +18,7 @@ exports.defineAutoTests = function () { jasmine.DEFAULT_TIMEOUT_INTERVAL = 9000; var appId = chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID; - var videoUrl = 'https://archive.org/download/CosmosLaundromatFirstCycle/Cosmos%20Laundromat%20-%20First%20Cycle%20%281080p%29.mp4' + var videoUrl = 'https://archive.org/download/CosmosLaundromatFirstCycle/Cosmos%20Laundromat%20-%20First%20Cycle%20%281080p%29.mp4'; describe('chrome.cast', function () { @@ -628,46 +635,57 @@ exports.defineAutoTests = function () { function startRouteScan () { return new Promise(function (resolve) { - var once = true; + var finished = false; chrome.cast.cordova.startRouteScan(function routeUpdate (routes) { - if (once && routes.length > 0) { - once = false; - - var route = routes[0]; + if (finished) { + return; + } + for (var i = 0; i < routes.length; i++) { + var route = routes[i]; test(route).toBeInstanceOf(chrome.cast.cordova.Route); test(route.id).toBeDefined(); test(route.name).toBeDefined(); - + test(route.isNearbyDevice).toBeDefined(); + if (!route.isNearbyDevice) { + finished = true; + } + } + if (finished) { resolve(routes); } }, function (err) { fail(err.code + ': ' + err.description); - resolve(); }); }); } - function stopRouteScan (routes) { + function stopRouteScan (arg) { return new Promise(function (resolve) { // Make sure we can stop the scan chrome.cast.cordova.stopRouteScan(function () { - resolve(routes); + resolve(arg); }, function (err) { test().fail(err.code + ': ' + err.description); - resolve(); }); }); } function selectRoute (routes) { return new Promise(function (resolve) { - chrome.cast.cordova.selectRoute(routes[0], function (session) { + // Find a non-nearby device so that the join is automatic + var route; + for (var i = 0; i < routes.length; i++) { + route = routes[i]; + if (!route.isNearbyDevice) { + break; + } + } + chrome.cast.cordova.selectRoute(route, function (session) { Promise.resolve(session) .then(sessionProperties) .then(resolve); }, function (err) { fail(err.code + ': ' + err.description); - resolve(routes); }); }); } diff --git a/www/chrome.cast.js b/www/chrome.cast.js index 45fd6f3..17e916c 100644 --- a/www/chrome.cast.js +++ b/www/chrome.cast.js @@ -523,9 +523,6 @@ chrome.cast = { } }; -var _sessionRequest = null; -var _autoJoinPolicy = null; -var _defaultActionPolicy = null; var _sessionListener = function () {}; var _receiverListener = function () {}; @@ -545,14 +542,12 @@ chrome.cast.initialize = function (apiConfig, successCallback, errorCallback) { return; } - _autoJoinPolicy = apiConfig.autoJoinPolicy; - _defaultActionPolicy = apiConfig.defaultActionPolicy; - _sessionRequest = apiConfig.sessionRequest; - _sessionListener = apiConfig.sessionListener; - _receiverListener = apiConfig.receiverListener; - - execute('initialize', _sessionRequest.appId, _autoJoinPolicy, _defaultActionPolicy, function (err) { + execute('initialize', apiConfig.sessionRequest.appId, apiConfig.autoJoinPolicy, apiConfig.defaultActionPolicy, function (err) { if (!err) { + // Don't set the listeners config until success + _sessionListener = apiConfig.sessionListener; + _receiverListener = apiConfig.receiverListener; + successCallback(); chrome.cast._.receiverUpdate(false); } else { @@ -1139,7 +1134,8 @@ chrome.cast.cordova = { execute('startRouteScan', function (err, routes) { if (!err) { for (var i = 0; i < routes.length; i++) { - routes[i] = new chrome.cast.cordova.Route(routes[i].id, routes[i].name); + var route = routes[i]; + routes[i] = new chrome.cast.cordova.Route(route.id, route.name, route.isNearbyDevice); } successCallback(routes); } else { @@ -1176,9 +1172,10 @@ chrome.cast.cordova = { } }); }, - Route: function (id, name) { + Route: function (id, name, isNearbyDevice) { this.id = id; this.name = name; + this.isNearbyDevice = isNearbyDevice; } };