diff options
Diffstat (limited to 'core')
-rw-r--r-- | core/build.gradle | 12 | ||||
-rw-r--r-- | core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java | 48 | ||||
-rw-r--r-- | core/src/free/java/de/danoeh/antennapod/core/feed/FeedMedia.java | 567 | ||||
-rw-r--r-- | core/src/free/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java | 1773 | ||||
-rw-r--r-- | core/src/free/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java | 592 | ||||
-rw-r--r-- | core/src/free/java/de/danoeh/antennapod/core/util/playback/Playable.java | 245 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java (renamed from core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java) | 0 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java (renamed from core/src/main/java/de/danoeh/antennapod/core/cast/CastConsumer.java) | 0 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java (renamed from core/src/main/java/de/danoeh/antennapod/core/cast/CastManager.java) | 0 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java (renamed from core/src/main/java/de/danoeh/antennapod/core/cast/CastUtils.java) | 0 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java (renamed from core/src/main/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java) | 0 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/cast/RemoteMedia.java (renamed from core/src/main/java/de/danoeh/antennapod/core/cast/RemoteMedia.java) | 0 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java (renamed from core/src/main/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java) | 0 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/feed/FeedMedia.java (renamed from core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java) | 0 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java (renamed from core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java) | 0 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java (renamed from core/src/main/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java) | 0 | ||||
-rw-r--r-- | core/src/play/java/de/danoeh/antennapod/core/util/playback/Playable.java (renamed from core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java) | 0 |
17 files changed, 3235 insertions, 2 deletions
diff --git a/core/build.gradle b/core/build.gradle index 31042456b..fa95800c2 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -30,6 +30,14 @@ android { targetCompatibility JavaVersion.VERSION_1_8 } + publishNonDefault true + productFlavors { + free { + } + play { + } + } + } repositories { @@ -59,9 +67,9 @@ dependencies { compile "com.github.AntennaPod:AntennaPod-AudioPlayer:$audioPlayerVersion" // Add casting features - compile "com.google.android.libraries.cast.companionlibrary:ccl:$castCompanionLibVer" + playCompile "com.google.android.libraries.cast.companionlibrary:ccl:$castCompanionLibVer" compile "com.android.support:mediarouter-v7:$supportVersion" - compile "com.google.android.gms:play-services-cast:$playServicesVersion" + playCompile "com.google.android.gms:play-services-cast:$playServicesVersion" } allprojects { diff --git a/core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java b/core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java new file mode 100644 index 000000000..d1c93d782 --- /dev/null +++ b/core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java @@ -0,0 +1,48 @@ +package de.danoeh.antennapod.core; + +import android.content.Context; + +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.storage.PodDBAdapter; +import de.danoeh.antennapod.core.util.NetworkUtils; + +/** + * Stores callbacks for core classes like Services, DB classes etc. and other configuration variables. + * Apps using the core module of AntennaPod should register implementations of all interfaces here. + */ +public class ClientConfig { + + /** + * Should be used when setting User-Agent header for HTTP-requests. + */ + public static String USER_AGENT; + + public static ApplicationCallbacks applicationCallbacks; + + public static DownloadServiceCallbacks downloadServiceCallbacks; + + public static PlaybackServiceCallbacks playbackServiceCallbacks; + + public static GpodnetCallbacks gpodnetCallbacks; + + public static FlattrCallbacks flattrCallbacks; + + public static DBTasksCallbacks dbTasksCallbacks; + + private static boolean initialized = false; + + public static synchronized void initialize(Context context) { + if(initialized) { + return; + } + PodDBAdapter.init(context); + UserPreferences.init(context); + UpdateManager.init(context); + PlaybackPreferences.init(context); + NetworkUtils.init(context); +// CastManager.init(context); + initialized = true; + } + +} diff --git a/core/src/free/java/de/danoeh/antennapod/core/feed/FeedMedia.java b/core/src/free/java/de/danoeh/antennapod/core/feed/FeedMedia.java new file mode 100644 index 000000000..cde66835a --- /dev/null +++ b/core/src/free/java/de/danoeh/antennapod/core/feed/FeedMedia.java @@ -0,0 +1,567 @@ +package de.danoeh.antennapod.core.feed; + +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.database.Cursor; +import android.media.MediaMetadataRetriever; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.Nullable; + +import java.util.Date; +import java.util.List; +import java.util.concurrent.Callable; + +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.core.storage.PodDBAdapter; +import de.danoeh.antennapod.core.util.ChapterUtils; +import de.danoeh.antennapod.core.util.playback.Playable; + +public class FeedMedia extends FeedFile implements Playable { + private static final String TAG = "FeedMedia"; + + public static final int FEEDFILETYPE_FEEDMEDIA = 2; + public static final int PLAYABLE_TYPE_FEEDMEDIA = 1; + + public static final String PREF_MEDIA_ID = "FeedMedia.PrefMediaId"; + public static final String PREF_FEED_ID = "FeedMedia.PrefFeedId"; + + /** + * Indicates we've checked on the size of the item via the network + * and got an invalid response. Using Integer.MIN_VALUE because + * 1) we'll still check on it in case it gets downloaded (it's <= 0) + * 2) By default all FeedMedia have a size of 0 if we don't know it, + * so this won't conflict with existing practice. + */ + private static final int CHECKED_ON_SIZE_BUT_UNKNOWN = Integer.MIN_VALUE; + + private int duration; + private int position; // Current position in file + private long lastPlayedTime; // Last time this media was played (in ms) + private int played_duration; // How many ms of this file have been played (for autoflattring) + private long size; // File size in Byte + private String mime_type; + @Nullable private volatile FeedItem item; + private Date playbackCompletionDate; + + // if null: unknown, will be checked + private Boolean hasEmbeddedPicture; + + /* Used for loading item when restoring from parcel. */ + private long itemID; + + public FeedMedia(FeedItem i, String download_url, long size, + String mime_type) { + super(null, download_url, false); + this.item = i; + this.size = size; + this.mime_type = mime_type; + } + + public FeedMedia(long id, FeedItem item, int duration, int position, + long size, String mime_type, String file_url, String download_url, + boolean downloaded, Date playbackCompletionDate, int played_duration, + long lastPlayedTime) { + super(file_url, download_url, downloaded); + this.id = id; + this.item = item; + this.duration = duration; + this.position = position; + this.played_duration = played_duration; + this.size = size; + this.mime_type = mime_type; + this.playbackCompletionDate = playbackCompletionDate == null + ? null : (Date) playbackCompletionDate.clone(); + this.lastPlayedTime = lastPlayedTime; + } + + public FeedMedia(long id, FeedItem item, int duration, int position, + long size, String mime_type, String file_url, String download_url, + boolean downloaded, Date playbackCompletionDate, int played_duration, + Boolean hasEmbeddedPicture, long lastPlayedTime) { + this(id, item, duration, position, size, mime_type, file_url, download_url, downloaded, + playbackCompletionDate, played_duration, lastPlayedTime); + this.hasEmbeddedPicture = hasEmbeddedPicture; + } + + public static FeedMedia fromCursor(Cursor cursor) { + int indexId = cursor.getColumnIndex(PodDBAdapter.KEY_ID); + int indexPlaybackCompletionDate = cursor.getColumnIndex(PodDBAdapter.KEY_PLAYBACK_COMPLETION_DATE); + int indexDuration = cursor.getColumnIndex(PodDBAdapter.KEY_DURATION); + int indexPosition = cursor.getColumnIndex(PodDBAdapter.KEY_POSITION); + int indexSize = cursor.getColumnIndex(PodDBAdapter.KEY_SIZE); + int indexMimeType = cursor.getColumnIndex(PodDBAdapter.KEY_MIME_TYPE); + int indexFileUrl = cursor.getColumnIndex(PodDBAdapter.KEY_FILE_URL); + int indexDownloadUrl = cursor.getColumnIndex(PodDBAdapter.KEY_DOWNLOAD_URL); + int indexDownloaded = cursor.getColumnIndex(PodDBAdapter.KEY_DOWNLOADED); + int indexPlayedDuration = cursor.getColumnIndex(PodDBAdapter.KEY_PLAYED_DURATION); + int indexLastPlayedTime = cursor.getColumnIndex(PodDBAdapter.KEY_LAST_PLAYED_TIME); + + long mediaId = cursor.getLong(indexId); + Date playbackCompletionDate = null; + long playbackCompletionTime = cursor.getLong(indexPlaybackCompletionDate); + if (playbackCompletionTime > 0) { + playbackCompletionDate = new Date(playbackCompletionTime); + } + + Boolean hasEmbeddedPicture; + switch(cursor.getInt(cursor.getColumnIndex(PodDBAdapter.KEY_HAS_EMBEDDED_PICTURE))) { + case 1: + hasEmbeddedPicture = Boolean.TRUE; + break; + case 0: + hasEmbeddedPicture = Boolean.FALSE; + break; + default: + hasEmbeddedPicture = null; + break; + } + + return new FeedMedia( + mediaId, + null, + cursor.getInt(indexDuration), + cursor.getInt(indexPosition), + cursor.getLong(indexSize), + cursor.getString(indexMimeType), + cursor.getString(indexFileUrl), + cursor.getString(indexDownloadUrl), + cursor.getInt(indexDownloaded) > 0, + playbackCompletionDate, + cursor.getInt(indexPlayedDuration), + hasEmbeddedPicture, + cursor.getLong(indexLastPlayedTime) + ); + } + + + @Override + public String getHumanReadableIdentifier() { + if (item != null && item.getTitle() != null) { + return item.getTitle(); + } else { + return download_url; + } + } + + /** + * Uses mimetype to determine the type of media. + */ + public MediaType getMediaType() { + return MediaType.fromMimeType(mime_type); + } + + public void updateFromOther(FeedMedia other) { + super.updateFromOther(other); + if (other.size > 0) { + size = other.size; + } + if (other.mime_type != null) { + mime_type = other.mime_type; + } + } + + public boolean compareWithOther(FeedMedia other) { + if (super.compareWithOther(other)) { + return true; + } + if (other.mime_type != null) { + if (mime_type == null || !mime_type.equals(other.mime_type)) { + return true; + } + } + if (other.size > 0 && other.size != size) { + return true; + } + return false; + } + + /** + * Reads playback preferences to determine whether this FeedMedia object is + * currently being played. + */ + public boolean isPlaying() { + return PlaybackPreferences.getCurrentlyPlayingMedia() == FeedMedia.PLAYABLE_TYPE_FEEDMEDIA + && PlaybackPreferences.getCurrentlyPlayingFeedMediaId() == id; + } + + /** + * Reads playback preferences to determine whether this FeedMedia object is + * currently being played and the current player status is playing. + */ + public boolean isCurrentlyPlaying() { + return isPlaying() && + ((PlaybackPreferences.getCurrentPlayerStatus() == PlaybackPreferences.PLAYER_STATUS_PLAYING)); + } + + /** + * Reads playback preferences to determine whether this FeedMedia object is + * currently being played and the current player status is paused. + */ + public boolean isCurrentlyPaused() { + return isPlaying() && + ((PlaybackPreferences.getCurrentPlayerStatus() == PlaybackPreferences.PLAYER_STATUS_PAUSED)); + } + + + public boolean hasAlmostEnded() { + int smartMarkAsPlayedSecs = UserPreferences.getSmartMarkAsPlayedSecs(); + return this.position >= this.duration - smartMarkAsPlayedSecs * 1000; + } + + @Override + public int getTypeAsInt() { + return FEEDFILETYPE_FEEDMEDIA; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + @Override + public void setLastPlayedTime(long lastPlayedTime) { + this.lastPlayedTime = lastPlayedTime; + } + + public int getPlayedDuration() { + return played_duration; + } + + public void setPlayedDuration(int played_duration) { + this.played_duration = played_duration; + } + + public int getPosition() { + return position; + } + + @Override + public long getLastPlayedTime() { + return lastPlayedTime; + } + + public void setPosition(int position) { + this.position = position; + if(position > 0 && item != null && item.isNew()) { + this.item.setPlayed(false); + } + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + this.size = size; + } + + /** + * Indicates we asked the service what the size was, but didn't + * get a valid answer and we shoudln't check using the network again. + */ + public void setCheckedOnSizeButUnknown() { + this.size = CHECKED_ON_SIZE_BUT_UNKNOWN; + } + + public boolean checkedOnSizeButUnknown() { + return (CHECKED_ON_SIZE_BUT_UNKNOWN == this.size); + } + + public String getMime_type() { + return mime_type; + } + + public void setMime_type(String mime_type) { + this.mime_type = mime_type; + } + + @Nullable + public FeedItem getItem() { + return item; + } + + /** + * Sets the item object of this FeedMedia. If the given + * FeedItem object is not null, it's 'media'-attribute value + * will also be set to this media object. + */ + public void setItem(FeedItem item) { + this.item = item; + if (item != null && item.getMedia() != this) { + item.setMedia(this); + } + } + + public Date getPlaybackCompletionDate() { + return playbackCompletionDate == null + ? null : (Date) playbackCompletionDate.clone(); + } + + public void setPlaybackCompletionDate(Date playbackCompletionDate) { + this.playbackCompletionDate = playbackCompletionDate == null + ? null : (Date) playbackCompletionDate.clone(); + } + + public boolean isInProgress() { + return (this.position > 0); + } + + @Override + public int describeContents() { + return 0; + } + + public boolean hasEmbeddedPicture() { + if(hasEmbeddedPicture == null) { + checkEmbeddedPicture(); + } + return hasEmbeddedPicture; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeLong(item != null ? item.getId() : 0L); + + dest.writeInt(duration); + dest.writeInt(position); + dest.writeLong(size); + dest.writeString(mime_type); + dest.writeString(file_url); + dest.writeString(download_url); + dest.writeByte((byte) ((downloaded) ? 1 : 0)); + dest.writeLong((playbackCompletionDate != null) ? playbackCompletionDate.getTime() : 0); + dest.writeInt(played_duration); + dest.writeLong(lastPlayedTime); + } + + @Override + public void writeToPreferences(Editor prefEditor) { + if(item != null && item.getFeed() != null) { + prefEditor.putLong(PREF_FEED_ID, item.getFeed().getId()); + } else { + prefEditor.putLong(PREF_FEED_ID, 0L); + } + prefEditor.putLong(PREF_MEDIA_ID, id); + } + + @Override + public void loadMetadata() throws PlayableException { + if (item == null && itemID != 0) { + item = DBReader.getFeedItem(itemID); + } + } + + @Override + public void loadChapterMarks() { + if (item == null && itemID != 0) { + item = DBReader.getFeedItem(itemID); + } + // check if chapters are stored in db and not loaded yet. + if (item != null && item.hasChapters() && item.getChapters() == null) { + DBReader.loadChaptersOfFeedItem(item); + } else if (item != null && item.getChapters() == null) { + if(localFileAvailable()) { + ChapterUtils.loadChaptersFromFileUrl(this); + } else { + ChapterUtils.loadChaptersFromStreamUrl(this); + } + if (getChapters() != null && item != null) { + DBWriter.setFeedItem(item); + } + } + } + + @Override + public String getEpisodeTitle() { + if (item == null) { + return null; + } + if (item.getTitle() != null) { + return item.getTitle(); + } else { + return item.getIdentifyingValue(); + } + } + + @Override + public List<Chapter> getChapters() { + if (item == null) { + return null; + } + return item.getChapters(); + } + + @Override + public String getWebsiteLink() { + if (item == null) { + return null; + } + return item.getLink(); + } + + @Override + public String getFeedTitle() { + if (item == null || item.getFeed() == null) { + return null; + } + return item.getFeed().getTitle(); + } + + @Override + public Object getIdentifier() { + return id; + } + + @Override + public String getLocalMediaUrl() { + return file_url; + } + + @Override + public String getStreamUrl() { + return download_url; + } + + @Override + public String getPaymentLink() { + if (item == null) { + return null; + } + return item.getPaymentLink(); + } + + @Override + public boolean localFileAvailable() { + return isDownloaded() && file_url != null; + } + + @Override + public boolean streamAvailable() { + return download_url != null; + } + + @Override + public void saveCurrentPosition(SharedPreferences pref, int newPosition, long timeStamp) { + if(item != null && item.isNew()) { + DBWriter.markItemPlayed(FeedItem.UNPLAYED, item.getId()); + } + setPosition(newPosition); + setLastPlayedTime(timeStamp); + DBWriter.setFeedMediaPlaybackInformation(this); + } + + @Override + public void onPlaybackStart() { + } + @Override + public void onPlaybackCompleted() { + + } + + @Override + public int getPlayableType() { + return PLAYABLE_TYPE_FEEDMEDIA; + } + + @Override + public void setChapters(List<Chapter> chapters) { + if(item != null) { + item.setChapters(chapters); + } + } + + @Override + public Callable<String> loadShownotes() { + return () -> { + if (item == null) { + item = DBReader.getFeedItem( + itemID); + } + if (item.getContentEncoded() == null || item.getDescription() == null) { + DBReader.loadExtraInformationOfFeedItem( + item); + + } + return (item.getContentEncoded() != null) ? item.getContentEncoded() : item.getDescription(); + }; + } + + public static final Parcelable.Creator<FeedMedia> CREATOR = new Parcelable.Creator<FeedMedia>() { + public FeedMedia createFromParcel(Parcel in) { + final long id = in.readLong(); + final long itemID = in.readLong(); + FeedMedia result = new FeedMedia(id, null, in.readInt(), in.readInt(), in.readLong(), in.readString(), in.readString(), + in.readString(), in.readByte() != 0, new Date(in.readLong()), in.readInt(), in.readLong()); + result.itemID = itemID; + return result; + } + + public FeedMedia[] newArray(int size) { + return new FeedMedia[size]; + } + }; + + @Override + public String getImageLocation() { + if (hasEmbeddedPicture()) { + return getLocalMediaUrl(); + } else if(item != null) { + return item.getImageLocation(); + } else { + return null; + } + } + + public void setHasEmbeddedPicture(Boolean hasEmbeddedPicture) { + this.hasEmbeddedPicture = hasEmbeddedPicture; + } + + @Override + public void setDownloaded(boolean downloaded) { + super.setDownloaded(downloaded); + if(item != null && downloaded) { + item.setPlayed(false); + } + } + + @Override + public void setFile_url(String file_url) { + super.setFile_url(file_url); + } + + public void checkEmbeddedPicture() { + if (!localFileAvailable()) { + hasEmbeddedPicture = Boolean.FALSE; + return; + } + MediaMetadataRetriever mmr = new MediaMetadataRetriever(); + try { + mmr.setDataSource(getLocalMediaUrl()); + byte[] image = mmr.getEmbeddedPicture(); + if(image != null) { + hasEmbeddedPicture = Boolean.TRUE; + } else { + hasEmbeddedPicture = Boolean.FALSE; + } + } catch (Exception e) { + e.printStackTrace(); + hasEmbeddedPicture = Boolean.FALSE; + } + } + +// @Override +// public boolean equals(Object o) { +// if (o instanceof RemoteMedia) { +// return o.equals(this); +// } +// return super.equals(o); +// } +} diff --git a/core/src/free/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/free/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java new file mode 100644 index 000000000..01b803d80 --- /dev/null +++ b/core/src/free/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java @@ -0,0 +1,1773 @@ +package de.danoeh.antennapod.core.service.playback; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.bluetooth.BluetoothA2dp; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.net.NetworkInfo; +import android.net.wifi.WifiManager; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.Vibrator; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.StringRes; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; +import android.support.v7.app.NotificationCompat; +import android.text.TextUtils; +import android.util.Log; +import android.util.Pair; +import android.view.Display; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.SurfaceHolder; +import android.view.WindowManager; +import android.widget.Toast; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.target.Target; + +import java.util.List; + +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.glide.ApGlideSettings; +import de.danoeh.antennapod.core.gpoddernet.model.GpodnetEpisodeAction; +import de.danoeh.antennapod.core.gpoddernet.model.GpodnetEpisodeAction.Action; +import de.danoeh.antennapod.core.preferences.GpodnetPreferences; +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; +import de.danoeh.antennapod.core.storage.DBTasks; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.core.util.IntList; +import de.danoeh.antennapod.core.util.QueueAccess; +import de.danoeh.antennapod.core.util.flattr.FlattrUtils; +import de.danoeh.antennapod.core.util.playback.Playable; + +/** + * Controls the MediaPlayer that plays a FeedMedia-file + */ +public class PlaybackService extends Service { + public static final String FORCE_WIDGET_UPDATE = "de.danoeh.antennapod.FORCE_WIDGET_UPDATE"; + public static final String STOP_WIDGET_UPDATE = "de.danoeh.antennapod.STOP_WIDGET_UPDATE"; + /** + * Logging tag + */ + private static final String TAG = "PlaybackService"; + + /** + * Parcelable of type Playable. + */ + public static final String EXTRA_PLAYABLE = "PlaybackService.PlayableExtra"; + /** + * True if cast session should disconnect. + */ + public static final String EXTRA_CAST_DISCONNECT = "extra.de.danoeh.antennapod.core.service.castDisconnect"; + /** + * True if media should be streamed. + */ + public static final String EXTRA_SHOULD_STREAM = "extra.de.danoeh.antennapod.core.service.shouldStream"; + /** + * True if playback should be started immediately after media has been + * prepared. + */ + public static final String EXTRA_START_WHEN_PREPARED = "extra.de.danoeh.antennapod.core.service.startWhenPrepared"; + + public static final String EXTRA_PREPARE_IMMEDIATELY = "extra.de.danoeh.antennapod.core.service.prepareImmediately"; + + public static final String ACTION_PLAYER_STATUS_CHANGED = "action.de.danoeh.antennapod.core.service.playerStatusChanged"; + public static final String EXTRA_NEW_PLAYER_STATUS = "extra.de.danoeh.antennapod.service.playerStatusChanged.newStatus"; + private static final String AVRCP_ACTION_PLAYER_STATUS_CHANGED = "com.android.music.playstatechanged"; + private static final String AVRCP_ACTION_META_CHANGED = "com.android.music.metachanged"; + + public static final String ACTION_PLAYER_NOTIFICATION = "action.de.danoeh.antennapod.core.service.playerNotification"; + public static final String EXTRA_NOTIFICATION_CODE = "extra.de.danoeh.antennapod.core.service.notificationCode"; + public static final String EXTRA_NOTIFICATION_TYPE = "extra.de.danoeh.antennapod.core.service.notificationType"; + + /** + * If the PlaybackService receives this action, it will stop playback and + * try to shutdown. + */ + public static final String ACTION_SHUTDOWN_PLAYBACK_SERVICE = "action.de.danoeh.antennapod.core.service.actionShutdownPlaybackService"; + + /** + * If the PlaybackService receives this action, it will end playback of the + * current episode and load the next episode if there is one available. + */ + public static final String ACTION_SKIP_CURRENT_EPISODE = "action.de.danoeh.antennapod.core.service.skipCurrentEpisode"; + + /** + * If the PlaybackService receives this action, it will pause playback. + */ + public static final String ACTION_PAUSE_PLAY_CURRENT_EPISODE = "action.de.danoeh.antennapod.core.service.pausePlayCurrentEpisode"; + + + /** + * If the PlaybackService receives this action, it will resume playback. + */ + public static final String ACTION_RESUME_PLAY_CURRENT_EPISODE = "action.de.danoeh.antennapod.core.service.resumePlayCurrentEpisode"; + + + /** + * Used in NOTIFICATION_TYPE_RELOAD. + */ + public static final int EXTRA_CODE_AUDIO = 1; + public static final int EXTRA_CODE_VIDEO = 2; + public static final int EXTRA_CODE_CAST = 3; + + public static final int NOTIFICATION_TYPE_ERROR = 0; + public static final int NOTIFICATION_TYPE_INFO = 1; + public static final int NOTIFICATION_TYPE_BUFFER_UPDATE = 2; + + /** + * Receivers of this intent should update their information about the curently playing media + */ + public static final int NOTIFICATION_TYPE_RELOAD = 3; + /** + * The state of the sleeptimer changed. + */ + public static final int NOTIFICATION_TYPE_SLEEPTIMER_UPDATE = 4; + public static final int NOTIFICATION_TYPE_BUFFER_START = 5; + public static final int NOTIFICATION_TYPE_BUFFER_END = 6; + /** + * No more episodes are going to be played. + */ + public static final int NOTIFICATION_TYPE_PLAYBACK_END = 7; + + /** + * Playback speed has changed + */ + public static final int NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE = 8; + + /** + * Ability to set the playback speed has changed + */ + public static final int NOTIFICATION_TYPE_SET_SPEED_ABILITY_CHANGED = 9; + + /** + * Send a message to the user (with provided String resource id) + */ + public static final int NOTIFICATION_TYPE_SHOW_TOAST = 10; + + /** + * Returned by getPositionSafe() or getDurationSafe() if the playbackService + * is in an invalid state. + */ + public static final int INVALID_TIME = -1; + + /** + * Time in seconds during which the CastManager will try to reconnect to the Cast Device after + * the Wifi Connection is regained. + */ + private static final int RECONNECTION_ATTEMPT_PERIOD_S = 15; + + /** + * Is true if service is running. + */ + public static boolean isRunning = false; + /** + * Is true if service has received a valid start command. + */ + public static boolean started = false; + /** + * Is true if the service was running, but paused due to headphone disconnect + */ + public static boolean transientPause = false; + /** + * Is true if a Cast Device is connected to the service. + */ + private static volatile boolean isCasting = false; + /** + * Stores the state of the cast playback just before it disconnects. + */ + private volatile PlaybackServiceMediaPlayer.PSMPInfo infoBeforeCastDisconnection; + + private boolean wifiConnectivity = true; + private BroadcastReceiver wifiBroadcastReceiver; + + private static final int NOTIFICATION_ID = 1; + + private PlaybackServiceMediaPlayer mediaPlayer; + private PlaybackServiceTaskManager taskManager; + +// private CastManager castManager; +// private MediaRouter mediaRouter; + /** + * Only used for Lollipop notifications. + */ + private MediaSessionCompat mediaSession; + + private int startPosition; + + private static volatile MediaType currentMediaType = MediaType.UNKNOWN; + + private final IBinder mBinder = new LocalBinder(); + + public class LocalBinder extends Binder { + public PlaybackService getService() { + return PlaybackService.this; + } + } + + @Override + public boolean onUnbind(Intent intent) { + Log.d(TAG, "Received onUnbind event"); + return super.onUnbind(intent); + } + + /** + * Returns an intent which starts an audio- or videoplayer, depending on the + * type of media that is being played. If the playbackservice is not + * running, the type of the last played media will be looked up. + */ + public static Intent getPlayerActivityIntent(Context context) { + if (isRunning) { + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, currentMediaType, isCasting); + } else { + if (PlaybackPreferences.getCurrentEpisodeIsVideo()) { + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.VIDEO, isCasting); + } else { + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.AUDIO, isCasting); + } + } + } + + /** + * Same as getPlayerActivityIntent(context), but here the type of activity + * depends on the FeedMedia that is provided as an argument. + */ + public static Intent getPlayerActivityIntent(Context context, Playable media) { + MediaType mt = media.getMediaType(); + return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, mt, isCasting); + } + + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "Service created."); + isRunning = true; + + registerReceiver(headsetDisconnected, new IntentFilter( + Intent.ACTION_HEADSET_PLUG)); + registerReceiver(shutdownReceiver, new IntentFilter( + ACTION_SHUTDOWN_PLAYBACK_SERVICE)); + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + registerReceiver(bluetoothStateUpdated, new IntentFilter( + BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)); + } + registerReceiver(audioBecomingNoisy, new IntentFilter( + AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + registerReceiver(skipCurrentEpisodeReceiver, new IntentFilter( + ACTION_SKIP_CURRENT_EPISODE)); + registerReceiver(pausePlayCurrentEpisodeReceiver, new IntentFilter( + ACTION_PAUSE_PLAY_CURRENT_EPISODE)); + registerReceiver(pauseResumeCurrentEpisodeReceiver, new IntentFilter( + ACTION_RESUME_PLAY_CURRENT_EPISODE)); + taskManager = new PlaybackServiceTaskManager(this, taskManagerCallback); + +// mediaRouter = MediaRouter.getInstance(getApplicationContext()); + PreferenceManager.getDefaultSharedPreferences(this) + .registerOnSharedPreferenceChangeListener(prefListener); + + ComponentName eventReceiver = new ComponentName(getApplicationContext(), + MediaButtonReceiver.class); + Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); + mediaButtonIntent.setComponent(eventReceiver); + PendingIntent buttonReceiverIntent = PendingIntent.getBroadcast(this, 0, mediaButtonIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + mediaSession = new MediaSessionCompat(getApplicationContext(), TAG, eventReceiver, buttonReceiverIntent); + + try { + mediaSession.setCallback(sessionCallback); + mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); + } catch (NullPointerException npe) { + // on some devices (Huawei) setting active can cause a NullPointerException + // even with correct use of the api. + // See http://stackoverflow.com/questions/31556679/android-huawei-mediassessioncompat + // and https://plus.google.com/+IanLake/posts/YgdTkKFxz7d + Log.e(TAG, "NullPointerException while setting up MediaSession"); + npe.printStackTrace(); + } + +// castManager = CastManager.getInstance(); +// castManager.addCastConsumer(castConsumer); +// isCasting = castManager.isConnected(); +// if (isCasting) { +// if (UserPreferences.isCastEnabled()) { +// onCastAppConnected(false); +// } else { +// castManager.disconnect(); +// } +// } else { + mediaPlayer = new LocalPSMP(this, mediaPlayerCallback); +// } + + mediaSession.setActive(true); + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "Service is about to be destroyed"); + isRunning = false; + started = false; + currentMediaType = MediaType.UNKNOWN; + + PreferenceManager.getDefaultSharedPreferences(this) + .unregisterOnSharedPreferenceChangeListener(prefListener); + if (mediaSession != null) { + mediaSession.release(); + } + unregisterReceiver(headsetDisconnected); + unregisterReceiver(shutdownReceiver); + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + unregisterReceiver(bluetoothStateUpdated); + } + unregisterReceiver(audioBecomingNoisy); + unregisterReceiver(skipCurrentEpisodeReceiver); + unregisterReceiver(pausePlayCurrentEpisodeReceiver); + unregisterReceiver(pauseResumeCurrentEpisodeReceiver); +// castManager.removeCastConsumer(castConsumer); + unregisterWifiBroadcastReceiver(); + mediaPlayer.shutdown(); + taskManager.shutdown(); + } + + @Override + public IBinder onBind(Intent intent) { + Log.d(TAG, "Received onBind event"); + return mBinder; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + super.onStartCommand(intent, flags, startId); + + Log.d(TAG, "OnStartCommand called"); + final int keycode = intent.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1); + final boolean castDisconnect = intent.getBooleanExtra(EXTRA_CAST_DISCONNECT, false); + final Playable playable = intent.getParcelableExtra(EXTRA_PLAYABLE); + if (keycode == -1 && playable == null && !castDisconnect) { + Log.e(TAG, "PlaybackService was started with no arguments"); + stopSelf(); + return Service.START_REDELIVER_INTENT; + } + + if ((flags & Service.START_FLAG_REDELIVERY) != 0) { + Log.d(TAG, "onStartCommand is a redelivered intent, calling stopForeground now."); + stopForeground(true); + } else { + + if (keycode != -1) { + Log.d(TAG, "Received media button event"); + handleKeycode(keycode, intent.getIntExtra(MediaButtonReceiver.EXTRA_SOURCE, + InputDevice.SOURCE_CLASS_NONE)); +// } else if (castDisconnect) { +// castManager.disconnect(); + } else { + started = true; + boolean stream = intent.getBooleanExtra(EXTRA_SHOULD_STREAM, + true); + boolean startWhenPrepared = intent.getBooleanExtra(EXTRA_START_WHEN_PREPARED, false); + boolean prepareImmediately = intent.getBooleanExtra(EXTRA_PREPARE_IMMEDIATELY, false); + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0); + //If the user asks to play External Media, the casting session, if on, should end. +// if (playable instanceof ExternalMedia) { +// castManager.disconnect(); +// } + mediaPlayer.playMediaObject(playable, stream, startWhenPrepared, prepareImmediately); + } + } + + return Service.START_REDELIVER_INTENT; + } + + /** + * Handles media button events + */ + private void handleKeycode(int keycode, int source) { + Log.d(TAG, "Handling keycode: " + keycode); + final PlaybackServiceMediaPlayer.PSMPInfo info = mediaPlayer.getPSMPInfo(); + final PlayerStatus status = info.playerStatus; + switch (keycode) { + case KeyEvent.KEYCODE_HEADSETHOOK: + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + if (status == PlayerStatus.PLAYING) { + mediaPlayer.pause(!UserPreferences.isPersistNotify(), true); + } else if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) { + mediaPlayer.resume(); + } else if (status == PlayerStatus.PREPARING) { + mediaPlayer.setStartWhenPrepared(!mediaPlayer.isStartWhenPrepared()); + } else if (status == PlayerStatus.INITIALIZED) { + mediaPlayer.setStartWhenPrepared(true); + mediaPlayer.prepare(); + } + break; + case KeyEvent.KEYCODE_MEDIA_PLAY: + if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) { + mediaPlayer.resume(); + } else if (status == PlayerStatus.INITIALIZED) { + mediaPlayer.setStartWhenPrepared(true); + mediaPlayer.prepare(); + } + break; + case KeyEvent.KEYCODE_MEDIA_PAUSE: + if (status == PlayerStatus.PLAYING) { + mediaPlayer.pause(!UserPreferences.isPersistNotify(), true); + } + + break; + case KeyEvent.KEYCODE_MEDIA_NEXT: + if(source == InputDevice.SOURCE_CLASS_NONE || + UserPreferences.shouldHardwareButtonSkip()) { + // assume the skip command comes from a notification or the lockscreen + // a >| skip button should actually skip + mediaPlayer.endPlayback(true, false); + } else { + // assume skip command comes from a (bluetooth) media button + // user actually wants to fast-forward + seekDelta(UserPreferences.getFastFowardSecs() * 1000); + } + break; + case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: + mediaPlayer.seekDelta(UserPreferences.getFastFowardSecs() * 1000); + break; + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + case KeyEvent.KEYCODE_MEDIA_REWIND: + mediaPlayer.seekDelta(-UserPreferences.getRewindSecs() * 1000); + break; + case KeyEvent.KEYCODE_MEDIA_STOP: + if (status == PlayerStatus.PLAYING) { + mediaPlayer.pause(true, true); + started = false; + } + + stopForeground(true); // gets rid of persistent notification + break; + default: + Log.d(TAG, "Unhandled key code: " + keycode); + if (info.playable != null && info.playerStatus == PlayerStatus.PLAYING) { // only notify the user about an unknown key event if it is actually doing something + String message = String.format(getResources().getString(R.string.unknown_media_key), keycode); + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + } + break; + } + } + + /** + * Called by a mediaplayer Activity as soon as it has prepared its + * mediaplayer. + */ + public void setVideoSurface(SurfaceHolder sh) { + Log.d(TAG, "Setting display"); + mediaPlayer.setVideoSurface(sh); + } + + /** + * Called when the surface holder of the mediaplayer has to be changed. + */ + private void resetVideoSurface() { + taskManager.cancelPositionSaver(); + mediaPlayer.resetVideoSurface(); + } + + public void notifyVideoSurfaceAbandoned() { + stopForeground(!UserPreferences.isPersistNotify()); + mediaPlayer.resetVideoSurface(); + } + + private final PlaybackServiceTaskManager.PSTMCallback taskManagerCallback = new PlaybackServiceTaskManager.PSTMCallback() { + @Override + public void positionSaverTick() { + saveCurrentPosition(true, PlaybackServiceTaskManager.POSITION_SAVER_WAITING_INTERVAL); + } + + @Override + public void onSleepTimerAlmostExpired() { + float leftVolume = 0.1f * UserPreferences.getLeftVolume(); + float rightVolume = 0.1f * UserPreferences.getRightVolume(); + mediaPlayer.setVolume(leftVolume, rightVolume); + } + + @Override + public void onSleepTimerExpired() { + mediaPlayer.pause(true, true); + float leftVolume = UserPreferences.getLeftVolume(); + float rightVolume = UserPreferences.getRightVolume(); + mediaPlayer.setVolume(leftVolume, rightVolume); + sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0); + } + + @Override + public void onSleepTimerReset() { + float leftVolume = UserPreferences.getLeftVolume(); + float rightVolume = UserPreferences.getRightVolume(); + mediaPlayer.setVolume(leftVolume, rightVolume); + } + + @Override + public void onWidgetUpdaterTick() { + updateWidget(); + } + + @Override + public void onChapterLoaded(Playable media) { + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0); + } + }; + + private final PlaybackServiceMediaPlayer.PSMPCallback mediaPlayerCallback = new PlaybackServiceMediaPlayer.PSMPCallback() { + @Override + public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + currentMediaType = mediaPlayer.getCurrentMediaType(); + updateMediaSession(newInfo.playerStatus); + switch (newInfo.playerStatus) { + case INITIALIZED: + writePlaybackPreferences(); + break; + + case PREPARED: + taskManager.startChapterLoader(newInfo.playable); + break; + + case PAUSED: + taskManager.cancelPositionSaver(); + saveCurrentPosition(false, 0); + taskManager.cancelWidgetUpdater(); + if ((UserPreferences.isPersistNotify() || isCasting) && + android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + // do not remove notification on pause based on user pref and whether android version supports expanded notifications + // Change [Play] button to [Pause] + setupNotification(newInfo); + } else if (!UserPreferences.isPersistNotify() && !isCasting) { + // remove notification on pause + stopForeground(true); + } + writePlayerStatusPlaybackPreferences(); + + final Playable playable = newInfo.playable; + + // Gpodder: send play action + if(GpodnetPreferences.loggedIn() && playable instanceof FeedMedia) { + FeedMedia media = (FeedMedia) playable; + FeedItem item = media.getItem(); + GpodnetEpisodeAction action = new GpodnetEpisodeAction.Builder(item, Action.PLAY) + .currentDeviceId() + .currentTimestamp() + .started(startPosition / 1000) + .position(getCurrentPosition() / 1000) + .total(getDuration() / 1000) + .build(); + GpodnetPreferences.enqueueEpisodeAction(action); + } + break; + + case STOPPED: + //setCurrentlyPlayingMedia(PlaybackPreferences.NO_MEDIA_PLAYING); + //stopSelf(); + break; + + case PLAYING: + Log.d(TAG, "Audiofocus successfully requested"); + Log.d(TAG, "Resuming/Starting playback"); + + taskManager.startPositionSaver(); + taskManager.startWidgetUpdater(); + writePlayerStatusPlaybackPreferences(); + setupNotification(newInfo); + started = true; + startPosition = mediaPlayer.getPosition(); + break; + + case ERROR: + writePlaybackPreferencesNoMediaPlaying(); + break; + + } + + Intent statusUpdate = new Intent(ACTION_PLAYER_STATUS_CHANGED); + // statusUpdate.putExtra(EXTRA_NEW_PLAYER_STATUS, newInfo.playerStatus.ordinal()); + sendBroadcast(statusUpdate); + updateWidget(); + bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED); + bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED); + } + + @Override + public void shouldStop() { + stopSelf(); + } + + @Override + public void playbackSpeedChanged(float s) { + sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE, 0); + } + + public void setSpeedAbilityChanged() { + sendNotificationBroadcast(NOTIFICATION_TYPE_SET_SPEED_ABILITY_CHANGED, 0); + } + + @Override + public void onBufferingUpdate(int percent) { + sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_UPDATE, percent); + } + + @Override + public void onMediaChanged(boolean reloadUI) { + Log.d(TAG, "reloadUI callback reached"); + if (reloadUI) { + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0); + } + PlaybackService.this.updateMediaSessionMetadata(getPlayable()); + } + + @Override + public boolean onMediaPlayerInfo(int code, @StringRes int resourceId) { + switch (code) { + case MediaPlayer.MEDIA_INFO_BUFFERING_START: + sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_START, 0); + return true; + case MediaPlayer.MEDIA_INFO_BUFFERING_END: + sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_END, 0); + return true; +// case RemotePSMP.CAST_ERROR: +// sendNotificationBroadcast(NOTIFICATION_TYPE_SHOW_TOAST, resourceId); +// return true; +// case RemotePSMP.CAST_ERROR_PRIORITY_HIGH: +// Toast.makeText(PlaybackService.this, resourceId, Toast.LENGTH_SHORT).show(); +// return true; + default: + return false; + } + } + + @Override + public boolean onMediaPlayerError(Object inObj, int what, int extra) { + final String TAG = "PlaybackSvc.onErrorLtsn"; + Log.w(TAG, "An error has occured: " + what + " " + extra); + if (mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING) { + mediaPlayer.pause(true, false); + } + sendNotificationBroadcast(NOTIFICATION_TYPE_ERROR, what); + writePlaybackPreferencesNoMediaPlaying(); + stopSelf(); + return true; + } + + @Override + public boolean endPlayback(Playable media, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { + PlaybackService.this.endPlayback(media, playNextEpisode, wasSkipped, switchingPlayers); + return true; + } + }; + + private void endPlayback(final Playable playable, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers) { + Log.d(TAG, "Playback ended" + (switchingPlayers ? " from switching players": "")); + + if (playable == null) { + Log.e(TAG, "Cannot end playback: media was null"); + return; + } + + taskManager.cancelPositionSaver(); + + boolean isInQueue = false; + FeedItem nextItem = null; + + if (playable instanceof FeedMedia && ((FeedMedia) playable).getItem() != null) { + FeedMedia media = (FeedMedia) playable; + FeedItem item = media.getItem(); + + if (!switchingPlayers) { + try { + final List<FeedItem> queue = taskManager.getQueue(); + isInQueue = QueueAccess.ItemListAccess(queue).contains(item.getId()); + nextItem = DBTasks.getQueueSuccessorOfItem(item.getId(), queue); + } catch (InterruptedException e) { + e.printStackTrace(); + // isInQueue remains false + } + + boolean shouldKeep = wasSkipped && UserPreferences.shouldSkipKeepEpisode(); + + if (!shouldKeep) { + // only mark the item as played if we're not keeping it anyways + DBWriter.markItemPlayed(item, FeedItem.PLAYED, true); + + if (isInQueue) { + DBWriter.removeQueueItem(PlaybackService.this, item, true); + } + + // Delete episode if enabled + if (item.getFeed().getPreferences().getCurrentAutoDelete()) { + DBWriter.deleteFeedMediaOfItem(PlaybackService.this, media.getId()); + Log.d(TAG, "Episode Deleted"); + } + } + } + + + DBWriter.addItemToPlaybackHistory(media); + + // auto-flattr if enabled + if (isAutoFlattrable(media) && UserPreferences.getAutoFlattrPlayedDurationThreshold() == 1.0f) { + DBTasks.flattrItemIfLoggedIn(PlaybackService.this, item); + } + + // gpodder play action + if(GpodnetPreferences.loggedIn()) { + GpodnetEpisodeAction action = new GpodnetEpisodeAction.Builder(item, Action.PLAY) + .currentDeviceId() + .currentTimestamp() + .started(startPosition / 1000) + .position(getDuration() / 1000) + .total(getDuration() / 1000) + .build(); + GpodnetPreferences.enqueueEpisodeAction(action); + } + } + + if (!switchingPlayers) { + // Load next episode if previous episode was in the queue and if there + // is an episode in the queue left. + // Start playback immediately if continuous playback is enabled + Playable nextMedia = null; + boolean loadNextItem = ClientConfig.playbackServiceCallbacks.useQueue() && + isInQueue && + nextItem != null; + + playNextEpisode = playNextEpisode && + loadNextItem && + UserPreferences.isFollowQueue(); + + if (loadNextItem) { + Log.d(TAG, "Loading next item in queue"); + nextMedia = nextItem.getMedia(); + } + final boolean prepareImmediately; + final boolean startWhenPrepared; + final boolean stream; + + if (playNextEpisode) { + Log.d(TAG, "Playback of next episode will start immediately."); + prepareImmediately = startWhenPrepared = true; + } else { + Log.d(TAG, "No more episodes available to play"); + prepareImmediately = startWhenPrepared = false; + stopForeground(true); + stopWidgetUpdater(); + } + + writePlaybackPreferencesNoMediaPlaying(); + if (nextMedia != null) { + stream = !nextMedia.localFileAvailable(); + mediaPlayer.playMediaObject(nextMedia, stream, startWhenPrepared, prepareImmediately); + sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, + isCasting ? EXTRA_CODE_CAST : + (nextMedia.getMediaType() == MediaType.VIDEO) ? EXTRA_CODE_VIDEO : EXTRA_CODE_AUDIO); + } else { + sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_END, 0); + mediaPlayer.stop(); + //stopSelf(); + } + } + } + + public void setSleepTimer(long waitingTime, boolean shakeToReset, boolean vibrate) { + Log.d(TAG, "Setting sleep timer to " + Long.toString(waitingTime) + " milliseconds"); + taskManager.setSleepTimer(waitingTime, shakeToReset, vibrate); + sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0); + } + + public void disableSleepTimer() { + taskManager.disableSleepTimer(); + sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0); + } + + private void writePlaybackPreferencesNoMediaPlaying() { + SharedPreferences.Editor editor = PreferenceManager + .getDefaultSharedPreferences(getApplicationContext()).edit(); + editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_MEDIA, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putInt( + PlaybackPreferences.PREF_CURRENT_PLAYER_STATUS, + PlaybackPreferences.PLAYER_STATUS_OTHER); + editor.commit(); + } + + private int getCurrentPlayerStatusAsInt(PlayerStatus playerStatus) { + int playerStatusAsInt; + switch (playerStatus) { + case PLAYING: + playerStatusAsInt = PlaybackPreferences.PLAYER_STATUS_PLAYING; + break; + case PAUSED: + playerStatusAsInt = PlaybackPreferences.PLAYER_STATUS_PAUSED; + break; + default: + playerStatusAsInt = PlaybackPreferences.PLAYER_STATUS_OTHER; + } + return playerStatusAsInt; + } + + private void writePlaybackPreferences() { + Log.d(TAG, "Writing playback preferences"); + + SharedPreferences.Editor editor = PreferenceManager + .getDefaultSharedPreferences(getApplicationContext()).edit(); + PlaybackServiceMediaPlayer.PSMPInfo info = mediaPlayer.getPSMPInfo(); + MediaType mediaType = mediaPlayer.getCurrentMediaType(); + boolean stream = mediaPlayer.isStreaming(); + int playerStatus = getCurrentPlayerStatusAsInt(info.playerStatus); + + if (info.playable != null) { + editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_MEDIA, + info.playable.getPlayableType()); + editor.putBoolean( + PlaybackPreferences.PREF_CURRENT_EPISODE_IS_STREAM, + stream); + editor.putBoolean( + PlaybackPreferences.PREF_CURRENT_EPISODE_IS_VIDEO, + mediaType == MediaType.VIDEO); + if (info.playable instanceof FeedMedia) { + FeedMedia fMedia = (FeedMedia) info.playable; + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID, + fMedia.getItem().getFeed().getId()); + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, + fMedia.getId()); + } else { + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + } + info.playable.writeToPreferences(editor); + } else { + editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_MEDIA, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putLong(PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEED_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + editor.putLong( + PlaybackPreferences.PREF_CURRENTLY_PLAYING_FEEDMEDIA_ID, + PlaybackPreferences.NO_MEDIA_PLAYING); + } + editor.putInt( + PlaybackPreferences.PREF_CURRENT_PLAYER_STATUS, playerStatus); + + editor.commit(); + } + + private void writePlayerStatusPlaybackPreferences() { + Log.d(TAG, "Writing player status playback preferences"); + + SharedPreferences.Editor editor = PreferenceManager + .getDefaultSharedPreferences(getApplicationContext()).edit(); + int playerStatus = getCurrentPlayerStatusAsInt(mediaPlayer.getPlayerStatus()); + + editor.putInt( + PlaybackPreferences.PREF_CURRENT_PLAYER_STATUS, playerStatus); + + editor.commit(); + } + + /** + * Send ACTION_PLAYER_STATUS_CHANGED without changing the status attribute. + */ + private void postStatusUpdateIntent() { + sendBroadcast(new Intent(ACTION_PLAYER_STATUS_CHANGED)); + } + + private void sendNotificationBroadcast(int type, int code) { + Intent intent = new Intent(ACTION_PLAYER_NOTIFICATION); + intent.putExtra(EXTRA_NOTIFICATION_TYPE, type); + intent.putExtra(EXTRA_NOTIFICATION_CODE, code); + sendBroadcast(intent); + } + + /** + * Updates the Media Session for the corresponding status. + * @param playerStatus the current {@link PlayerStatus} + */ + private void updateMediaSession(final PlayerStatus playerStatus) { + PlaybackStateCompat.Builder sessionState = new PlaybackStateCompat.Builder(); + + int state; + if (playerStatus != null) { + switch (playerStatus) { + case PLAYING: + state = PlaybackStateCompat.STATE_PLAYING; + break; + case PREPARED: + case PAUSED: + state = PlaybackStateCompat.STATE_PAUSED; + break; + case STOPPED: + state = PlaybackStateCompat.STATE_STOPPED; + break; + case SEEKING: + state = PlaybackStateCompat.STATE_FAST_FORWARDING; + break; + case PREPARING: + case INITIALIZING: + state = PlaybackStateCompat.STATE_CONNECTING; + break; + case INITIALIZED: + case INDETERMINATE: + state = PlaybackStateCompat.STATE_NONE; + break; + case ERROR: + state = PlaybackStateCompat.STATE_ERROR; + break; + default: + state = PlaybackStateCompat.STATE_NONE; + break; + } + } else { + state = PlaybackStateCompat.STATE_NONE; + } + sessionState.setState(state, mediaPlayer.getPosition(), mediaPlayer.getPlaybackSpeed()); + sessionState.setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE + | PlaybackStateCompat.ACTION_REWIND + | PlaybackStateCompat.ACTION_FAST_FORWARD + | PlaybackStateCompat.ACTION_SKIP_TO_NEXT); + mediaSession.setPlaybackState(sessionState.build()); + } + + /** + * Used by updateMediaSessionMetadata to load notification data in another thread. + */ + private Thread mediaSessionSetupThread; + + private void updateMediaSessionMetadata(final Playable p) { + if (p == null || mediaSession == null) { + return; + } + if (mediaSessionSetupThread != null) { + mediaSessionSetupThread.interrupt(); + } + + Runnable mediaSessionSetupTask = () -> { + MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder(); + builder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, p.getFeedTitle()); + builder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, p.getEpisodeTitle()); + builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, p.getDuration()); + builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, p.getEpisodeTitle()); + builder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, p.getFeedTitle()); + + if (p.getImageLocation() != null && UserPreferences.setLockscreenBackground()) { + builder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, p.getImageLocation().toString()); + try { + if (isCasting) { + Bitmap art = Glide.with(this) + .load(p.getImageLocation()) + .asBitmap() + .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) + .into(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .get(); + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, art); + } else { + WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + Bitmap art = Glide.with(this) + .load(p.getImageLocation()) + .asBitmap() + .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) + .centerCrop() + .into(display.getWidth(), display.getHeight()) + .get(); + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, art); + } + } catch (Throwable tr) { + Log.e(TAG, Log.getStackTraceString(tr)); + } + } + if (!Thread.currentThread().isInterrupted() && started) { + mediaSession.setMetadata(builder.build()); + } + }; + + mediaSessionSetupThread = new Thread(mediaSessionSetupTask); + mediaSessionSetupThread.start(); + } + + /** + * Used by setupNotification to load notification data in another thread. + */ + private Thread notificationSetupThread; + + /** + * Prepares notification and starts the service in the foreground. + */ + private void setupNotification(final PlaybackServiceMediaPlayer.PSMPInfo info) { + final PendingIntent pIntent = PendingIntent.getActivity(this, 0, + PlaybackService.getPlayerActivityIntent(this), + PendingIntent.FLAG_UPDATE_CURRENT); + + if (notificationSetupThread != null) { + notificationSetupThread.interrupt(); + } + Runnable notificationSetupTask = new Runnable() { + Bitmap icon = null; + + @Override + public void run() { + Log.d(TAG, "Starting background work"); + if (android.os.Build.VERSION.SDK_INT >= 11) { + if (info.playable != null) { + int iconSize = getResources().getDimensionPixelSize( + android.R.dimen.notification_large_icon_width); + try { + icon = Glide.with(PlaybackService.this) + .load(info.playable.getImageLocation()) + .asBitmap() + .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) + .centerCrop() + .into(iconSize, iconSize) + .get(); + } catch (Throwable tr) { + Log.e(TAG, "Error loading the media icon for the notification", tr); + } + } + } + if (icon == null) { + icon = BitmapFactory.decodeResource(getApplicationContext().getResources(), + ClientConfig.playbackServiceCallbacks.getNotificationIconResource(getApplicationContext())); + } + + if (mediaPlayer == null) { + return; + } + PlayerStatus playerStatus = mediaPlayer.getPlayerStatus(); + final int smallIcon = ClientConfig.playbackServiceCallbacks.getNotificationIconResource(getApplicationContext()); + + if (!Thread.currentThread().isInterrupted() && started && info.playable != null) { + String contentText = info.playable.getEpisodeTitle(); + String contentTitle = info.playable.getFeedTitle(); + Notification notification; + + // Builder is v7, even if some not overwritten methods return its parent's v4 interface + NotificationCompat.Builder notificationBuilder = (NotificationCompat.Builder) new NotificationCompat.Builder( + PlaybackService.this) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setOngoing(false) + .setContentIntent(pIntent) + .setLargeIcon(icon) + .setSmallIcon(smallIcon) + .setWhen(0) // we don't need the time + .setPriority(UserPreferences.getNotifyPriority()); // set notification priority + IntList compactActionList = new IntList(); + + int numActions = 0; // we start and 0 and then increment by 1 for each call to addAction + + if (isCasting) { + Intent stopCastingIntent = new Intent(PlaybackService.this, PlaybackService.class); + stopCastingIntent.putExtra(EXTRA_CAST_DISCONNECT, true); + PendingIntent stopCastingPendingIntent = PendingIntent.getService(PlaybackService.this, + numActions, stopCastingIntent, PendingIntent.FLAG_UPDATE_CURRENT); + notificationBuilder.addAction(R.drawable.ic_media_cast_disconnect, + getString(R.string.cast_disconnect_label), + stopCastingPendingIntent); + numActions++; + } + + // always let them rewind + PendingIntent rewindButtonPendingIntent = getPendingIntentForMediaAction( + KeyEvent.KEYCODE_MEDIA_REWIND, numActions); + notificationBuilder.addAction(android.R.drawable.ic_media_rew, + getString(R.string.rewind_label), + rewindButtonPendingIntent); + if(UserPreferences.showRewindOnCompactNotification()) { + compactActionList.add(numActions); + } + numActions++; + + if (playerStatus == PlayerStatus.PLAYING) { + PendingIntent pauseButtonPendingIntent = getPendingIntentForMediaAction( + KeyEvent.KEYCODE_MEDIA_PAUSE, numActions); + notificationBuilder.addAction(android.R.drawable.ic_media_pause, //pause action + getString(R.string.pause_label), + pauseButtonPendingIntent); + compactActionList.add(numActions++); + } else { + PendingIntent playButtonPendingIntent = getPendingIntentForMediaAction( + KeyEvent.KEYCODE_MEDIA_PLAY, numActions); + notificationBuilder.addAction(android.R.drawable.ic_media_play, //play action + getString(R.string.play_label), + playButtonPendingIntent); + compactActionList.add(numActions++); + } + + // ff follows play, then we have skip (if it's present) + PendingIntent ffButtonPendingIntent = getPendingIntentForMediaAction( + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, numActions); + notificationBuilder.addAction(android.R.drawable.ic_media_ff, + getString(R.string.fast_forward_label), + ffButtonPendingIntent); + if(UserPreferences.showFastForwardOnCompactNotification()) { + compactActionList.add(numActions); + } + numActions++; + + if (UserPreferences.isFollowQueue()) { + PendingIntent skipButtonPendingIntent = getPendingIntentForMediaAction( + KeyEvent.KEYCODE_MEDIA_NEXT, numActions); + notificationBuilder.addAction(android.R.drawable.ic_media_next, + getString(R.string.skip_episode_label), + skipButtonPendingIntent); + if(UserPreferences.showSkipOnCompactNotification()) { + compactActionList.add(numActions); + } + numActions++; + } + + PendingIntent stopButtonPendingIntent = getPendingIntentForMediaAction( + KeyEvent.KEYCODE_MEDIA_STOP, numActions); + notificationBuilder.setStyle(new android.support.v7.app.NotificationCompat.MediaStyle() + .setMediaSession(mediaSession.getSessionToken()) + .setShowActionsInCompactView(compactActionList.toArray()) + .setShowCancelButton(true) + .setCancelButtonIntent(stopButtonPendingIntent)) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setColor(Notification.COLOR_DEFAULT); + + notification = notificationBuilder.build(); + + if (playerStatus == PlayerStatus.PLAYING || + playerStatus == PlayerStatus.PREPARING || + playerStatus == PlayerStatus.SEEKING || + isCasting) { + startForeground(NOTIFICATION_ID, notification); + } else { + stopForeground(false); + NotificationManager mNotificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + mNotificationManager.notify(NOTIFICATION_ID, notification); + } + Log.d(TAG, "Notification set up"); + } + } + }; + notificationSetupThread = new Thread(notificationSetupTask); + notificationSetupThread.start(); + } + + private PendingIntent getPendingIntentForMediaAction(int keycodeValue, int requestCode) { + Intent intent = new Intent( + PlaybackService.this, PlaybackService.class); + intent.putExtra( + MediaButtonReceiver.EXTRA_KEYCODE, + keycodeValue); + return PendingIntent + .getService(PlaybackService.this, requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** + * Persists the current position and last played time of the media file. + * + * @param updatePlayedDuration true if played_duration should be updated. This applies only to FeedMedia objects + * @param deltaPlayedDuration value by which played_duration should be increased. + */ + private synchronized void saveCurrentPosition(boolean updatePlayedDuration, int deltaPlayedDuration) { + int position = getCurrentPosition(); + int duration = getDuration(); + float playbackSpeed = getCurrentPlaybackSpeed(); + final Playable playable = mediaPlayer.getPlayable(); + if (position != INVALID_TIME && duration != INVALID_TIME && playable != null) { + Log.d(TAG, "Saving current position to " + position); + if (updatePlayedDuration && playable instanceof FeedMedia) { + FeedMedia media = (FeedMedia) playable; + FeedItem item = media.getItem(); + media.setPlayedDuration(media.getPlayedDuration() + ((int) (deltaPlayedDuration * playbackSpeed))); + // Auto flattr + if (isAutoFlattrable(media) && + (media.getPlayedDuration() > UserPreferences.getAutoFlattrPlayedDurationThreshold() * duration)) { + Log.d(TAG, "saveCurrentPosition: performing auto flattr since played duration " + Integer.toString(media.getPlayedDuration()) + + " is " + UserPreferences.getAutoFlattrPlayedDurationThreshold() * 100 + "% of file duration " + Integer.toString(duration)); + DBTasks.flattrItemIfLoggedIn(this, item); + } + } + playable.saveCurrentPosition( + PreferenceManager.getDefaultSharedPreferences(getApplicationContext()), + position, + System.currentTimeMillis()); + } + } + + private void stopWidgetUpdater() { + taskManager.cancelWidgetUpdater(); + sendBroadcast(new Intent(STOP_WIDGET_UPDATE)); + } + + private void updateWidget() { + PlaybackService.this.sendBroadcast(new Intent( + FORCE_WIDGET_UPDATE)); + } + + public boolean sleepTimerActive() { + return taskManager.isSleepTimerActive(); + } + + public long getSleepTimerTimeLeft() { + return taskManager.getSleepTimerTimeLeft(); + } + + private void bluetoothNotifyChange(PlaybackServiceMediaPlayer.PSMPInfo info, String whatChanged) { + boolean isPlaying = false; + + if (info.playerStatus == PlayerStatus.PLAYING) { + isPlaying = true; + } + + if (info.playable != null) { + Intent i = new Intent(whatChanged); + i.putExtra("id", 1); + i.putExtra("artist", ""); + i.putExtra("album", info.playable.getFeedTitle()); + i.putExtra("track", info.playable.getEpisodeTitle()); + i.putExtra("playing", isPlaying); + final List<FeedItem> queue = taskManager.getQueueIfLoaded(); + if (queue != null) { + i.putExtra("ListSize", queue.size()); + } + i.putExtra("duration", info.playable.getDuration()); + i.putExtra("position", info.playable.getPosition()); + sendBroadcast(i); + } + } + + /** + * Pauses playback when the headset is disconnected and the preference is + * set + */ + private final BroadcastReceiver headsetDisconnected = new BroadcastReceiver() { + private static final String TAG = "headsetDisconnected"; + private static final int UNPLUGGED = 0; + private static final int PLUGGED = 1; + + @Override + public void onReceive(Context context, Intent intent) { + if (TextUtils.equals(intent.getAction(), Intent.ACTION_HEADSET_PLUG)) { + int state = intent.getIntExtra("state", -1); + if (state != -1) { + Log.d(TAG, "Headset plug event. State is " + state); + if (state == UNPLUGGED) { + Log.d(TAG, "Headset was unplugged during playback."); + pauseIfPauseOnDisconnect(); + } else if (state == PLUGGED) { + Log.d(TAG, "Headset was plugged in during playback."); + unpauseIfPauseOnDisconnect(false); + } + } else { + Log.e(TAG, "Received invalid ACTION_HEADSET_PLUG intent"); + } + } + } + }; + + private final BroadcastReceiver bluetoothStateUpdated = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + if (TextUtils.equals(intent.getAction(), BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)) { + int state = intent.getIntExtra(BluetoothA2dp.EXTRA_STATE, -1); + if (state == BluetoothA2dp.STATE_CONNECTED) { + Log.d(TAG, "Received bluetooth connection intent"); + unpauseIfPauseOnDisconnect(true); + } + } + } + } + }; + + private final BroadcastReceiver audioBecomingNoisy = new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + // sound is about to change, eg. bluetooth -> speaker + Log.d(TAG, "Pausing playback because audio is becoming noisy"); + pauseIfPauseOnDisconnect(); + } + // android.media.AUDIO_BECOMING_NOISY + }; + + /** + * Pauses playback if PREF_PAUSE_ON_HEADSET_DISCONNECT was set to true. + */ + private void pauseIfPauseOnDisconnect() { + if (UserPreferences.isPauseOnHeadsetDisconnect()) { + if (mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING) { + transientPause = true; + } + mediaPlayer.pause(!UserPreferences.isPersistNotify(), true); + } + } + + /** + * @param bluetooth true if the event for unpausing came from bluetooth + */ + private void unpauseIfPauseOnDisconnect(boolean bluetooth) { + if (transientPause) { + transientPause = false; + if (!bluetooth && UserPreferences.isUnpauseOnHeadsetReconnect()) { + mediaPlayer.resume(); + } else if (bluetooth && UserPreferences.isUnpauseOnBluetoothReconnect()){ + // let the user know we've started playback again... + Vibrator v = (Vibrator) getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE); + if(v != null) { + v.vibrate(500); + } + mediaPlayer.resume(); + } + } + } + + private final BroadcastReceiver shutdownReceiver = new BroadcastReceiver() { + + @Override + public void onReceive(Context context, Intent intent) { + if (TextUtils.equals(intent.getAction(), ACTION_SHUTDOWN_PLAYBACK_SERVICE)) { + stopSelf(); + } + } + + }; + + private final BroadcastReceiver skipCurrentEpisodeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (TextUtils.equals(intent.getAction(), ACTION_SKIP_CURRENT_EPISODE)) { + Log.d(TAG, "Received SKIP_CURRENT_EPISODE intent"); + mediaPlayer.endPlayback(true, false); + } + } + }; + + private final BroadcastReceiver pauseResumeCurrentEpisodeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (TextUtils.equals(intent.getAction(), ACTION_RESUME_PLAY_CURRENT_EPISODE)) { + Log.d(TAG, "Received RESUME_PLAY_CURRENT_EPISODE intent"); + mediaPlayer.resume(); + } + } + }; + + private final BroadcastReceiver pausePlayCurrentEpisodeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (TextUtils.equals(intent.getAction(), ACTION_PAUSE_PLAY_CURRENT_EPISODE)) { + Log.d(TAG, "Received PAUSE_PLAY_CURRENT_EPISODE intent"); + mediaPlayer.pause(false, false); + } + } + }; + + public static MediaType getCurrentMediaType() { + return currentMediaType; + } + + public static boolean isCasting() { + return isCasting; + } + + public void resume() { + mediaPlayer.resume(); + } + + public void prepare() { + mediaPlayer.prepare(); + } + + public void pause(boolean abandonAudioFocus, boolean reinit) { + mediaPlayer.pause(abandonAudioFocus, reinit); + } + + public void reinit() { + mediaPlayer.reinit(); + } + + public PlaybackServiceMediaPlayer.PSMPInfo getPSMPInfo() { + return mediaPlayer.getPSMPInfo(); + } + + public PlayerStatus getStatus() { + return mediaPlayer.getPlayerStatus(); + } + + public Playable getPlayable() { return mediaPlayer.getPlayable(); } + + public boolean canSetSpeed() { + return mediaPlayer.canSetSpeed(); + } + + public void setSpeed(float speed) { + mediaPlayer.setSpeed(speed); + } + + public void setVolume(float leftVolume, float rightVolume) { + mediaPlayer.setVolume(leftVolume, rightVolume); + } + + public float getCurrentPlaybackSpeed() { + return mediaPlayer.getPlaybackSpeed(); + } + + public boolean canDownmix() { + return mediaPlayer.canDownmix(); + } + + public void setDownmix(boolean enable) { + mediaPlayer.setDownmix(enable); + } + + public boolean isStartWhenPrepared() { + return mediaPlayer.isStartWhenPrepared(); + } + + public void setStartWhenPrepared(boolean s) { + mediaPlayer.setStartWhenPrepared(s); + } + + + public void seekTo(final int t) { + if(mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING + && GpodnetPreferences.loggedIn()) { + final Playable playable = mediaPlayer.getPlayable(); + if (playable instanceof FeedMedia) { + FeedMedia media = (FeedMedia) playable; + FeedItem item = media.getItem(); + GpodnetEpisodeAction action = new GpodnetEpisodeAction.Builder(item, Action.PLAY) + .currentDeviceId() + .currentTimestamp() + .started(startPosition / 1000) + .position(getCurrentPosition() / 1000) + .total(getDuration() / 1000) + .build(); + GpodnetPreferences.enqueueEpisodeAction(action); + } + } + mediaPlayer.seekTo(t); + if(mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING ) { + startPosition = t; + } + } + + + public void seekDelta(final int d) { + mediaPlayer.seekDelta(d); + } + + /** + * @see LocalPSMP#seekToChapter(de.danoeh.antennapod.core.feed.Chapter) + */ + public void seekToChapter(Chapter c) { + mediaPlayer.seekToChapter(c); + } + + /** + * call getDuration() on mediaplayer or return INVALID_TIME if player is in + * an invalid state. + */ + public int getDuration() { + return mediaPlayer.getDuration(); + } + + /** + * call getCurrentPosition() on mediaplayer or return INVALID_TIME if player + * is in an invalid state. + */ + public int getCurrentPosition() { + return mediaPlayer.getPosition(); + } + + public boolean isStreaming() { + return mediaPlayer.isStreaming(); + } + + public Pair<Integer, Integer> getVideoSize() { + return mediaPlayer.getVideoSize(); + } + + private boolean isAutoFlattrable(FeedMedia media) { + if (media != null) { + FeedItem item = media.getItem(); + return item != null && FlattrUtils.hasToken() && UserPreferences.isAutoFlattr() && item.getPaymentLink() != null && item.getFlattrStatus().getUnflattred(); + } else { + return false; + } + } + +// private CastConsumer castConsumer = new DefaultCastConsumer() { +// @Override +// public void onApplicationConnected(ApplicationMetadata appMetadata, String sessionId, boolean wasLaunched) { +// PlaybackService.this.onCastAppConnected(wasLaunched); +// } +// +// @Override +// public void onDisconnectionReason(int reason) { +// Log.d(TAG, "onDisconnectionReason() with code " + reason); +// // This is our final chance to update the underlying stream position +// // In onDisconnected(), the underlying CastPlayback#mVideoCastConsumer +// // is disconnected and hence we update our local value of stream position +// // to the latest position. +// if (mediaPlayer != null) { +// saveCurrentPosition(false, 0); +// infoBeforeCastDisconnection = mediaPlayer.getPSMPInfo(); +// if (reason != BaseCastManager.DISCONNECT_REASON_EXPLICIT && +// infoBeforeCastDisconnection.playerStatus == PlayerStatus.PLAYING) { +// // If it's NOT based on user action, we shouldn't automatically resume local playback +// infoBeforeCastDisconnection.playerStatus = PlayerStatus.PAUSED; +// } +// } +// } +// +// @Override +// public void onDisconnected() { +// Log.d(TAG, "onDisconnected()"); +// isCasting = false; +// PlaybackServiceMediaPlayer.PSMPInfo info = infoBeforeCastDisconnection; +// infoBeforeCastDisconnection = null; +// if (info == null && mediaPlayer != null) { +// info = mediaPlayer.getPSMPInfo(); +// } +// if (info == null) { +// info = new PlaybackServiceMediaPlayer.PSMPInfo(PlayerStatus.STOPPED, null); +// } +// switchMediaPlayer(new LocalPSMP(PlaybackService.this, mediaPlayerCallback), +// info, true); +// if (info.playable != null) { +// sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, +// info.playable.getMediaType() == MediaType.AUDIO ? EXTRA_CODE_AUDIO : EXTRA_CODE_VIDEO); +// } else { +// Log.d(TAG, "Cast session disconnected, but no current media"); +// sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_END, 0); +// } +// // hardware volume buttons control the local device volume +// mediaRouter.setMediaSessionCompat(null); +// unregisterWifiBroadcastReceiver(); +// PlayerStatus status = info.playerStatus; +// if ((status == PlayerStatus.PLAYING || +// status == PlayerStatus.SEEKING || +// status == PlayerStatus.PREPARING || +// UserPreferences.isPersistNotify()) && +// android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { +// setupNotification(info); +// } else if (!UserPreferences.isPersistNotify()){ +// stopForeground(true); +// } +// } +// }; + + private final MediaSessionCompat.Callback sessionCallback = new MediaSessionCompat.Callback() { + + private static final String TAG = "MediaSessionCompat"; + + @Override + public void onPlay() { + Log.d(TAG, "onPlay()"); + PlayerStatus status = getStatus(); + if (status == PlayerStatus.PAUSED || status == PlayerStatus.PREPARED) { + resume(); + } else if (status == PlayerStatus.INITIALIZED) { + setStartWhenPrepared(true); + prepare(); + } + } + + @Override + public void onPause() { + Log.d(TAG, "onPause()"); + if (getStatus() == PlayerStatus.PLAYING) { + pause(false, true); + } + if (UserPreferences.isPersistNotify()) { + pause(false, true); + } else { + pause(true, true); + } + } + + @Override + public void onStop() { + Log.d(TAG, "onStop()"); + mediaPlayer.stop(); + } + + @Override + public void onSkipToPrevious() { + Log.d(TAG, "onSkipToPrevious()"); + seekDelta(-UserPreferences.getRewindSecs() * 1000); + } + + @Override + public void onRewind() { + Log.d(TAG, "onRewind()"); + seekDelta(-UserPreferences.getRewindSecs() * 1000); + } + + @Override + public void onFastForward() { + Log.d(TAG, "onFastForward()"); + seekDelta(UserPreferences.getFastFowardSecs() * 1000); + } + + @Override + public void onSkipToNext() { + Log.d(TAG, "onSkipToNext()"); + if(UserPreferences.shouldHardwareButtonSkip()) { + mediaPlayer.endPlayback(true, false); + } else { + seekDelta(UserPreferences.getFastFowardSecs() * 1000); + } + } + + + @Override + public void onSeekTo(long pos) { + Log.d(TAG, "onSeekTo()"); + seekTo((int) pos); + } + + @Override + public boolean onMediaButtonEvent(final Intent mediaButton) { + Log.d(TAG, "onMediaButtonEvent(" + mediaButton + ")"); + if (mediaButton != null) { + KeyEvent keyEvent = (KeyEvent) mediaButton.getExtras().get(Intent.EXTRA_KEY_EVENT); + if (keyEvent != null && + keyEvent.getAction() == KeyEvent.ACTION_DOWN && + keyEvent.getRepeatCount() == 0){ + handleKeycode(keyEvent.getKeyCode(), keyEvent.getSource()); + } + } + return false; + } + }; + +// private void onCastAppConnected(boolean wasLaunched) { +// Log.d(TAG, "A cast device application was " + (wasLaunched ? "launched" : "joined")); +// isCasting = true; +// PlaybackServiceMediaPlayer.PSMPInfo info = null; +// if (mediaPlayer != null) { +// info = mediaPlayer.getPSMPInfo(); +// if (info.playerStatus == PlayerStatus.PLAYING) { +// // could be pause, but this way we make sure the new player will get the correct position, +// // since pause runs asynchronously and we could be directing the new player to play even before +// // the old player gives us back the position. +// saveCurrentPosition(false, 0); +// } +// } +// if (info == null) { +// info = new PlaybackServiceMediaPlayer.PSMPInfo(PlayerStatus.STOPPED, null); +// } +// sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, EXTRA_CODE_CAST); +// switchMediaPlayer(new RemotePSMP(PlaybackService.this, mediaPlayerCallback), +// info, +// wasLaunched); +// // hardware volume buttons control the remote device volume +// mediaRouter.setMediaSessionCompat(mediaSession); +// registerWifiBroadcastReceiver(); +// setupNotification(info); +// } + + private void switchMediaPlayer(@NonNull PlaybackServiceMediaPlayer newPlayer, + @NonNull PlaybackServiceMediaPlayer.PSMPInfo info, + boolean wasLaunched) { + if (mediaPlayer != null) { + mediaPlayer.endPlayback(true, true); + mediaPlayer.shutdownQuietly(); + } + mediaPlayer = newPlayer; + Log.d(TAG, "switched to " + mediaPlayer.getClass().getSimpleName()); + if (!wasLaunched) { + PlaybackServiceMediaPlayer.PSMPInfo candidate = mediaPlayer.getPSMPInfo(); + if (candidate.playable != null && + candidate.playerStatus.isAtLeast(PlayerStatus.PREPARING)) { + // do not automatically send new media to cast device + info.playable = null; + } + } + if (info.playable != null) { + mediaPlayer.playMediaObject(info.playable, + !info.playable.localFileAvailable(), + info.playerStatus == PlayerStatus.PLAYING, + info.playerStatus.isAtLeast(PlayerStatus.PREPARING)); + } + } + + private void registerWifiBroadcastReceiver() { + if (wifiBroadcastReceiver != null) { + return; + } + wifiBroadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(WifiManager.NETWORK_STATE_CHANGED_ACTION)) { + NetworkInfo info = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO); + boolean isConnected = info.isConnected(); + //apparently this method gets called twice when a change happens, but one run is enough. + if (isConnected && !wifiConnectivity) { + wifiConnectivity = true; +// castManager.startCastDiscovery(); +// castManager.reconnectSessionIfPossible(RECONNECTION_ATTEMPT_PERIOD_S, NetworkUtils.getWifiSsid()); + } else { + wifiConnectivity = isConnected; + } + } + } + }; + registerReceiver(wifiBroadcastReceiver, + new IntentFilter(WifiManager.NETWORK_STATE_CHANGED_ACTION)); + } + + private void unregisterWifiBroadcastReceiver() { + if (wifiBroadcastReceiver != null) { + unregisterReceiver(wifiBroadcastReceiver); + wifiBroadcastReceiver = null; + } + } + + private SharedPreferences.OnSharedPreferenceChangeListener prefListener = + (sharedPreferences, key) -> { +// if (UserPreferences.PREF_CAST_ENABLED.equals(key)) { +// if (!UserPreferences.isCastEnabled()) { +// if (castManager.isConnecting() || castManager.isConnected()) { +// Log.d(TAG, "Disconnecting cast device due to a change in user preferences"); +// castManager.disconnect(); +// } +// } +// } else + if (UserPreferences.PREF_LOCKSCREEN_BACKGROUND.equals(key)) { + updateMediaSessionMetadata(getPlayable()); + } + }; +} diff --git a/core/src/free/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java b/core/src/free/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java new file mode 100644 index 000000000..abf787ce8 --- /dev/null +++ b/core/src/free/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java @@ -0,0 +1,592 @@ +//package de.danoeh.antennapod.core.service.playback; +// +//import android.content.Context; +//import android.media.MediaPlayer; +//import android.support.annotation.NonNull; +//import android.util.Log; +//import android.util.Pair; +//import android.view.SurfaceHolder; +// +//import com.google.android.gms.cast.Cast; +//import com.google.android.gms.cast.CastStatusCodes; +//import com.google.android.gms.cast.MediaInfo; +//import com.google.android.gms.cast.MediaStatus; +//import com.google.android.libraries.cast.companionlibrary.cast.exceptions.CastException; +//import com.google.android.libraries.cast.companionlibrary.cast.exceptions.NoConnectionException; +//import com.google.android.libraries.cast.companionlibrary.cast.exceptions.TransientNetworkDisconnectionException; +// +//import java.util.concurrent.atomic.AtomicBoolean; +// +//import de.danoeh.antennapod.core.R; +//import de.danoeh.antennapod.core.cast.CastConsumer; +//import de.danoeh.antennapod.core.cast.CastManager; +//import de.danoeh.antennapod.core.cast.CastUtils; +//import de.danoeh.antennapod.core.cast.DefaultCastConsumer; +//import de.danoeh.antennapod.core.cast.RemoteMedia; +//import de.danoeh.antennapod.core.feed.FeedMedia; +//import de.danoeh.antennapod.core.feed.MediaType; +//import de.danoeh.antennapod.core.util.RewindAfterPauseUtils; +//import de.danoeh.antennapod.core.util.playback.Playable; +// +///** +// * Implementation of PlaybackServiceMediaPlayer suitable for remote playback on Cast Devices. +// */ +//public class RemotePSMP extends PlaybackServiceMediaPlayer { +// +// public static final String TAG = "RemotePSMP"; +// +// public static final int CAST_ERROR = 3001; +// +// public static final int CAST_ERROR_PRIORITY_HIGH = 3005; +// +// private final CastManager castMgr; +// +// private volatile Playable media; +// private volatile MediaInfo remoteMedia; +// private volatile MediaType mediaType; +// +// private final AtomicBoolean isBuffering; +// +// private final AtomicBoolean startWhenPrepared; +// +// public RemotePSMP(@NonNull Context context, @NonNull PSMPCallback callback) { +// super(context, callback); +// +// castMgr = CastManager.getInstance(); +// media = null; +// mediaType = null; +// startWhenPrepared = new AtomicBoolean(false); +// isBuffering = new AtomicBoolean(false); +// +// try { +// if (castMgr.isConnected() && castMgr.isRemoteMediaLoaded()) { +// // updates the state, but does not start playing new media if it was going to +// onRemoteMediaPlayerStatusUpdated( +// ((p, playNextEpisode, wasSkipped, switchingPlayers) -> +// this.callback.endPlayback(p, false, wasSkipped, switchingPlayers))); +// } +// } catch (TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Unable to do initial check for loaded media", e); +// } +// +// castMgr.addCastConsumer(castConsumer); +// //TODO +// } +// +// private CastConsumer castConsumer = new DefaultCastConsumer() { +// @Override +// public void onRemoteMediaPlayerMetadataUpdated() { +// RemotePSMP.this.onRemoteMediaPlayerStatusUpdated(callback::endPlayback); +// } +// +// @Override +// public void onRemoteMediaPlayerStatusUpdated() { +// RemotePSMP.this.onRemoteMediaPlayerStatusUpdated(callback::endPlayback); +// } +// +// @Override +// public void onMediaLoadResult(int statusCode) { +// if (playerStatus == PlayerStatus.PREPARING) { +// if (statusCode == CastStatusCodes.SUCCESS) { +// setPlayerStatus(PlayerStatus.PREPARED, media); +// if (media.getDuration() == 0) { +// Log.d(TAG, "Setting duration of media"); +// try { +// media.setDuration((int) castMgr.getMediaDuration()); +// } catch (TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Unable to get remote media's duration"); +// } +// } +// } else if (statusCode != CastStatusCodes.REPLACED){ +// Log.d(TAG, "Remote media failed to load"); +// setPlayerStatus(PlayerStatus.INITIALIZED, media); +// } +// } else { +// Log.d(TAG, "onMediaLoadResult called, but Player Status wasn't in preparing state, so we ignore the result"); +// } +// } +// +// @Override +// public void onApplicationStatusChanged(String appStatus) { +// if (playerStatus != PlayerStatus.PLAYING) { +// Log.d(TAG, "onApplicationStatusChanged, but no media was playing"); +// return; +// } +// boolean playbackEnded = false; +// try { +// int standbyState = castMgr.getApplicationStandbyState(); +// Log.d(TAG, "standbyState: " + standbyState); +// playbackEnded = standbyState == Cast.STANDBY_STATE_YES; +// } catch (IllegalStateException e) { +// Log.d(TAG, "unable to get standbyState on onApplicationStatusChanged()"); +// } +// if (playbackEnded) { +// setPlayerStatus(PlayerStatus.INDETERMINATE, media); +// callback.endPlayback(media, true, false, false); +// } +// } +// +// @Override +// public void onFailed(int resourceId, int statusCode) { +// callback.onMediaPlayerInfo(CAST_ERROR, resourceId); +// } +// }; +// +// private void setBuffering(boolean buffering) { +// if (buffering && isBuffering.compareAndSet(false, true)) { +// callback.onMediaPlayerInfo(MediaPlayer.MEDIA_INFO_BUFFERING_START, 0); +// } else if (!buffering && isBuffering.compareAndSet(true, false)) { +// callback.onMediaPlayerInfo(MediaPlayer.MEDIA_INFO_BUFFERING_END, 0); +// } +// } +// +// private Playable localVersion(MediaInfo info){ +// if (info == null) { +// return null; +// } +// if (CastUtils.matches(info, media)) { +// return media; +// } +// return CastUtils.getPlayable(info, true); +// } +// +// private MediaInfo remoteVersion(Playable playable) { +// if (playable == null) { +// return null; +// } +// if (CastUtils.matches(remoteMedia, playable)) { +// return remoteMedia; +// } +// if (playable instanceof FeedMedia) { +// return CastUtils.convertFromFeedMedia((FeedMedia) playable); +// } +// if (playable instanceof RemoteMedia) { +// return ((RemoteMedia) playable).extractMediaInfo(); +// } +// return null; +// } +// +// private void onRemoteMediaPlayerStatusUpdated(@NonNull EndPlaybackCall endPlaybackCall) { +// MediaStatus status = castMgr.getMediaStatus(); +// if (status == null) { +// Log.d(TAG, "Received null MediaStatus"); +// //setBuffering(false); +// //setPlayerStatus(PlayerStatus.INDETERMINATE, null); +// return; +// } else { +// Log.d(TAG, "Received remote status/media update. New state=" + status.getPlayerState()); +// } +// Playable currentMedia = localVersion(status.getMediaInfo()); +// boolean updateUI = currentMedia != media; +// if (currentMedia != null) { +// long position = status.getStreamPosition(); +// if (position > 0 && currentMedia.getPosition() == 0) { +// currentMedia.setPosition((int) position); +// } +// } +// int state = status.getPlayerState(); +// setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING); +// switch (state) { +// case MediaStatus.PLAYER_STATE_PLAYING: +// setPlayerStatus(PlayerStatus.PLAYING, currentMedia); +// break; +// case MediaStatus.PLAYER_STATE_PAUSED: +// setPlayerStatus(PlayerStatus.PAUSED, currentMedia); +// break; +// case MediaStatus.PLAYER_STATE_BUFFERING: +// setPlayerStatus(playerStatus, currentMedia); +// break; +// case MediaStatus.PLAYER_STATE_IDLE: +// int reason = status.getIdleReason(); +// switch (reason) { +// case MediaStatus.IDLE_REASON_CANCELED: +// // check if we're already loading something else +// if (!updateUI || media == null) { +// setPlayerStatus(PlayerStatus.STOPPED, currentMedia); +// } else { +// updateUI = false; +// } +// break; +// case MediaStatus.IDLE_REASON_INTERRUPTED: +// // check if we're already loading something else +// if (!updateUI || media == null) { +// setPlayerStatus(PlayerStatus.PREPARING, currentMedia); +// } else { +// updateUI = false; +// } +// break; +// case MediaStatus.IDLE_REASON_NONE: +// setPlayerStatus(PlayerStatus.INITIALIZED, currentMedia); +// break; +// case MediaStatus.IDLE_REASON_FINISHED: +// boolean playing = playerStatus == PlayerStatus.PLAYING; +// setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); +// endPlaybackCall.endPlayback(currentMedia,playing, false, false); +// // endPlayback already updates the UI, so no need to trigger it ourselves +// updateUI = false; +// break; +// case MediaStatus.IDLE_REASON_ERROR: +// Log.w(TAG, "Got an error status from the Chromecast. Skipping, if possible, to the next episode..."); +// setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); +// callback.onMediaPlayerInfo(CAST_ERROR_PRIORITY_HIGH, +// R.string.cast_failed_media_error_skipping); +// endPlaybackCall.endPlayback(currentMedia, startWhenPrepared.get(), true, false); +// // endPlayback already updates the UI, so no need to trigger it ourselves +// updateUI = false; +// } +// break; +// case MediaStatus.PLAYER_STATE_UNKNOWN: +// //is this right? +// setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); +// break; +// default: +// Log.e(TAG, "Remote media state undetermined!"); +// setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); +// } +// if (updateUI) { +// callback.onMediaChanged(true); +// } +// } +// +// @Override +// public void playMediaObject(@NonNull final Playable playable, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { +// Log.d(TAG, "playMediaObject() called"); +// playMediaObject(playable, false, stream, startWhenPrepared, prepareImmediately); +// } +// +// /** +// * Internal implementation of playMediaObject. This method has an additional parameter that allows the caller to force a media player reset even if +// * the given playable parameter is the same object as the currently playing media. +// * +// * @see #playMediaObject(de.danoeh.antennapod.core.util.playback.Playable, boolean, boolean, boolean) +// */ +// private void playMediaObject(@NonNull final Playable playable, final boolean forceReset, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { +// if (!CastUtils.isCastable(playable)) { +// Log.d(TAG, "media provided is not compatible with cast device"); +// callback.onMediaPlayerInfo(CAST_ERROR_PRIORITY_HIGH, R.string.cast_not_castable); +// try { +// playable.loadMetadata(); +// } catch (Playable.PlayableException e) { +// Log.e(TAG, "Unable to load metadata of playable", e); +// } +// callback.endPlayback(playable, startWhenPrepared, true, false); +// return; +// } +// +// if (media != null) { +// if (!forceReset && media.getIdentifier().equals(playable.getIdentifier()) +// && playerStatus == PlayerStatus.PLAYING) { +// // episode is already playing -> ignore method call +// Log.d(TAG, "Method call to playMediaObject was ignored: media file already playing."); +// return; +// } else { +// // set temporarily to pause in order to update list with current position +// try { +// if (castMgr.isRemoteMediaPlaying()) { +// setPlayerStatus(PlayerStatus.PAUSED, media); +// } +// } catch (TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Unable to determine whether media was playing, falling back to stored player status", e); +// // this might end up just being pointless if we need to query the remote device for the position +// if (playerStatus == PlayerStatus.PLAYING) { +// setPlayerStatus(PlayerStatus.PAUSED, media); +// } +// } +// smartMarkAsPlayed(media); +// +// +// setPlayerStatus(PlayerStatus.INDETERMINATE, null); +// } +// } +// +// this.media = playable; +// remoteMedia = remoteVersion(playable); +// //this.stream = stream; +// this.mediaType = media.getMediaType(); +// this.startWhenPrepared.set(startWhenPrepared); +// setPlayerStatus(PlayerStatus.INITIALIZING, media); +// try { +// media.loadMetadata(); +// callback.onMediaChanged(true); +// setPlayerStatus(PlayerStatus.INITIALIZED, media); +// if (prepareImmediately) { +// prepare(); +// } +// } catch (Playable.PlayableException e) { +// Log.e(TAG, "Error while loading media metadata", e); +// setPlayerStatus(PlayerStatus.STOPPED, null); +// } +// } +// +// @Override +// public void resume() { +// try { +// // TODO see comment on prepare() +// // setVolume(UserPreferences.getLeftVolume(), UserPreferences.getRightVolume()); +// if (playerStatus == PlayerStatus.PREPARED && media.getPosition() > 0) { +// int newPosition = RewindAfterPauseUtils.calculatePositionWithRewind( +// media.getPosition(), +// media.getLastPlayedTime()); +// castMgr.play(newPosition); +// } +// castMgr.play(); +// } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Unable to resume remote playback", e); +// } +// } +// +// @Override +// public void pause(boolean abandonFocus, boolean reinit) { +// try { +// if (castMgr.isRemoteMediaPlaying()) { +// castMgr.pause(); +// } +// } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Unable to pause", e); +// } +// } +// +// @Override +// public void prepare() { +// if (playerStatus == PlayerStatus.INITIALIZED) { +// Log.d(TAG, "Preparing media player"); +// setPlayerStatus(PlayerStatus.PREPARING, media); +// try { +// int position = media.getPosition(); +// if (position > 0) { +// position = RewindAfterPauseUtils.calculatePositionWithRewind( +// position, +// media.getLastPlayedTime()); +// } +// // TODO We're not supporting user set stream volume yet, as we need to make a UI +// // that doesn't allow changing playback speed or have different values for left/right +// //setVolume(UserPreferences.getLeftVolume(), UserPreferences.getRightVolume()); +// castMgr.loadMedia(remoteMedia, startWhenPrepared.get(), position); +// } catch (TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Error loading media", e); +// setPlayerStatus(PlayerStatus.INITIALIZED, media); +// } +// } +// } +// +// @Override +// public void reinit() { +// Log.d(TAG, "reinit() called"); +// if (media != null) { +// playMediaObject(media, true, false, startWhenPrepared.get(), false); +// } else { +// Log.d(TAG, "Call to reinit was ignored: media was null"); +// } +// } +// +// @Override +// public void seekTo(int t) { +// //TODO check other seek implementations and see if there's no issue with sending too many seek commands to the remote media player +// try { +// if (castMgr.isRemoteMediaLoaded()) { +// setPlayerStatus(PlayerStatus.SEEKING, media); +// castMgr.seek(t); +// } else if (media != null && playerStatus == PlayerStatus.INITIALIZED){ +// media.setPosition(t); +// startWhenPrepared.set(false); +// prepare(); +// } +// } catch (TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Unable to seek", e); +// } +// } +// +// @Override +// public void seekDelta(int d) { +// int position = getPosition(); +// if (position != INVALID_TIME) { +// seekTo(position + d); +// } else { +// Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta"); +// } +// } +// +// @Override +// public int getDuration() { +// int retVal = INVALID_TIME; +// boolean prepared; +// try { +// prepared = castMgr.isRemoteMediaLoaded(); +// } catch (TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Unable to check if remote media is loaded", e); +// prepared = playerStatus.isAtLeast(PlayerStatus.PREPARED); +// } +// if (prepared) { +// try { +// retVal = (int) castMgr.getMediaDuration(); +// } catch (TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Unable to determine remote media's duration", e); +// } +// } +// if(retVal == INVALID_TIME && media != null && media.getDuration() > 0) { +// retVal = media.getDuration(); +// } +// Log.d(TAG, "getDuration() -> " + retVal); +// return retVal; +// } +// +// @Override +// public int getPosition() { +// int retVal = INVALID_TIME; +// boolean prepared; +// try { +// prepared = castMgr.isRemoteMediaLoaded(); +// } catch (TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Unable to check if remote media is loaded", e); +// prepared = playerStatus.isAtLeast(PlayerStatus.PREPARED); +// } +// if (prepared) { +// try { +// retVal = (int) castMgr.getCurrentMediaPosition(); +// } catch (TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Unable to determine remote media's position", e); +// } +// } +// if(retVal <= 0 && media != null && media.getPosition() >= 0) { +// retVal = media.getPosition(); +// } +// Log.d(TAG, "getPosition() -> " + retVal); +// return retVal; +// } +// +// @Override +// public boolean isStartWhenPrepared() { +// return startWhenPrepared.get(); +// } +// +// @Override +// public void setStartWhenPrepared(boolean startWhenPrepared) { +// this.startWhenPrepared.set(startWhenPrepared); +// } +// +// //TODO I believe some parts of the code make the same decision skipping this check, so that +// //should be changed as well +// @Override +// public boolean canSetSpeed() { +// return false; +// } +// +// @Override +// public void setSpeed(float speed) { +// throw new UnsupportedOperationException("Setting playback speed unsupported for Remote Playback"); +// } +// +// @Override +// public float getPlaybackSpeed() { +// return 1; +// } +// +// @Override +// public void setVolume(float volumeLeft, float volumeRight) { +// Log.d(TAG, "Setting the Stream volume on Remote Media Player"); +// double volume = (volumeLeft+volumeRight)/2; +// if (volume > 1.0) { +// volume = 1.0; +// } +// if (volume < 0.0) { +// volume = 0.0; +// } +// try { +// castMgr.setStreamVolume(volume); +// } catch (TransientNetworkDisconnectionException | NoConnectionException | CastException e) { +// Log.e(TAG, "Unable to set the volume", e); +// } +// } +// +// @Override +// public boolean canDownmix() { +// return false; +// } +// +// @Override +// public void setDownmix(boolean enable) { +// throw new UnsupportedOperationException("Setting downmix unsupported in Remote Media Player"); +// } +// +// @Override +// public MediaType getCurrentMediaType() { +// return mediaType; +// } +// +// @Override +// public boolean isStreaming() { +// return true; +// } +// +// @Override +// public void shutdown() { +// castMgr.removeCastConsumer(castConsumer); +// } +// +// @Override +// public void shutdownQuietly() { +// shutdown(); +// } +// +// @Override +// public void setVideoSurface(SurfaceHolder surface) { +// throw new UnsupportedOperationException("Setting Video Surface unsupported in Remote Media Player"); +// } +// +// @Override +// public void resetVideoSurface() { +// Log.e(TAG, "Resetting Video Surface unsupported in Remote Media Player"); +// } +// +// @Override +// public Pair<Integer, Integer> getVideoSize() { +// return null; +// } +// +// @Override +// public Playable getPlayable() { +// return media; +// } +// +// @Override +// protected void setPlayable(Playable playable) { +// if (playable != media) { +// media = playable; +// remoteMedia = remoteVersion(playable); +// } +// } +// +// @Override +// public void endPlayback(boolean wasSkipped, boolean switchingPlayers) { +// Log.d(TAG, "endPlayback() called"); +// boolean isPlaying = playerStatus == PlayerStatus.PLAYING; +// try { +// isPlaying = castMgr.isRemoteMediaPlaying(); +// } catch (TransientNetworkDisconnectionException | NoConnectionException e) { +// Log.e(TAG, "Could not determine if media is playing", e); +// } +// // TODO make sure we stop playback whenever there's no next episode. +// if (playerStatus != PlayerStatus.INDETERMINATE) { +// setPlayerStatus(PlayerStatus.INDETERMINATE, media); +// } +// callback.endPlayback(media, isPlaying, wasSkipped, switchingPlayers); +// } +// +// @Override +// public void stop() { +// if (playerStatus == PlayerStatus.INDETERMINATE) { +// setPlayerStatus(PlayerStatus.STOPPED, null); +// } else { +// Log.d(TAG, "Ignored call to stop: Current player state is: " + playerStatus); +// } +// } +// +// @Override +// protected boolean shouldLockWifi() { +// return false; +// } +// +// private interface EndPlaybackCall { +// boolean endPlayback(Playable media, boolean playNextEpisode, boolean wasSkipped, boolean switchingPlayers); +// } +//} diff --git a/core/src/free/java/de/danoeh/antennapod/core/util/playback/Playable.java b/core/src/free/java/de/danoeh/antennapod/core/util/playback/Playable.java new file mode 100644 index 000000000..bc22e063c --- /dev/null +++ b/core/src/free/java/de/danoeh/antennapod/core/util/playback/Playable.java @@ -0,0 +1,245 @@ +package de.danoeh.antennapod.core.util.playback; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Parcelable; +import android.util.Log; + +import java.util.List; + +import de.danoeh.antennapod.core.asynctask.ImageResource; +import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.util.ShownotesProvider; + +/** + * Interface for objects that can be played by the PlaybackService. + */ +public interface Playable extends Parcelable, + ShownotesProvider, ImageResource { + + /** + * Save information about the playable in a preference so that it can be + * restored later via PlayableUtils.createInstanceFromPreferences. + * Implementations must NOT call commit() after they have written the values + * to the preferences file. + */ + void writeToPreferences(SharedPreferences.Editor prefEditor); + + /** + * This method is called from a separate thread by the PlaybackService. + * Playable objects should load their metadata in this method. This method + * should execute as quickly as possible and NOT load chapter marks if no + * local file is available. + */ + void loadMetadata() throws PlayableException; + + /** + * This method is called from a separate thread by the PlaybackService. + * Playable objects should load their chapter marks in this method if no + * local file was available when loadMetadata() was called. + */ + void loadChapterMarks(); + + /** + * Returns the title of the episode that this playable represents + */ + String getEpisodeTitle(); + + /** + * Returns a list of chapter marks or null if this Playable has no chapters. + */ + List<Chapter> getChapters(); + + /** + * Returns a link to a website that is meant to be shown in a browser + */ + String getWebsiteLink(); + + String getPaymentLink(); + + /** + * Returns the title of the feed this Playable belongs to. + */ + String getFeedTitle(); + + /** + * Returns a unique identifier, for example a file url or an ID from a + * database. + */ + Object getIdentifier(); + + /** + * Return duration of object or 0 if duration is unknown. + */ + int getDuration(); + + /** + * Return position of object or 0 if position is unknown. + */ + int getPosition(); + + /** + * Returns last time (in ms) when this playable was played or 0 + * if last played time is unknown. + */ + long getLastPlayedTime(); + + /** + * Returns the type of media. This method should return the correct value + * BEFORE loadMetadata() is called. + */ + MediaType getMediaType(); + + /** + * Returns an url to a local file that can be played or null if this file + * does not exist. + */ + String getLocalMediaUrl(); + + /** + * Returns an url to a file that can be streamed by the player or null if + * this url is not known. + */ + String getStreamUrl(); + + /** + * Returns true if a local file that can be played is available. getFileUrl + * MUST return a non-null string if this method returns true. + */ + boolean localFileAvailable(); + + /** + * Returns true if a streamable file is available. getStreamUrl MUST return + * a non-null string if this method returns true. + */ + boolean streamAvailable(); + + /** + * Saves the current position of this object. Implementations can use the + * provided SharedPreference to save this information and retrieve it later + * via PlayableUtils.createInstanceFromPreferences. + * + * @param pref shared prefs that might be used to store this object + * @param newPosition new playback position in ms + * @param timestamp current time in ms + */ + void saveCurrentPosition(SharedPreferences pref, int newPosition, long timestamp); + + void setPosition(int newPosition); + + void setDuration(int newDuration); + + /** + * @param lastPlayedTimestamp timestamp in ms + */ + void setLastPlayedTime(long lastPlayedTimestamp); + + /** + * Is called by the PlaybackService when playback starts. + */ + void onPlaybackStart(); + + /** + * Is called by the PlaybackService when playback is completed. + */ + void onPlaybackCompleted(); + + /** + * Returns an integer that must be unique among all Playable classes. The + * return value is later used by PlayableUtils to determine the type of the + * Playable object that is restored. + */ + int getPlayableType(); + + void setChapters(List<Chapter> chapters); + + /** + * Provides utility methods for Playable objects. + */ + class PlayableUtils { + private static final String TAG = "PlayableUtils"; + + /** + * Restores a playable object from a sharedPreferences file. This method might load data from the database, + * depending on the type of playable that was restored. + * + * @param type An integer that represents the type of the Playable object + * that is restored. + * @param pref The SharedPreferences file from which the Playable object + * is restored + * @return The restored Playable object + */ + public static Playable createInstanceFromPreferences(Context context, int type, + SharedPreferences pref) { + Playable result = null; + // ADD new Playable types here: + switch (type) { + case FeedMedia.PLAYABLE_TYPE_FEEDMEDIA: + result = createFeedMediaInstance(pref); + break; + case ExternalMedia.PLAYABLE_TYPE_EXTERNAL_MEDIA: + result = createExternalMediaInstance(pref); + break; +// case RemoteMedia.PLAYABLE_TYPE_REMOTE_MEDIA: +// result = createRemoteMediaInstance(pref); +// break; + } + if (result == null) { + Log.e(TAG, "Could not restore Playable object from preferences"); + } + return result; + } + + private static Playable createFeedMediaInstance(SharedPreferences pref) { + Playable result = null; + long mediaId = pref.getLong(FeedMedia.PREF_MEDIA_ID, -1); + if (mediaId != -1) { + result = DBReader.getFeedMedia(mediaId); + } + return result; + } + + private static Playable createExternalMediaInstance(SharedPreferences pref) { + Playable result = null; + String source = pref.getString(ExternalMedia.PREF_SOURCE_URL, null); + String mediaType = pref.getString(ExternalMedia.PREF_MEDIA_TYPE, null); + if (source != null && mediaType != null) { + int position = pref.getInt(ExternalMedia.PREF_POSITION, 0); + long lastPlayedTime = pref.getLong(ExternalMedia.PREF_LAST_PLAYED_TIME, 0); + result = new ExternalMedia(source, MediaType.valueOf(mediaType), + position, lastPlayedTime); + } + return result; + } + + private static Playable createRemoteMediaInstance(SharedPreferences pref) { + //TODO there's probably no point in restoring RemoteMedia from preferences, because we + //only care about it while it's playing on the cast device. + return null; + } + } + + class PlayableException extends Exception { + private static final long serialVersionUID = 1L; + + public PlayableException() { + super(); + } + + public PlayableException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } + + public PlayableException(String detailMessage) { + super(detailMessage); + } + + public PlayableException(Throwable throwable) { + super(throwable); + } + + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java b/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java index 9bbccbb82..9bbccbb82 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java +++ b/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/CastConsumer.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java index 213dd1875..213dd1875 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/cast/CastConsumer.java +++ b/core/src/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/CastManager.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java index 5b1fdab61..5b1fdab61 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/cast/CastManager.java +++ b/core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/CastUtils.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java index f0a7214c9..f0a7214c9 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/cast/CastUtils.java +++ b/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java b/core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java index fe4183d54..fe4183d54 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java +++ b/core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/RemoteMedia.java b/core/src/play/java/de/danoeh/antennapod/core/cast/RemoteMedia.java index e2d8f8ad5..e2d8f8ad5 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/cast/RemoteMedia.java +++ b/core/src/play/java/de/danoeh/antennapod/core/cast/RemoteMedia.java diff --git a/core/src/main/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java b/core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java index f063cf5e3..f063cf5e3 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java +++ b/core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java b/core/src/play/java/de/danoeh/antennapod/core/feed/FeedMedia.java index 068669af9..068669af9 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java +++ b/core/src/play/java/de/danoeh/antennapod/core/feed/FeedMedia.java diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/play/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java index e2d63a385..e2d63a385 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java +++ b/core/src/play/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java b/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java index 4262b8a70..4262b8a70 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java +++ b/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java b/core/src/play/java/de/danoeh/antennapod/core/util/playback/Playable.java index 201efbc81..201efbc81 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java +++ b/core/src/play/java/de/danoeh/antennapod/core/util/playback/Playable.java |