From 3c033cc0fbaa4b1418cda330ac0287d735c621f2 Mon Sep 17 00:00:00 2001 From: Martin Fietz Date: Sat, 4 Jun 2016 01:36:25 +0200 Subject: Create one flavor with Google Cast support and one (free) without --- .../de/danoeh/antennapod/core/ClientConfig.java | 49 + .../danoeh/antennapod/core/cast/CastConsumer.java | 11 + .../danoeh/antennapod/core/cast/CastManager.java | 1766 +++++++++++++++++++ .../de/danoeh/antennapod/core/cast/CastUtils.java | 317 ++++ .../antennapod/core/cast/DefaultCastConsumer.java | 10 + .../danoeh/antennapod/core/cast/RemoteMedia.java | 357 ++++ .../cast/SwitchableMediaRouteActionProvider.java | 106 ++ .../de/danoeh/antennapod/core/feed/FeedMedia.java | 568 +++++++ .../core/service/playback/PlaybackService.java | 1780 ++++++++++++++++++++ .../core/service/playback/RemotePSMP.java | 592 +++++++ .../antennapod/core/util/playback/Playable.java | 246 +++ 11 files changed, 5802 insertions(+) create mode 100644 core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java create mode 100644 core/src/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java create mode 100644 core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java create mode 100644 core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java create mode 100644 core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java create mode 100644 core/src/play/java/de/danoeh/antennapod/core/cast/RemoteMedia.java create mode 100644 core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java create mode 100644 core/src/play/java/de/danoeh/antennapod/core/feed/FeedMedia.java create mode 100644 core/src/play/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java create mode 100644 core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java create mode 100644 core/src/play/java/de/danoeh/antennapod/core/util/playback/Playable.java (limited to 'core/src/play/java') 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 + * + * 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). + *

+ * 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()}. + *

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 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 null. + * + * @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 true 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 true 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 true if remote Stream is muted. + * + * @throws NoConnectionException + * @throws TransientNetworkDisconnectionException + */ + public boolean isStreamMute() throws TransientNetworkDisconnectionException, NoConnectionException { + checkConnectivity(); + checkRemoteMediaPlayerAvailable(); + return remoteMediaPlayer.getMediaStatus().isMute(); + } + + /** + * Returns true 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 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 true, playback starts after load + * @param position Where to start the playback (only used if autoPlay is true. + * 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 true, playback starts after load + * @param position Where to start the playback (only used if autoPlay is true). + * 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 true, playback starts after load + * @param position Where to start the playback (only used if autoPlay is true). + * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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}. + *

+ * For example: + *

+ * If insertBeforeItemId is not specified
+ * Existing queue: "A","D","G","H","B","E"
+ * itemIds: "D","H","B"
+ * New Order: "A","G","E","D","H","B"
+ *

+ * If insertBeforeItemId is "A"
+ * Existing queue: "A","D","G","H","B"
+ * itemIds: "D","H","B"
+ * New Order: "D","H","B","A","G","E"
+ *

+ * If insertBeforeItemId is "G"
+ * Existing queue: "A","D","G","H","B"
+ * itemIds: "D","H","B"
+ * New Order: "A","D","H","B","G","E"
+ *

+ * 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. + *

+ * 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 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 + *

+ */ + 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 MediaStatus.IDLE_*. Note that the returned + * value is only meaningful if the status is truly MediaStatus.PLAYER_STATE_IDLE + * + * + *

Possible values are: + *

+ */ + 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 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 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: + *
+     public boolean dispatchKeyEvent(KeyEvent event) {
+     if (mCastManager.onDispatchVolumeKeyEvent(event, VOLUME_DELTA)) {
+     return true;
+     }
+     return super.dispatchKeyEvent(event);
+     }
+     * 
+ * @param event The dispatched event. + * @param volumeDelta The amount by which volume should be increased or decreased in each step + * @return true if volume is handled by the library, false 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: + *
    + *
  • {@link CastDevice#CAPABILITY_AUDIO_IN}
  • + *
  • {@link CastDevice#CAPABILITY_AUDIO_OUT}
  • + *
  • {@link CastDevice#CAPABILITY_VIDEO_IN}
  • + *
  • {@link CastDevice#CAPABILITY_VIDEO_OUT}
  • + *
+ * @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 AntennaPod.FormatVersion 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 + * MAX_VERSION_FORWARD_COMPATIBILITY. If an update makes the format unreadable for + * an earlier version, then its version number should be greater than the + * MAX_VERSION_FORWARD_COMPATIBILITY 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 searchFeedMedia is set to false, this method should not run + * on the GUI thread. + * + * @param media The {@link MediaInfo} object to be converted. + * @param searchFeedMedia If set to true, 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 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 if there's a match, false 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 if there's a match, false 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 if there's a match, false 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 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 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 chapters) { + this.chapters = chapters; + } + + @Override + @Nullable + public String getImageLocation() { + return imageUrl; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public Callable 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 CREATOR = new Parcelable.Creator() { + @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; + +/** + *

Action Provider that extends {@link MediaRouteActionProvider} and allows the client to + * disable completely the button by calling {@link #setEnabled(boolean)}.

+ * + *

It is disabled by default, so if a client wants to initially have it enabled it must call + * setEnabled(true).

+ */ +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; + } + + /** + *

Sets whether the Media Router button should be allowed to become visible or not.

+ * + *

It's invisible by default.

+ */ + 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 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 chapters) { + if(item != null) { + item.setChapters(chapters); + } + } + + @Override + public Callable 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 CREATOR = new Parcelable.Creator() { + 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 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 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 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 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 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 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); + } + + } +} -- cgit v1.2.3