summaryrefslogtreecommitdiff
path: root/core/src/play/java
diff options
context:
space:
mode:
authorMartin Fietz <Martin.Fietz@gmail.com>2016-06-04 01:36:25 +0200
committerMartin Fietz <Martin.Fietz@gmail.com>2016-06-04 01:36:25 +0200
commit3c033cc0fbaa4b1418cda330ac0287d735c621f2 (patch)
tree44d563b702ef5ebe466ca3e4953e654e220ff328 /core/src/play/java
parent0aaa14923c9b99c6bda8e4a72c0dd61202ae9680 (diff)
downloadAntennaPod-3c033cc0fbaa4b1418cda330ac0287d735c621f2.zip
Create one flavor with Google Cast support and one (free) without
Diffstat (limited to 'core/src/play/java')
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java49
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java11
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java1766
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java317
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java10
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/cast/RemoteMedia.java357
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java106
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/feed/FeedMedia.java568
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java1780
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java592
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/util/playback/Playable.java246
11 files changed, 5802 insertions, 0 deletions
diff --git a/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java b/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java
new file mode 100644
index 000000000..9bbccbb82
--- /dev/null
+++ b/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java
@@ -0,0 +1,49 @@
+package de.danoeh.antennapod.core;
+
+import android.content.Context;
+
+import de.danoeh.antennapod.core.cast.CastManager;
+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/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java
new file mode 100644
index 000000000..213dd1875
--- /dev/null
+++ b/core/src/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java
@@ -0,0 +1,11 @@
+package de.danoeh.antennapod.core.cast;
+
+import com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumer;
+
+public interface CastConsumer extends VideoCastConsumer{
+
+ /**
+ * Called when the stream's volume is changed.
+ */
+ void onStreamVolumeChanged(double value, boolean isMute);
+}
diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java
new file mode 100644
index 000000000..5b1fdab61
--- /dev/null
+++ b/core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java
@@ -0,0 +1,1766 @@
+/*
+ * Copyright (C) 2015 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * ------------------------------------------------------------------------
+ *
+ * Changes made by Domingos Lopes <domingos86lopes@gmail.com>
+ *
+ * original can be found at http://www.github.com/googlecast/CastCompanionLibrary-android
+ */
+
+package de.danoeh.antennapod.core.cast;
+
+import android.content.Context;
+import android.os.Build;
+import android.support.v4.view.MenuItemCompat;
+import android.support.v7.media.MediaRouter;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.MenuItem;
+
+import com.google.android.gms.cast.ApplicationMetadata;
+import com.google.android.gms.cast.Cast;
+import com.google.android.gms.cast.CastDevice;
+import com.google.android.gms.cast.CastMediaControlIntent;
+import com.google.android.gms.cast.CastStatusCodes;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaMetadata;
+import com.google.android.gms.cast.MediaQueueItem;
+import com.google.android.gms.cast.MediaStatus;
+import com.google.android.gms.cast.RemoteMediaPlayer;
+import com.google.android.gms.common.ConnectionResult;
+import com.google.android.gms.common.GoogleApiAvailability;
+import com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager;
+import com.google.android.libraries.cast.companionlibrary.cast.CastConfiguration;
+import com.google.android.libraries.cast.companionlibrary.cast.MediaQueue;
+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.OnFailedListener;
+import com.google.android.libraries.cast.companionlibrary.cast.exceptions.TransientNetworkDisconnectionException;
+
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.TimeUnit;
+
+import de.danoeh.antennapod.core.R;
+
+import static com.google.android.gms.cast.RemoteMediaPlayer.RESUME_STATE_PLAY;
+import static com.google.android.gms.cast.RemoteMediaPlayer.RESUME_STATE_UNCHANGED;
+
+/**
+ * A subclass of {@link BaseCastManager} that is suitable for casting video contents (it
+ * also provides a single custom data channel/namespace if an out-of-band communication is
+ * needed).
+ * <p>
+ * Clients need to initialize this class by calling
+ * {@link #init(android.content.Context)} in the Application's
+ * {@code onCreate()} method. To access the (singleton) instance of this class, clients
+ * need to call {@link #getInstance()}.
+ * <p>This
+ * class manages various states of the remote cast device. Client applications, however, can
+ * complement the default behavior of this class by hooking into various callbacks that it provides
+ * (see {@link CastConsumer}).
+ * Since the number of these callbacks is usually much larger than what a single application might
+ * be interested in, there is a no-op implementation of this interface (see
+ * {@link DefaultCastConsumer}) that applications can subclass to override only those methods that
+ * they are interested in. Since this library depends on the cast functionalities provided by the
+ * Google Play services, the library checks to ensure that the right version of that service is
+ * installed. It also provides a simple static method {@code checkGooglePlayServices()} that clients
+ * can call at an early stage of their applications to provide a dialog for users if they need to
+ * update/activate their Google Play Services library.
+ *
+ * @see CastConfiguration
+ */
+public class CastManager extends BaseCastManager implements OnFailedListener {
+ public static final String TAG = "CastManager";
+
+ public static final String CAST_APP_ID = CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID;
+
+ public static final double DEFAULT_VOLUME_STEP = 0.05;
+ public static final long DEFAULT_LIVE_STREAM_DURATION_MS = TimeUnit.HOURS.toMillis(2);
+ private double volumeStep = DEFAULT_VOLUME_STEP;
+ private MediaQueue mediaQueue;
+ private MediaStatus mediaStatus;
+
+ private static CastManager INSTANCE;
+ private RemoteMediaPlayer remoteMediaPlayer;
+ private int state = MediaStatus.PLAYER_STATE_IDLE;
+ private int idleReason;
+ private final Set<CastConsumer> castConsumers = new CopyOnWriteArraySet<>();
+ private long liveStreamDuration = DEFAULT_LIVE_STREAM_DURATION_MS;
+ private MediaQueueItem preLoadingItem;
+
+ public static final int QUEUE_OPERATION_LOAD = 1;
+ public static final int QUEUE_OPERATION_INSERT_ITEMS = 2;
+ public static final int QUEUE_OPERATION_UPDATE_ITEMS = 3;
+ public static final int QUEUE_OPERATION_JUMP = 4;
+ public static final int QUEUE_OPERATION_REMOVE_ITEM = 5;
+ public static final int QUEUE_OPERATION_REMOVE_ITEMS = 6;
+ public static final int QUEUE_OPERATION_REORDER = 7;
+ public static final int QUEUE_OPERATION_MOVE = 8;
+ public static final int QUEUE_OPERATION_APPEND = 9;
+ public static final int QUEUE_OPERATION_NEXT = 10;
+ public static final int QUEUE_OPERATION_PREV = 11;
+ public static final int QUEUE_OPERATION_SET_REPEAT = 12;
+
+ private CastManager(Context context, CastConfiguration castConfiguration) {
+ super(context, castConfiguration);
+ Log.d(TAG, "CastManager is instantiated");
+ }
+
+ public static synchronized CastManager init(Context context) {
+ if (INSTANCE == null) {
+ //TODO also setup dialog factory if necessary
+ CastConfiguration castConfiguration = new CastConfiguration.Builder(CAST_APP_ID)
+ .enableDebug()
+ .enableAutoReconnect()
+ .enableWifiReconnection()
+ .setLaunchOptions(true, Locale.getDefault())
+ .build();
+ Log.d(TAG, "New instance of CastManager is created");
+ if (ConnectionResult.SUCCESS != GoogleApiAvailability.getInstance()
+ .isGooglePlayServicesAvailable(context)) {
+ Log.e(TAG, "Couldn't find the appropriate version of Google Play Services");
+ //TODO check whether creating an instance without google play services installed actually gives an exception
+ }
+ INSTANCE = new CastManager(context, castConfiguration);
+ }
+ return INSTANCE;
+ }
+
+ /**
+ * Returns a (singleton) instance of this class. Clients should call this method in order to
+ * get a hold of this singleton instance, only after it is initialized. If it is not initialized
+ * yet, an {@link IllegalStateException} will be thrown.
+ *
+ */
+ public static CastManager getInstance() {
+ if (INSTANCE == null) {
+ String msg = "No CastManager instance was found, did you forget to initialize it?";
+ Log.e(TAG, msg);
+ throw new IllegalStateException(msg);
+ }
+ return INSTANCE;
+ }
+
+ /**
+ * Returns the active {@link RemoteMediaPlayer} instance. Since there are a number of media
+ * control APIs that this library do not provide a wrapper for, client applications can call
+ * those methods directly after obtaining an instance of the active {@link RemoteMediaPlayer}.
+ */
+ public final RemoteMediaPlayer getRemoteMediaPlayer() {
+ return remoteMediaPlayer;
+ }
+
+ /**
+ * Determines if the media that is loaded remotely is a live stream or not.
+ *
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public final boolean isRemoteStreamLive() throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ MediaInfo info = getRemoteMediaInformation();
+ return (info != null) && (info.getStreamType() == MediaInfo.STREAM_TYPE_LIVE);
+ }
+
+ /*
+ * A simple check to make sure remoteMediaPlayer is not null
+ */
+ private void checkRemoteMediaPlayerAvailable() throws NoConnectionException {
+ if (remoteMediaPlayer == null) {
+ throw new NoConnectionException();
+ }
+ }
+
+ /**
+ * Returns the url for the media that is currently playing on the remote device. If there is no
+ * connection, this will return <code>null</code>.
+ *
+ * @throws NoConnectionException If no connectivity to the device exists
+ * @throws TransientNetworkDisconnectionException If framework is still trying to recover from
+ * a possibly transient loss of network
+ */
+ public String getRemoteMediaUrl() throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ if (remoteMediaPlayer != null && remoteMediaPlayer.getMediaInfo() != null) {
+ MediaInfo info = remoteMediaPlayer.getMediaInfo();
+ remoteMediaPlayer.getMediaStatus().getPlayerState();
+ return info.getContentId();
+ }
+ throw new NoConnectionException();
+ }
+
+ /**
+ * Indicates if the remote media is currently playing (or buffering).
+ *
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public boolean isRemoteMediaPlaying() throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ return state == MediaStatus.PLAYER_STATE_BUFFERING
+ || state == MediaStatus.PLAYER_STATE_PLAYING;
+ }
+
+ /**
+ * Returns <code>true</code> if the remote connected device is playing a movie.
+ *
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public boolean isRemoteMediaPaused() throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ return state == MediaStatus.PLAYER_STATE_PAUSED;
+ }
+
+ /**
+ * Returns <code>true</code> only if there is a media on the remote being played, paused or
+ * buffered.
+ *
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public boolean isRemoteMediaLoaded() throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ return isRemoteMediaPaused() || isRemoteMediaPlaying();
+ }
+
+ /**
+ * Returns the {@link MediaInfo} for the current media
+ *
+ * @throws NoConnectionException If no connectivity to the device exists
+ * @throws TransientNetworkDisconnectionException If framework is still trying to recover from
+ * a possibly transient loss of network
+ */
+ public MediaInfo getRemoteMediaInformation() throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ checkRemoteMediaPlayerAvailable();
+ return remoteMediaPlayer.getMediaInfo();
+ }
+
+ /**
+ * Gets the remote's system volume. It internally detects what type of volume is used.
+ *
+ * @throws NoConnectionException If no connectivity to the device exists
+ * @throws TransientNetworkDisconnectionException If framework is still trying to recover from
+ * a possibly transient loss of network
+ */
+ public double getStreamVolume() throws TransientNetworkDisconnectionException, NoConnectionException {
+ checkConnectivity();
+ checkRemoteMediaPlayerAvailable();
+ return remoteMediaPlayer.getMediaStatus().getStreamVolume();
+ }
+
+ /**
+ * Sets the stream volume.
+ *
+ * @param volume Should be a value between 0 and 1, inclusive.
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ * @throws CastException If setting system volume fails
+ */
+ public void setStreamVolume(double volume) throws CastException,
+ TransientNetworkDisconnectionException, NoConnectionException {
+ checkConnectivity();
+ if (volume > 1.0) {
+ volume = 1.0;
+ } else if (volume < 0) {
+ volume = 0.0;
+ }
+
+ RemoteMediaPlayer mediaPlayer = getRemoteMediaPlayer();
+ if (mediaPlayer == null) {
+ throw new NoConnectionException();
+ }
+ mediaPlayer.setStreamVolume(mApiClient, volume).setResultCallback(
+ (result) -> {
+ if (!result.getStatus().isSuccess()) {
+ onFailed(R.string.cast_failed_setting_volume,
+ result.getStatus().getStatusCode());
+ } else {
+ CastManager.this.onStreamVolumeChanged();
+ }
+ });
+ }
+
+ /**
+ * Returns <code>true</code> if remote Stream is muted.
+ *
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public boolean isStreamMute() throws TransientNetworkDisconnectionException, NoConnectionException {
+ checkConnectivity();
+ checkRemoteMediaPlayerAvailable();
+ return remoteMediaPlayer.getMediaStatus().isMute();
+ }
+
+ /**
+ * Returns <code>true</code> if remote device is muted.
+ *
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public boolean isMute() throws TransientNetworkDisconnectionException, NoConnectionException {
+ return isStreamMute() || isDeviceMute();
+ }
+
+ /**
+ * Mutes or un-mutes the stream volume.
+ *
+ * @throws CastException
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void setStreamMute(boolean mute) throws CastException, TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ checkRemoteMediaPlayerAvailable();
+ remoteMediaPlayer.setStreamMute(mApiClient, mute);
+ }
+
+ /**
+ * Returns the duration of the media that is loaded, in milliseconds.
+ *
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public long getMediaDuration() throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ checkRemoteMediaPlayerAvailable();
+ return remoteMediaPlayer.getStreamDuration();
+ }
+
+ /**
+ * Returns the time left (in milliseconds) of the current media. If there is no
+ * {@code RemoteMediaPlayer}, it returns -1.
+ *
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public long getMediaTimeRemaining()
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ return -1;
+ }
+ return isRemoteStreamLive() ? liveStreamDuration : remoteMediaPlayer.getStreamDuration()
+ - remoteMediaPlayer.getApproximateStreamPosition();
+ }
+
+ /**
+ * Returns the current (approximate) position of the current media, in milliseconds.
+ *
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public long getCurrentMediaPosition() throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ checkRemoteMediaPlayerAvailable();
+ return remoteMediaPlayer.getApproximateStreamPosition();
+ }
+
+ public int getApplicationStandbyState() throws IllegalStateException {
+ Log.d(TAG, "getApplicationStandbyState()");
+ return Cast.CastApi.getStandbyState(mApiClient);
+ }
+
+ private void onApplicationDisconnected(int errorCode) {
+ Log.d(TAG, "onApplicationDisconnected() reached with error code: " + errorCode);
+ mApplicationErrorCode = errorCode;
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onApplicationDisconnected(errorCode);
+ }
+ if (mMediaRouter != null) {
+ Log.d(TAG, "onApplicationDisconnected(): Cached RouteInfo: " + getRouteInfo());
+ Log.d(TAG, "onApplicationDisconnected(): Selected RouteInfo: "
+ + mMediaRouter.getSelectedRoute());
+ if (getRouteInfo() == null || mMediaRouter.getSelectedRoute().equals(getRouteInfo())) {
+ Log.d(TAG, "onApplicationDisconnected(): Setting route to default");
+ mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute());
+ }
+ }
+ onDeviceSelected(null /* CastDevice */, null /* RouteInfo */);
+ }
+
+ private void onApplicationStatusChanged() {
+ if (!isConnected()) {
+ return;
+ }
+ try {
+ String appStatus = Cast.CastApi.getApplicationStatus(mApiClient);
+ Log.d(TAG, "onApplicationStatusChanged() reached: " + appStatus);
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onApplicationStatusChanged(appStatus);
+ }
+ } catch (IllegalStateException e) {
+ Log.e(TAG, "onApplicationStatusChanged()", e);
+ }
+ }
+
+ private void onDeviceVolumeChanged() {
+ Log.d(TAG, "onDeviceVolumeChanged() reached");
+ double volume;
+ try {
+ volume = getDeviceVolume();
+ boolean isMute = isDeviceMute();
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onVolumeChanged(volume, isMute);
+ }
+ } catch (TransientNetworkDisconnectionException | NoConnectionException e) {
+ Log.e(TAG, "Failed to get volume", e);
+ }
+
+ }
+
+ private void onStreamVolumeChanged() {
+ Log.d(TAG, "onStreamVolumeChanged() reached");
+ double volume;
+ try {
+ volume = getStreamVolume();
+ boolean isMute = isStreamMute();
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onStreamVolumeChanged(volume, isMute);
+ }
+ } catch (TransientNetworkDisconnectionException | NoConnectionException e) {
+ Log.e(TAG, "Failed to get volume", e);
+ }
+ }
+
+ @Override
+ protected void onApplicationConnected(ApplicationMetadata appMetadata,
+ String applicationStatus, String sessionId, boolean wasLaunched) {
+ Log.d(TAG, "onApplicationConnected() reached with sessionId: " + sessionId
+ + ", and mReconnectionStatus=" + mReconnectionStatus);
+ mApplicationErrorCode = NO_APPLICATION_ERROR;
+ if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) {
+ // we have tried to reconnect and successfully launched the app, so
+ // it is time to select the route and make the cast icon happy :-)
+ List<MediaRouter.RouteInfo> routes = mMediaRouter.getRoutes();
+ if (routes != null) {
+ String routeId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_ROUTE_ID);
+ for (MediaRouter.RouteInfo routeInfo : routes) {
+ if (routeId.equals(routeInfo.getId())) {
+ // found the right route
+ Log.d(TAG, "Found the correct route during reconnection attempt");
+ mReconnectionStatus = RECONNECTION_STATUS_FINALIZED;
+ mMediaRouter.selectRoute(routeInfo);
+ break;
+ }
+ }
+ }
+ }
+ try {
+ //attachDataChannel();
+ attachMediaChannel();
+ mSessionId = sessionId;
+ // saving device for future retrieval; we only save the last session info
+ mPreferenceAccessor.saveStringToPreference(PREFS_KEY_SESSION_ID, mSessionId);
+ remoteMediaPlayer.requestStatus(mApiClient).setResultCallback(result -> {
+ if (!result.getStatus().isSuccess()) {
+ onFailed(R.string.cast_failed_status_request,
+ result.getStatus().getStatusCode());
+ }
+ });
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onApplicationConnected(appMetadata, mSessionId, wasLaunched);
+ }
+ } catch (TransientNetworkDisconnectionException e) {
+ Log.e(TAG, "Failed to attach media/data channel due to network issues", e);
+ onFailed(R.string.cast_failed_no_connection_trans, NO_STATUS_CODE);
+ } catch (NoConnectionException e) {
+ Log.e(TAG, "Failed to attach media/data channel due to network issues", e);
+ onFailed(R.string.cast_failed_no_connection, NO_STATUS_CODE);
+ }
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager
+ * #onConnectivityRecovered()
+ */
+ @Override
+ public void onConnectivityRecovered() {
+ reattachMediaChannel();
+ //reattachDataChannel();
+ super.onConnectivityRecovered();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see com.google.android.gms.cast.CastClient.Listener#onApplicationStopFailed (int)
+ */
+ @Override
+ public void onApplicationStopFailed(int errorCode) {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onApplicationStopFailed(errorCode);
+ }
+ }
+
+ @Override
+ public void onApplicationConnectionFailed(int errorCode) {
+ Log.d(TAG, "onApplicationConnectionFailed() reached with errorCode: " + errorCode);
+ mApplicationErrorCode = errorCode;
+ if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) {
+ if (errorCode == CastStatusCodes.APPLICATION_NOT_RUNNING) {
+ // while trying to re-establish session, we found out that the app is not running
+ // so we need to disconnect
+ mReconnectionStatus = RECONNECTION_STATUS_INACTIVE;
+ onDeviceSelected(null /* CastDevice */, null /* RouteInfo */);
+ }
+ } else {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onApplicationConnectionFailed(errorCode);
+ }
+ onDeviceSelected(null /* CastDevice */, null /* RouteInfo */);
+ if (mMediaRouter != null) {
+ Log.d(TAG, "onApplicationConnectionFailed(): Setting route to default");
+ mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute());
+ }
+ }
+ }
+
+ /**
+ * Loads a media. For this to succeed, you need to have successfully launched the application.
+ *
+ * @param media The media to be loaded
+ * @param autoPlay If <code>true</code>, playback starts after load
+ * @param position Where to start the playback (only used if autoPlay is <code>true</code>.
+ * Units is milliseconds.
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void loadMedia(MediaInfo media, boolean autoPlay, int position)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ loadMedia(media, autoPlay, position, null);
+ }
+
+ /**
+ * Loads a media. For this to succeed, you need to have successfully launched the application.
+ *
+ * @param media The media to be loaded
+ * @param autoPlay If <code>true</code>, playback starts after load
+ * @param position Where to start the playback (only used if autoPlay is <code>true</code>).
+ * Units is milliseconds.
+ * @param customData Optional {@link JSONObject} data to be passed to the cast device
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void loadMedia(MediaInfo media, boolean autoPlay, int position, JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ loadMedia(media, null, autoPlay, position, customData);
+ }
+
+ /**
+ * Loads a media. For this to succeed, you need to have successfully launched the application.
+ *
+ * @param media The media to be loaded
+ * @param activeTracks An array containing the list of track IDs to be set active for this
+ * media upon a successful load
+ * @param autoPlay If <code>true</code>, playback starts after load
+ * @param position Where to start the playback (only used if autoPlay is <code>true</code>).
+ * Units is milliseconds.
+ * @param customData Optional {@link JSONObject} data to be passed to the cast device
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void loadMedia(MediaInfo media, final long[] activeTracks, boolean autoPlay,
+ int position, JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "loadMedia");
+ checkConnectivity();
+ if (media == null) {
+ return;
+ }
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to load a video with no active media session");
+ throw new NoConnectionException();
+ }
+
+ Log.d(TAG, "remoteMediaPlayer.load() with media=" + media.getMetadata().getString(MediaMetadata.KEY_TITLE)
+ + ", position=" + position + ", autoplay=" + autoPlay);
+ remoteMediaPlayer.load(mApiClient, media, autoPlay, position, activeTracks, customData)
+ .setResultCallback(result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaLoadResult(result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Loads and optionally starts playback of a new queue of media items.
+ *
+ * @param items Array of items to load, in the order that they should be played. Must not be
+ * {@code null} or empty.
+ * @param startIndex The array index of the item in the {@code items} array that should be
+ * played first (i.e., it will become the currentItem).If {@code repeatMode}
+ * is {@link MediaStatus#REPEAT_MODE_REPEAT_OFF} playback will end when the
+ * last item in the array is played.
+ * <p>
+ * This may be useful for continuation scenarios where the user was already
+ * using the sender application and in the middle decides to cast. This lets
+ * the sender application avoid mapping between the local and remote queue
+ * positions and/or avoid issuing an extra request to update the queue.
+ * <p>
+ * This value must be less than the length of {@code items}.
+ * @param repeatMode The repeat playback mode for the queue. One of
+ * {@link MediaStatus#REPEAT_MODE_REPEAT_OFF},
+ * {@link MediaStatus#REPEAT_MODE_REPEAT_ALL},
+ * {@link MediaStatus#REPEAT_MODE_REPEAT_SINGLE} and
+ * {@link MediaStatus#REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE}.
+ * @param customData Custom application-specific data to pass along with the request, may be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void queueLoad(final MediaQueueItem[] items, final int startIndex, final int repeatMode,
+ final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "queueLoad");
+ checkConnectivity();
+ if (items == null || items.length == 0) {
+ return;
+ }
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to queue one or more videos with no active media session");
+ throw new NoConnectionException();
+ }
+ Log.d(TAG, "remoteMediaPlayer.queueLoad() with " + items.length + "items, starting at "
+ + startIndex);
+ remoteMediaPlayer
+ .queueLoad(mApiClient, items, startIndex, repeatMode, customData)
+ .setResultCallback(result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_LOAD,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Inserts a list of new media items into the queue.
+ *
+ * @param itemsToInsert List of items to insert into the queue, in the order that they should be
+ * played. The itemId field of the items should be unassigned or the
+ * request will fail with an INVALID_PARAMS error. Must not be {@code null}
+ * or empty.
+ * @param insertBeforeItemId ID of the item that will be located immediately after the inserted
+ * list. If the value is {@link MediaQueueItem#INVALID_ITEM_ID} or
+ * invalid, the inserted list will be appended to the end of the
+ * queue.
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ * @throws IllegalArgumentException
+ */
+ public void queueInsertItems(final MediaQueueItem[] itemsToInsert, final int insertBeforeItemId,
+ final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "queueInsertItems");
+ checkConnectivity();
+ if (itemsToInsert == null || itemsToInsert.length == 0) {
+ throw new IllegalArgumentException("items cannot be empty or null");
+ }
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to insert into queue with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queueInsertItems(mApiClient, itemsToInsert, insertBeforeItemId, customData)
+ .setResultCallback(
+ result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(
+ QUEUE_OPERATION_INSERT_ITEMS,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Updates properties of a subset of the existing items in the media queue.
+ *
+ * @param itemsToUpdate List of queue items to be updated. The items will retain the existing
+ * order and will be fully replaced with the ones provided, including the
+ * media information. Any other items currently in the queue will remain
+ * unchanged. The tracks information can not change once the item is loaded
+ * (if the item is the currentItem). If any of the items does not exist it
+ * will be ignored.
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void queueUpdateItems(final MediaQueueItem[] itemsToUpdate, final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to update the queue with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queueUpdateItems(mApiClient, itemsToUpdate, customData).setResultCallback(
+ result -> {
+ Log.d(TAG, "queueUpdateItems() " + result.getStatus() + result.getStatus()
+ .isSuccess());
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_UPDATE_ITEMS,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Plays the item with {@code itemId} in the queue.
+ * <p>
+ * If {@code itemId} is not found in the queue, this method will report success without sending
+ * a request to the receiver.
+ *
+ * @param itemId The ID of the item to which to jump.
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ * @throws IllegalArgumentException
+ */
+ public void queueJumpToItem(int itemId, final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException,
+ IllegalArgumentException {
+ checkConnectivity();
+ if (itemId == MediaQueueItem.INVALID_ITEM_ID) {
+ throw new IllegalArgumentException("itemId is not valid");
+ }
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to jump in a queue with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queueJumpToItem(mApiClient, itemId, customData).setResultCallback(
+ result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_JUMP,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Removes a list of items from the queue. If the remaining queue is empty, the media session
+ * will be terminated.
+ *
+ * @param itemIdsToRemove The list of media item IDs to remove. Must not be {@code null} or
+ * empty.
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ * @throws IllegalArgumentException
+ */
+ public void queueRemoveItems(final int[] itemIdsToRemove, final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException,
+ IllegalArgumentException {
+ Log.d(TAG, "queueRemoveItems");
+ checkConnectivity();
+ if (itemIdsToRemove == null || itemIdsToRemove.length == 0) {
+ throw new IllegalArgumentException("itemIds cannot be empty or null");
+ }
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to remove items from queue with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queueRemoveItems(mApiClient, itemIdsToRemove, customData).setResultCallback(
+ result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_REMOVE_ITEMS,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Removes the item with {@code itemId} from the queue.
+ * <p>
+ * If {@code itemId} is not found in the queue, this method will silently return without sending
+ * a request to the receiver. A {@code itemId} may not be in the queue because it wasn't
+ * originally in the queue, or it was removed by another sender.
+ *
+ * @param itemId The ID of the item to be removed.
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ * @throws IllegalArgumentException
+ */
+ public void queueRemoveItem(final int itemId, final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException,
+ IllegalArgumentException {
+ Log.d(TAG, "queueRemoveItem");
+ checkConnectivity();
+ if (itemId == MediaQueueItem.INVALID_ITEM_ID) {
+ throw new IllegalArgumentException("itemId is invalid");
+ }
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to remove an item from queue with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queueRemoveItem(mApiClient, itemId, customData).setResultCallback(
+ result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_REMOVE_ITEM,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Reorder a list of media items in the queue.
+ *
+ * @param itemIdsToReorder The list of media item IDs to reorder, in the new order. Any other
+ * items currently in the queue will maintain their existing order. The
+ * list will be inserted just before the item specified by
+ * {@code insertBeforeItemId}, or at the end of the queue if
+ * {@code insertBeforeItemId} is {@link MediaQueueItem#INVALID_ITEM_ID}.
+ * <p>
+ * For example:
+ * <p>
+ * If insertBeforeItemId is not specified <br>
+ * Existing queue: "A","D","G","H","B","E" <br>
+ * itemIds: "D","H","B" <br>
+ * New Order: "A","G","E","D","H","B" <br>
+ * <p>
+ * If insertBeforeItemId is "A" <br>
+ * Existing queue: "A","D","G","H","B" <br>
+ * itemIds: "D","H","B" <br>
+ * New Order: "D","H","B","A","G","E" <br>
+ * <p>
+ * If insertBeforeItemId is "G" <br>
+ * Existing queue: "A","D","G","H","B" <br>
+ * itemIds: "D","H","B" <br>
+ * New Order: "A","D","H","B","G","E" <br>
+ * <p>
+ * If any of the items does not exist it will be ignored.
+ * Must not be {@code null} or empty.
+ * @param insertBeforeItemId ID of the item that will be located immediately after the reordered
+ * list. If set to {@link MediaQueueItem#INVALID_ITEM_ID}, the
+ * reordered list will be appended at the end of the queue.
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void queueReorderItems(final int[] itemIdsToReorder, final int insertBeforeItemId,
+ final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException,
+ IllegalArgumentException {
+ Log.d(TAG, "queueReorderItems");
+ checkConnectivity();
+ if (itemIdsToReorder == null || itemIdsToReorder.length == 0) {
+ throw new IllegalArgumentException("itemIdsToReorder cannot be empty or null");
+ }
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to reorder items in a queue with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queueReorderItems(mApiClient, itemIdsToReorder, insertBeforeItemId, customData)
+ .setResultCallback(
+ result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_REORDER,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Moves the item with {@code itemId} to a new position in the queue.
+ * <p>
+ * If {@code itemId} is not found in the queue, either because it wasn't there originally or it
+ * was removed by another sender before calling this function, this function will silently
+ * return without sending a request to the receiver.
+ *
+ * @param itemId The ID of the item to be moved.
+ * @param newIndex The new index of the item. If the value is negative, an error will be
+ * returned. If the value is out of bounds, or becomes out of bounds because the
+ * queue was shortened by another sender while this request is in progress, the
+ * item will be moved to the end of the queue.
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void queueMoveItemToNewIndex(int itemId, int newIndex, final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "queueMoveItemToNewIndex");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to mote item to new index with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queueMoveItemToNewIndex(mApiClient, itemId, newIndex, customData)
+ .setResultCallback(
+ result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_MOVE,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Appends a new media item to the end of the queue.
+ *
+ * @param item The item to append. Must not be {@code null}.
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void queueAppendItem(MediaQueueItem item, final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "queueAppendItem");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to append item with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queueAppendItem(mApiClient, item, customData)
+ .setResultCallback(
+ result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_APPEND,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Jumps to the next item in the queue.
+ *
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void queueNext(final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "queueNext");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to update the queue with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queueNext(mApiClient, customData).setResultCallback(
+ result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_NEXT,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Jumps to the previous item in the queue.
+ *
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void queuePrev(final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "queuePrev");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to update the queue with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queuePrev(mApiClient, customData).setResultCallback(
+ result -> {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_PREV,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Inserts an item in the queue and starts the playback of that newly inserted item. It is
+ * assumed that we are inserting before the "current item"
+ *
+ * @param item The item to be inserted
+ * @param insertBeforeItemId ID of the item that will be located immediately after the inserted
+ * and is assumed to be the "current item"
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ * @throws IllegalArgumentException
+ */
+ public void queueInsertBeforeCurrentAndPlay(MediaQueueItem item, int insertBeforeItemId,
+ final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "queueInsertBeforeCurrentAndPlay");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to insert into queue with no active media session");
+ throw new NoConnectionException();
+ }
+ if (item == null || insertBeforeItemId == MediaQueueItem.INVALID_ITEM_ID) {
+ throw new IllegalArgumentException(
+ "item cannot be empty or insertBeforeItemId cannot be invalid");
+ }
+ remoteMediaPlayer.queueInsertItems(mApiClient, new MediaQueueItem[]{item},
+ insertBeforeItemId, customData).setResultCallback(
+ result -> {
+ if (result.getStatus().isSuccess()) {
+
+ try {
+ queuePrev(customData);
+ } catch (TransientNetworkDisconnectionException |
+ NoConnectionException e) {
+ Log.e(TAG, "queuePrev() Failed to skip to previous", e);
+ }
+ }
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_INSERT_ITEMS,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Sets the repeat mode of the queue.
+ *
+ * @param repeatMode The repeat playback mode for the queue.
+ * @param customData Custom application-specific data to pass along with the request. May be
+ * {@code null}.
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void queueSetRepeatMode(final int repeatMode, final JSONObject customData)
+ throws TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "queueSetRepeatMode");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to update the queue with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer
+ .queueSetRepeatMode(mApiClient, repeatMode, customData).setResultCallback(
+ result -> {
+ if (!result.getStatus().isSuccess()) {
+ Log.d(TAG, "Failed with status: " + result.getStatus());
+ }
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueOperationResult(QUEUE_OPERATION_SET_REPEAT,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Plays the loaded media.
+ *
+ * @param position Where to start the playback. Units is milliseconds.
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void play(int position) throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ Log.d(TAG, "attempting to play media at position " + position + " seconds");
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to play a video with no active media session");
+ throw new NoConnectionException();
+ }
+ seekAndPlay(position);
+ }
+
+ /**
+ * Resumes the playback from where it was left (can be the beginning).
+ *
+ * @param customData Optional {@link JSONObject} data to be passed to the cast device
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void play(JSONObject customData) throws
+ TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "play(customData)");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to play a video with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer.play(mApiClient, customData)
+ .setResultCallback(result -> {
+ if (!result.getStatus().isSuccess()) {
+ onFailed(R.string.cast_failed_to_play,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Resumes the playback from where it was left (can be the beginning).
+ *
+ * @throws CastException
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void play() throws CastException, TransientNetworkDisconnectionException,
+ NoConnectionException {
+ play(null);
+ }
+
+ /**
+ * Stops the playback of media/stream
+ *
+ * @param customData Optional {@link JSONObject}
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void stop(JSONObject customData) throws
+ TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "stop()");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to stop a stream with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer.stop(mApiClient, customData).setResultCallback(
+ result -> {
+ if (!result.getStatus().isSuccess()) {
+ onFailed(R.string.cast_failed_to_stop,
+ result.getStatus().getStatusCode());
+ }
+ }
+ );
+ }
+
+ /**
+ * Stops the playback of media/stream
+ *
+ * @throws CastException
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void stop() throws CastException,
+ TransientNetworkDisconnectionException, NoConnectionException {
+ stop(null);
+ }
+
+ /**
+ * Pauses the playback.
+ *
+ * @throws CastException
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void pause() throws CastException, TransientNetworkDisconnectionException,
+ NoConnectionException {
+ pause(null);
+ }
+
+ /**
+ * Pauses the playback.
+ *
+ * @param customData Optional {@link JSONObject} data to be passed to the cast device
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void pause(JSONObject customData) throws
+ TransientNetworkDisconnectionException, NoConnectionException {
+ Log.d(TAG, "attempting to pause media");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to pause a video with no active media session");
+ throw new NoConnectionException();
+ }
+ remoteMediaPlayer.pause(mApiClient, customData)
+ .setResultCallback(result -> {
+ if (!result.getStatus().isSuccess()) {
+ onFailed(R.string.cast_failed_to_pause,
+ result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Seeks to the given point without changing the state of the player, i.e. after seek is
+ * completed, it resumes what it was doing before the start of seek.
+ *
+ * @param position in milliseconds
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void seek(int position) throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ Log.d(TAG, "attempting to seek media");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to seek a video with no active media session");
+ throw new NoConnectionException();
+ }
+ Log.d(TAG, "remoteMediaPlayer.seek() to position " + position);
+ remoteMediaPlayer.seek(mApiClient,
+ position,
+ RESUME_STATE_UNCHANGED).
+ setResultCallback(result -> {
+ if (!result.getStatus().isSuccess()) {
+ onFailed(R.string.cast_failed_seek, result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Fast forwards the media by the given amount. If {@code lengthInMillis} is negative, it
+ * rewinds the media.
+ *
+ * @param lengthInMillis The amount to fast forward the media, given in milliseconds
+ * @throws TransientNetworkDisconnectionException
+ * @throws NoConnectionException
+ */
+ public void forward(int lengthInMillis) throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ Log.d(TAG, "forward(): attempting to forward media by " + lengthInMillis);
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to seek a video with no active media session");
+ throw new NoConnectionException();
+ }
+ long position = remoteMediaPlayer.getApproximateStreamPosition() + lengthInMillis;
+ seek((int) position);
+ }
+
+ /**
+ * Seeks to the given point and starts playback regardless of the starting state.
+ *
+ * @param position in milliseconds
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void seekAndPlay(int position) throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ Log.d(TAG, "attempting to seek media");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ Log.e(TAG, "Trying to seekAndPlay a video with no active media session");
+ throw new NoConnectionException();
+ }
+ Log.d(TAG, "remoteMediaPlayer.seek() to position " + position + "and play");
+ remoteMediaPlayer.seek(mApiClient, position, RESUME_STATE_PLAY)
+ .setResultCallback(result -> {
+ if (!result.getStatus().isSuccess()) {
+ onFailed(R.string.cast_failed_seek, result.getStatus().getStatusCode());
+ }
+ });
+ }
+
+ /**
+ * Toggles the playback of the media.
+ *
+ * @throws CastException
+ * @throws NoConnectionException
+ * @throws TransientNetworkDisconnectionException
+ */
+ public void togglePlayback() throws CastException, TransientNetworkDisconnectionException,
+ NoConnectionException {
+ checkConnectivity();
+ boolean isPlaying = isRemoteMediaPlaying();
+ if (isPlaying) {
+ pause();
+ } else {
+ if (state == MediaStatus.PLAYER_STATE_IDLE
+ && idleReason == MediaStatus.IDLE_REASON_FINISHED) {
+ loadMedia(getRemoteMediaInformation(), true, 0);
+ } else {
+ play();
+ }
+ }
+ }
+
+ private void attachMediaChannel() throws TransientNetworkDisconnectionException,
+ NoConnectionException {
+ Log.d(TAG, "attachMediaChannel()");
+ checkConnectivity();
+ if (remoteMediaPlayer == null) {
+ remoteMediaPlayer = new RemoteMediaPlayer();
+
+ remoteMediaPlayer.setOnStatusUpdatedListener(
+ () -> {
+ Log.d(TAG, "RemoteMediaPlayer::onStatusUpdated() is reached");
+ CastManager.this.onRemoteMediaPlayerStatusUpdated();
+ }
+ );
+
+ remoteMediaPlayer.setOnPreloadStatusUpdatedListener(
+ () -> {
+ Log.d(TAG, "RemoteMediaPlayer::onPreloadStatusUpdated() is reached");
+ CastManager.this.onRemoteMediaPreloadStatusUpdated();
+ });
+
+
+ remoteMediaPlayer.setOnMetadataUpdatedListener(
+ () -> {
+ Log.d(TAG, "RemoteMediaPlayer::onMetadataUpdated() is reached");
+ CastManager.this.onRemoteMediaPlayerMetadataUpdated();
+ }
+ );
+
+ remoteMediaPlayer.setOnQueueStatusUpdatedListener(
+ () -> {
+ Log.d(TAG, "RemoteMediaPlayer::onQueueStatusUpdated() is reached");
+ mediaStatus = remoteMediaPlayer.getMediaStatus();
+ if (mediaStatus != null
+ && mediaStatus.getQueueItems() != null) {
+ List<MediaQueueItem> queueItems = mediaStatus
+ .getQueueItems();
+ int itemId = mediaStatus.getCurrentItemId();
+ MediaQueueItem item = mediaStatus
+ .getQueueItemById(itemId);
+ int repeatMode = mediaStatus.getQueueRepeatMode();
+ onQueueUpdated(queueItems, item, repeatMode, false);
+ } else {
+ onQueueUpdated(null, null,
+ MediaStatus.REPEAT_MODE_REPEAT_OFF, false);
+ }
+ });
+
+ }
+ try {
+ Log.d(TAG, "Registering MediaChannel namespace");
+ Cast.CastApi.setMessageReceivedCallbacks(mApiClient, remoteMediaPlayer.getNamespace(),
+ remoteMediaPlayer);
+ } catch (IOException | IllegalStateException e) {
+ Log.e(TAG, "attachMediaChannel()", e);
+ }
+ }
+
+ private void reattachMediaChannel() {
+ if (remoteMediaPlayer != null && mApiClient != null) {
+ try {
+ Log.d(TAG, "Registering MediaChannel namespace");
+ Cast.CastApi.setMessageReceivedCallbacks(mApiClient,
+ remoteMediaPlayer.getNamespace(), remoteMediaPlayer);
+ } catch (IOException | IllegalStateException e) {
+ Log.e(TAG, "reattachMediaChannel()", e);
+ }
+ }
+ }
+
+ private void detachMediaChannel() {
+ Log.d(TAG, "trying to detach media channel");
+ if (remoteMediaPlayer != null) {
+ try {
+ Cast.CastApi.removeMessageReceivedCallbacks(mApiClient,
+ remoteMediaPlayer.getNamespace());
+ } catch (IOException | IllegalStateException e) {
+ Log.e(TAG, "detachMediaChannel()", e);
+ }
+ remoteMediaPlayer = null;
+ }
+ }
+
+ /**
+ * Returns the playback status of the remote device.
+ *
+ * @return Returns one of the values
+ * <ul>
+ * <li> <code>MediaStatus.PLAYER_STATE_UNKNOWN</code></li>
+ * <li> <code>MediaStatus.PLAYER_STATE_IDLE</code></li>
+ * <li> <code>MediaStatus.PLAYER_STATE_PLAYING</code></li>
+ * <li> <code>MediaStatus.PLAYER_STATE_PAUSED</code></li>
+ * <li> <code>MediaStatus.PLAYER_STATE_BUFFERING</code></li>
+ * </ul>
+ */
+ public int getPlaybackStatus() {
+ return state;
+ }
+
+ /**
+ * Returns the latest retrieved value for the {@link MediaStatus}. This value is updated
+ * whenever the onStatusUpdated callback is called.
+ */
+ public final MediaStatus getMediaStatus() {
+ return mediaStatus;
+ }
+
+ /**
+ * Returns the Idle reason, defined in <code>MediaStatus.IDLE_*</code>. Note that the returned
+ * value is only meaningful if the status is truly <code>MediaStatus.PLAYER_STATE_IDLE
+ * </code>
+ *
+ * <p>Possible values are:
+ * <ul>
+ * <li>IDLE_REASON_NONE</li>
+ * <li>IDLE_REASON_FINISHED</li>
+ * <li>IDLE_REASON_CANCELED</li>
+ * <li>IDLE_REASON_INTERRUPTED</li>
+ * <li>IDLE_REASON_ERROR</li>
+ * </ul>
+ */
+ public int getIdleReason() {
+ return idleReason;
+ }
+
+ private void onMessageSendFailed(int errorCode) {
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onDataMessageSendFailed(errorCode);
+ }
+ }
+
+ /*
+ * This is called by onStatusUpdated() of the RemoteMediaPlayer
+ */
+ private void onRemoteMediaPlayerStatusUpdated() {
+ Log.d(TAG, "onRemoteMediaPlayerStatusUpdated() reached");
+ if (mApiClient == null || remoteMediaPlayer == null) {
+ Log.d(TAG, "mApiClient or remoteMediaPlayer is null, so will not proceed");
+ return;
+ }
+ mediaStatus = remoteMediaPlayer.getMediaStatus();
+ if (mediaStatus == null) {
+ Log.d(TAG, "MediaStatus is null, so will not proceed");
+ return;
+ } else {
+ List<MediaQueueItem> queueItems = mediaStatus.getQueueItems();
+ if (queueItems != null) {
+ int itemId = mediaStatus.getCurrentItemId();
+ MediaQueueItem item = mediaStatus.getQueueItemById(itemId);
+ int repeatMode = mediaStatus.getQueueRepeatMode();
+ onQueueUpdated(queueItems, item, repeatMode, false);
+ } else {
+ onQueueUpdated(null, null, MediaStatus.REPEAT_MODE_REPEAT_OFF, false);
+ }
+ state = mediaStatus.getPlayerState();
+ idleReason = mediaStatus.getIdleReason();
+
+ if (state == MediaStatus.PLAYER_STATE_PLAYING) {
+ Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = playing");
+ } else if (state == MediaStatus.PLAYER_STATE_PAUSED) {
+ Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = paused");
+ } else if (state == MediaStatus.PLAYER_STATE_IDLE) {
+ Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = IDLE with reason: "
+ + idleReason);
+ if (idleReason == MediaStatus.IDLE_REASON_ERROR) {
+ // something bad happened on the cast device
+ Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): IDLE reason = ERROR");
+ onFailed(R.string.cast_failed_receiver_player_error, NO_STATUS_CODE);
+ }
+ } else if (state == MediaStatus.PLAYER_STATE_BUFFERING) {
+ Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = buffering");
+ } else {
+ Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = unknown");
+ }
+ }
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onRemoteMediaPlayerStatusUpdated();
+ }
+ if (mediaStatus != null) {
+ double volume = mediaStatus.getStreamVolume();
+ boolean isMute = mediaStatus.isMute();
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onStreamVolumeChanged(volume, isMute);
+ }
+ }
+ }
+
+ private void onRemoteMediaPreloadStatusUpdated() {
+ MediaQueueItem item = null;
+ mediaStatus = remoteMediaPlayer.getMediaStatus();
+ if (mediaStatus != null) {
+ item = mediaStatus.getQueueItemById(mediaStatus.getPreloadedItemId());
+ }
+ preLoadingItem = item;
+ Log.d(TAG, "onRemoteMediaPreloadStatusUpdated() " + item);
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onRemoteMediaPreloadStatusUpdated(item);
+ }
+ }
+
+ public MediaQueueItem getPreLoadingItem() {
+ return preLoadingItem;
+ }
+
+ /*
+ * This is called by onQueueStatusUpdated() of RemoteMediaPlayer
+ */
+ private void onQueueUpdated(List<MediaQueueItem> queueItems, MediaQueueItem item,
+ int repeatMode, boolean shuffle) {
+ Log.d(TAG, "onQueueUpdated() reached");
+ Log.d(TAG, String.format("Queue Items size: %d, Item: %s, Repeat Mode: %d, Shuffle: %s",
+ queueItems == null ? 0 : queueItems.size(), item, repeatMode, shuffle));
+ if (queueItems != null) {
+ mediaQueue = new MediaQueue(new CopyOnWriteArrayList<>(queueItems), item, shuffle,
+ repeatMode);
+ } else {
+ mediaQueue = new MediaQueue(new CopyOnWriteArrayList<>(), null, false,
+ MediaStatus.REPEAT_MODE_REPEAT_OFF);
+ }
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onMediaQueueUpdated(queueItems, item, repeatMode, shuffle);
+ }
+ }
+
+ /*
+ * This is called by onMetadataUpdated() of RemoteMediaPlayer
+ */
+ public void onRemoteMediaPlayerMetadataUpdated() {
+ Log.d(TAG, "onRemoteMediaPlayerMetadataUpdated() reached");
+ for (CastConsumer consumer : castConsumers) {
+ consumer.onRemoteMediaPlayerMetadataUpdated();
+ }
+ }
+
+ /**
+ * Registers a {@link CastConsumer} interface with this class.
+ * Registered listeners will be notified of changes to a variety of
+ * lifecycle and media status changes through the callbacks that the interface provides.
+ *
+ * @see DefaultCastConsumer
+ */
+ public synchronized void addCastConsumer(CastConsumer listener) {
+ if (listener != null) {
+ addBaseCastConsumer(listener);
+ castConsumers.add(listener);
+ Log.d(TAG, "Successfully added the new CastConsumer listener " + listener);
+ }
+ }
+
+ /**
+ * Unregisters a {@link CastConsumer}.
+ */
+ public synchronized void removeCastConsumer(CastConsumer listener) {
+ if (listener != null) {
+ removeBaseCastConsumer(listener);
+ castConsumers.remove(listener);
+ }
+ }
+
+ @Override
+ protected void onDeviceUnselected() {
+ detachMediaChannel();
+ //removeDataChannel();
+ state = MediaStatus.PLAYER_STATE_IDLE;
+ mediaStatus = null;
+ }
+
+ @Override
+ protected Cast.CastOptions.Builder getCastOptionBuilder(CastDevice device) {
+ Cast.CastOptions.Builder builder = new Cast.CastOptions.Builder(mSelectedCastDevice, new CastListener());
+ if (isFeatureEnabled(CastConfiguration.FEATURE_DEBUGGING)) {
+ builder.setVerboseLoggingEnabled(true);
+ }
+ return builder;
+ }
+
+ @Override
+ public void onConnectionFailed(ConnectionResult result) {
+ super.onConnectionFailed(result);
+ state = MediaStatus.PLAYER_STATE_IDLE;
+ mediaStatus = null;
+ }
+
+ @Override
+ public void onDisconnected(boolean stopAppOnExit, boolean clearPersistedConnectionData,
+ boolean setDefaultRoute) {
+ super.onDisconnected(stopAppOnExit, clearPersistedConnectionData, setDefaultRoute);
+ state = MediaStatus.PLAYER_STATE_IDLE;
+ mediaStatus = null;
+ mediaQueue = null;
+ }
+
+ class CastListener extends Cast.Listener {
+
+ /*
+ * (non-Javadoc)
+ * @see com.google.android.gms.cast.Cast.Listener#onApplicationDisconnected (int)
+ */
+ @Override
+ public void onApplicationDisconnected(int statusCode) {
+ CastManager.this.onApplicationDisconnected(statusCode);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see com.google.android.gms.cast.Cast.Listener#onApplicationStatusChanged ()
+ */
+ @Override
+ public void onApplicationStatusChanged() {
+ CastManager.this.onApplicationStatusChanged();
+ }
+
+ @Override
+ public void onVolumeChanged() {
+ CastManager.this.onDeviceVolumeChanged();
+ }
+ }
+
+ @Override
+ public void onFailed(int resourceId, int statusCode) {
+ Log.d(TAG, "onFailed: " + mContext.getString(resourceId) + ", code: " + statusCode);
+ super.onFailed(resourceId, statusCode);
+ }
+
+ /**
+ * Clients can call this method to delegate handling of the volume. Clients should override
+ * {@code dispatchEvent} and call this method:
+ * <pre>
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ if (mCastManager.onDispatchVolumeKeyEvent(event, VOLUME_DELTA)) {
+ return true;
+ }
+ return super.dispatchKeyEvent(event);
+ }
+ * </pre>
+ * @param event The dispatched event.
+ * @param volumeDelta The amount by which volume should be increased or decreased in each step
+ * @return <code>true</code> if volume is handled by the library, <code>false</code> otherwise.
+ */
+ public boolean onDispatchVolumeKeyEvent(KeyEvent event, double volumeDelta) {
+ if (isConnected()) {
+ boolean isKeyDown = event.getAction() == KeyEvent.ACTION_DOWN;
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_VOLUME_UP:
+ return changeVolume(volumeDelta, isKeyDown);
+ case KeyEvent.KEYCODE_VOLUME_DOWN:
+ return changeVolume(-volumeDelta, isKeyDown);
+ }
+ }
+ return false;
+ }
+
+ private boolean changeVolume(double volumeIncrement, boolean isKeyDown) {
+ if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
+ && getPlaybackStatus() == MediaStatus.PLAYER_STATE_PLAYING
+ && isFeatureEnabled(CastConfiguration.FEATURE_LOCKSCREEN)) {
+ return false;
+ }
+
+ if (isKeyDown) {
+ try {
+ adjustDeviceVolume(volumeIncrement);
+ } catch (CastException | TransientNetworkDisconnectionException |
+ NoConnectionException e) {
+ Log.e(TAG, "Failed to change volume", e);
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Sets the volume step, i.e. the fraction by which volume will increase or decrease each time
+ * user presses the hard volume buttons on the device.
+ *
+ * @param volumeStep Should be a double between 0 and 1, inclusive.
+ */
+ public CastManager setVolumeStep(double volumeStep) {
+ if ((volumeStep > 1) || (volumeStep < 0)) {
+ throw new IllegalArgumentException("Volume Step should be between 0 and 1, inclusive");
+ }
+ this.volumeStep = volumeStep;
+ return this;
+ }
+
+ /**
+ * Returns the volume step. The default value is {@code DEFAULT_VOLUME_STEP}.
+ */
+ public double getVolumeStep() {
+ return volumeStep;
+ }
+
+ public final MediaQueue getMediaQueue() {
+ return mediaQueue;
+ }
+
+ /**
+ * Checks whether the selected Cast Device has the specified audio or video capabilities.
+ *
+ * @param capability capability from:
+ * <ul>
+ * <li>{@link CastDevice#CAPABILITY_AUDIO_IN}</li>
+ * <li>{@link CastDevice#CAPABILITY_AUDIO_OUT}</li>
+ * <li>{@link CastDevice#CAPABILITY_VIDEO_IN}</li>
+ * <li>{@link CastDevice#CAPABILITY_VIDEO_OUT}</li>
+ * </ul>
+ * @param defaultVal value to return whenever there's no device selected.
+ * @return {@code true} if the selected device has the specified capability,
+ * {@code false} otherwise.
+ */
+ public boolean hasCapability(final int capability, final boolean defaultVal) {
+ if (mSelectedCastDevice != null) {
+ return mSelectedCastDevice.hasCapability(capability);
+ } else {
+ return defaultVal;
+ }
+ }
+
+ /**
+ * Adds and wires up the Switchable Media Router cast button. It returns a reference to the
+ * {@link SwitchableMediaRouteActionProvider} associated with the button if the caller needs
+ * such reference. It is assumed that the enclosing
+ * {@link android.app.Activity} inherits (directly or indirectly) from
+ * {@link android.support.v7.app.AppCompatActivity}.
+ *
+ * @param menuItem MenuItem of the Media Router cast button.
+ */
+ public final SwitchableMediaRouteActionProvider addMediaRouterButton(MenuItem menuItem) {
+ SwitchableMediaRouteActionProvider mediaRouteActionProvider = (SwitchableMediaRouteActionProvider)
+ MenuItemCompat.getActionProvider(menuItem);
+ mediaRouteActionProvider.setRouteSelector(mMediaRouteSelector);
+ if (mCastConfiguration.getMediaRouteDialogFactory() != null) {
+ mediaRouteActionProvider.setDialogFactory(mCastConfiguration.getMediaRouteDialogFactory());
+ }
+ return mediaRouteActionProvider;
+ }
+
+ /* (non-Javadoc)
+ * These methods startReconnectionService and stopReconnectionService simply override the ones
+ * from BaseCastManager with empty implementations because we handle the service ourselves, but
+ * need to allow BaseCastManager to save current network information.
+ */
+ @Override
+ protected void startReconnectionService(long mediaDurationLeft) {
+ // Do nothing
+ }
+
+ @Override
+ protected void stopReconnectionService() {
+ // Do nothing
+ }
+}
diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java
new file mode 100644
index 000000000..f0a7214c9
--- /dev/null
+++ b/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java
@@ -0,0 +1,317 @@
+package de.danoeh.antennapod.core.cast;
+
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.google.android.gms.cast.CastDevice;
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaMetadata;
+import com.google.android.gms.common.images.WebImage;
+
+import java.util.Calendar;
+import java.util.List;
+
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedImage;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.storage.DBReader;
+import de.danoeh.antennapod.core.util.playback.ExternalMedia;
+import de.danoeh.antennapod.core.util.playback.Playable;
+
+/**
+ * Helper functions for Cast support.
+ */
+public class CastUtils {
+ private static final String TAG = "CastUtils";
+
+ public static final String KEY_MEDIA_ID = "de.danoeh.antennapod.core.cast.MediaId";
+
+ public static final String KEY_EPISODE_IDENTIFIER = "de.danoeh.antennapod.core.cast.EpisodeId";
+ public static final String KEY_EPISODE_LINK = "de.danoeh.antennapod.core.cast.EpisodeLink";
+ public static final String KEY_FEED_URL = "de.danoeh.antennapod.core.cast.FeedUrl";
+ public static final String KEY_FEED_WEBSITE = "de.danoeh.antennapod.core.cast.FeedWebsite";
+ public static final String KEY_EPISODE_NOTES = "de.danoeh.antennapod.core.cast.EpisodeNotes";
+ public static final int EPISODE_NOTES_MAX_LENGTH = Integer.MAX_VALUE;
+
+ /**
+ * The field <code>AntennaPod.FormatVersion</code> specifies which version of MediaMetaData
+ * fields we're using. Future implementations should try to be backwards compatible with earlier
+ * versions, and earlier versions should be forward compatible until the version indicated by
+ * <code>MAX_VERSION_FORWARD_COMPATIBILITY</code>. If an update makes the format unreadable for
+ * an earlier version, then its version number should be greater than the
+ * <code>MAX_VERSION_FORWARD_COMPATIBILITY</code> value set on the earlier one, so that it
+ * doesn't try to parse the object.
+ */
+ public static final String KEY_FORMAT_VERSION = "de.danoeh.antennapod.core.cast.FormatVersion";
+ public static final int FORMAT_VERSION_VALUE = 1;
+ public static final int MAX_VERSION_FORWARD_COMPATIBILITY = 9999;
+
+ public static boolean isCastable(Playable media){
+ if (media == null || media instanceof ExternalMedia) {
+ return false;
+ }
+ if (media instanceof FeedMedia || media instanceof RemoteMedia){
+ String url = media.getStreamUrl();
+ if(url == null || url.isEmpty()){
+ return false;
+ }
+ switch (media.getMediaType()) {
+ case UNKNOWN:
+ return false;
+ case AUDIO:
+ return CastManager.getInstance().hasCapability(CastDevice.CAPABILITY_AUDIO_OUT, true);
+ case VIDEO:
+ return CastManager.getInstance().hasCapability(CastDevice.CAPABILITY_VIDEO_OUT, true);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Converts {@link FeedMedia} objects into a format suitable for sending to a Cast Device.
+ * Before using this method, one should make sure {@link #isCastable(Playable)} returns
+ * {@code true}.
+ *
+ * Unless media.{@link FeedMedia#loadMetadata() loadMetadata()} has already been called,
+ * this method should not run on the main thread.
+ *
+ * @param media The {@link FeedMedia} object to be converted.
+ * @return {@link MediaInfo} object in a format proper for casting.
+ */
+ public static MediaInfo convertFromFeedMedia(FeedMedia media){
+ if(media == null) {
+ return null;
+ }
+ MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC);
+ try{
+ media.loadMetadata();
+ } catch (Playable.PlayableException e) {
+ Log.e(TAG, "Unable to load FeedMedia metadata", e);
+ }
+ FeedItem feedItem = media.getItem();
+ if (feedItem != null) {
+ metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle());
+ String subtitle = media.getFeedTitle();
+ if (subtitle != null) {
+ metadata.putString(MediaMetadata.KEY_SUBTITLE, subtitle);
+ }
+ FeedImage image = feedItem.getImage();
+ if (image != null && !TextUtils.isEmpty(image.getDownload_url())) {
+ metadata.addImage(new WebImage(Uri.parse(image.getDownload_url())));
+ }
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(media.getItem().getPubDate());
+ metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar);
+ Feed feed = feedItem.getFeed();
+ if (feed != null) {
+ if (!TextUtils.isEmpty(feed.getAuthor())) {
+ metadata.putString(MediaMetadata.KEY_ARTIST, feed.getAuthor());
+ }
+ if (!TextUtils.isEmpty(feed.getDownload_url())) {
+ metadata.putString(KEY_FEED_URL, feed.getDownload_url());
+ }
+ if (!TextUtils.isEmpty(feed.getLink())) {
+ metadata.putString(KEY_FEED_WEBSITE, feed.getLink());
+ }
+ }
+ if (!TextUtils.isEmpty(feedItem.getItemIdentifier())) {
+ metadata.putString(KEY_EPISODE_IDENTIFIER, feedItem.getItemIdentifier());
+ } else {
+ metadata.putString(KEY_EPISODE_IDENTIFIER, media.getStreamUrl());
+ }
+ if (!TextUtils.isEmpty(feedItem.getLink())) {
+ metadata.putString(KEY_EPISODE_LINK, feedItem.getLink());
+ }
+ }
+ String notes = null;
+ try {
+ notes = media.loadShownotes().call();
+ } catch (Exception e) {
+ Log.e(TAG, "Unable to load FeedMedia notes", e);
+ }
+ if (notes != null) {
+ if (notes.length() > EPISODE_NOTES_MAX_LENGTH) {
+ notes = notes.substring(0, EPISODE_NOTES_MAX_LENGTH);
+ }
+ metadata.putString(KEY_EPISODE_NOTES, notes);
+ }
+ // This field only identifies the id on the device that has the original version.
+ // Idea is to perhaps, on a first approach, check if the version on the local DB with the
+ // same id matches the remote object, and if not then search for episode and feed identifiers.
+ // This at least should make media recognition for a single device much quicker.
+ metadata.putInt(KEY_MEDIA_ID, ((Long) media.getIdentifier()).intValue());
+ // A way to identify different casting media formats in case we change it in the future and
+ // senders with different versions share a casting device.
+ metadata.putInt(KEY_FORMAT_VERSION, FORMAT_VERSION_VALUE);
+
+ MediaInfo.Builder builder = new MediaInfo.Builder(media.getStreamUrl())
+ .setContentType(media.getMime_type())
+ .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
+ .setMetadata(metadata);
+ if (media.getDuration() > 0) {
+ builder.setStreamDuration(media.getDuration());
+ }
+ return builder.build();
+ }
+
+ //TODO make unit tests for all the conversion methods
+ /**
+ * Converts {@link MediaInfo} objects into the appropriate implementation of {@link Playable}.
+ *
+ * Unless <code>searchFeedMedia</code> is set to <code>false</code>, this method should not run
+ * on the GUI thread.
+ *
+ * @param media The {@link MediaInfo} object to be converted.
+ * @param searchFeedMedia If set to <code>true</code>, the database will be queried to find a
+ * {@link FeedMedia} instance that matches {@param media}.
+ * @return {@link Playable} object in a format proper for casting.
+ */
+ public static Playable getPlayable(MediaInfo media, boolean searchFeedMedia) {
+ Log.d(TAG, "getPlayable called with searchFeedMedia=" + searchFeedMedia);
+ if (media == null) {
+ Log.d(TAG, "MediaInfo object provided is null, not converting to any Playable instance");
+ return null;
+ }
+ MediaMetadata metadata = media.getMetadata();
+ int version = metadata.getInt(KEY_FORMAT_VERSION);
+ if (version <= 0 || version > MAX_VERSION_FORWARD_COMPATIBILITY) {
+ Log.w(TAG, "MediaInfo object obtained from the cast device is not compatible with this" +
+ "version of AntennaPod CastUtils, curVer=" + FORMAT_VERSION_VALUE +
+ ", object version=" + version);
+ return null;
+ }
+ Playable result = null;
+ if (searchFeedMedia) {
+ long mediaId = metadata.getInt(KEY_MEDIA_ID);
+ if (mediaId > 0) {
+ FeedMedia fMedia = DBReader.getFeedMedia(mediaId);
+ if (fMedia != null) {
+ try {
+ fMedia.loadMetadata();
+ if (matches(media, fMedia)) {
+ result = fMedia;
+ Log.d(TAG, "FeedMedia object obtained matches the MediaInfo provided. id=" + mediaId);
+ } else {
+ Log.d(TAG, "FeedMedia object obtained does NOT match the MediaInfo provided. id=" + mediaId);
+ }
+ } catch (Playable.PlayableException e) {
+ Log.e(TAG, "Unable to load FeedMedia metadata to compare with MediaInfo", e);
+ }
+ } else {
+ Log.d(TAG, "Unable to find in database a FeedMedia with id=" + mediaId);
+ }
+ }
+ if (result == null) {
+ FeedItem feedItem = DBReader.getFeedItem(metadata.getString(KEY_FEED_URL),
+ metadata.getString(KEY_EPISODE_IDENTIFIER));
+ if (feedItem != null) {
+ result = feedItem.getMedia();
+ Log.d(TAG, "Found episode that matches the MediaInfo provided. Using its media, if existing.");
+ }
+ }
+ }
+ if (result == null) {
+ List<WebImage> imageList = metadata.getImages();
+ String imageUrl = null;
+ if (!imageList.isEmpty()) {
+ imageUrl = imageList.get(0).getUrl().toString();
+ }
+ result = new RemoteMedia(media.getContentId(),
+ metadata.getString(KEY_EPISODE_IDENTIFIER),
+ metadata.getString(KEY_FEED_URL),
+ metadata.getString(MediaMetadata.KEY_SUBTITLE),
+ metadata.getString(MediaMetadata.KEY_TITLE),
+ metadata.getString(KEY_EPISODE_LINK),
+ metadata.getString(MediaMetadata.KEY_ARTIST),
+ imageUrl,
+ metadata.getString(KEY_FEED_WEBSITE),
+ media.getContentType(),
+ metadata.getDate(MediaMetadata.KEY_RELEASE_DATE).getTime());
+ String notes = metadata.getString(KEY_EPISODE_NOTES);
+ if (!TextUtils.isEmpty(notes)) {
+ ((RemoteMedia) result).setNotes(notes);
+ }
+ Log.d(TAG, "Converted MediaInfo into RemoteMedia");
+ }
+ if (result.getDuration() == 0 && media.getStreamDuration() > 0) {
+ result.setDuration((int) media.getStreamDuration());
+ }
+ return result;
+ }
+
+ /**
+ * Compares a {@link MediaInfo} instance with a {@link FeedMedia} one and evaluates whether they
+ * represent the same podcast episode.
+ *
+ * @param info the {@link MediaInfo} object to be compared.
+ * @param media the {@link FeedMedia} object to be compared.
+ * @return <true>true</true> if there's a match, <code>false</code> otherwise.
+ *
+ * @see RemoteMedia#equals(Object)
+ */
+ public static boolean matches(MediaInfo info, FeedMedia media) {
+ if (info == null || media == null) {
+ return false;
+ }
+ if (!TextUtils.equals(info.getContentId(), media.getStreamUrl())) {
+ return false;
+ }
+ MediaMetadata metadata = info.getMetadata();
+ FeedItem fi = media.getItem();
+ if (fi == null || metadata == null ||
+ !TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), fi.getItemIdentifier())) {
+ return false;
+ }
+ Feed feed = fi.getFeed();
+ return feed != null && TextUtils.equals(metadata.getString(KEY_FEED_URL), feed.getDownload_url());
+ }
+
+ /**
+ * Compares a {@link MediaInfo} instance with a {@link RemoteMedia} one and evaluates whether they
+ * represent the same podcast episode.
+ *
+ * @param info the {@link MediaInfo} object to be compared.
+ * @param media the {@link RemoteMedia} object to be compared.
+ * @return <true>true</true> if there's a match, <code>false</code> otherwise.
+ *
+ * @see RemoteMedia#equals(Object)
+ */
+ public static boolean matches(MediaInfo info, RemoteMedia media) {
+ if (info == null || media == null) {
+ return false;
+ }
+ if (!TextUtils.equals(info.getContentId(), media.getStreamUrl())) {
+ return false;
+ }
+ MediaMetadata metadata = info.getMetadata();
+ return metadata != null &&
+ TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), media.getEpisodeIdentifier()) &&
+ TextUtils.equals(metadata.getString(KEY_FEED_URL), media.getFeedUrl());
+ }
+
+ /**
+ * Compares a {@link MediaInfo} instance with a {@link Playable} and evaluates whether they
+ * represent the same podcast episode. Useful every time we get a MediaInfo from the Cast Device
+ * and want to avoid unnecessary conversions.
+ *
+ * @param info the {@link MediaInfo} object to be compared.
+ * @param media the {@link Playable} object to be compared.
+ * @return <true>true</true> if there's a match, <code>false</code> otherwise.
+ *
+ * @see RemoteMedia#equals(Object)
+ */
+ public static boolean matches(MediaInfo info, Playable media) {
+ if (info == null || media == null) {
+ return false;
+ }
+ if (media instanceof RemoteMedia) {
+ return matches(info, (RemoteMedia) media);
+ }
+ return media instanceof FeedMedia && matches(info, (FeedMedia) media);
+ }
+
+
+ //TODO Queue handling perhaps
+}
diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java b/core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java
new file mode 100644
index 000000000..fe4183d54
--- /dev/null
+++ b/core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java
@@ -0,0 +1,10 @@
+package de.danoeh.antennapod.core.cast;
+
+import com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumerImpl;
+
+public class DefaultCastConsumer extends VideoCastConsumerImpl implements CastConsumer {
+ @Override
+ public void onStreamVolumeChanged(double value, boolean isMute) {
+ // no-op
+ }
+}
diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/RemoteMedia.java b/core/src/play/java/de/danoeh/antennapod/core/cast/RemoteMedia.java
new file mode 100644
index 000000000..e2d8f8ad5
--- /dev/null
+++ b/core/src/play/java/de/danoeh/antennapod/core/cast/RemoteMedia.java
@@ -0,0 +1,357 @@
+package de.danoeh.antennapod.core.cast;
+
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+
+import com.google.android.gms.cast.MediaInfo;
+import com.google.android.gms.cast.MediaMetadata;
+import com.google.android.gms.common.images.WebImage;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+import de.danoeh.antennapod.core.feed.Chapter;
+import de.danoeh.antennapod.core.feed.Feed;
+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.storage.DBReader;
+import de.danoeh.antennapod.core.util.ChapterUtils;
+import de.danoeh.antennapod.core.util.playback.Playable;
+
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+
+/**
+ * Playable implementation for media on a Cast Device for which a local version of
+ * {@link de.danoeh.antennapod.core.feed.FeedMedia} hasn't been found.
+ */
+public class RemoteMedia implements Playable {
+ public static final String TAG = "RemoteMedia";
+
+ public static final int PLAYABLE_TYPE_REMOTE_MEDIA = 3;
+
+ private String downloadUrl;
+ private String itemIdentifier;
+ private String feedUrl;
+ private String feedTitle;
+ private String episodeTitle;
+ private String episodeLink;
+ private String feedAuthor;
+ private String imageUrl;
+ private String feedLink;
+ private String mime_type;
+ private Date pubDate;
+ private String notes;
+ private List<Chapter> chapters;
+ private int duration;
+ private int position;
+ private long lastPlayedTime;
+
+ public RemoteMedia(String downloadUrl, String itemId, String feedUrl, String feedTitle,
+ String episodeTitle, String episodeLink, String feedAuthor,
+ String imageUrl, String feedLink, String mime_type, Date pubDate) {
+ this.downloadUrl = downloadUrl;
+ this.itemIdentifier = itemId;
+ this.feedUrl = feedUrl;
+ this.feedTitle = feedTitle;
+ this.episodeTitle = episodeTitle;
+ this.episodeLink = episodeLink;
+ this.feedAuthor = feedAuthor;
+ this.imageUrl = imageUrl;
+ this.feedLink = feedLink;
+ this.mime_type = mime_type;
+ this.pubDate = pubDate;
+ }
+
+ public void setNotes(String notes) {
+ this.notes = notes;
+ }
+
+ public MediaInfo extractMediaInfo() {
+ MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC);
+
+ metadata.putString(MediaMetadata.KEY_TITLE, episodeTitle);
+ metadata.putString(MediaMetadata.KEY_SUBTITLE, feedTitle);
+ if (!TextUtils.isEmpty(imageUrl)) {
+ metadata.addImage(new WebImage(Uri.parse(imageUrl)));
+ }
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTime(pubDate);
+ metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar);
+ if (!TextUtils.isEmpty(feedAuthor)) {
+ metadata.putString(MediaMetadata.KEY_ARTIST, feedAuthor);
+ }
+ if (!TextUtils.isEmpty(feedUrl)) {
+ metadata.putString(CastUtils.KEY_FEED_URL, feedUrl);
+ }
+ if (!TextUtils.isEmpty(feedLink)) {
+ metadata.putString(CastUtils.KEY_FEED_WEBSITE, feedLink);
+ }
+ if (!TextUtils.isEmpty(itemIdentifier)) {
+ metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, itemIdentifier);
+ } else {
+ metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, downloadUrl);
+ }
+ if (!TextUtils.isEmpty(episodeLink)) {
+ metadata.putString(CastUtils.KEY_EPISODE_LINK, episodeLink);
+ }
+ String notes = this.notes;
+ if (notes != null) {
+ if (notes.length() > CastUtils.EPISODE_NOTES_MAX_LENGTH) {
+ notes = notes.substring(0, CastUtils.EPISODE_NOTES_MAX_LENGTH);
+ }
+ metadata.putString(CastUtils.KEY_EPISODE_NOTES, notes);
+ }
+ // Default id value
+ metadata.putInt(CastUtils.KEY_MEDIA_ID, 0);
+ metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE);
+
+ MediaInfo.Builder builder = new MediaInfo.Builder(downloadUrl)
+ .setContentType(mime_type)
+ .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
+ .setMetadata(metadata);
+ if (duration > 0) {
+ builder.setStreamDuration(duration);
+ }
+ return builder.build();
+ }
+
+ public String getEpisodeIdentifier() {
+ return itemIdentifier;
+ }
+
+ public String getFeedUrl() {
+ return feedUrl;
+ }
+
+ public FeedMedia lookForFeedMedia() {
+ FeedItem feedItem = DBReader.getFeedItem(feedUrl, itemIdentifier);
+ if (feedItem == null) {
+ return null;
+ }
+ return feedItem.getMedia();
+ }
+
+ @Override
+ public void writeToPreferences(SharedPreferences.Editor prefEditor) {
+ //it seems pointless to do it, since the session should be kept by the remote device.
+ }
+
+ @Override
+ public void loadMetadata() throws PlayableException {
+ //Already loaded
+ }
+
+ @Override
+ public void loadChapterMarks() {
+ ChapterUtils.loadChaptersFromStreamUrl(this);
+ }
+
+ @Override
+ public String getEpisodeTitle() {
+ return episodeTitle;
+ }
+
+ @Override
+ public List<Chapter> getChapters() {
+ return chapters;
+ }
+
+ @Override
+ public String getWebsiteLink() {
+ if (episodeLink != null) {
+ return episodeLink;
+ } else {
+ return feedUrl;
+ }
+ }
+
+ @Override
+ public String getPaymentLink() {
+ return null;
+ }
+
+ @Override
+ public String getFeedTitle() {
+ return feedTitle;
+ }
+
+ @Override
+ public Object getIdentifier() {
+ return itemIdentifier + "@" + feedUrl;
+ }
+
+ @Override
+ public int getDuration() {
+ return duration;
+ }
+
+ @Override
+ public int getPosition() {
+ return position;
+ }
+
+ @Override
+ public long getLastPlayedTime() {
+ return lastPlayedTime;
+ }
+
+ @Override
+ public MediaType getMediaType() {
+ return MediaType.fromMimeType(mime_type);
+ }
+
+ @Override
+ public String getLocalMediaUrl() {
+ return null;
+ }
+
+ @Override
+ public String getStreamUrl() {
+ return downloadUrl;
+ }
+
+ @Override
+ public boolean localFileAvailable() {
+ return false;
+ }
+
+ @Override
+ public boolean streamAvailable() {
+ return true;
+ }
+
+ @Override
+ public void saveCurrentPosition(SharedPreferences pref, int newPosition, long timestamp) {
+ //we're not saving playback information for this kind of items on preferences
+ setPosition(newPosition);
+ setLastPlayedTime(timestamp);
+ }
+
+ @Override
+ public void setPosition(int newPosition) {
+ position = newPosition;
+ }
+
+ @Override
+ public void setDuration(int newDuration) {
+ duration = newDuration;
+ }
+
+ @Override
+ public void setLastPlayedTime(long lastPlayedTimestamp) {
+ lastPlayedTime = lastPlayedTimestamp;
+ }
+
+ @Override
+ public void onPlaybackStart() {
+ // no-op
+ }
+
+ @Override
+ public void onPlaybackCompleted() {
+ // no-op
+ }
+
+ @Override
+ public int getPlayableType() {
+ return PLAYABLE_TYPE_REMOTE_MEDIA;
+ }
+
+ @Override
+ public void setChapters(List<Chapter> chapters) {
+ this.chapters = chapters;
+ }
+
+ @Override
+ @Nullable
+ public String getImageLocation() {
+ return imageUrl;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public Callable<String> loadShownotes() {
+ return () -> (notes != null) ? notes : "";
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeString(downloadUrl);
+ dest.writeString(itemIdentifier);
+ dest.writeString(feedUrl);
+ dest.writeString(feedTitle);
+ dest.writeString(episodeTitle);
+ dest.writeString(episodeLink);
+ dest.writeString(feedAuthor);
+ dest.writeString(imageUrl);
+ dest.writeString(feedLink);
+ dest.writeString(mime_type);
+ dest.writeLong(pubDate.getTime());
+ dest.writeString(notes);
+ dest.writeInt(duration);
+ dest.writeInt(position);
+ dest.writeLong(lastPlayedTime);
+ }
+
+ public static final Parcelable.Creator<RemoteMedia> CREATOR = new Parcelable.Creator<RemoteMedia>() {
+ @Override
+ public RemoteMedia createFromParcel(Parcel in) {
+ RemoteMedia result = new RemoteMedia(in.readString(), in.readString(), in.readString(),
+ in.readString(), in.readString(), in.readString(), in.readString(), in.readString(),
+ in.readString(), in.readString(), new Date(in.readLong()));
+ result.setNotes(in.readString());
+ result.setDuration(in.readInt());
+ result.setPosition(in.readInt());
+ result.setLastPlayedTime(in.readLong());
+ return result;
+ }
+
+ @Override
+ public RemoteMedia[] newArray(int size) {
+ return new RemoteMedia[size];
+ }
+ };
+
+ @Override
+ public boolean equals(Object other) {
+ if (other instanceof RemoteMedia) {
+ RemoteMedia rm = (RemoteMedia) other;
+ return TextUtils.equals(downloadUrl, rm.downloadUrl) &&
+ TextUtils.equals(feedUrl, rm.feedUrl) &&
+ TextUtils.equals(itemIdentifier, rm.itemIdentifier);
+ }
+ if (other instanceof FeedMedia) {
+ FeedMedia fm = (FeedMedia) other;
+ if (!TextUtils.equals(downloadUrl, fm.getStreamUrl())) {
+ return false;
+ }
+ FeedItem fi = fm.getItem();
+ if (fi == null || !TextUtils.equals(itemIdentifier, fi.getItemIdentifier())) {
+ return false;
+ }
+ Feed feed = fi.getFeed();
+ return feed != null && TextUtils.equals(feedUrl, feed.getDownload_url());
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return new HashCodeBuilder()
+ .append(downloadUrl)
+ .append(feedUrl)
+ .append(itemIdentifier)
+ .toHashCode();
+ }
+}
diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java b/core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java
new file mode 100644
index 000000000..f063cf5e3
--- /dev/null
+++ b/core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java
@@ -0,0 +1,106 @@
+package de.danoeh.antennapod.core.cast;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.support.v4.app.FragmentActivity;
+import android.support.v4.app.FragmentManager;
+import android.support.v7.app.MediaRouteActionProvider;
+import android.support.v7.app.MediaRouteChooserDialogFragment;
+import android.support.v7.app.MediaRouteControllerDialogFragment;
+import android.support.v7.media.MediaRouter;
+import android.util.Log;
+
+/**
+ * <p>Action Provider that extends {@link MediaRouteActionProvider} and allows the client to
+ * disable completely the button by calling {@link #setEnabled(boolean)}.</p>
+ *
+ * <p>It is disabled by default, so if a client wants to initially have it enabled it must call
+ * <code>setEnabled(true)</code>.</p>
+ */
+public class SwitchableMediaRouteActionProvider extends MediaRouteActionProvider {
+ public static final String TAG = "SwitchblMediaRtActProv";
+
+ private static final String CHOOSER_FRAGMENT_TAG =
+ "android.support.v7.mediarouter:MediaRouteChooserDialogFragment";
+ private static final String CONTROLLER_FRAGMENT_TAG =
+ "android.support.v7.mediarouter:MediaRouteControllerDialogFragment";
+ private boolean enabled;
+
+ public SwitchableMediaRouteActionProvider(Context context) {
+ super(context);
+ enabled = false;
+ }
+
+ /**
+ * <p>Sets whether the Media Router button should be allowed to become visible or not.</p>
+ *
+ * <p>It's invisible by default.</p>
+ */
+ public void setEnabled(boolean newVal) {
+ enabled = newVal;
+ refreshVisibility();
+ }
+
+ @Override
+ public boolean isVisible() {
+ return enabled && super.isVisible();
+ }
+
+ @Override
+ public boolean onPerformDefaultAction() {
+ if (!super.onPerformDefaultAction()) {
+ // there is no button, but we should still show the dialog if it's the case.
+ if (!isVisible()) {
+ return false;
+ }
+ FragmentManager fm = getFragmentManager();
+ if (fm == null) {
+ return false;
+ }
+ MediaRouter.RouteInfo route = MediaRouter.getInstance(getContext()).getSelectedRoute();
+ if (route.isDefault() || !route.matchesSelector(getRouteSelector())) {
+ if (fm.findFragmentByTag(CHOOSER_FRAGMENT_TAG) != null) {
+ Log.w(TAG, "showDialog(): Route chooser dialog already showing!");
+ return false;
+ }
+ MediaRouteChooserDialogFragment f =
+ getDialogFactory().onCreateChooserDialogFragment();
+ f.setRouteSelector(getRouteSelector());
+ f.show(fm, CHOOSER_FRAGMENT_TAG);
+ } else {
+ if (fm.findFragmentByTag(CONTROLLER_FRAGMENT_TAG) != null) {
+ Log.w(TAG, "showDialog(): Route controller dialog already showing!");
+ return false;
+ }
+ MediaRouteControllerDialogFragment f =
+ getDialogFactory().onCreateControllerDialogFragment();
+ f.show(fm, CONTROLLER_FRAGMENT_TAG);
+ }
+ return true;
+
+ } else {
+ return true;
+ }
+ }
+
+ private FragmentManager getFragmentManager() {
+ Activity activity = getActivity();
+ if (activity instanceof FragmentActivity) {
+ return ((FragmentActivity)activity).getSupportFragmentManager();
+ }
+ return null;
+ }
+
+ private Activity getActivity() {
+ // Gross way of unwrapping the Activity so we can get the FragmentManager
+ Context context = getContext();
+ while (context instanceof ContextWrapper) {
+ if (context instanceof Activity) {
+ return (Activity)context;
+ }
+ context = ((ContextWrapper)context).getBaseContext();
+ }
+ return null;
+ }
+}
diff --git a/core/src/play/java/de/danoeh/antennapod/core/feed/FeedMedia.java b/core/src/play/java/de/danoeh/antennapod/core/feed/FeedMedia.java
new file mode 100644
index 000000000..068669af9
--- /dev/null
+++ b/core/src/play/java/de/danoeh/antennapod/core/feed/FeedMedia.java
@@ -0,0 +1,568 @@
+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.cast.RemoteMedia;
+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/play/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/play/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java
new file mode 100644
index 000000000..e2d63a385
--- /dev/null
+++ b/core/src/play/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java
@@ -0,0 +1,1780 @@
+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.support.v7.media.MediaRouter;
+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 com.google.android.gms.cast.ApplicationMetadata;
+import com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager;
+
+import java.util.List;
+
+import de.danoeh.antennapod.core.ClientConfig;
+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.DefaultCastConsumer;
+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.NetworkUtils;
+import de.danoeh.antennapod.core.util.QueueAccess;
+import de.danoeh.antennapod.core.util.flattr.FlattrUtils;
+import de.danoeh.antennapod.core.util.playback.ExternalMedia;
+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/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java b/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java
new file mode 100644
index 000000000..4262b8a70
--- /dev/null
+++ b/core/src/play/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/play/java/de/danoeh/antennapod/core/util/playback/Playable.java b/core/src/play/java/de/danoeh/antennapod/core/util/playback/Playable.java
new file mode 100644
index 000000000..201efbc81
--- /dev/null
+++ b/core/src/play/java/de/danoeh/antennapod/core/util/playback/Playable.java
@@ -0,0 +1,246 @@
+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.cast.RemoteMedia;
+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);
+ }
+
+ }
+}