package de.danoeh.antennapod.core.cast;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
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.common.images.WebImage;
import java.util.Calendar;
import java.util.List;
import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.feed.FeedImage;
import de.danoeh.antennapod.core.feed.FeedItem;
import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.util.playback.ExternalMedia;
import de.danoeh.antennapod.core.util.playback.Playable;
/**
* Helper functions for Cast support.
*/
public class CastUtils {
private CastUtils(){}
private static final String TAG = "CastUtils";
public static final String KEY_MEDIA_ID = "de.danoeh.antennapod.core.cast.MediaId";
public static final String KEY_EPISODE_IDENTIFIER = "de.danoeh.antennapod.core.cast.EpisodeId";
public static final String KEY_EPISODE_LINK = "de.danoeh.antennapod.core.cast.EpisodeLink";
public static final String KEY_FEED_URL = "de.danoeh.antennapod.core.cast.FeedUrl";
public static final String KEY_FEED_WEBSITE = "de.danoeh.antennapod.core.cast.FeedWebsite";
public static final String KEY_EPISODE_NOTES = "de.danoeh.antennapod.core.cast.EpisodeNotes";
public static final int EPISODE_NOTES_MAX_LENGTH = Integer.MAX_VALUE;
/**
* The field <code>AntennaPod.FormatVersion</code> specifies which version of MediaMetaData
* fields we're using. Future implementations should try to be backwards compatible with earlier
* versions, and earlier versions should be forward compatible until the version indicated by
* <code>MAX_VERSION_FORWARD_COMPATIBILITY</code>. If an update makes the format unreadable for
* an earlier version, then its version number should be greater than the
* <code>MAX_VERSION_FORWARD_COMPATIBILITY</code> value set on the earlier one, so that it
* doesn't try to parse the object.
*/
public static final String KEY_FORMAT_VERSION = "de.danoeh.antennapod.core.cast.FormatVersion";
public static final int FORMAT_VERSION_VALUE = 1;
public static final int MAX_VERSION_FORWARD_COMPATIBILITY = 9999;
public static boolean isCastable(Playable media){
if (media == null || media instanceof ExternalMedia) {
return false;
}
if (media instanceof FeedMedia || media instanceof RemoteMedia){
String url = media.getStreamUrl();
if(url == null || url.isEmpty()){
return false;
}
switch (media.getMediaType()) {
case UNKNOWN:
return false;
case AUDIO:
return CastManager.getInstance().hasCapability(CastDevice.CAPABILITY_AUDIO_OUT, true);
case VIDEO:
return CastManager.getInstance().hasCapability(CastDevice.CAPABILITY_VIDEO_OUT, true);
}
}
return false;
}
/**
* Converts {@link FeedMedia} objects into a format suitable for sending to a Cast Device.
* Before using this method, one should make sure {@link #isCastable(Playable)} returns
* {@code true}.
*
* Unless media.{@link FeedMedia#loadMetadata() loadMetadata()} has already been called,
* this method should not run on the main thread.
*
* @param media The {@link FeedMedia} object to be converted.
* @return {@link MediaInfo} object in a format proper for casting.
*/
public static MediaInfo convertFromFeedMedia(FeedMedia media){
if(media == null) {
return null;
}
MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC);
try{
media.loadMetadata();
} catch (Playable.PlayableException e) {
Log.e(TAG, "Unable to load FeedMedia metadata", e);
}
FeedItem feedItem = media.getItem();
if (feedItem != null) {
metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle());
String subtitle = media.getFeedTitle();
if (subtitle != null) {
metadata.putString(MediaMetadata.KEY_SUBTITLE, subtitle);
}
FeedImage image = feedItem.getImage();
if (image != null && !TextUtils.isEmpty(image.getDownload_url())) {
metadata.addImage(new WebImage(Uri.parse(image.getDownload_url())));
}
Calendar calendar = Calendar.getInstance();
calendar.setTime(media.getItem().getPubDate());
metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar);
Feed feed = feedItem.getFeed();
if (feed != null) {
if (!TextUtils.isEmpty(feed.getAuthor())) {
metadata.putString(MediaMetadata.KEY_ARTIST, feed.getAuthor());
}
if (!TextUtils.isEmpty(feed.getDownload_url())) {
metadata.putString(KEY_FEED_URL, feed.getDownload_url());
}
if (!TextUtils.isEmpty(feed.getLink())) {
metadata.putString(KEY_FEED_WEBSITE, feed.getLink());
}
}
if (!TextUtils.isEmpty(feedItem.getItemIdentifier())) {
metadata.putString(KEY_EPISODE_IDENTIFIER, feedItem.getItemIdentifier());
} else {
metadata.putString(KEY_EPISODE_IDENTIFIER, media.getStreamUrl());
}
if (!TextUtils.isEmpty(feedItem.getLink())) {
metadata.putString(KEY_EPISODE_LINK, feedItem.getLink());
}
}
String notes = null;
try {
notes = media.loadShownotes().call();
} catch (Exception e) {
Log.e(TAG, "Unable to load FeedMedia notes", e);
}
if (notes != null) {
if (notes.length() > EPISODE_NOTES_MAX_LENGTH) {
notes = notes.substring(0, EPISODE_NOTES_MAX_LENGTH);
}
metadata.putString(KEY_EPISODE_NOTES, notes);
}
// This field only identifies the id on the device that has the original version.
// Idea is to perhaps, on a first approach, check if the version on the local DB with the
// same id matches the remote object, and if not then search for episode and feed identifiers.
// This at least should make media recognition for a single device much quicker.
metadata.putInt(KEY_MEDIA_ID, ((Long) media.getIdentifier()).intValue());
// A way to identify different casting media formats in case we change it in the future and
// senders with different versions share a casting device.
metadata.putInt(KEY_FORMAT_VERSION, FORMAT_VERSION_VALUE);
MediaInfo.Builder builder = new MediaInfo.Builder(media.getStreamUrl())
.setContentType(media.getMime_type())
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(metadata);
if (media.getDuration() > 0) {
builder.setStreamDuration(media.getDuration());
}
return builder.build();
}
//TODO make unit tests for all the conversion methods
/**
* Converts {@link MediaInfo} objects into the appropriate implementation of {@link Playable}.
*
* Unless <code>searchFeedMedia</code> is set to <code>false</code>, this method should not run
* on the GUI thread.
*
* @param media The {@link MediaInfo} object to be converted.
* @param searchFeedMedia If set to <code>true</code>, the database will be queried to find a
* {@link FeedMedia} instance that matches {@param media}.
* @return {@link Playable} object in a format proper for casting.
*/
public static Playable getPlayable(MediaInfo media, boolean searchFeedMedia) {
Log.d(TAG, "getPlayable called with searchFeedMedia=" + searchFeedMedia);
if (media == null) {
Log.d(TAG, "MediaInfo object provided is null, not converting to any Playable instance");
return null;
}
MediaMetadata metadata = media.getMetadata();
int version = metadata.getInt(KEY_FORMAT_VERSION);
if (version <= 0 || version > MAX_VERSION_FORWARD_COMPATIBILITY) {
Log.w(TAG, "MediaInfo object obtained from the cast device is not compatible with this" +
"version of AntennaPod CastUtils, curVer=" + FORMAT_VERSION_VALUE +
", object version=" + version);
return null;
}
Playable result = null;
if (searchFeedMedia) {
long mediaId = metadata.getInt(KEY_MEDIA_ID);
if (mediaId > 0) {
FeedMedia fMedia = DBReader.getFeedMedia(mediaId);
if (fMedia != null) {
try {
fMedia.loadMetadata();
if (matches(media, fMedia)) {
result = fMedia;
Log.d(TAG, "FeedMedia object obtained matches the MediaInfo provided. id=" + mediaId);
} else {
Log.d(TAG, "FeedMedia object obtained does NOT match the MediaInfo provided. id=" + mediaId);
}
} catch (Playable.PlayableException e) {
Log.e(TAG, "Unable to load FeedMedia metadata to compare with MediaInfo", e);
}
} else {
Log.d(TAG, "Unable to find in database a FeedMedia with id=" + mediaId);
}
}
if (result == null) {
FeedItem feedItem = DBReader.getFeedItem(metadata.getString(KEY_FEED_URL),
metadata.getString(KEY_EPISODE_IDENTIFIER));
if (feedItem != null) {
result = feedItem.getMedia();
Log.d(TAG, "Found episode that matches the MediaInfo provided. Using its media, if existing.");
}
}
}
if (result == null) {
List<WebImage> imageList = metadata.getImages();
String imageUrl = null;
if (!imageList.isEmpty()) {
imageUrl = imageList.get(0).getUrl().toString();
}
result = new RemoteMedia(media.getContentId(),
metadata.getString(KEY_EPISODE_IDENTIFIER),
metadata.getString(KEY_FEED_URL),
metadata.getString(MediaMetadata.KEY_SUBTITLE),
metadata.getString(MediaMetadata.KEY_TITLE),
metadata.getString(KEY_EPISODE_LINK),
metadata.getString(MediaMetadata.KEY_ARTIST),
imageUrl,
metadata.getString(KEY_FEED_WEBSITE),
media.getContentType(),
metadata.getDate(MediaMetadata.KEY_RELEASE_DATE).getTime());
String notes = metadata.getString(KEY_EPISODE_NOTES);
if (!TextUtils.isEmpty(notes)) {
((RemoteMedia) result).setNotes(notes);
}
Log.d(TAG, "Converted MediaInfo into RemoteMedia");
}
if (result.getDuration() == 0 && media.getStreamDuration() > 0) {
result.setDuration((int) media.getStreamDuration());
}
return result;
}
/**
* Compares a {@link MediaInfo} instance with a {@link FeedMedia} one and evaluates whether they
* represent the same podcast episode.
*
* @param info the {@link MediaInfo} object to be compared.
* @param media the {@link FeedMedia} object to be compared.
* @return <true>true</true> if there's a match, <code>false</code> otherwise.
*
* @see RemoteMedia#equals(Object)
*/
public static boolean matches(MediaInfo info, FeedMedia media) {
if (info == null || media == null) {
return false;
}
if (!TextUtils.equals(info.getContentId(), media.getStreamUrl())) {
return false;
}
MediaMetadata metadata = info.getMetadata();
FeedItem fi = media.getItem();
if (fi == null || metadata == null ||
!TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), fi.getItemIdentifier())) {
return false;
}
Feed feed = fi.getFeed();
return feed != null && TextUtils.equals(metadata.getString(KEY_FEED_URL), feed.getDownload_url());
}
/**
* Compares a {@link MediaInfo} instance with a {@link RemoteMedia} one and evaluates whether they
* represent the same podcast episode.
*
* @param info the {@link MediaInfo} object to be compared.
* @param media the {@link RemoteMedia} object to be compared.
* @return <true>true</true> if there's a match, <code>false</code> otherwise.
*
* @see RemoteMedia#equals(Object)
*/
public static boolean matches(MediaInfo info, RemoteMedia media) {
if (info == null || media == null) {
return false;
}
if (!TextUtils.equals(info.getContentId(), media.getStreamUrl())) {
return false;
}
MediaMetadata metadata = info.getMetadata();
return metadata != null &&
TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), media.getEpisodeIdentifier()) &&
TextUtils.equals(metadata.getString(KEY_FEED_URL), media.getFeedUrl());
}
/**
* Compares a {@link MediaInfo} instance with a {@link Playable} and evaluates whether they
* represent the same podcast episode. Useful every time we get a MediaInfo from the Cast Device
* and want to avoid unnecessary conversions.
*
* @param info the {@link MediaInfo} object to be compared.
* @param media the {@link Playable} object to be compared.
* @return <true>true</true> if there's a match, <code>false</code> otherwise.
*
* @see RemoteMedia#equals(Object)
*/
public static boolean matches(MediaInfo info, Playable media) {
if (info == null || media == null) {
return false;
}
if (media instanceof RemoteMedia) {
return matches(info, (RemoteMedia) media);
}
return media instanceof FeedMedia && matches(info, (FeedMedia) media);
}
//TODO Queue handling perhaps
}