From 574b327445a944271f76354d646d53cda7eebef0 Mon Sep 17 00:00:00 2001 From: Lindsay-Needs-Sleep Date: Thu, 17 Oct 2019 12:47:38 -0600 Subject: [PATCH] Added base queue functions. Towards issue #36 Move some object generation methods to Utilities Disable method length rule. We are grown ups and can decide if we want to use ridiculously long methods or not. :) --- check_style.xml | 2 +- src/android/Chromecast.java | 37 ++ src/android/ChromecastSession.java | 497 ++++++++++++++++++--------- src/android/ChromecastUtilities.java | 366 ++++++++++++++++++-- tests/www/js/tests_auto.js | 240 +++++++++++++ tests/www/js/utils.js | 28 ++ www/chrome.cast.js | 97 +++++- 7 files changed, 1069 insertions(+), 198 deletions(-) diff --git a/check_style.xml b/check_style.xml index cec5dc6..632fa18 100644 --- a/check_style.xml +++ b/check_style.xml @@ -134,7 +134,7 @@ - + diff --git a/src/android/Chromecast.java b/src/android/Chromecast.java index 3c01000..8cb1fcf 100644 --- a/src/android/Chromecast.java +++ b/src/android/Chromecast.java @@ -385,6 +385,43 @@ public boolean mediaEditTracksInfo(JSONArray activeTrackIds, JSONObject textTrac return true; } + /** + * Loads a queue of media to the Chromecast. + * @param queueLoadRequest chrome.cast.media.QueueLoadRequest + * @param callbackContext called with .success or .error depending on the result + * @return true for cordova + */ + public boolean queueLoad(JSONObject queueLoadRequest, final CallbackContext callbackContext) { + this.media.queueLoad(queueLoadRequest, callbackContext); + return true; + } + + /** + * Plays the item with itemId in the queue. + * @param itemId The ID of the item to jump to. + * @param callbackContext called with .success or .error depending on the result + * @return true for cordova + */ + public boolean queueJumpToItem(Integer itemId, final CallbackContext callbackContext) { + this.media.queueJumpToItem(itemId, callbackContext); + return true; + } + + /** + * Plays the item with itemId in the queue. + * @param itemId The ID of the item to jump to. + * @param callbackContext called with .success or .error depending on the result + * @return true for cordova + */ + public boolean queueJumpToItem(Double itemId, final CallbackContext callbackContext) { + if (itemId - Double.valueOf(itemId).intValue() == 0) { + // Only perform the jump if the double is a whole number + return queueJumpToItem(Double.valueOf(itemId).intValue(), callbackContext); + } else { + return true; + } + } + /** * Stops the session. * @param callbackContext called with .success or .error depending on the result diff --git a/src/android/ChromecastSession.java b/src/android/ChromecastSession.java index 92fd75e..0bdfdf1 100644 --- a/src/android/ChromecastSession.java +++ b/src/android/ChromecastSession.java @@ -1,8 +1,7 @@ package acidhax.cordova.chromecast; import java.io.IOException; -import java.util.GregorianCalendar; -import java.util.Iterator; +import java.util.ArrayList; import org.apache.cordova.CallbackContext; import org.json.JSONArray; @@ -13,26 +12,24 @@ import com.google.android.gms.cast.Cast; import com.google.android.gms.cast.MediaInfo; import com.google.android.gms.cast.MediaLoadRequestData; -import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.MediaQueueItem; import com.google.android.gms.cast.MediaSeekOptions; import com.google.android.gms.cast.MediaStatus; -import com.google.android.gms.cast.TextTrackStyle; import com.google.android.gms.cast.framework.CastSession; +import com.google.android.gms.cast.framework.media.MediaQueue; import com.google.android.gms.cast.framework.media.RemoteMediaClient; import com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult; import com.google.android.gms.common.api.ResultCallback; import com.google.android.gms.common.api.Status; -import com.google.android.gms.common.images.WebImage; import android.app.Activity; -import android.net.Uri; + import androidx.annotation.NonNull; /* * All of the Chromecast session specific functions should start here. */ public class ChromecastSession { - /** The current context. */ private Activity activity; /** A registered callback that we will un-register and re-register each time the session changes. */ @@ -41,6 +38,14 @@ public class ChromecastSession { private CastSession session; /** The current session's client for controlling playback. */ private RemoteMediaClient client; + /** Indicates whether we are requesting media or not. **/ + private boolean requestingMedia = false; + /** Keeps track of the queueItems. **/ + private JSONArray queueItems; + /** Stores a callback that should be called when the queue is loaded. **/ + private Runnable queueReloadCallback; + /** Stores a callback that should be called when the queue status is updated. **/ + private Runnable queueStatusUpdatedCallback; /** * ChromecastSession constructor. @@ -68,53 +73,43 @@ public void run() { return; } session = castSession; - client = session.getRemoteMediaClient(); + client = session.getRemoteMediaClient(); if (client == null) { return; } client.registerCallback(new RemoteMediaClient.Callback() { - private String currentState = "idle"; + private int prevState = MediaStatus.PLAYER_STATE_IDLE; + private MediaInfo lastMedia; @Override public void onStatusUpdated() { MediaStatus status = client.getMediaStatus(); + if (requestingMedia + || queueStatusUpdatedCallback != null + || queueReloadCallback != null) { + return; + } + if (status != null) { - switch (status.getPlayerState()) { - case MediaStatus.PLAYER_STATE_LOADING: - case MediaStatus.PLAYER_STATE_IDLE: - if (!currentState.equals("requesting")) { - currentState = "loading"; - } - break; - default: - if (currentState.equals("loading")) { - clientListener.onMediaLoaded(createMediaObject()); - } - currentState = "loaded"; - break; + int state = status.getPlayerState(); + if (lastMedia != null + && state != prevState + && state == MediaStatus.PLAYER_STATE_LOADING) { + // It appears the queue has advanced to the next item + // So send an update to indicate the previous has finished + clientListener.onMediaUpdate(createMediaObject(MediaStatus.IDLE_REASON_FINISHED)); } + prevState = status.getPlayerState(); } + // Send update clientListener.onMediaUpdate(createMediaObject()); - } - @Override - public void onMetadataUpdated() { - clientListener.onMediaUpdate(createMediaObject()); + lastMedia = client.getMediaInfo(); } @Override public void onQueueStatusUpdated() { - clientListener.onMediaUpdate(createMediaObject()); - } - @Override - public void onPreloadStatusUpdated() { - clientListener.onMediaUpdate(createMediaObject()); - } - @Override - public void onSendingRemoteMediaRequest() { - currentState = "requesting"; - clientListener.onMediaUpdate(createMediaObject()); - } - @Override - public void onAdBreakStatusUpdated() { - clientListener.onMediaUpdate(createMediaObject()); + if (queueStatusUpdatedCallback != null) { + queueStatusUpdatedCallback.run(); + setQueueStatusUpdatedCallback(null); + } } }); session.addCastListener(new Cast.Listener() { @@ -144,6 +139,7 @@ public void onVolumeChanged() { clientListener.onSessionUpdate(createSessionObject()); } }); + setupQueue(); } }); } @@ -195,6 +191,8 @@ public void onResult(Status result) { }); } +/* ------------------------------------ MEDIA FNs ------------------------------------------- */ + /** * Loads media over the media API. * @param contentId - The URL of the content @@ -215,24 +213,25 @@ public void loadMedia(String contentId, JSONObject customData, String contentTyp } activity.runOnUiThread(new Runnable() { public void run() { - MediaInfo mediaInfo = createMediaInfo(contentId, customData, contentType, duration, streamType, metadata, textTrackStyle); + MediaInfo mediaInfo = ChromecastUtilities.createMediaInfo(contentId, customData, contentType, duration, streamType, metadata, textTrackStyle); MediaLoadRequestData loadRequest = new MediaLoadRequestData.Builder() .setMediaInfo(mediaInfo) .setAutoplay(autoPlay) .setCurrentTime((long) currentTime * 1000) .build(); + requestingMedia = true; + setQueueReloadCallback(new Runnable() { + @Override + public void run() { + callback.success(createMediaObject()); + } + }); client.load(loadRequest).setResultCallback(new ResultCallback() { @Override public void onResult(@NonNull MediaChannelResult result) { - if (result.getStatus().isSuccess()) { - JSONObject out = createMediaObject(); - if (out == null) { - callback.success(); - } else { - callback.success(out); - } - } else { + requestingMedia = false; + if (!result.getStatus().isSuccess()) { callback.error("session_error"); } } @@ -241,124 +240,6 @@ public void onResult(@NonNull MediaChannelResult result) { }); } - private MediaInfo createMediaInfo(String contentId, JSONObject customData, String contentType, long duration, String streamType, JSONObject metadata, JSONObject textTrackStyle) { - MediaInfo.Builder mediaInfoBuilder = new MediaInfo.Builder(contentId); - - MediaMetadata mediaMetadata; - try { - mediaMetadata = new MediaMetadata(metadata.getInt("metadataType")); - } catch (JSONException e) { - mediaMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC); - } - // Add any images - try { - JSONArray images = metadata.getJSONArray("images"); - for (int i = 0; i < images.length(); i++) { - JSONObject imageObj = images.getJSONObject(i); - try { - Uri imageURI = Uri.parse(imageObj.getString("url")); - mediaMetadata.addImage(new WebImage(imageURI)); - } catch (Exception e) { - } - } - } catch (JSONException e) { - } - - // Dynamically add other parameters - Iterator keys = metadata.keys(); - String key; - String convertedKey; - Object value; - while (keys.hasNext()) { - key = keys.next(); - if (key.equals("metadataType") - || key.equals("images") - || key.equals("type")) { - continue; - } - try { - value = metadata.get(key); - convertedKey = ChromecastUtilities.getAndroidMetadataName(key); - // Try to add the translated version of the key - switch (ChromecastUtilities.getMetadataType(convertedKey)) { - case "string": - mediaMetadata.putString(convertedKey, metadata.getString(key)); - break; - case "int": - mediaMetadata.putInt(convertedKey, metadata.getInt(key)); - break; - case "double": - mediaMetadata.putDouble(convertedKey, metadata.getDouble(key)); - break; - case "date": - GregorianCalendar c = new GregorianCalendar(); - if (value instanceof java.lang.Integer - || value instanceof java.lang.Long - || value instanceof java.lang.Float - || value instanceof java.lang.Double) { - c.setTimeInMillis(metadata.getLong(key)); - mediaMetadata.putDate(convertedKey, c); - } else { - String stringValue; - try { - stringValue = " value: " + metadata.getString(key); - } catch (JSONException e) { - stringValue = ""; - } - new Error("Cannot date from metadata key: " + key + stringValue - + "\n Dates must be in milliseconds from epoch UTC") - .printStackTrace(); - } - break; - case "ms": - mediaMetadata.putTimeMillis(convertedKey, metadata.getLong(key)); - break; - default: - } - // Also always add the client's version of the key because sometimes the - // MediaMetadata object removes some parameters. - // eg. If you pass metadataType == 2 == MEDIA_TYPE_TV_SHOW you will lose any - // subtitle added for "com.google.android.gms.cast.metadata.SUBTITLE", but this - // is not in-line with chrome desktop which preserves the value. - if (!key.equals(convertedKey)) { - // It is is really stubborn and if you try to add the key "subtitle" that is - // also stripped. (Hence the "cordova-plugin-chromecast_metadata_key=" prefix - convertedKey = "cordova-plugin-chromecast_metadata_key=" + key; - } - mediaMetadata.putString(convertedKey, metadata.getString(key)); - } catch (JSONException e) { - e.printStackTrace(); - } catch (IllegalArgumentException e) { - e.printStackTrace(); - } - } - - mediaInfoBuilder.setMetadata(mediaMetadata); - - int intStreamType; - switch (streamType) { - case "buffered": - intStreamType = MediaInfo.STREAM_TYPE_BUFFERED; - break; - case "live": - intStreamType = MediaInfo.STREAM_TYPE_LIVE; - break; - default: - intStreamType = MediaInfo.STREAM_TYPE_NONE; - } - - TextTrackStyle trackStyle = ChromecastUtilities.parseTextTrackStyle(textTrackStyle); - - mediaInfoBuilder - .setContentType(contentType) - .setCustomData(customData) - .setStreamType(intStreamType) - .setStreamDuration(duration) - .setTextTrackStyle(trackStyle); - - return mediaInfoBuilder.build(); - } - /** * Media API - Calls play on the current media. * @param callback called with success or error @@ -535,6 +416,268 @@ public void run() { }); } +/* ------------------------------------ QUEUE FNs ------------------------------------------- */ + + private void setQueueReloadCallback(Runnable callback) { + this.queueReloadCallback = callback; + } + + private void setQueueStatusUpdatedCallback(Runnable callback) { + this.queueStatusUpdatedCallback = callback; + } + + /** + * Sets up the objects and listeners required for queue functionality. + */ + public void setupQueue() { + MediaQueue queue = client.getMediaQueue(); + queueItems = null; + ChromecastUtilities.setQueueItems(queueItems); + queueReloadCallback = null; + // Set up the queue listener + queue.registerCallback(new MediaQueue.Callback() { + private boolean isQueueFinishedLoading = false; + private ArrayList lookingForIndexes = new ArrayList(); + + private void queueItemsPutAt(int index, JSONObject item) { + try { + queueItems.put(index, item); + } catch (JSONException e) { + e.printStackTrace(); + throw new RuntimeException("See above stack trace for error: " + e.getMessage()); + } + } + private void lookForItems(ArrayList indexes) { + synchronized (queue) { + // Merge the two arrays + lookingForIndexes.addAll(indexes); + checkItems(); + } + } + private void checkItems() { + MediaQueueItem item; + int index; + while (lookingForIndexes.size() > 0) { + index = lookingForIndexes.get(0); + item = queue.getItemAtIndex(index, true); + if (item != null) { + queueItemsPutAt(index, ChromecastUtilities.createQueueItem(item, index)); + lookingForIndexes.remove(0); + } else { + break; + } + } + if (lookingForIndexes.size() == 0) { + updateFinished(); + } + } + private void updateFinished() { + // Update the queueItems + ChromecastUtilities.setQueueItems(queueItems); + if (!isQueueFinishedLoading) { + isQueueFinishedLoading = true; + if (queueReloadCallback != null && queue.getItemCount() > 0) { + queueReloadCallback.run(); + setQueueReloadCallback(null); + } + } + clientListener.onMediaUpdate(createMediaObject()); + } + + @Override + public void itemsReloaded() { + synchronized (queue) { + isQueueFinishedLoading = false; + int itemCount = queue.getItemCount(); + if (queueReloadCallback == null) { + setQueueReloadCallback(new Runnable() { + @Override + public void run() { + // This was externally loaded + clientListener.onMediaLoaded(createMediaObject()); + } + }); + } + // init the arrays + ArrayList findIndexes = new ArrayList<>(); + queueItems = new JSONArray(); + for (int i = 0; i < itemCount; i++) { + findIndexes.add(i); + queueItems.put(null); + } + // Start loading the items + lookForItems(findIndexes); + } + } + @Override + public void itemsUpdatedAtIndexes(int[] ints) { + synchronized (queue) { + ArrayList unread = new ArrayList<>(); + for (int i : ints) { + if (queue.getItemAtIndex(i) == null) { + unread.add(i); + } + } + lookForItems(unread); + } + } + @Override + public void itemsInsertedInRange(int startIndex, int insertCount) { + synchronized (queue) { + // Make room for inserts + for (int i = 0; i < insertCount; i++) { + queueItems.put(new JSONObject()); + } + // Shift existing entries + for (int i = startIndex; i < startIndex + insertCount; i++) { + JSONObject movingObj = new JSONObject(); + try { + movingObj = queueItems.getJSONObject(i); + } catch (JSONException e) { + e.printStackTrace(); + throw new RuntimeException("Expected queueItems to contain index: " + + i + " queueItems.length: " + queueItems.length() + + "\nSee above stack trace for error: " + e.getMessage()); + } + queueItemsPutAt(i + insertCount, movingObj); + } + // Shift the lookingForIndexes + int index; + for (int i = 0; i < lookingForIndexes.size(); i++) { + index = lookingForIndexes.get(i); + if (index >= startIndex) { + lookingForIndexes.set(i, index + insertCount); + } + } + // null new entries and build indexes array to update + ArrayList updateIndexes = new ArrayList<>(); + for (int i = startIndex; i < startIndex + insertCount; i++) { + updateIndexes.add(startIndex + i); + queueItemsPutAt(startIndex + i, null); + } + // Trigger the media update + lookForItems(updateIndexes); + } + } + @Override + public void itemsRemovedAtIndexes(int[] ints) { + synchronized (queue) { + ArrayList lookingForInts = new ArrayList(); + // Remove the required indexes + int index; + for (int i : ints) { + queueItems.remove(i); + // Also, update/remove any references to look for these indexes + for (int j = 0; j < lookingForIndexes.size(); j++) { + index = lookingForIndexes.get(j); + if (index > i) { + lookingForInts.add(j, index - 1); + } else if (index < i) { + lookingForInts.add(index); + } + } + lookingForIndexes = lookingForInts; + } + // Trigger the media update + itemsUpdatedAtIndexes(new int[0]); + } + } + }); + } + + /** + * Loads a queue of media to the Chromecast. + * @param queueLoadRequest chrome.cast.media.QueueLoadRequest + * @param callback called with success or error + */ + public void queueLoad(JSONObject queueLoadRequest, CallbackContext callback) { + if (client == null || session == null) { + callback.error("session_error"); + return; + } + activity.runOnUiThread(new Runnable() { + public void run() { + try { + JSONArray qItems = queueLoadRequest.getJSONArray("items"); + MediaQueueItem[] items = new MediaQueueItem[qItems.length()]; + for (int i = 0; i < qItems.length(); i++) { + items[i] = ChromecastUtilities.createMediaQueueItem(qItems.getJSONObject(i)); + } + + int startIndex = queueLoadRequest.getInt("startIndex"); + int repeatMode = ChromecastUtilities.getAndroidRepeatMode(queueLoadRequest.getString("repeatMode")); + long playPosition = Double.valueOf(items[startIndex].getStartTime() * 1000).longValue(); + JSONObject customData = null; + try { + customData = queueLoadRequest.getJSONObject("customData"); + } catch (JSONException e) { + } + + setQueueReloadCallback(new Runnable() { + @Override + public void run() { + callback.success(createMediaObject()); + } + }); + client.queueLoad(items, startIndex, repeatMode, playPosition, customData).setResultCallback(new ResultCallback() { + @Override + public void onResult(@NonNull MediaChannelResult result) { + if (!result.getStatus().isSuccess()) { + callback.error("session_error"); + setQueueReloadCallback(null); + } + } + }); + } catch (JSONException e) { + callback.error(ChromecastUtilities.createError("invalid_parameter", e.getMessage())); + } + } + }); + } + + /** + * Plays the item with itemId in the queue. + * @param itemId The ID of the item to jump to. + * @param callback called with .success or .error depending on the result + */ + public void queueJumpToItem(Integer itemId, CallbackContext callback) { + if (client == null || session == null) { + callback.error("session_error"); + return; + } + + activity.runOnUiThread(new Runnable() { + public void run() { + setQueueStatusUpdatedCallback(new Runnable() { + @Override + public void run() { + clientListener.onMediaUpdate(createMediaObject(MediaStatus.IDLE_REASON_INTERRUPTED)); + } + }); + client.queueJumpToItem(itemId, null) + .setResultCallback(new ResultCallback() { + @Override + public void onResult(@NonNull MediaChannelResult result) { + + if (result.getStatus().isSuccess()) { + callback.success(); + } else { + setQueueStatusUpdatedCallback(null); + JSONObject errorResult = result.getCustomData(); + String error = "Failed to jump to queue item with ID: " + itemId; + if (errorResult != null) { + error += "\nError details: " + errorResult; + } + callback.error(error); + } + } + }); + } + }); + } + +/* ------------------------------------ SESSION FNs ------------------------------------------- */ + /** * Sets the receiver volume level. * @param volume volume to set the receiver to @@ -579,6 +722,8 @@ public void run() { }); } +/* ------------------------------------ HELPERS ---------------------------------------------- */ + /** * Returns a resultCallback that wraps the callback and calls the onMediaUpdate listener. * @param callback client callback @@ -607,8 +752,24 @@ private JSONObject createSessionObject() { return ChromecastUtilities.createSessionObject(session); } + /** Last sent media object. **/ + private JSONObject lastMediaObject; private JSONObject createMediaObject() { - return ChromecastUtilities.createMediaObject(session); + return createMediaObject(null); + } + + private JSONObject createMediaObject(Integer idleReason) { + if (idleReason != null && lastMediaObject != null) { + try { + lastMediaObject.put("playerState", ChromecastUtilities.getMediaPlayerState(MediaStatus.PLAYER_STATE_IDLE)); + lastMediaObject.put("idleReason", ChromecastUtilities.getMediaIdleReason(idleReason)); + return lastMediaObject; + } catch (JSONException e) { + } + } + JSONObject out = ChromecastUtilities.createMediaObject(session); + lastMediaObject = out; + return out; } interface Listener extends Cast.MessageReceivedCallback { diff --git a/src/android/ChromecastUtilities.java b/src/android/ChromecastUtilities.java index 2b0f15d..dd719bd 100644 --- a/src/android/ChromecastUtilities.java +++ b/src/android/ChromecastUtilities.java @@ -1,13 +1,17 @@ package acidhax.cordova.chromecast; import android.graphics.Color; +import android.net.Uri; +import androidx.annotation.NonNull; import androidx.mediarouter.media.MediaRouter; import com.google.android.gms.cast.ApplicationMetadata; import com.google.android.gms.cast.CastDevice; import com.google.android.gms.cast.MediaInfo; import com.google.android.gms.cast.MediaMetadata; +import com.google.android.gms.cast.MediaQueueData; +import com.google.android.gms.cast.MediaQueueItem; import com.google.android.gms.cast.MediaStatus; import com.google.android.gms.cast.MediaTrack; import com.google.android.gms.cast.TextTrackStyle; @@ -18,17 +22,33 @@ import org.json.JSONException; import org.json.JSONObject; +import java.util.GregorianCalendar; +import java.util.Iterator; import java.util.List; import java.util.Set; final class ChromecastUtilities { + /** Stores a cache of the queueItems for building Media Objects. */ + private static JSONArray queueItems = null; private ChromecastUtilities() { //not called } - static String getMediaIdleReason(MediaStatus mediaStatus) { - switch (mediaStatus.getIdleReason()) { + /** + * Sets the queueItems to be returned with the media object. + * @param arr queueItems + */ + static void setQueueItems(JSONArray arr) { + // For some reason the desktop chrome behavior is that the queue items is never wiped out + // once they are in existence + if (arr == null || arr.length() > 0) { + queueItems = arr; + } + } + + static String getMediaIdleReason(int idleReason) { + switch (idleReason) { case MediaStatus.IDLE_REASON_CANCELED: return "CANCELLED"; case MediaStatus.IDLE_REASON_ERROR: @@ -43,8 +63,8 @@ static String getMediaIdleReason(MediaStatus mediaStatus) { } } - static String getMediaPlayerState(MediaStatus mediaStatus) { - switch (mediaStatus.getPlayerState()) { + static String getMediaPlayerState(int playerState) { + switch (playerState) { case MediaStatus.PLAYER_STATE_LOADING: case MediaStatus.PLAYER_STATE_BUFFERING: return "BUFFERING"; @@ -169,8 +189,8 @@ static String getWindowType(TextTrackStyle textTrackStyle) { } } - static String getRepeatMode(MediaStatus mediaStatus) { - switch (mediaStatus.getQueueRepeatMode()) { + static String getRepeatMode(int repeatMode) { + switch (repeatMode) { case MediaStatus.REPEAT_MODE_REPEAT_OFF: return "REPEAT_OFF"; case MediaStatus.REPEAT_MODE_REPEAT_ALL: @@ -184,6 +204,21 @@ static String getRepeatMode(MediaStatus mediaStatus) { } } + static int getAndroidRepeatMode(String clientRepeatMode) throws JSONException { + switch (clientRepeatMode) { + case "REPEAT_OFF": + return MediaStatus.REPEAT_MODE_REPEAT_OFF; + case "REPEAT_ALL": + return MediaStatus.REPEAT_MODE_REPEAT_ALL; + case "REPEAT_SINGLE": + return MediaStatus.REPEAT_MODE_REPEAT_SINGLE; + case "REPEAT_ALL_AND_SHUFFLE": + return MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE; + default: + throw new JSONException("Invalid repeat mode: " + clientRepeatMode); + } + } + static String getAndroidMetadataName(String clientName) { switch (clientName) { case "albumArtist": @@ -456,6 +491,10 @@ static JSONArray createMediaArray(CastSession session) { } static JSONObject createMediaObject(CastSession session) { + return createMediaObject(session, queueItems); + }; + + static JSONObject createMediaObject(CastSession session, JSONArray items) { JSONObject out = new JSONObject(); try { @@ -468,20 +507,21 @@ static JSONObject createMediaObject(CastSession session) { out.put("currentTime", mediaStatus.getStreamPosition() / 1000.0); out.put("customData", mediaStatus.getCustomData()); //out.put("extendedStatus",); - String idleReason = ChromecastUtilities.getMediaIdleReason(mediaStatus); + String idleReason = ChromecastUtilities.getMediaIdleReason(mediaStatus.getIdleReason()); if (idleReason != null) { out.put("idleReason", idleReason); } - //out.put("items", mediaStatus.getQueueItems()); + out.put("items", items); + out.put("isAlive", mediaStatus.getPlayerState() != MediaStatus.PLAYER_STATE_IDLE); //out.put("liveSeekableRange",); out.put("loadingItemId", mediaStatus.getLoadingItemId()); - out.put("media", createMediaInfoObject(session)); + out.put("media", createMediaInfoObject(session.getRemoteMediaClient().getMediaInfo())); out.put("mediaSessionId", 1); out.put("playbackRate", mediaStatus.getPlaybackRate()); - out.put("playerState", ChromecastUtilities.getMediaPlayerState(mediaStatus)); + out.put("playerState", ChromecastUtilities.getMediaPlayerState(mediaStatus.getPlayerState())); out.put("preloadedItemId", mediaStatus.getPreloadedItemId()); - //out.put("queueData", ); - out.put("repeatMode", getRepeatMode(mediaStatus)); + out.put("queueData", createQueueData(mediaStatus)); + out.put("repeatMode", getRepeatMode(mediaStatus.getQueueRepeatMode())); out.put("sessionId", session.getSessionId()); //out.put("supportedMediaCommands", ); //out.put("videoInfo", ); @@ -490,15 +530,7 @@ static JSONObject createMediaObject(CastSession session) { volume.put("level", mediaStatus.getStreamVolume()); volume.put("muted", mediaStatus.isMute()); out.put("volume", volume); - - long[] activeTrackIds = mediaStatus.getActiveTrackIds(); - if (activeTrackIds != null) { - JSONArray activeTracks = new JSONArray(); - for (long activeTrackId : activeTrackIds) { - activeTracks.put(activeTrackId); - } - out.put("activeTrackIds", activeTracks); - } + out.put("activeTrackIds", createActiveTrackIds(mediaStatus.getActiveTrackIds())); } catch (JSONException e) { } catch (NullPointerException e) { return null; @@ -507,13 +539,69 @@ static JSONObject createMediaObject(CastSession session) { return out; } - private static JSONArray createMediaInfoTracks(CastSession session) { + private static JSONArray createActiveTrackIds(long[] activeTrackIds) { JSONArray out = new JSONArray(); + try { + if (activeTrackIds.length == 0) { + return null; + } + for (long id : activeTrackIds) { + out.put(id); + } + } catch (NullPointerException e) { + return null; + } + return out; + } + static JSONObject createQueueData(MediaStatus status) { + JSONObject out = new JSONObject(); try { - MediaStatus mediaStatus = session.getRemoteMediaClient().getMediaStatus(); - MediaInfo mediaInfo = mediaStatus.getMediaInfo(); + MediaQueueData data = status.getQueueData(); + if (data == null) { + return null; + } + out.put("repeatMode", ChromecastUtilities.getRepeatMode(data.getRepeatMode())); + out.put("shuffle", data.getRepeatMode() == MediaStatus.REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE); + out.put("startIndex", data.getStartIndex()); + } catch (JSONException e) { + e.printStackTrace(); + throw new RuntimeException("See above stack trace for error: " + e.getMessage()); + } + return out; + } + static JSONObject createQueueItem(@NonNull MediaQueueItem item, int orderId) { + JSONObject out = new JSONObject(); + try { + out.put("activeTrackIds", createActiveTrackIds(item.getActiveTrackIds())); + out.put("autoplay", item.getAutoplay()); + out.put("customData", item.getCustomData()); + out.put("itemId", item.getItemId()); + out.put("media", createMediaInfoObject(item.getMedia())); + out.put("orderId", orderId); + Double playbackDuration = item.getPlaybackDuration(); + if (Double.isInfinite(playbackDuration)) { + playbackDuration = null; + } + out.put("playbackDuration", playbackDuration); + out.put("preloadTime", item.getPreloadTime()); + Double startTime = item.getStartTime(); + if (Double.isNaN(startTime)) { + startTime = null; + } + out.put("startTime", startTime); + } catch (JSONException e) { + e.printStackTrace(); + throw new RuntimeException("See above stack trace for error: " + e.getMessage()); + } + return out; + } + + private static JSONArray createMediaInfoTracks(MediaInfo mediaInfo) { + JSONArray out = new JSONArray(); + + try { if (mediaInfo.getMediaTracks() == null) { return out; } @@ -543,12 +631,10 @@ private static JSONArray createMediaInfoTracks(CastSession session) { return out; } - private static JSONObject createMediaInfoObject(CastSession session) { + private static JSONObject createMediaInfoObject(MediaInfo mediaInfo) { JSONObject out = new JSONObject(); try { - MediaInfo mediaInfo = session.getRemoteMediaClient().getMediaInfo(); - // TODO: Missing attributes are commented out. // These are returned by the chromecast desktop SDK, we should probably return them too //out.put("breakClips",); @@ -560,7 +646,7 @@ private static JSONObject createMediaInfoObject(CastSession session) { //out.put("mediaCategory",); out.put("metadata", createMetadataObject(mediaInfo.getMetadata())); out.put("streamType", ChromecastUtilities.getMediaInfoStreamType(mediaInfo)); - out.put("tracks", createMediaInfoTracks(session)); + out.put("tracks", createMediaInfoTracks(mediaInfo)); out.put("textTrackStyle", ChromecastUtilities.createTextTrackObject(mediaInfo.getTextTrackStyle())); } catch (JSONException e) { @@ -572,6 +658,9 @@ private static JSONObject createMediaInfoObject(CastSession session) { static JSONObject createMetadataObject(MediaMetadata metadata) { JSONObject out = new JSONObject(); + if (metadata == null) { + return out; + } try { try { // Must be in own try catch @@ -685,4 +774,225 @@ static JSONObject createError(String code, String message) { } return out; } + +/* ------------------- Create NON-JSON (non-output) Objects ---------------------------------- */ + + /** + * Creates a MediaQueueItem from a JSONObject representation of a MediaQueueItem. + * @param mediaQueueItem a JSONObject representation of a MediaQueueItem + * @return a MediaQueueItem + * @throws JSONException If the input mediaQueueItem is incorrect + */ + static MediaQueueItem createMediaQueueItem(JSONObject mediaQueueItem) throws JSONException { + MediaInfo mediaInfo = createMediaInfo(mediaQueueItem.getJSONObject("media")); + MediaQueueItem.Builder builder = new MediaQueueItem.Builder(mediaInfo); + + try { + long[] activeTrackIds; + JSONArray trackIds = mediaQueueItem.getJSONArray("activeTrackIds"); + activeTrackIds = new long[trackIds.length()]; + for (int i = 0; i < trackIds.length(); i++) { + activeTrackIds[i] = trackIds.getLong(i); + } + builder.setActiveTrackIds(activeTrackIds); + } catch (JSONException e) { + } + try { + builder.setAutoplay(mediaQueueItem.getBoolean("autoplay")); + } catch (JSONException e) { + } + JSONObject customData = new JSONObject(); + try { + customData.getJSONObject("customData"); + } catch (JSONException e) { + } + try { + builder.setPlaybackDuration(mediaQueueItem.getDouble("playbackDuration")); + } catch (JSONException e) { + } + try { + builder.setPreloadTime(mediaQueueItem.getDouble("preloadTime")); + } catch (JSONException e) { + } + try { + builder.setStartTime(mediaQueueItem.getDouble("startTime")); + } catch (JSONException e) { + } + return builder.build(); + } + + static MediaInfo createMediaInfo(JSONObject mediaInfo) { + // Set defaults + String contentId = ""; + JSONObject customData = new JSONObject(); + String contentType = "unknown"; + long duration = 0; + String streamType = "unknown"; + JSONObject metadata = new JSONObject(); + JSONObject textTrackStyle = new JSONObject(); + + // Try to get the actual values + + // Try to get the actual values + try { + contentId = mediaInfo.getString("contentId"); + } catch (JSONException e) { + e.printStackTrace(); + } + try { + customData = mediaInfo.getJSONObject("customData"); + } catch (JSONException e) { + e.printStackTrace(); + } + try { + contentType = mediaInfo.getString("contentType"); + } catch (JSONException e) { + e.printStackTrace(); + } + try { + duration = mediaInfo.getLong("duration"); + } catch (JSONException e) { + e.printStackTrace(); + } + try { + streamType = mediaInfo.getString("streamType"); + } catch (JSONException e) { + e.printStackTrace(); + } + try { + metadata = mediaInfo.getJSONObject("metadata"); + } catch (JSONException e) { + e.printStackTrace(); + } + try { + textTrackStyle = mediaInfo.getJSONObject("textTrackStyle"); + } catch (JSONException e) { + e.printStackTrace(); + } + + return createMediaInfo(contentId, customData, contentType, duration, streamType, metadata, textTrackStyle); + } + + static MediaInfo createMediaInfo(String contentId, JSONObject customData, String contentType, long duration, String streamType, JSONObject metadata, JSONObject textTrackStyle) { + MediaInfo.Builder mediaInfoBuilder = new MediaInfo.Builder(contentId); + + mediaInfoBuilder.setMetadata(createMediaMetadata(metadata)); + + int intStreamType; + switch (streamType) { + case "buffered": + intStreamType = MediaInfo.STREAM_TYPE_BUFFERED; + break; + case "live": + intStreamType = MediaInfo.STREAM_TYPE_LIVE; + break; + default: + intStreamType = MediaInfo.STREAM_TYPE_NONE; + } + + TextTrackStyle trackStyle = ChromecastUtilities.parseTextTrackStyle(textTrackStyle); + + mediaInfoBuilder + .setContentType(contentType) + .setCustomData(customData) + .setStreamType(intStreamType) + .setStreamDuration(duration) + .setTextTrackStyle(trackStyle); + + return mediaInfoBuilder.build(); + } + + private static MediaMetadata createMediaMetadata(JSONObject metadata) { + + MediaMetadata mediaMetadata; + try { + mediaMetadata = new MediaMetadata(metadata.getInt("metadataType")); + } catch (JSONException e) { + mediaMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC); + } + // Add any images + try { + JSONArray images = metadata.getJSONArray("images"); + for (int i = 0; i < images.length(); i++) { + JSONObject imageObj = images.getJSONObject(i); + try { + Uri imageURI = Uri.parse(imageObj.getString("url")); + mediaMetadata.addImage(new WebImage(imageURI)); + } catch (Exception e) { + } + } + } catch (JSONException e) { + } + + // Dynamically add other parameters + Iterator keys = metadata.keys(); + String key; + String convertedKey; + Object value; + while (keys.hasNext()) { + key = keys.next(); + if (key.equals("metadataType") + || key.equals("images") + || key.equals("type")) { + continue; + } + try { + value = metadata.get(key); + convertedKey = ChromecastUtilities.getAndroidMetadataName(key); + // Try to add the translated version of the key + switch (ChromecastUtilities.getMetadataType(convertedKey)) { + case "string": + mediaMetadata.putString(convertedKey, metadata.getString(key)); + break; + case "int": + mediaMetadata.putInt(convertedKey, metadata.getInt(key)); + break; + case "double": + mediaMetadata.putDouble(convertedKey, metadata.getDouble(key)); + break; + case "date": + GregorianCalendar c = new GregorianCalendar(); + if (value instanceof java.lang.Integer + || value instanceof java.lang.Long + || value instanceof java.lang.Float + || value instanceof java.lang.Double) { + c.setTimeInMillis(metadata.getLong(key)); + mediaMetadata.putDate(convertedKey, c); + } else { + String stringValue; + try { + stringValue = " value: " + metadata.getString(key); + } catch (JSONException e) { + stringValue = ""; + } + new Error("Cannot date from metadata key: " + key + stringValue + + "\n Dates must be in milliseconds from epoch UTC") + .printStackTrace(); + } + break; + case "ms": + mediaMetadata.putTimeMillis(convertedKey, metadata.getLong(key)); + break; + default: + } + // Also always add the client's version of the key because sometimes the + // MediaMetadata object removes some parameters. + // eg. If you pass metadataType == 2 == MEDIA_TYPE_TV_SHOW you will lose any + // subtitle added for "com.google.android.gms.cast.metadata.SUBTITLE", but this + // is not in-line with chrome desktop which preserves the value. + if (!key.equals(convertedKey)) { + // It is is really stubborn and if you try to add the key "subtitle" that is + // also stripped. (Hence the "cordova-plugin-chromecast_metadata_key=" prefix + convertedKey = "cordova-plugin-chromecast_metadata_key=" + key; + } + mediaMetadata.putString(convertedKey, metadata.getString(key)); + } catch (JSONException e) { + e.printStackTrace(); + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } + } + return mediaMetadata; + } + } diff --git a/tests/www/js/tests_auto.js b/tests/www/js/tests_auto.js index 00c9783..46c6c8a 100644 --- a/tests/www/js/tests_auto.js +++ b/tests/www/js/tests_auto.js @@ -39,6 +39,7 @@ var success = 'success'; var update = 'update'; var stopped = 'stopped'; + var newMedia = 'newMedia'; var session; @@ -546,6 +547,7 @@ session.loadMedia(new chrome.cast.media.LoadRequest(mediaInfo), function (m) { media = m; utils.testMediaProperties(media); + assert.isUndefined(media.queueData); assert.equal(media.media.metadata.title, mediaInfo.metadata.title); assert.equal(media.media.metadata.subtitle, mediaInfo.metadata.subtitle); assert.equal(media.media.metadata.releaseDate, mediaInfo.metadata.releaseDate); @@ -1003,6 +1005,244 @@ assert.fail(err.code + ': ' + err.description); }); }); + describe('Queues', function () { + var videoItem; + var audioItem; + var startTime = 40; + function getCurrentItemIndex (media) { + for (var i = 0; i < media.items.length; i++) { + if (media.items[i].itemId === media.currentItemId) { + return i; + } + } + return 'Could get current item index for itemId: ' + media.currentItemId; + } + function checkItems (items) { + assert.isTrue(items[0].autoplay); + assert.equal(items[0].startTime, startTime); + assert.equal(items[0].media.contentId, videoUrl); + assert.isTrue(items[1].autoplay); + assert.equal(items[1].startTime, startTime * 2); + assert.equal(items[1].media.contentId, audioUrl); + } + before(function () { + videoItem = new chrome.cast.media.MediaInfo(videoUrl, 'video/mp4'); + videoItem.metadata = new chrome.cast.media.TvShowMediaMetadata(); + videoItem.metadata.title = 'DaTitle'; + videoItem.metadata.subtitle = 'DaSubtitle'; + videoItem.metadata.originalAirDate = new Date().valueOf(); + videoItem.metadata.episode = 15; + videoItem.metadata.season = 2; + videoItem.metadata.seriesTitle = 'DaSeries'; + videoItem.metadata.images = [new chrome.cast.Image(imageUrl)]; + + audioItem = new chrome.cast.media.MediaInfo(audioUrl, 'audio/mpeg'); + audioItem.metadata = new chrome.cast.media.MusicTrackMediaMetadata(); + audioItem.metadata.albumArtist = 'DaAlmbumArtist'; + audioItem.metadata.albumName = 'DaAlbum'; + audioItem.metadata.artist = 'DaArtist'; + audioItem.metadata.composer = 'DaComposer'; + audioItem.metadata.title = 'DaTitle'; + audioItem.metadata.songName = 'DaSongName'; + audioItem.metadata.myMadeUpMetadata = '15'; + audioItem.metadata.releaseDate = new Date().valueOf(); + audioItem.metadata.images = [new chrome.cast.Image(imageUrl)]; + }); + it('session.queueLoad should return an error when we attempt to load an empty queue', function (done) { + session.queueLoad(new chrome.cast.media.QueueLoadRequest([]), function (m) { + assert.fail('Should not be able to load an empty queue.'); + }, function (err) { + assert.isObject(err); + assert.equal(err.code, chrome.cast.ErrorCode.SESSION_ERROR); + assert.equal(err.description, 'INVALID_PARAMS'); + assert.deepEqual(err.details, { reason: 'INVALID_PARAMS', type: 'INVALID_REQUEST' }); + done(); + }); + }); + it('session.queueLoad should be able to load remote audio/video queue and return the correct Metadata', function (done) { + var item; + var queue = []; + + // Add items to the queue + item = new chrome.cast.media.QueueItem(videoItem); + item.startTime = startTime; + queue.push(item); + item = new chrome.cast.media.QueueItem(audioItem); + item.startTime = startTime * 2; + queue.push(item); + + // Create request to repeat all and start at 2nd item + var request = new chrome.cast.media.QueueLoadRequest(queue); + request.repeatMode = chrome.cast.media.RepeatMode.ALL; + request.startIndex = 1; + + session.queueLoad(request, function (m) { + media = m; + var i = getCurrentItemIndex(media); + utils.testMediaProperties(media); + assert.equal(media.currentItemId, media.items[i].itemId); + assert.equal(media.repeatMode, chrome.cast.media.RepeatMode.ALL); + assert.isObject(media.queueData); + assert.equal(media.queueData.repeatMode, request.repeatMode); + assert.isFalse(media.queueData.shuffle); + assert.equal(media.queueData.startIndex, request.startIndex); + utils.testQueueItems(media.items); + assert.equal(media.media.contentId, audioUrl); + assert.equal(media.items.length, 2); + checkItems(media.items); + assert.equal(media.items[i].media.metadata.albumArtist, audioItem.metadata.albumArtist); + assert.equal(media.items[i].media.metadata.albumName, audioItem.metadata.albumName); + assert.equal(media.items[i].media.metadata.artist, audioItem.metadata.artist); + assert.equal(media.items[i].media.metadata.composer, audioItem.metadata.composer); + assert.equal(media.items[i].media.metadata.title, audioItem.metadata.title); + assert.equal(media.items[i].media.metadata.songName, audioItem.metadata.songName); + assert.equal(media.items[i].media.metadata.releaseDate, audioItem.metadata.releaseDate); + assert.equal(media.items[i].media.metadata.images[0].url, audioItem.metadata.images[0].url); + assert.equal(media.items[i].media.metadata.myMadeUpMetadata, audioItem.metadata.myMadeUpMetadata); + assert.equal(media.items[i].media.metadata.metadataType, chrome.cast.media.MetadataType.MUSIC_TRACK); + assert.equal(media.items[i].media.metadata.type, chrome.cast.media.MetadataType.MUSIC_TRACK); + media.addUpdateListener(function listener (isAlive) { + assert.isTrue(isAlive); + utils.testMediaProperties(media); + assert.oneOf(media.playerState, [ + chrome.cast.media.PlayerState.PLAYING, + chrome.cast.media.PlayerState.BUFFERING]); + if (media.playerState === chrome.cast.media.PlayerState.PLAYING) { + media.removeUpdateListener(listener); + assert.closeTo(media.getEstimatedTime(), startTime * 2, 5); + done(); + } + }); + }, function (err) { + assert.fail(err.code + ': ' + err.description); + }); + }); + it('Queue should start the next item automatically when previous one finishes (tests loop around of repeat_all as well)', function (done) { + var called = utils.callOrder([ + { id: success, repeats: false }, + { id: stopped, repeats: true }, + { id: newMedia, repeats: true }, + { id: update, repeats: true } + ], done); + // Create request + var request = new chrome.cast.media.SeekRequest(); + request.currentTime = media.media.duration - 1; + + var i = getCurrentItemIndex(media); + // Listen for current media end + media.addUpdateListener(function listener (isAlive) { + if (media.playerState === chrome.cast.media.PlayerState.IDLE) { + assert.equal(media.idleReason, chrome.cast.media.IdleReason.FINISHED); + assert.isTrue(isAlive); + called(stopped); + } + if (media.currentItemId !== media.items[i].itemId) { + i = getCurrentItemIndex(media); + media.removeUpdateListener(listener); + utils.testMediaProperties(media); + assert.equal(media.repeatMode, chrome.cast.media.RepeatMode.ALL); + assert.equal(media.media.contentId, videoUrl); + utils.testQueueItems(media.items); + assert.equal(media.items.length, 2); + checkItems(media.items); + assert.equal(media.items[i].media.contentId, videoUrl); + assert.equal(media.items[i].media.metadata.title, videoItem.metadata.title); + assert.equal(media.items[i].media.metadata.subtitle, videoItem.metadata.subtitle); + assert.equal(media.items[i].media.metadata.originalAirDate, videoItem.metadata.originalAirDate); + assert.equal(media.items[i].media.metadata.episode, videoItem.metadata.episode); + assert.equal(media.items[i].media.metadata.season, videoItem.metadata.season); + assert.equal(media.items[i].media.metadata.seriesTitle, videoItem.metadata.seriesTitle); + assert.equal(media.items[i].media.metadata.images[0].url, videoItem.metadata.images[0].url); + assert.equal(media.items[i].media.metadata.metadataType, chrome.cast.media.MetadataType.TV_SHOW); + assert.equal(media.items[i].media.metadata.type, chrome.cast.media.MetadataType.TV_SHOW); + called(newMedia); + window.m = media; + if (media.getEstimatedTime() > startTime - 5 + && media.getEstimatedTime() < startTime + 5) { + called(update); + } + } + }); + // Seek to just before the end + media.seek(request, function () { + called(success); + }, function (err) { + assert.fail(err.code + ': ' + err.description); + }); + }); + it('media.queueJumpToItem should not call a callback for null contentId', function () { + media.queueJumpToItem(null, function () { + assert.fail('Should not be called when passing null content id to queueJumpToItem'); + }, function () { + assert.fail('Should not be called when passing null content id to queueJumpToItem'); + }); + }); + it('media.queueJumpToItem should not call a callback for unknown contentId', function () { + media.queueJumpToItem('unknown_content_id', function () { + assert.fail('Should not be called when passing unknown content id to queueJumpToItem'); + }, function () { + assert.fail('Should not be called when passing unknown content id to queueJumpToItem'); + }); + }); + it('media.queueJumpToItem should not call a callback for decimal contentId', function () { + media.queueJumpToItem(1.5, function () { + assert.fail('Should not be called when passing decimal content id to queueJumpToItem'); + }, function () { + assert.fail('Should not be called when passing decimal content id to queueJumpToItem'); + }); + }); + it('media.queueJumpToItem should jump to selected item', function (done) { + var calledAnyOrder = utils.waitForAllCalls([ + { id: success, repeats: false }, + { id: update, repeats: true } + ], done); + var calledOrder = utils.callOrder([ + { id: stopped, repeats: true }, + { id: newMedia, repeats: true } + ], function () { + calledAnyOrder(update); + }); + var i = getCurrentItemIndex(media); + media.addUpdateListener(function listener (isAlive) { + if (media.playerState === chrome.cast.media.PlayerState.IDLE) { + assert.equal(media.idleReason, chrome.cast.media.IdleReason.INTERRUPTED); + assert.isTrue(isAlive); + calledOrder(stopped); + } + if (media.currentItemId !== media.items[i].itemId) { + i = getCurrentItemIndex(media); + media.removeUpdateListener(listener); + utils.testMediaProperties(media); + assert.equal(media.currentItemId, media.items[i].itemId); + utils.testQueueItems(media.items); + assert.equal(media.media.contentId, audioUrl); + assert.equal(media.items.length, 2); + checkItems(media.items); + assert.equal(media.items[i].media.contentId, audioUrl); + assert.equal(media.items[i].media.metadata.albumArtist, audioItem.metadata.albumArtist); + assert.equal(media.items[i].media.metadata.albumName, audioItem.metadata.albumName); + assert.equal(media.items[i].media.metadata.artist, audioItem.metadata.artist); + assert.equal(media.items[i].media.metadata.composer, audioItem.metadata.composer); + assert.equal(media.items[i].media.metadata.title, audioItem.metadata.title); + assert.equal(media.items[i].media.metadata.songName, audioItem.metadata.songName); + assert.equal(media.items[i].media.metadata.releaseDate, audioItem.metadata.releaseDate); + assert.equal(media.items[i].media.metadata.images[0].url, audioItem.metadata.images[0].url); + assert.equal(media.items[i].media.metadata.myMadeUpMetadata, audioItem.metadata.myMadeUpMetadata); + assert.equal(media.items[i].media.metadata.metadataType, chrome.cast.media.MetadataType.MUSIC_TRACK); + assert.equal(media.items[i].media.metadata.type, chrome.cast.media.MetadataType.MUSIC_TRACK); + assert.closeTo(media.getEstimatedTime(), startTime * 2, 5); + calledOrder(newMedia); + } + }); + // Jump + var jumpIndex = (i + 1) % media.items.length; + media.queueJumpToItem(media.items[jumpIndex].itemId, function () { + calledAnyOrder(success); + }, function (err) { + assert.fail(err.code + ': ' + err.description); + }); + }); + }); after(function (done) { // Set up the expected calls var called = utils.waitForAllCalls([ diff --git a/tests/www/js/utils.js b/tests/www/js/utils.js index f2cb210..f0435c7 100644 --- a/tests/www/js/utils.js +++ b/tests/www/js/utils.js @@ -230,6 +230,34 @@ } }; + utils.testQueueItems = function (items) { + assert.isArray(items); + var item; + for (var i = 0; i < items.length; i++) { + item = items[i]; + assert.isBoolean(item.autoplay); + assert.isNumber(item.itemId); + utils.testQueueItemMediaInfoProperties(item.media); + assert.isNumber(item.orderId); + assert.isNumber(item.preloadTime); + assert.isNumber(item.startTime); + } + }; + + utils.testQueueItemMediaInfoProperties = function (mediaInfo) { + assert.isObject(mediaInfo); + assert.isString(mediaInfo.contentId); + assert.isString(mediaInfo.contentType); + if (mediaInfo.duration) { + assert.isNumber(mediaInfo.duration); + } + utils.testMediaMetadata(mediaInfo.metadata); + assert.isString(mediaInfo.streamType); + if (mediaInfo.tracks) { + assert.isArray(mediaInfo.tracks); + } + }; + window['cordova-plugin-chromecast-tests'] = window['cordova-plugin-chromecast-tests'] || {}; window['cordova-plugin-chromecast-tests'].utils = utils; }()); diff --git a/www/chrome.cast.js b/www/chrome.cast.js index 42e14c5..77fe342 100644 --- a/www/chrome.cast.js +++ b/www/chrome.cast.js @@ -333,6 +333,35 @@ chrome.cast = { this.customData = null; }, + /** + * Represents an item in a media queue. + * @param {chrome.cast.media.MediaInfo} mediaInfo - Value must not be null. + */ + QueueItem: function (item) { + this.itemId = null; + this.media = item; + this.autoplay = !0; + this.startTime = 0; + this.playbackDuration = null; + this.preloadTime = 0; + this.customData = this.activeTrackIds = null; + }, + + /** + * A request to load and optionally start playback of a new ordered + * list of media items. + * @param {chrome.cast.media.QueueItem} items - The list of media items + * to load. Must not be null or empty. Value must not be null. + */ + QueueLoadRequest: function (items) { + this.type = 'QUEUE_LOAD'; + this.sessionId = this.requestId = null; + this.items = items; + this.startIndex = 0; + this.repeatMode = chrome.cast.media.RepeatMode.OFF; + this.customData = null; + }, + /** * A generic media description. * @property {chrome.cast.Image[]} images Content images. @@ -713,6 +742,38 @@ chrome.cast.Session.prototype.loadMedia = function (loadRequest, successCallback }); }; +/** + * Loads and optionally starts playback of a new queue of media items into a + * running receiver application. + * @param {chrome.cast.media.QueueLoadRequest} loadRequest - Request to load a + * new queue of media items. Value must not be null. + * @param {function} successCallback Invoked with the loaded Media on success. + * @param {function} errorCallback Invoked on error. The possible errors + * are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, + * SESSION_ERROR, and EXTENSION_MISSING. + */ +chrome.cast.Session.prototype.queueLoad = function (loadRequest, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + if (!loadRequest.items || loadRequest.items.length === 0) { + return errorCallback && errorCallback(new chrome.cast.Error( + chrome.cast.ErrorCode.SESSION_ERROR, 'INVALID_PARAMS', + { reason: 'INVALID_PARAMS', type: 'INVALID_REQUEST' })); + } + var self = this; + + execute('queueLoad', loadRequest, function (err, obj) { + if (!err) { + _currentMedia = new chrome.cast.media.Media(self.sessionId, obj.mediaSessionId); + _currentMedia._update(obj); + successCallback(_currentMedia); + // Also trigger the update notification + _currentMedia.emit('_mediaUpdated', _currentMedia.playerState !== 'IDLE'); + } else { + handleError(err, errorCallback); + } + }); +}; + /** * Adds a listener that is invoked when the Session has changed. * Changes to the following properties will trigger the listener: @@ -962,6 +1023,7 @@ chrome.cast.media.Media = function Media (sessionId, mediaSessionId) { this.volume = new chrome.cast.Volume(1, false); this._lastUpdatedTime = Date.now(); this.media = null; + this.queueData = undefined; }; chrome.cast.media.Media.prototype = Object.create(EventEmitter.prototype); @@ -1126,7 +1188,40 @@ chrome.cast.media.Media.prototype.editTracksInfo = function (editTracksInfoReque handleError(err, errorCallback); } }); +}; +/** + * Plays the item with itemId in the queue. + * If itemId is not found in the queue, either because it wasn't there + * originally or it was removed by another sender before calling this function, + * this function will silently return without sending a request to the + * receiver. + * + * @param {number} itemId The ID of the item to which to jump. + * Value must not be null. + * @param {function()} successCallback Invoked on success. + * @param {function(not-null chrome.cast.Error)} errorCallback Invoked on error. The possible errors are TIMEOUT, API_NOT_INITIALIZED, INVALID_PARAMETER, CHANNEL_ERROR, SESSION_ERROR, and EXTENSION_MISSING. + **/ +chrome.cast.media.Media.prototype.queueJumpToItem = function (itemId, successCallback, errorCallback) { + if (this._preCheck(errorCallback)) { return; } + var isValidItemId = false; + for (var i = 0; i < _currentMedia.items.length; i++) { + if (_currentMedia.items[i].itemId === itemId) { + isValidItemId = true; + break; + } + } + if (!isValidItemId) { + return; + } + + execute('queueJumpToItem', itemId, function (err) { + if (!err) { + successCallback && successCallback(); + } else { + handleError(err, errorCallback); + } + }); }; /** @@ -1299,7 +1394,7 @@ execute('setup', function (err, args) { if (_session) { _session.media[0] = _currentMedia; } - _currentMedia.emit('_mediaUpdated', _currentMedia.playerState !== 'IDLE'); + _currentMedia.emit('_mediaUpdated', !!media.isAlive); }, MEDIA_LOAD: function (media) { if (_session) {