/* * Copyright (C) 2017 Team Gateship-One * (Hendrik Borghorst & Frederik Luetkes) * * The AUTHORS.md file contains a detailed contributors list: * <https://github.com/gateship-one/odyssey/blob/master/AUTHORS.md> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. * */ package org.gateshipone.odyssey.playbackservice; import java.io.IOException; import java.util.ArrayList; import java.util.concurrent.Semaphore; import android.content.Intent; import android.media.AudioManager; import android.media.MediaPlayer; import android.media.MediaPlayer.OnPreparedListener; import android.media.audiofx.AudioEffect; import android.os.PowerManager; import android.util.Log; import org.gateshipone.odyssey.utils.FormatHelper; /** * This class provides an easy to use interface for the android provided * mediaplayer class. It handles the transition between songs so the playback * appears to be gapless. * <p> * This does not work on all devices as some devices have a gap during song transition * because of hardware decoders that don't handle transitions gapless. */ public class GaplessPlayer { private final static String TAG = "OdysseyGaplessPlayer"; public enum REASON { IOError, SecurityError, StateError, ArgumentError } /** * MediaPlayer of the currently playing track (if any) */ private MediaPlayer mCurrentMediaPlayer = null; /** * MediaPlayer of the next track to play. This can be null if no next player is set. */ private MediaPlayer mNextMediaPlayer = null; /** * Saves if the player for the current track is prepared or not. */ private boolean mCurrentPrepared = false; /** * Saves if the second player is finished with preparing. */ private boolean mSecondPrepared = false; /** * Saves if the second player is currently preparing to play (buffering,opening codec, opening hw decoder,...) */ private boolean mSecondPreparing = false; /** * URL of the currently playing song (if any) */ private String mPrimarySource = null; /** * URL of the next song to play. */ private String mSecondarySource = null; /** * Time value to seek to after preparing the current song. This is used for resuming playback * at a specific position. */ private int mPrepareTime = 0; /** * PlaybackService using this class. This required as a context for wakelocks, callbacks,... */ private PlaybackService mPlaybackService; /** * Lock to synchronize access to the boolean variable mSecondPreparing */ private Semaphore mSecondPreparingLock; /** * Registered listeners for track finish callbacks */ private ArrayList<OnTrackFinishedListener> mTrackFinishedListeners; /** * Registered listeners for track start callbacks */ private ArrayList<OnTrackStartedListener> mTrackStartListeners; /** * Public constructor. * * @param service PlaybackService to use as context and for callbacks. */ public GaplessPlayer(PlaybackService service) { this.mTrackFinishedListeners = new ArrayList<>(); this.mTrackStartListeners = new ArrayList<>(); mPlaybackService = service; mSecondPreparingLock = new Semaphore(1); Log.v(TAG, "MyPid: " + android.os.Process.myPid() + " MyTid: " + android.os.Process.myTid()); } /** * This method will prepare the song with the URL to play and after preparing starts playing it. * * @param uri URL of the ressource to play. * @throws PlaybackException In case of error the Exception is thrown */ public void play(String uri) throws PlaybackException { play(uri, 0); } /** * Initializes the first mediaplayers with uri and prepares it so it can get * started * * @param uri - Path to media file * @throws IllegalArgumentException * @throws SecurityException * @throws IllegalStateException * @throws IOException */ public synchronized void play(String uri, int jumpTime) throws PlaybackException { // Another player currently exists, remove it. if (mCurrentMediaPlayer != null) { mCurrentMediaPlayer.reset(); mCurrentMediaPlayer.release(); mCurrentMediaPlayer = null; } // Create new MediaPlayer object. mCurrentMediaPlayer = new MediaPlayer(); mCurrentPrepared = false; // Set the type of the stream to music. mCurrentMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); try { // Set the datasource of the player to the provider URI uri = FormatHelper.encodeFileURI(uri); mCurrentMediaPlayer.setDataSource(uri); } catch (IllegalArgumentException e) { throw new PlaybackException(REASON.ArgumentError); } catch (SecurityException e) { throw new PlaybackException(REASON.SecurityError); } catch (IllegalStateException e) { throw new PlaybackException(REASON.StateError); } catch (IOException e) { throw new PlaybackException(REASON.IOError); } // Save parameters for later usage mPrimarySource = uri; // Set a listener for completion of playback mCurrentMediaPlayer.setOnCompletionListener(new TrackCompletionListener()); // Set a listener for the prepare complete event. This can then start the playback. mCurrentMediaPlayer.setOnPreparedListener(mPrimaryPreparedListener); // Save the requested seek time mPrepareTime = jumpTime; // Start the prepare procedure of the MediaPlayer. This happens asynchronously so a the callback // above is required. mCurrentMediaPlayer.prepareAsync(); } /** * Pauses the currently running mediaplayer If already paused it continues * the playback */ public synchronized void togglePause() { // Check if a Mediaplayer exits and if it is actual playing if (mCurrentMediaPlayer != null && mCurrentMediaPlayer.isPlaying()) { // In this case pause the playback mCurrentMediaPlayer.pause(); } else if (mCurrentMediaPlayer != null && mCurrentPrepared) { // If a MediaPlayer exists and is also prepared the toggle command should start playback. mCurrentMediaPlayer.start(); // Enable wakelock during playback. mCurrentMediaPlayer.setWakeMode(mPlaybackService.getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK); } } /** * Just pauses currently running player */ public synchronized void pause() { // Check if a MediaPlayer exits and if it is actual playing if (mCurrentMediaPlayer != null && mCurrentMediaPlayer.isPlaying()) { mCurrentMediaPlayer.pause(); } } /** * Resumes playback */ public synchronized void resume() { // If a MediaPlayer exists and is also prepared this command should start playback. if (mCurrentMediaPlayer != null && mCurrentPrepared) { mCurrentMediaPlayer.start(); } } /** * Stops media playback */ public synchronized void stop() { // Check if a player exists otherwise there is nothing to do. if (mCurrentMediaPlayer != null) { // Check if the player for the next song exists already if (mNextMediaPlayer != null) { // Remove the next player from the currently playing one. mCurrentMediaPlayer.setNextMediaPlayer(null); // Release the MediaPlayer, not usable after this command mNextMediaPlayer.release(); // Reset variables to clean internal state mNextMediaPlayer = null; mSecondPrepared = false; mSecondPreparing = false; } // Check if the currently active player is ready if (mCurrentPrepared) { /* * Signal android desire to close audio effect session */ Intent audioEffectIntent = new Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION); audioEffectIntent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mCurrentMediaPlayer.getAudioSessionId()); audioEffectIntent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, mPlaybackService.getPackageName()); mPlaybackService.sendBroadcast(audioEffectIntent); Log.v(TAG,"Closing effect for session: " + mCurrentMediaPlayer.getAudioSessionId()); } // Release the current player mCurrentMediaPlayer.release(); // Reset variables to clean internal state mCurrentMediaPlayer = null; mCurrentPrepared = false; } } /** * Seeks the currently playing track to the requested position. Bounds/state check are done. * * @param position Position in milliseconds to seek to. */ public synchronized void seekTo(int position) { try { // Check if the MediaPlayer is in a valid state to seek and the requested position is within bounds if (mCurrentMediaPlayer != null && mCurrentPrepared && position < mCurrentMediaPlayer.getDuration()) { mCurrentMediaPlayer.seekTo(position); } } catch (IllegalStateException exception) { Log.e(TAG, "Illegal state during seekTo"); } } /** * Returns the position of the currently playing track in milliseconds. * * @return Position of the currently playing track in milliseconds. 0 if not playing. */ public synchronized int getPosition() { try { // State checks for the MediaPlayer, only request time if object exists and the player is prepared. if (mCurrentMediaPlayer != null && mCurrentPrepared) { return mCurrentMediaPlayer.getCurrentPosition(); } } catch (IllegalStateException exception) { Log.e(TAG, "Illegal state during CurrentPositon"); return 0; } return 0; } /** * Returns the duration of the current track * * @return Duration of the currently playing track in milliseconds. 0 if not playing. */ public synchronized int getDuration() { try { // State checks for the MediaPlayer, only request time if object exists and the player is prepared. if (mCurrentMediaPlayer != null && mCurrentPrepared) { return mCurrentMediaPlayer.getDuration(); } } catch (IllegalStateException exception) { Log.e(TAG, "Illegal state during CurrentPositon"); return 0; } return 0; } /** * Checks if this player is currently running * * @return True if the player actually plays a track, false otherwise. */ public synchronized boolean isRunning() { if (mCurrentMediaPlayer != null) { return mCurrentMediaPlayer.isPlaying(); } return false; } /** * Checks if the first player is prepared. * * @return True if prepared and ready to play, false otherwise. */ public synchronized boolean isPrepared() { return mCurrentMediaPlayer != null && mCurrentPrepared; } /** * Sets the volume of the currently playing MediaPlayer * * @param leftChannel Volume from 0.0 - 1.0 for left playback channel * @param rightChannel Volume from 0.0 - 1.0 for right playback channel */ public synchronized void setVolume(float leftChannel, float rightChannel) { if (mCurrentMediaPlayer != null) { mCurrentMediaPlayer.setVolume(leftChannel, rightChannel); } } /** * Sets next MediaPlayer to uri and start preparing it. If next MediaPlayer * was already initialized it gets reset * * @param uri URI of the next song to play. */ public synchronized void setNextTrack(String uri) throws PlaybackException { // Reset the prepared state of the second mediaplayer mSecondPrepared = false; // If the current MediaPlayer is not already set, this should not be called. Wait for // prepare finish then. if (mCurrentMediaPlayer == null) { // This call makes absolutely no sense at this point so abort throw new PlaybackException(REASON.StateError); } // Next mediaplayer already set, clear it first. if (mNextMediaPlayer != null) { // Remove this player from the currently active one as a next one mCurrentMediaPlayer.setNextMediaPlayer(null); // Release the player that is not needed any longer mNextMediaPlayer.release(); // Reset internal state variables mNextMediaPlayer = null; mSecondPrepared = false; mSecondPreparing = false; } // Check if the uri contains something if (uri != null && !uri.isEmpty()) { // Create a new MediaPlayer to prepare as next song playback mNextMediaPlayer = new MediaPlayer(); // Set the old audio session ID to reuse the opened audio effect session mNextMediaPlayer.setAudioSessionId(mCurrentMediaPlayer.getAudioSessionId()); // Set the prepare finished listener mNextMediaPlayer.setOnPreparedListener(mSecondaryPreparedListener); // Set the playback type to music again mNextMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); try { // Try setting the data source uri = FormatHelper.encodeFileURI(uri); mNextMediaPlayer.setDataSource(uri); } catch (IllegalArgumentException e) { throw new PlaybackException(REASON.ArgumentError); } catch (SecurityException e) { throw new PlaybackException(REASON.SecurityError); } catch (IllegalStateException e) { throw new PlaybackException(REASON.StateError); } catch (IOException e) { throw new PlaybackException(REASON.IOError); } // Save the uri for latter usage mSecondarySource = uri; // Check if primary is prepared before preparing the second one. try { mSecondPreparingLock.acquire(); } catch (InterruptedException e) { throw new PlaybackException(REASON.StateError); } // If the first MediaPlayer is prepared already just start the second prepare here. if (mCurrentPrepared) { mSecondPreparing = true; mNextMediaPlayer.prepareAsync(); } mSecondPreparingLock.release(); } } private OnPreparedListener mPrimaryPreparedListener = new MediaPlayer.OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { // Sequentially execute all critical operations on the MP objects synchronized (GaplessPlayer.this) { // Check if the callback happened from the current media player if (!mp.equals(mCurrentMediaPlayer)) { return; } // If mp equals currentMediaPlayback it should start playing mCurrentPrepared = true; // only start playing if its desired // Check if an immediate jump is requested if (mPrepareTime > 0) { mp.seekTo(mPrepareTime); mPrepareTime = 0; } mp.setWakeMode(mPlaybackService.getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK); mp.start(); /* * Signal audio effect desire to android */ Intent audioEffectIntent = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION); audioEffectIntent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mp.getAudioSessionId()); audioEffectIntent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, mPlaybackService.getPackageName()); audioEffectIntent.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC); Log.v(TAG,"Opening effect session: " + mp.getAudioSessionId()); mPlaybackService.sendBroadcast(audioEffectIntent); mp.setAuxEffectSendLevel(1.0f); // Notify connected listeners for (OnTrackStartedListener listener : mTrackStartListeners) { listener.onTrackStarted(mPrimarySource); } try { mSecondPreparingLock.acquire(); } catch (InterruptedException e) { // FIXME some handling? Not sure if necessary } // If second MediaPlayer exists and is not already prepared and not already preparing // Start preparing the second MP here. if (!mSecondPrepared && mNextMediaPlayer != null && !mSecondPreparing) { mSecondPreparing = true; // Delayed initialization second mediaplayer mNextMediaPlayer.prepareAsync(); } mSecondPreparingLock.release(); } } }; private OnPreparedListener mSecondaryPreparedListener = new MediaPlayer.OnPreparedListener() { @Override public void onPrepared(MediaPlayer mp) { // Sequentially execute all critical operations on the MP objects synchronized (GaplessPlayer.this) { // Check if the callback happened from the current second media player, it can // happen that callbacks are called when the MP is no longer relevant, abort then. if (!mp.equals(mNextMediaPlayer) && !mp.equals(mCurrentMediaPlayer)) { return; } if (mp == mCurrentMediaPlayer) { // MediaPlayer got primary MP before finishing preparing, start playback // Workaround for issue #48 // Enable wakelock mp.setWakeMode(mPlaybackService.getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK); // Playback start mCurrentMediaPlayer.start(); /* * Signal audio effect desire to android */ Intent audioEffectOpenIntent = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION); audioEffectOpenIntent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mp.getAudioSessionId()); audioEffectOpenIntent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, mPlaybackService.getPackageName()); audioEffectOpenIntent.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC); Log.v(TAG,"Opening effect for session: " + mp.getAudioSessionId()); mPlaybackService.sendBroadcast(audioEffectOpenIntent); mCurrentMediaPlayer.setAuxEffectSendLevel(1.0f); // Notify connected listeners that playback has started for (OnTrackStartedListener listener : mTrackStartListeners) { listener.onTrackStarted(mPrimarySource); } } else { // Normal case. Second is prepared while primary MP is playing. // Set as next player // Second MediaPlayer is now ready to be used and can be set as a next MediaPlayer to the current one mSecondPreparing = false; // If it is nextMediaPlayer it should be set for currentMP mp.setWakeMode(mPlaybackService.getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK); // Set the internal state mSecondPrepared = true; // Set this now prepared MediaPlayer as the next one mCurrentMediaPlayer.setNextMediaPlayer(mp); } } } }; // Notification for Services using GaplessPlayer public interface OnTrackFinishedListener { void onTrackFinished(); } public interface OnTrackStartedListener { void onTrackStarted(String URI); } /** * Registers a listener to this class to be notified when a track finishes playback * * @param listener Listener to register */ public void setOnTrackFinishedListener(OnTrackFinishedListener listener) { mTrackFinishedListeners.add(listener); } /** * Removes a track finish listener from this class. * * @param listener Listener to remove from list */ public void removeOnTrackFinishedListener(OnTrackFinishedListener listener) { mTrackFinishedListeners.remove(listener); } /** * Registers a track start listener to this class (Called when a MediaPlayer is prepared and started) * * @param listener Listener to register */ public void setOnTrackStartListener(OnTrackStartedListener listener) { mTrackStartListeners.add(listener); } /** * Removes a track start listener from this class. * * @param listener Listener to remove from list */ public void removeOnTrackStartListener(OnTrackStartedListener listener) { mTrackStartListeners.remove(listener); } /** * This listener will handle callbacks when a track finishes playback */ private class TrackCompletionListener implements MediaPlayer.OnCompletionListener { @Override public void onCompletion(MediaPlayer mp) { // Sequentially execute all critical operations on the MP objects synchronized (GaplessPlayer.this) { // Reset the current MediaPlayer variable mCurrentMediaPlayer = null; // Notify connected listeners that the last track is now finished for (OnTrackFinishedListener listener : mTrackFinishedListeners) { listener.onTrackFinished(); } int audioSessionID = mp.getAudioSessionId(); /* * Signal android desire to close audio effect session */ Intent audioEffectIntent = new Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION); audioEffectIntent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionID); audioEffectIntent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, mPlaybackService.getPackageName()); Log.v(TAG,"Closing effect for session: " + audioSessionID); mPlaybackService.sendBroadcast(audioEffectIntent); mp.release(); // Set current MP to next MP if one is ready if (mNextMediaPlayer != null && (mSecondPrepared || mSecondPreparing)) { // Next media player should now be playing already, so make this the current one. mCurrentMediaPlayer = mNextMediaPlayer; // Register this listener to the now playing MediaPlayer also mCurrentMediaPlayer.setOnCompletionListener(new TrackCompletionListener()); // Move the second to primary source (URI) mPrimarySource = mSecondarySource; // Reset the now obsolete second MediaPlayer state variables mSecondarySource = null; mNextMediaPlayer = null; /* * Signal audio effect desire to android */ Intent audioEffectOpenIntent = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION); audioEffectOpenIntent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mCurrentMediaPlayer.getAudioSessionId()); audioEffectOpenIntent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, mPlaybackService.getPackageName()); audioEffectOpenIntent.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC); Log.v(TAG,"Opening effect for session: " + mCurrentMediaPlayer.getAudioSessionId()); mPlaybackService.sendBroadcast(audioEffectOpenIntent); mCurrentMediaPlayer.setAuxEffectSendLevel(1.0f); if (mSecondPrepared) { // Notify connected listeners that playback has started for (OnTrackStartedListener listener : mTrackStartListeners) { listener.onTrackStarted(mPrimarySource); } } } } } } /** * Exception class used to signal playback errors */ public class PlaybackException extends Exception { REASON mReason; public PlaybackException(REASON reason) { mReason = reason; } public REASON getReason() { return mReason; } } /** * Returns whether {@link GaplessPlayer} is active or inactive so it can receive commands * * @return True if this class is busy and false if it is not doing important work. */ public synchronized boolean getActive() { if (mSecondPreparing) { return true; } else if (!mCurrentPrepared && (mCurrentMediaPlayer != null)) { return true; } return false; } }