/**
* AirConsole.
* @copyright 2024 by N-Dream AG, Switzerland. All rights reserved.
* @version 1.9.0
*
* IMPORTANT:
* @see http://developers.airconsole.com/ for API documentation
*
* This file is grouped into the following chapters:
* - Constants: Constants you should use
* - Connectivity: Device Ids, connects and disconnects
* - Messaging: Sending messages between devices
* - Device States: Setting data for a device that is readable for all devices
* - Profile data: User profile data, including nicknames and profile pictures
* - Active players: Setting a couple of devices as active players for a game
* - Controller Inputs: Special device inputs like device motion
* - Ads: Showing ads and handling their events
* - Premium: Handling premium users
* - Navigation: Changing games and opening external links
* - User Interface: Changing orientation
* - Persistent Data: Storing data across sessions
* - High Scores: Storing and retrieving high scores
* - Environment Events: Events triggered by the real world
*
* If your prefer an event driven api with .on() .off() and .dispatch()
* interface instead of sending messages,
* @see http://github.com/AirConsole/airconsole-events/
*
*/
/**
* Your gateway object to AirConsole.
* There are getter and setter functions for all properties.
* Do not access properties of this object directly.
* @constructor
* @param {AirConsole~Config} opts - Constructor config, see bellow.
* @return {AirConsoleObject} The AirConsole object.
*/
function AirConsole(opts) {
this.init_(opts);
}
/**
* The configuration for the AirConsole constructor.
* @typedef {object} AirConsole~Config
* @property {string} orientation - AirConsole.ORIENTATION_PORTRAIT or
* AirConsole.ORIENTATION_LANDSCAPE.
* @property {boolean|undefined} synchronize_time - If set to true, you can
* call getServerTime() to get the time on the game server.
* Default is false.
* @property {boolean|undefined} setup_document - Sets up the document so
* nothing is selectable, zoom is fixed to 1 and scrolling is
* disabled (iOS 8 clients drop out of fullscreen when scrolling).
* Default: true
* @property {number|undefined} device_motion - If set, onDeviceMotion gets
* called every "device_motion" milliseconds with data from the
* accelerometer and the gyroscope. Only for controllers.
* @property {boolean} translation - If an AirConsole translation file should
* be loaded.
* @property {boolean} [silence_inactive_players] - If set, newly joining devices will be
* prompted to wait while an active game is going on.<br />
* To start a game round, call setActivePlayers(X) with X larger than 0 eg 1,2,3,...<br />
* To finish a game round, call setActivePlayers(0).<br />
* Default: true, unless the game uses the automatically upgrading API version.<br />
* See {@link https://developers.airconsole.com/#!/guides/player_silencing Player Silencing Guide} for details.<br />
* Added in 1.9.0
*/
/** ------------------------------------------------------------------------ *
* @chapter CONSTANTS *
* ------------------------------------------------------------------------- */
/**
* The device ID of the game screen.
* @constant {number}
*/
AirConsole.SCREEN = 0;
/**
* The portrait orientation.
* @constant {string}
*/
AirConsole.ORIENTATION_PORTRAIT = "portrait";
/**
* The landscape orientation.
* @constant {string}
*/
AirConsole.ORIENTATION_LANDSCAPE = "landscape";
/** ------------------------------------------------------------------------ *
* @chapter CONNECTIVITY *
* @see http://developers.airconsole.com/#!/guides/pong *
* ------------------------------------------------------------------------- */
/**
* Gets called when the game console is ready.
* This event also fires onConnect for all devices that already are
* connected and have loaded your game.
* This event also fires onCustomDeviceStateChange for all devices that are
* connected, have loaded your game and have set a custom Device State.
* @abstract
* @param {string} code - The AirConsole join code.
*/
AirConsole.prototype.onReady = function(code) {};
/**
* Gets called when a device has connected and loaded the game.
* @abstract
* @param {number} device_id - the device ID that loaded the game.
*/
AirConsole.prototype.onConnect = function(device_id) {};
/**
* Gets called when a device has left the game.
* @abstract
* @param {number} device_id - the device ID that left the game.
*/
AirConsole.prototype.onDisconnect = function(device_id) {};
/**
* Returns the device_id of this device.
* Every device in an AirConsole session has a device_id.
* The screen always has device_id 0. You can use the AirConsole.SCREEN
* constant instead of 0.
* All controllers also get a device_id. You can NOT assume that the device_ids
* of controllers are consecutive or that they start at 1.
*
* DO NOT HARDCODE CONTROLLER DEVICE IDS!
*
* If you want to have a logic with "players numbers" (Player 0, Player 1,
* Player 2, Player 3) use the setActivePlayers helper function! You can
* hardcode player numbers, but not device_ids.
*
* Within an AirConsole session, devices keep the same device_id when they
* disconnect and reconnect. Different controllers will never get the same
* device_id in a session. Every device_id remains reserved for the device that
* originally got it.
*
* @see http:// developers.airconsole.com/#/guides/device_ids_and_states
*
* @return {number}
*/
AirConsole.prototype.getDeviceId = function() {
return this.device_id;
};
/**
* Returns the device ID of the master controller.
* Premium devices are prioritzed.
* @return {number|undefined}
*/
AirConsole.prototype.getMasterControllerDeviceId = function() {
var premium_device_ids = this.getPremiumDeviceIds();
if (premium_device_ids.length) {
return premium_device_ids[0];
}
return this.getControllerDeviceIds()[0];
};
/**
* Returns all controller device ids that have loaded your game.
* @return {Array}
*/
AirConsole.prototype.getControllerDeviceIds = function() {
var result = [];
var game_url = this.getGameUrl_(this.getLocationUrl_());
for (var i = AirConsole.SCREEN + 1; i < this.devices.length; ++i) {
if (this.devices[i] &&
this.getGameUrl_(this.devices[i].location) == game_url) {
result.push(i);
}
}
return result;
};
/**
* Returns the current time of the game server.
* This allows you to have a synchronized clock: You can send the server
* time in a message to know exactly at what point something happened on a
* device. This function can only be called if the AirConsole was instantiated
* with the "synchronize_time" opts set to true and after onReady was called.
* @return {number} Timestamp in milliseconds.
*/
AirConsole.prototype.getServerTime = function() {
if (this.server_time_offset === false) {
throw "AirConsole constructor was not called with " +
"{synchronize_time: true}";
}
return new Date().getTime() + this.server_time_offset;
};
/**
* Queries, if new devices are currently silenced.
* @returns {boolean} True, if new devices that are not players are silenced.
* @since 1.9.0
*/
AirConsole.prototype.arePlayersSilenced = function () {
if(this.devices[AirConsole.SCREEN] === undefined) {
return false;
}
var playersSilenced = this.devices[AirConsole.SCREEN].hasOwnProperty("silencePlayers") ? this.devices[AirConsole.SCREEN]["silencePlayers"] : false;
return (!!this.silence_inactive_players || playersSilenced)
&& (this.devices[AirConsole.SCREEN]["players"] !== undefined && this.devices[AirConsole.SCREEN]["players"].length > 0);
}
/**
* Dictionary of silenced update messages queued during a running game session.
* @private
* @since 1.9.0
*/
AirConsole.prototype.silencedUpdatesQueue_ = {};
/** ------------------------------------------------------------------------ *
* @chapter MESSAGING *
* @see http://developers.airconsole.com/#!/guides/pong *
* ------------------------------------------------------------------------- */
/**
* Sends a message to another device.
* @param device_id {number|undefined} - The device ID to send the message to.
* If "device_id" is undefined, the
* message is sent to all devices (except
* this one).
* @param data
*/
AirConsole.prototype.message = function (device_id, data) {
if (this.device_id !== undefined && !this.deviceIsSilenced_(device_id)) {
AirConsole.postMessage_({ action: "message", to: device_id, data: data });
}
}
/**
* Sends a message to all connected devices.
* @param data
*/
AirConsole.prototype.broadcast = function(data) {
this.message(undefined, data);
};
/**
* Gets called when a message is received from another device
* that called message() or broadcast().
* If you dont want to parse messages yourself and prefer an event driven
* approach, @see http://github.com/AirConsole/airconsole-events/
* @abstract
* @param {number} device_id - The device ID that sent the message.
* @param {serializable} data - The data that was sent.
*/
AirConsole.prototype.onMessage = function(device_id, data) {};
/** ------------------------------------------------------------------------ *
* @chapter DEVICE STATES *
* @see http://developers.airconsole.com/#!/guides/device_ids_and_states *
* ------------------------------------------------------------------------- */
/**
* Gets the custom DeviceState of a device.
* @param {number|undefined} device_id - The device ID of which you want the
* custom state. Default is this device.
* @return {Object|undefined} The custom data previously set by the device.
*/
AirConsole.prototype.getCustomDeviceState = function(device_id) {
if (device_id === undefined) {
device_id = this.device_id;
}
var device_data = this.devices[device_id];
if (device_data && this.getGameUrl_(this.getLocationUrl_()) ==
this.getGameUrl_(device_data.location)) {
return device_data["custom"];
}
};
/**
* Sets the custom DeviceState of this device.
* @param {Object} data - The custom data to set.
*/
AirConsole.prototype.setCustomDeviceState = function(data) {
if (this.device_id !== undefined) {
this.devices[this.device_id]["custom"] = data;
this.set_("custom", data);
}
};
/**
* Sets a property in the custom DeviceState of this device.
* @param {String} key - The property name.
* @param {mixed} value - The property value.
*/
AirConsole.prototype.setCustomDeviceStateProperty = function(key, value) {
if (this.device_id !== undefined) {
var state = this.getCustomDeviceState();
if (state === undefined) {
state = {};
} else if (typeof state !== "object") {
throw "Custom DeviceState needs to be of type object";
}
state[key] = value;
this.setCustomDeviceState(state);
}
};
/**
* @typedef {Object} ImmersiveLightOption
* @property {number} r - The red value of the light. Format: integer between 0 and 255.
* @property {number} g - The green value of the light. Format: integer between 0 and 255.
* @property {number} b - The blue value of the light. Format: integer between 0 and 255.
*/
/**
* @typedef {Object} ImmersiveOption
* @property {ImmersiveLightOption} [light] - Light state inside the car.
* @property {any} [experiment] - Experimental payload for experimental APIs
* */
/**
* Sets the immersive state of the AirConsole game based on the provided options.<br />
* At least one property is required for the immersive state to be set.
*
* @param {ImmersiveOption} immersiveState - The immersive state to send.
*/
AirConsole.prototype.setImmersiveState = function (immersiveState) {
if (this.device_id !== AirConsole.SCREEN) {
throw 'Only the screen can set the immersive state.';
}
if (immersiveState === undefined || typeof immersiveState !== 'object' || Object.keys(immersiveState).length === 0) {
return;
}
if (immersiveState.light === undefined && immersiveState.experiment === undefined) {
return;
}
this.set_('immersive', immersiveState);
};
/**
* Gets called when a device updates it's custom DeviceState
* by calling setCustomDeviceState or setCustomDeviceStateProperty.
* Make sure you understand the power of device states:
* @see http://developers.airconsole.com/#/guides/device_ids_and_states
* @abstract
* @param {number} device_id - the device ID that changed its custom
* DeviceState.
* @param {Object} custom_data - The custom DeviceState data value
*/
AirConsole.prototype.onCustomDeviceStateChange = function(device_id,
custom_data) {};
/**
* Gets called when a device joins/leaves a game session or updates its
* DeviceState (custom DeviceState, profile pic, nickname, internal state).
* This is function is also called every time onConnect, onDisconnect or
* onCustomDeviceStateChange, onDeviceProfileChange is called.
* It's like their root function.
* @abstract
* @param {number} device_id - the device_id that changed its DeviceState.
* @param user_data {AirConsole~DeviceState} - the data of that device.
* If undefined, the device has left.
*/
AirConsole.prototype.onDeviceStateChange = function(device_id, device_data) {};
/** ------------------------------------------------------------------------ *
* @chapter PROFILE *
* ------------------------------------------------------------------------- */
/**
* Returns the globally unique id of a device.
* @param {number|undefined} device_id - The device id for which you want the
* uid. Default is this device.
* @return {string|undefined}
*/
AirConsole.prototype.getUID = function(device_id) {
if (device_id === undefined) {
device_id = this.device_id;
}
var device_data = this.devices[device_id];
if (device_data) {
return device_data.uid;
}
};
/**
* Returns the nickname of a user.
* @param {number|undefined} device_id - The device id for which you want the
* nickname. Default is this device.
* Screens don't have nicknames.
* @return {string|undefined}
*/
AirConsole.prototype.getNickname = function(device_id) {
if (device_id === undefined) {
device_id = this.device_id;
}
var device_data = this.devices[device_id];
if (device_data) {
return device_data.nickname || ("Guest " + device_id);
}
};
/**
* Returns the url to a profile picture of the user.
* @param {number|string|undefined} device_id_or_uid - The device id or uid for
* which you want the
* profile picture.
* Default is the current
* user.
* Screens don't have
* profile pictures.
* @param {number|undefined} size - The size of in pixels of the picture.
* Default is 64.
* @return {string|undefined}
*/
AirConsole.prototype.getProfilePicture = function(device_id_or_uid, size) {
if (device_id_or_uid === undefined) {
device_id_or_uid = this.device_id;
} else if (typeof device_id_or_uid == "string") {
return "https://www.airconsole.com/api/profile-picture?uid=" +
device_id_or_uid + "&size=" + (size||64);
}
var device_data = this.devices[device_id_or_uid];
if (device_data) {
var url = "https://www.airconsole.com/api/profile-picture?uid=" +
device_data.uid + "&size=" + (size||64);
if (device_data.picture) {
url += "&v=" + device_data.picture;
}
return url;
}
};
/**
* Gets called when a device updates it's profile pic, nickname or email.
* @abstract
* @param {number} device_id - The device_id that changed its profile.
*/
AirConsole.prototype.onDeviceProfileChange = function(device_id) {};
/**
* Returns true if a user is logged in.
* @param {number|undefined} device_id - The device_id of the user.
* Default is this device.
* @returns {boolean}
*/
AirConsole.prototype.isUserLoggedIn = function(device_id) {
if (device_id == undefined) {
device_id = this.device_id;
}
var data = this.devices[device_id];
if (data) {
return data.auth;
}
};
/**
* Requests the email address of this device and calls onEmailAddress iff the
* request was granted. For privacy reasons, you need to whitelist your
* game in order to receive the email address of the user. To whitelist your
* game, contact developers@airconsole.com. For development purposes, localhost
* is always allowed.
*/
AirConsole.prototype.requestEmailAddress = function() {
this.set_("email", true);
};
/**
* Gets called if the request of requestEmailAddress() was granted.
* For privacy reasons, you need to whitelist your game in order to receive
* the email address of the user. To whitelist your game, contact
* developers@airconsole.com. For development purposes, localhost is always
* allowed.
* @abstract
* @param {string|undefined} email_address - The email address of the user if
* it was set.
*/
AirConsole.prototype.onEmailAddress = function(email_address) {};
/**
* Lets the user change his nickname, profile picture and email address.
* If you need a real nickname of the user, use this function.
* onDeviceProfileChange will be called if the user logs in.
*/
AirConsole.prototype.editProfile = function() {
this.set_("login", true);
};
/** ------------------------------------------------------------------------ *
* @chapter ACTIVE PLAYERS *
* @see http://developers.airconsole.com/#!/guides/device_ids_and_states *
* ------------------------------------------------------------------------- */
/**
* Takes all currently connected controllers and assigns them a player number.<br />
* Can only be called by the screen. You don't have to use this helper
* function, but this mechanism is very convenient if you want to know which
* device is the first player, the second player, the third player ...<br />
* The assigned player numbers always start with 0 and are consecutive.
* You can hardcode player numbers, but not device_ids.<br />
* Once the screen has called setActivePlayers you can get the device_id of
* the first player by calling convertPlayerNumberToDeviceId(0), the device_id
* of the second player by calling convertPlayerNumberToDeviceId(1), ...<br />
* You can also convert device_ids to player numbers by calling
* convertDeviceIdToPlayerNumber(device_id). You can get all device_ids that
* are active players by calling getActivePlayerDeviceIds().<br />
* The screen can call this function every time a game round starts.<br />
* When using {@link https://developers.airconsole.com/#!/guides/player_silencing Player Silencing}, the screen needs to call this every time a game round starts or finishes.<br />
* Calling it with max_players of 1 or more signals the start of the game round while calling it with max_players 0 signals the end of the game round.
* @param {number} max_players - The maximum number of controllers that should
* get a player number assigned.
*/
AirConsole.prototype.setActivePlayers = function(max_players) {
if (this.getDeviceId() !== AirConsole.SCREEN) {
throw "Only the AirConsole.SCREEN can set the active players!";
}
this.device_id_to_player_cache = undefined;
var players = this.getControllerDeviceIds();
if (max_players !== undefined) {
players = players.slice(0, Math.min(players.length, max_players));
}
this.devices[AirConsole.SCREEN]["players"] = players;
this.set_("players", players);
if (max_players === 0) {
for (var key in this.silencedUpdatesQueue_) {
if (this.silencedUpdatesQueue_.hasOwnProperty(key)) {
var events = this.silencedUpdatesQueue_[key];
for (var i = 0; i < events.length; i++) {
this.onPostMessage_(events[i]);
}
}
}
this.silencedUpdatesQueue_ = {};
}
};
/**
* Gets called when the screen sets the active players by calling
* setActivePlayers().
* @abstract
* @param {number|undefined} player_number - The player number of this device.
* Can be undefined if this device
* is not part of the active players.
*/
AirConsole.prototype.onActivePlayersChange = function(player_number) {};
/**
* Returns an array of device_ids of the active players previously set by the
* screen by calling setActivePlayers. The first device_id in the array is the
* first player, the second device_id in the array is the second player, ...
* @returns {Array}
*/
AirConsole.prototype.getActivePlayerDeviceIds = function() {
return this.devices[AirConsole.SCREEN]["players"] || [];
};
/**
* Returns the device_id of a player, if the player is part of the active
* players previously set by the screen by calling setActivePlayers. If fewer
* players are in the game than the passed in player_number or the active
* players have not been set by the screen, this function returns undefined.
* @param player_number
* @returns {number|undefined}
*/
AirConsole.prototype.convertPlayerNumberToDeviceId = function(player_number) {
return this.getActivePlayerDeviceIds()[player_number];
};
/**
* Returns the player number for a device_id, if the device_id is part of the
* active players previously set by the screen by calling setActivePlayers.
* Player numbers are zero based and are consecutive. If the device_id is not
* part of the active players, this function returns undefined.
* @param device_id
* @returns {number|undefined}
*/
AirConsole.prototype.convertDeviceIdToPlayerNumber = function(device_id) {
if (!this.devices[AirConsole.SCREEN] ||
!this.devices[AirConsole.SCREEN]["players"]) {
return;
}
if (!this.device_id_to_player_cache) {
this.device_id_to_player_cache = {};
var players = this.devices[AirConsole.SCREEN]["players"];
for (var i = 0; i < players.length; ++i) {
this.device_id_to_player_cache[players[i]] = i;
}
}
return this.device_id_to_player_cache[device_id];
};
/** ------------------------------------------------------------------------ *
* @chapter CONTROLLER INPUTS *
* ------------------------------------------------------------------------- */
/**
* Gets called every X milliseconds with device motion data iff the
* AirConsole was instantiated with the "device_motion" opts set to the
* interval in milliseconds. Only works for controllers.
* Note: Some browsers do not allow games to access accelerometer and gyroscope
* in an iframe (your game). So use this method if you need gyroscope
* or accelerometer data.
* @abstract
* @param {object} data - data.x, data.y, data.z for accelerometer
* data.alpha, data.beta, data.gamma for gyroscope
*/
AirConsole.prototype.onDeviceMotion = function(data) {};
/**
* Vibrates the device for a specific amount of time. Only works for controllers.
* Note: iOS ignores the specified time and vibrates for a pre-set amount of time.
* @param {Number} time - Milliseconds to vibrate the device
*/
AirConsole.prototype.vibrate = function(time) {
this.set_("vibrate", time);
};
/** ------------------------------------------------------------------------ *
* @chapter ADS *
* ------------------------------------------------------------------------- */
/**
* Requests that AirConsole shows a multiscreen advertisment.
* Can only be called by the AirConsole.SCREEN.
* onAdShow is called on all connected devices if an advertisement
* is shown (in this event please mute all sounds).
* onAdComplete is called on all connected devices when the
* advertisement is complete or no advertisement was shown.
*/
AirConsole.prototype.showAd = function() {
if (this.device_id != AirConsole.SCREEN) {
throw "Only the AirConsole.SCREEN can call showAd!";
}
this.set_("ad", true);
};
/**
* Gets called if a fullscreen advertisement is shown on this screen.
* In case this event gets called, please mute all sounds.
* @abstract
*/
AirConsole.prototype.onAdShow = function() {};
/**
* Gets called when an advertisement is finished or no advertisement was shown.
* @abstract
* @param {boolean} ad_was_shown - True iff an ad was shown and onAdShow was
* called.
*/
AirConsole.prototype.onAdComplete = function(ad_was_shown) {};
/** ------------------------------------------------------------------------ *
* @chapter PREMIUM *
* ------------------------------------------------------------------------- */
/**
* Returns true if the device is premium
* @param {number} device_id - The device_id that should be checked.
* Only controllers can be premium.
* Default is this device.
* @return {boolean|undefined} Returns true or false for a valid device_id and
* undefined if the device_id is not valid.
*
*/
AirConsole.prototype.isPremium = function(device_id) {
if (device_id === undefined) {
device_id = this.device_id;
}
var device_data = this.devices[device_id];
if (device_data && device_id != AirConsole.SCREEN) {
return !!device_data.premium;
}
};
/**
* Returns all device ids that are premium.
* @return {Array<number>}
*/
AirConsole.prototype.getPremiumDeviceIds = function() {
var premium = [];
for (var i = 1; i < this.devices.length; ++i) {
if (this.isPremium(i)) {
premium.push(i);
}
}
return premium;
};
/**
* Offers the user to become a premium member.
* Can only be called from controllers.
* If you call getPremium in development mode, the device becomes premium
* immediately.
*/
AirConsole.prototype.getPremium = function() {
this.set_("premium", true);
};
/**
* Gets called when a device becomes premium or when a premium device connects.
* @abstract
* @param {number} device_id - The device id of the premium device.
*/
AirConsole.prototype.onPremium = function(device_id) {};
/** ------------------------------------------------------------------------ *
* @chapter NAVIGATION *
* ------------------------------------------------------------------------- */
/**
* Request that all devices return to the AirConsole store.
*/
AirConsole.prototype.navigateHome = function() {
this.set_("home", true);
};
/**
* Request that all devices load a game by url or game id.
* @param {string} url - The base url of the game to navigate to
* (excluding screen.html or controller.html).
* Instead of a url you may also pass a game id.
* You can also navigate relatively to your current
* game directory: To navigate to a subdirectory,
* pass "./DIRECTORY_NAME". To navigate to a parent
* directory pass "..".
* @param {object} parameters - You can pass parameters to the game that gets
* loaded. Any jsonizable object is fine.
* The parameters will be appended to the url
* using a url hash.
*/
AirConsole.prototype.navigateTo = function(url, parameters) {
if (url.indexOf(".") == 0) {
var current_location = this.getLocationUrl_();
var full_path = current_location.split("#")[0].split("/");
full_path.pop();
var relative = url.split("/");
for (var i = 0; i < relative.length; ++i) {
if (relative[i] == "..") {
full_path.pop();
} else if (relative[i] != "." && relative[i] != "") {
full_path.push(relative[i]);
}
}
url = full_path.join("/") + "/";
}
if (parameters) {
url += "#" + encodeURIComponent(JSON.stringify(parameters));
}
this.set_("home", url);
};
/**
* Get the parameters in the loaded game that were passed to navigateTo.
* @returns {*}
*/
AirConsole.prototype.getNavigateParameters = function() {
if (this.navigate_parameters_cache_) {
return this.navigate_parameters_cache_;
}
if (document.location.hash.length > 1) {
var result = JSON.parse(decodeURIComponent(
document.location.hash.substr(1)));
this.navigate_parameters_cache_ = result;
return result;
}
};
/**
* Opens url in external (default-system) browser. Call this method instead of
* calling window.open. In-App it will open the system's default browser.
* Because of Safari iOS you can only use it with the onclick handler:
* <div onclick="airconsole.openExternalUrl('my-url.com');">Open browser</div>
* OR in JS with assigning element.onclick.
* @param {string} url - The url to open
*/
AirConsole.prototype.openExternalUrl = function(url) {
var data = this.devices[this.device_id];
if (data.client && data.client.pass_external_url === true) {
this.set_("pass_external_url", url);
} else {
window.open(url);
}
};
/** ------------------------------------------------------------------------ *
* @chapter USER INTERFACE *
* ------------------------------------------------------------------------- */
/**
* Sets the device orientation.
* @param {string} orientation - AirConsole.ORIENTATION_PORTRAIT or
* AirConsole.ORIENTATION_LANDSCAPE.
*/
AirConsole.prototype.setOrientation = function(orientation) {
this.set_("orientation", orientation);
};
/** ------------------------------------------------------------------------ *
* @chapter PERSISTENT DATA *
* ------------------------------------------------------------------------- */
/**
* Requests persistent data from the servers.
* @param {Array<String>} uids - The uids for which you would like to request the persistent data.
* For controllers, the default is the uid of this device.
* Screens must provide a valid array of uids.
* @version 1.9.0 - uids is no longer optional for requests from the screen
*/
AirConsole.prototype.requestPersistentData = function (uids) {
if (this.device_id === AirConsole.SCREEN) {
if (!uids) {
throw new Error("A valid array of uids must be provided on the screen");
} else if (uids.length < 1) {
throw new Error("At least one valid uid must be provided on the screen");
}
} else {
uids = uids || [];
uids.push(this.getUID());
}
this.set_("persistentrequest", { uids: uids });
};
/**
* Gets called when persistent data was loaded from requestPersistentData().
* @abstract
* @param {Object} data - An object mapping uids to all key value pairs.
*/
AirConsole.prototype.onPersistentDataLoaded = function(data) {};
/**
* Stores a key-value pair persistently on the AirConsole servers.
* Storage is per game. Total storage can not exceed 1 MB per game and uid.
* Storage is public, not secure and anyone can request and tamper with it.
* Do not store sensitive data.
* @param {String} key - The key of the data entry.
* @param {mixed} value - The value of the data entry.
* @param {String} uid - The uid for which the data should be stored.
* For controllers, the default is the uid of this device.
* Screens must provide a valid uid.
* @version 1.9.0 - uid is no longer optional for requests from the screen
*/
AirConsole.prototype.storePersistentData = function (key, value, uid) {
if (this.device_id === AirConsole.SCREEN) {
if (!uid) {
throw new Error("A valid uid must be provided on the screen");
}
} else {
uid = this.getUID();
}
this.set_("persistentstore", { key: key, value: value, uid: uid });
};
/**
* Gets called when persistent data was stored from storePersistentData().
* @abstract
* @param {String} uid - The uid for which the data was stored.
*/
AirConsole.prototype.onPersistentDataStored = function(uid) {};
/** ------------------------------------------------------------------------ *
* @chapter HIGH SCORES *
* @see http://developers.airconsole.com/#!/guides/highscore *
* ------------------------------------------------------------------------- */
/**
* Stores a high score of the current user on the AirConsole servers.
* High Scores are public, not secure and anyone can request and tamper with
* them. Do not store sensitive data. Only updates the high score if it was a
* higher or same score. Calls onHighScoreStored when the request is done.
* We highly recommend to read the High Score guide (developers.airconsole.com)
* @param {String} level_name - The name of the level the user was playing.
* This should be a human readable string because
* it appears in the high score sharing image.
* You can also just pass an empty string.
* @param {String} level_version - The version of the level the user was
* playing. This is for your internal use.
* @param {number} score - The score the user has achieved
* @param {String|Array<String>|undefined} uid - The UIDs of the users that
* achieved the high score.
* Can be a single uid or an
* array of uids. Default is the
* uid of this device.
* @param {mixed|undefined} data - Custom high score data (e.g. can be used to
* implement Ghost modes or include data to
* verify that it is not a fake high score).
* @param {String|undefined} score_string - A short human readable
* representation of the score.
* (e.g. "4 points in 3s").
* Defaults to "X points" where x is
* the score converted to an integer.
*/
AirConsole.prototype.storeHighScore = function(level_name, level_version,
score, uid, data,
score_string) {
if (isNaN(score) || typeof score != "number") {
throw "Score needs to be a number and not NaN!"
}
if (!uid) {
uid = this.getUID();
}
if (uid.constructor == Array) {
uid = uid.join("|");
}
this.set_("highscore",
{
"uid": uid,
"level_name": level_name,
"level_version": level_version,
"score": score,
"data": data,
"score_string": score_string
});
};
/**
* Gets called when a high score was successfully stored.
* We highly recommend to read the High Score guide (developers.airconsole.com)
* @param {AirConsole~HighScore|null} high_score - The stored high score if
* it is a new best for the
* user or else null.
* Ranks include "world",
* "country", "region", "city"
* if a high score is passed.
*/
AirConsole.prototype.onHighScoreStored = function(high_score) {};
/**
* Requests high score data of players (including global high scores and
* friends). Will call onHighScores when data was received.
* We highly recommend to read the High Score guide (developers.airconsole.com)
* @param {String} level_name - The name of the level
* @param {String} level_version - The version of the level
* @param {Array<String>|undefined} uids - An array of UIDs of the users that
* should be included in the result.
* Default is all connected controllers
* @param {Array<String>|undefined} ranks - An array of high score rank types.
* High score rank types can include
* data from across the world, only a
* specific area or a users friends.
* Valid array entries are "world",
* "country", "region", "city",
* "friends", "partner". <br />
* Default is ["world"].
* @param {number|undefined} total - Amount of high scores to return per rank
* type. Default is 8.
* @param {number|undefined} top - Amount of top high scores to return per rank
* type. top is part of total. Default is 5.
*/
AirConsole.prototype.requestHighScores = function(level_name, level_version, uids, ranks, total, top) {
if (!ranks) {
ranks = ["world"];
}
if (!uids) {
uids = [];
var device_ids = this.getControllerDeviceIds();
for (var i = 0; i < device_ids.length; ++i) {
uids.push(this.getUID(device_ids[i]));
}
}
if (total == undefined) {
total = 8;
}
if (top == undefined) {
top = 5;
}
this.set_("highscores",
{
"level_name": level_name,
"level_version": level_version,
"uids": uids,
"ranks": ranks,
"total": total,
"top": top
});
};
/**
* Gets called when high scores are returned after calling requestHighScores.
* We highly recommend to read the High Score guide (developers.airconsole.com)
* @param {Array<AirConsole~HighScore>} high_scores - The high scores.
*/
AirConsole.prototype.onHighScores = function(high_scores) {};
/**
* DeviceState contains information about a device in this session.
* Use the helper methods getUID, getNickname, getProfilePicture and
* getCustomDeviceState to access this data.
* @typedef {object} AirConsole~DeviceState
* @property {string} uid - The globally unique ID of the user.
* @property {string|undefined} custom - Custom device data that this API can set.
* @property {string|undefined} nickname - The nickname of the user.
* @property {boolean|undefined} slow_connection - If the user has a high server latency.
* @property {AirConsoleScreenEnvironment} environment - The games multiplayer environment to let multiple games in the
* same location play together. Only present for the screen device.
*/
/**
* HighScore contains information about a users high score
* We highly recommend to read the High Score guide (developers.airconsole.com)
* @typedef {object} AirConsole~HighScore
* @property {String} level_name - The name of the level the user was playing
* @property {String} level_version - The version of the level the user was
* playing
* @property {number} score - The score the user has achieved
* @property {String} score_string - A human readable version of score.
* @property {Object} ranks - A dictionary of rank type to actual rank.
* @property {mixed} data - Custom High Score data. Can be used to implement
* Ghost modes or to verify that it is not a fake
* high score.
* @property {String} uids - The unique ID of the users that achieved the
* high score.
* @property {number} timestamp - The timestamp of the high score
* @property {String} nicknames - The nicknames of the users
* @property {String} relationship - How the user relates to the current user
* - "requested" (a user which was requested)
* - "airconsole" (played AirConsole together)
* - "facebook" (a facebook friend)
* - "other" (about same skill level)
* @property {String} location_country_code - The iso3166 country code
* @property {String} location_country_name - The name of the country
* @property {String} location_region_code - The iso3166 region code
* @property {String} location_region_name - The name of the region
* @property {String} location_city_name - The name of the city
* @property {String} share_url - The URL that should be used to share this
* high score.
* @property {String} share_image - The URL to an image that displays this
* high score.
*/
/** ------------------------------------------------------------------------ *
* @chapter TRANSLATIONS *
* @see http://developers.airconsole.com/#!/guides/translations *
* ------------------------------------------------------------------------- */
/**
* Gets a translation for the users current language
* See http://developers.airconsole.com/#!/guides/translations
* @param {String} id - The id of the translation string.
* @param {Object|undefined} values - Values that should be used for
* replacement in the translated string.
* E.g. if a translated string is
* "Hi %name%" and values is {"name": "Tom"}
* then this will be replaced to "Hi Tom".
*/
AirConsole.prototype.getTranslation = function(id, values) {
if (this.translations) {
if (this.translations[id]) {
var result = this.translations[id];
if (values && result) {
var parts = result.split("%");
for (var i = 1; i < parts.length; i += 2) {
if (parts[i].length) {
parts[i] = values[parts[i]] || "";
} else {
parts[i] = "%";
}
}
result = parts.join("");
}
return result;
}
}
};
/**
* Returns the current IETF language tag of a device e.g. "en" or "en-US"
* @param {number|undefined} device_id - The device id for which you want the
* language. Default is this device.
* @return {String} IETF language
*/
AirConsole.prototype.getLanguage = function(device_id) {
if (device_id === undefined) {
device_id = this.device_id;
}
var device_data = this.devices[device_id];
if (device_data) {
return device_data.language;
}
};
/** ------------------------------------------------------------------------ *
* @chapter ENVIRONMENT EVENTS *
* ------------------------------------------------------------------------- */
/**
* Gets called on the Screen when the game should be paused.
* @abstract
*/
AirConsole.prototype.onPause = function() {};
/**
* Gets called on the Screen when the game should be resumed.
* @abstract
*/
AirConsole.prototype.onResume = function() {};
/** ------------------------------------------------------------------------ *
* ONLY PRIVATE FUNCTIONS BELLOW *
* ------------------------------------------------------------------------- */
/**
* Determines if AirConsole by default should have player silencing enabled or not.
* @returns If the player should be silenced based on environment factors.
* @private
*/
AirConsole.prototype.getDefaultPlayerSilencing_ = function() {
const referencedAirconsoleAPIScripts = Array.prototype.slice.call(document.getElementsByTagName('script'),0)
.map(it => it.src).filter(it => it.includes('api/airconsole-'));
if(referencedAirconsoleAPIScripts.length > 1) {
alert('only a single instance of api/airconsole-*.js must be used per screen/controller.')
return;
}
let airconsoleApiVersion = ['', this.version];
if(referencedAirconsoleAPIScripts.length > 0) {
airconsoleApiVersion = referencedAirconsoleAPIScripts[0]
.match(new RegExp('https?://.*/api/airconsole-(.*).js'));
}
return airconsoleApiVersion.length > 1 && airconsoleApiVersion[1] !== 'latest' || false;
}
/**
* Initializes the AirConsole.
* @param {AirConsole~Config} opts - The Config.
* @private
*/
AirConsole.prototype.init_ = function(opts) {
opts = opts || {};
var me = this;
me.version = "1.9.0";
me.devices = [];
me.silencedUpdatesQueue_ = {};
me.server_time_offset = opts.synchronize_time ? 0 : false;
const defaultPlayerSilencing = me.getDefaultPlayerSilencing_();
me.silence_inactive_players = opts.silence_inactive_players !== undefined ? opts.silence_inactive_players : defaultPlayerSilencing;
window.addEventListener("message", function(event) {
me.onPostMessage_(event);
}, false);
me.set_("orientation", opts.orientation);
if (opts.setup_document !== false) {
me.setupDocument_();
}
AirConsole.postMessage_({
action: "ready",
version: me.version,
device_motion: opts.device_motion,
synchronize_time: opts.synchronize_time,
silencePlayers: me.silence_inactive_players,
location: me.getLocationUrl_(),
translation: opts.translation
});
};
/**
* Checks if the location is in the same location of the sender
* @param {string} location The location to check.
* @param {string} sender_id The id of the sender.
* @returns {boolean} True if the location are identical.
* @private
*/
AirConsole.prototype.isDeviceInSameLocation_ = function (location, sender_id) {
return !!this.devices[sender_id] && location === this.getGameUrl_(this.devices[sender_id].location);
}
/**
* Queries if a given device_id is silenced
* @param {string} device_id The device_id to be queried.
* @returns {boolean} True, if the device_id is silenced.
* @private
* @since 1.9.0
*/
AirConsole.prototype.deviceIsSilenced_ = function (device_id) {
return this.arePlayersSilenced() &&
device_id !== undefined
&& device_id !== AirConsole.SCREEN
&& this.convertDeviceIdToPlayerNumber(device_id) === undefined;
}
/**
* Handling onMessage events
* @private
* @param {Event} event - Event object
*/
AirConsole.prototype.onPostMessage_ = function(event) {
var me = this;
var data = event.data;
var game_url = me.getGameUrl_(me.getLocationUrl_());
if (data.action === "device_motion") {
me.onDeviceMotion(data.data);
} else if (data.action === "message") {
if (me.device_id !== undefined) {
if (me.isDeviceInSameLocation_(game_url, data.from) && !me.deviceIsSilenced_(data.from) && !me.deviceIsSilenced_(data.to)) {
me.onMessage(data.from, data.data);
}
}
} else if (data.action === "update") {
if (me.device_id !== undefined) {
var game_url_before = null;
var game_url_after = null;
var before = me.devices[data.device_id];
if (before) {
game_url_before = me.getGameUrl_(before.location);
}
if (data.device_data) {
game_url_after = me.getGameUrl_(data.device_data.location);
}
if (me.deviceIsSilenced_(data.device_id)) {
var queue = me.silencedUpdatesQueue_[data.device_id] || [];
if (me.isLocationUnloadedMessage_(game_url_before, game_url, game_url_after)) {
let connect_removed = false;
for (let i = 0; i < queue.length; i++) {
if (queue[i].hasOwnProperty("_is_connect_event")) {
queue.splice(i);
connect_removed = true;
break;
}
}
delete me.silencedUpdatesQueue_[data.device_id];
if (connect_removed) return;
}
if (me.isLocationLoadedMessage_(game_url_before, game_url, game_url_after)) {
event._is_connect_event = true;
}
queue.push(event);
me.silencedUpdatesQueue_[data.device_id] = queue;
return;
}
var sender = data.device_id;
me.devices[sender] = data.device_data;
me.onDeviceStateChange(sender, data.device_data);
var is_connect = me.isLocationLoadedMessage_(game_url_before, game_url, game_url_after);
var is_disconnect = me.isLocationUnloadedMessage_(game_url_before, game_url, game_url_after);
if (is_connect) {
me.onConnect(sender);
} else if (is_disconnect) {
me.onDisconnect(sender);
}
if (data.device_data) {
if ((data.device_data._is_custom_update && game_url_after === game_url)
|| (is_connect && data.device_data.custom)) {
me.onCustomDeviceStateChange(sender, data.device_data.custom);
}
if ((data.device_data._is_players_update && game_url_after === game_url)
|| (data.device_id === AirConsole.SCREEN && data.device_data.players && is_connect)) {
me.device_id_to_player_cache = null;
me.onActivePlayersChange(me.convertDeviceIdToPlayerNumber(me.getDeviceId()));
}
if (data.device_data.premium && (data.device_data._is_premium_update || is_connect)) {
me.onPremium(sender);
}
if (data.device_data._is_profile_update) {
me.onDeviceProfileChange(sender);
}
}
}
} else if (data.action === "ready") {
me.device_id = data.device_id;
me.devices = data.devices;
if (me.server_time_offset !== false) {
me.server_time_offset = data.server_time_offset || 0;
}
if (data.translations) {
me.translations = data.translations;
var elements = document.querySelectorAll("[data-translation]");
for (var i = 0; i < elements.length; ++i) {
elements[i].innerHTML = me.getTranslation(elements[i].getAttribute(
"data-translation"));
}
}
var client = me.devices[data.device_id].client;
me.bindTouchFix_(client);
me.onReady(data.code);
for (let i = 0; i < me.devices.length; ++i) {
if (me.isDeviceInSameLocation_(game_url, i)) {
if (i !== me.getDeviceId()) {
me.onConnect(i);
var custom_state = me.getCustomDeviceState(i);
if (custom_state !== undefined) {
me.onCustomDeviceStateChange(i, custom_state);
}
if (i === AirConsole.SCREEN && me.devices[i].players) {
me.device_id_to_player_cache = null;
me.onActivePlayersChange(me.convertDeviceIdToPlayerNumber(me.getDeviceId()));
}
}
if (me.isPremium(i)) {
me.onPremium(i);
}
}
}
} else if (data.action == "profile") {
if (me.device_id) {
var state = me.devices[me.device_id];
state["auth"] = data.auth;
state["nickname"] = data.nickname;
state["picture"] = data.picture;
me.onDeviceStateChange(me.device_id, state);
me.onDeviceProfileChange(me.device_id);
}
} else if (data.action == "email") {
me.onEmailAddress(data.email);
} else if (data.action == "ad") {
if (data.complete == undefined) {
me.onAdShow();
} else {
me.onAdComplete(data.complete);
}
} else if (data.action == "highscores") {
me.onHighScores(data.highscores);
} else if (data.action == "highscore") {
me.onHighScoreStored(data.highscore);
} else if (data.action == "persistentstore") {
me.onPersistentDataStored(data.uid);
} else if (data.action == "persistentrequest") {
me.onPersistentDataLoaded(data.data);
} else if (data.action == "premium") {
me.devices[data.device_id].premium = true;
me.onPremium(data.device_id);
} else if (data.action == "pause") {
me.onPause();
} else if (data.action == "resume") {
me.onResume();
} else if (data.action == "debug") {
if (data.debug == "fps") {
if (window.requestAnimationFrame) {
var second_animation_frame = function(start) {
window.requestAnimationFrame(function(end) {
if (start != end) {
var delta = end - start;
AirConsole.postMessage_({
"action": "debug",
"fps": (1000 / delta)
});
} else {
second_animation_frame(start);
}
});
};
window.requestAnimationFrame(second_animation_frame);
}
}
}
};
/**
Checks if the urls imply that this is a connect update message
* @param game_url_before The url before the change of location
* @param game_url The url of the current location
* @param game_url_after The url after the change of location
* @returns {boolean} True, if it is a connect message
* @private
*/
AirConsole.prototype.isLocationLoadedMessage_ = function (game_url_before, game_url, game_url_after) {
return (game_url_before !== game_url && game_url_after === game_url);
}
/**
* Checks if the urls imply that this is a disconnect update message
* @param game_url_before The url before the change of location
* @param game_url The url of the current location
* @param game_url_after The url after the change of location
* @returns {boolean} True, if it is a disconnect message
* @private
*/
AirConsole.prototype.isLocationUnloadedMessage_ = function (game_url_before, game_url, game_url_after) {
return (game_url_before === game_url && game_url_after !== game_url)
}
/**
* @private
* @param {String} url - A url.
* @return {String} Returns the root game url over http.
*/
AirConsole.prototype.getGameUrl_ = function(url) {
if (!url) {
return;
}
url = url.split("#")[0];
url = url.split("?")[0];
if (url.indexOf("screen.html", url.length - 11) !== -1) {
url = url.substr(0, url.length - 11);
}
if (url.indexOf("controller.html", url.length - 15) !== -1) {
url = url.substr(0, url.length - 15);
}
if (url.indexOf("https://") == 0) {
url = "http://" + url.substr(8);
}
return url;
};
/**
* Posts a message to the parent window.
* @private
* @param {Object} data - the data to be sent to the parent window.
*/
AirConsole.postMessage_ = function(data) {
try {
window.parent.postMessage(data, document.referrer);
} catch(e) {
console.log("Posting message to parent failed: " + JSON.stringify(data));
}
};
/**
* Sets a variable in the external AirConsole framework.
* @private
* @param {string} key - The key to set.
* @param {serializable} value - The value to set.
*/
AirConsole.prototype.set_ = function(key, value) {
AirConsole.postMessage_({ action: "set", key: key, value: value });
};
/**
* Adds default css rules to documents so nothing is selectable, zoom is
* fixed to 1 and preventing scrolling down (iOS 8 clients drop out of
* fullscreen when scrolling).
* @private
*/
AirConsole.prototype.setupDocument_ = function() {
var style = document.createElement("style");
style.type = "text/css";
var css_code =
"html {\n" +
" -ms-touch-action: pan-x;\n" +
"}\n" +
"body {\n" +
" -webkit-touch-callout: none;\n" +
" -webkit-text-size-adjust: none;\n" +
" -ms-text-size-adjust: none;\n" +
" -webkit-user-select: none;\n" +
" -moz-user-select: none;\n" +
" -ms-user-select: none;\n" +
" user-select: none;\n" +
" -webkit-highlight: none;\n" +
" -webkit-tap-highlight-color: rgba(0,0,0,0);\n" +
" -webkit-tap-highlight-color: transparent;\n" +
" -ms-touch-action: pan-y;\n" +
" -ms-content-zooming: none;\n" +
"}\n" +
"\n" +
"input, textarea {\n" +
" -webkit-user-select: text;\n" +
" -moz-user-select: text;\n" +
" -ms-user-select: text;\n" +
" user-select: text;\n" +
"}\n" +
"-ms-@viewport {\n" +
" width: device-width;\n" +
" initial-scale: 1;\n" +
" zoom: 1;\n" +
" min-zoom: 1;\n" +
" max-zoom: 1;\n" +
" user-zoom: fixed;\n" +
"}";
if (style.styleSheet) {
style.styleSheet.cssText = css_code;
} else {
style.appendChild(document.createTextNode(css_code));
}
var meta = document.createElement("meta");
meta.setAttribute("name", "viewport");
meta.setAttribute("content", "width=device-width, minimum-scale=1, " +
"initial-scale=1, user-scalable=no");
var head = document.getElementsByTagName("head")[0];
head.appendChild(meta);
head.appendChild(style);
document.addEventListener('touchmove', function (e) {
e.preventDefault();
}, {passive: false });
if (navigator.userAgent.indexOf("Windows Phone ") != -1 &&
navigator.userAgent.indexOf("Edge/") != -1) {
document.oncontextmenu = document.body.oncontextmenu = function () {
return false;
}
}
};
/**
* Returns the current location url
* @return {string}
* @private
*/
AirConsole.prototype.getLocationUrl_ = function() {
return document.location.href;
};
/**
* Fixes delay in touchstart in crosswalk by calling preventDefault.
* @param {Object} client - The client object
* @private
*/
AirConsole.prototype.bindTouchFix_ = function(client) {
// This fix is only necessary for Android Crosswalk
if (navigator.userAgent.match(/Android/) &&
client && client.app === "intel-xdk" &&
client.version <= 2.3) {
document.addEventListener('touchstart', function(e) {
var els = ['DIV', 'IMG', 'SPAN', 'BODY', 'TD', 'TH', 'CANVAS', 'P', 'B',
'CENTER', 'EM', 'FONT', 'H1', 'H2', 'H3', 'H4',
'H5', 'H6', 'HR', 'I', 'LI', 'PRE', 'SMALL', 'STRONG', 'U'];
if (els.indexOf(e.target.nodeName) != -1) {
// Check if one of the parent elements is a link
var parent = e.target.parentNode;
while (parent && parent.nodeName != "BODY") {
if (parent.nodeName == "A") {
return;
}
parent = parent.parentNode;
}
e.preventDefault();
setTimeout(function() {
e.target.click();
}, 200);
}
});
}
};
window.addEventListener('error', function(e) {
var stack = undefined;
if (e.error && e.error.stack) {
stack = e.error.stack;
}
AirConsole.postMessage_({
"action": "jserror",
"url": document.location.href,
"exception": {
"message": e.message,
"error": {
"stack": stack
},
"filename": e.filename,
"lineno": e.lineno,
"colno": e.colno
}
});
});
window.addEventListener('unhandledrejection', function(e) {
var stack = undefined;
if (e.reason && e.reason.stack) {
stack = e.reason.stack;
}
AirConsole.postMessage_({
"action": "jserror",
"url": document.location.href,
"exception": {
"message": "Unhandled promise rejection: " + e.reason,
"error": {
"stack": stack
},
"filename": "unhandledrejection:" + e.reason,
"lineno": 0
}
});
});
/**
* The devices environment. Only available on the screen device.
* Please visit the {@link https://developers.airconsole.com/#!/guides/multiplayer Multiplayer} guide to see how to use this from onDeviceStateChange or through airconsole.devices[AirConsole.SCREEN].environment.id
* @typedef {object} AirConsoleScreenEnvironment
* @property {string} id - Identifier of the environment this screen is in. Where possible this is a specific physical
* location, like a specific car.
* @property {string} partner - Identifier of the partner in the environment.
*/
/**
* The AirConsole Screen device data of relevance to game developers.
* @typedef {object} AirConsoleDevice
* @property {AirConsoleScreenEnvironment} [environment] - The environment object this device is in. Only present on the screen.
*/
/**
* The AirConsole Screen device data of relevance to game developers.
* @typedef {object} AirConsoleObject
* @property {Array<AirConsoleDevice>} devices - List of devices in this session. Screen is always devices[AirConsole.SCREEN].
*/