Java tutorial
/* * Copyright (C) 2012-2013 Adrian Ulrich <adrian@blinkenlights.ch> * Copyright (C) 2010, 2011 Christopher Eby <kreed@kreed.org> * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package mp.teardrop; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.app.backup.BackupManager; import android.appwidget.AppWidgetManager; import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.database.ContentObserver; import android.database.Cursor; import android.graphics.Bitmap; import android.hardware.Sensor; import android.hardware.SensorEvent; import android.hardware.SensorEventListener; import android.hardware.SensorManager; import android.media.AudioManager; import android.media.MediaPlayer; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.os.PowerManager; import android.os.Process; import android.os.SystemClock; import android.preference.PreferenceManager; import android.provider.MediaStore; import android.support.v4.content.ContextCompat; import android.util.Log; import android.widget.RemoteViews; import android.widget.Toast; import com.dropbox.client2.exception.DropboxException; import org.json.JSONException; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.EOFException; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Date; import java.util.Random; import mp.teardrop.SongTimeline.Callback; /** * Handles music playback and pretty much all the other work. */ public final class PlaybackService extends Service implements Handler.Callback, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener, MediaPlayer.OnPreparedListener, SharedPreferences.OnSharedPreferenceChangeListener, SongTimeline.Callback, SensorEventListener, AudioManager.OnAudioFocusChangeListener { /** * Name of the state file. */ private static final String STATE_FILE = "state"; /** * Header for state file to help indicate if the file is in the right * format. */ private static final long STATE_FILE_MAGIC = 0x1533574DC74B6ECL; /** * State file version that indicates data order. */ private static final int STATE_VERSION = 6; private static final int NOTIFICATION_ID = 2; /** * Rewind song if we already played more than 2.5 sec */ private static final int REWIND_AFTER_PLAYED_MS = 2500; /** * Action for startService: toggle playback on/off. */ public static final String ACTION_TOGGLE_PLAYBACK = "ch.blinkenlights.android.vanilla.action.TOGGLE_PLAYBACK"; /** * Action for startService: start playback if paused. */ public static final String ACTION_PLAY = "ch.blinkenlights.android.vanilla.action.PLAY"; /** * Action for startService: pause playback if playing. */ public static final String ACTION_PAUSE = "ch.blinkenlights.android.vanilla.action.PAUSE"; /** * Action for startService: toggle playback on/off. * * Unlike {@link PlaybackService#ACTION_TOGGLE_PLAYBACK}, the toggle does * not occur immediately. Instead, it is delayed so that if two of these * actions are received within 400 ms, the playback activity is opened * instead. */ public static final String ACTION_TOGGLE_PLAYBACK_DELAYED = "ch.blinkenlights.android.vanilla.action.TOGGLE_PLAYBACK_DELAYED"; /** * Action for startService: toggle playback on/off. * * This works the same way as ACTION_PLAY_PAUSE but prevents the notification * from being hidden regardless of notification visibility settings. */ public static final String ACTION_TOGGLE_PLAYBACK_NOTIFICATION = "ch.blinkenlights.android.vanilla.action.TOGGLE_PLAYBACK_NOTIFICATION"; /** * Action for startService: advance to the next song. */ public static final String ACTION_NEXT_SONG = "ch.blinkenlights.android.vanilla.action.NEXT_SONG"; /** * Action for startService: advance to the next song. * * Unlike {@link PlaybackService#ACTION_NEXT_SONG}, the toggle does * not occur immediately. Instead, it is delayed so that if two of these * actions are received within 400 ms, the playback activity is opened * instead. */ public static final String ACTION_NEXT_SONG_DELAYED = "ch.blinkenlights.android.vanilla.action.NEXT_SONG_DELAYED"; /** * Action for startService: advance to the next song. * * Like ACTION_NEXT_SONG, but starts playing automatically if paused * when this is called. */ public static final String ACTION_NEXT_SONG_AUTOPLAY = "ch.blinkenlights.android.vanilla.action.NEXT_SONG_AUTOPLAY"; /** * Action for startService: go back to the previous song. */ public static final String ACTION_PREVIOUS_SONG = "ch.blinkenlights.android.vanilla.action.PREVIOUS_SONG"; /** * Action for startService: go back to the previous song OR just rewind if it played for less than 5 seconds */ public static final String ACTION_REWIND_SONG = "ch.blinkenlights.android.vanilla.action.REWIND_SONG"; /** * Change the shuffle mode. */ public static final String ACTION_CYCLE_SHUFFLE = "ch.blinkenlights.android.vanilla.CYCLE_SHUFFLE"; /** * Change the repeat mode. */ public static final String ACTION_CYCLE_REPEAT = "ch.blinkenlights.android.vanilla.CYCLE_REPEAT"; /** * Pause music and hide the notifcation. */ public static final String ACTION_CLOSE_NOTIFICATION = "ch.blinkenlights.android.vanilla.CLOSE_NOTIFICATION"; public static final int NEVER = 0; public static final int WHEN_PLAYING = 1; public static final int ALWAYS = 2; /** * Notification click action: open LaunchActivity. */ private static final int NOT_ACTION_MAIN_ACTIVITY = 0; /** * Notification click action: open MiniPlaybackActivity. */ private static final int NOT_ACTION_MINI_ACTIVITY = 1; /** * Notification click action: open FullPlaybackActivity. */ private static final int NOT_ACTION_FULL_ACTIVITY = 2; /** * Notification click action: skip to next song. */ private static final int NOT_ACTION_NEXT_SONG = 3; /** * If a user action is triggered within this time (in ms) after the * idle time fade-out occurs, playback will be resumed. */ private static final long IDLE_GRACE_PERIOD = 60000; /** * Minimum time in milliseconds between shake actions. */ private static final int MIN_SHAKE_PERIOD = 500; /** * Defer release of mWakeLock for this time (in ms). */ private static final int WAKE_LOCK_DELAY = 60000; /** * If set, music will play. */ public static final int FLAG_PLAYING = 0x1; /** * Set when there is no media available on the device. */ public static final int FLAG_NO_MEDIA = 0x2; /** * Set when the current song is unplayable. */ public static final int FLAG_ERROR = 0x4; /** * Set when the user needs to select songs to play. */ public static final int FLAG_EMPTY_QUEUE = 0x8; public static final int SHIFT_FINISH = 4; /** * These two bits will be one of SongTimeline.FINISH_*. */ public static final int MASK_FINISH = 0x7 << SHIFT_FINISH; public static final int SHIFT_SHUFFLE = 7; /** * These two bits will be one of SongTimeline.SHUFFLE_*. */ public static final int MASK_SHUFFLE = 0x3 << SHIFT_SHUFFLE; static boolean shouldCountSongStart = true; static final String PREFS_SAVED_SONGS = "savedSongs"; /** * The PlaybackService state, indicating if the service is playing, * repeating, etc. * * The format of this is 0b00000000_00000000_00000000f_feeedcba, * where each bit is: * a: {@link PlaybackService#FLAG_PLAYING} * b: {@link PlaybackService#FLAG_NO_MEDIA} * c: {@link PlaybackService#FLAG_ERROR} * d: {@link PlaybackService#FLAG_EMPTY_QUEUE} * eee: {@link PlaybackService#MASK_FINISH} * ff: {@link PlaybackService#MASK_SHUFFLE} */ int mState; /** * How many times we tried to regenerate the current song's streaming link */ int mRetryCurrent = 0; /** * How many broken songs we did already skip */ int mSkipBroken = 0; /** * Object used for state-related locking. */ final Object[] mStateLock = new Object[0]; /** * A lock used to prevent a race condition when an activity is added and wants to retrieve * the current song's duration just as a new song is being processed. */ private static final Object[] sDurationRefreshLock = new Object[0]; /** * Object used for PlaybackService startup waiting. */ private static final Object[] sWait = new Object[0]; /** * The appplication-wide instance of the PlaybackService. */ public static PlaybackService sInstance; private static final ArrayList<PlaybackActivity> sActivities = new ArrayList<PlaybackActivity>(5); /** * Cached app-wide SharedPreferences instance. */ private static SharedPreferences sSettings; boolean mHeadsetPause; private boolean mScrobble; /** * If true, emulate the music status broadcasts sent by the stock android * music player. */ private boolean mStockBroadcast; private int mNotificationMode; /** * If true, audio will not be played through the speaker. */ private boolean mHeadsetOnly; /** * The time to wait before considering the player idle. */ private int mIdleTimeout; /** * The intent for the notification to execute, created by * {@link PlaybackService#createNotificationAction(SharedPreferences)}. */ private PendingIntent mNotificationAction; /** * Use white text instead of black default text in notification. */ private boolean mInvertNotification; private Looper mLooper; private Handler mHandler; MediaPlayer mMediaPlayer; MediaPlayer mPreparedMediaPlayer; private boolean mMediaPlayerInitialized; private PowerManager.WakeLock mWakeLock; private NotificationManager mNotificationManager; private AudioManager mAudioManager; /** * The SensorManager service. */ private SensorManager mSensorManager; SongTimeline mTimeline; private Song mCurrentSong; /** * Stores the saved position in the current song from saved state. Should * be seeked to when the song is loaded into MediaPlayer. Used only during * initialization. The song that the saved position is for is stored in * {@link #mPendingSeekSong}. */ private int mPendingSeek; /** * The id of the song that the mPendingSeek position is for. -1 indicates * an invalid song. Value is undefined when mPendingSeek is 0. */ private long mPendingSeekSong; public Receiver mReceiver; private String mErrorMessage; /** * Current fade-out progress. 1.0f if we are not fading out */ private float mFadeOut = 1.0f; /** * Elapsed realtime at which playback was paused by idle timeout. -1 * indicates that no timeout has occurred. */ private long mIdleStart = -1; /** * True if the last audio focus loss can be ducked. */ private boolean mDuckedLoss; /** * Magnitude of last sensed acceleration. */ private double mAccelLast; /** * Filtered acceleration used for shake detection. */ private double mAccelFiltered; /** * Elapsed realtime of last shake action. */ private long mLastShakeTime; /** * Minimum jerk required for shake. */ private double mShakeThreshold; /** * What to do when an accelerometer shake is detected. */ private Action mShakeAction; /** * If true, the notification should not be hidden when pausing regardless * of user settings. */ private boolean mForceNotificationVisible; /** * Enables or disables Replay Gain */ private boolean mReplayGainTrackEnabled; private boolean mReplayGainAlbumEnabled; private int mReplayGainBump; private int mReplayGainUntaggedDeBump; /** * TRUE if the readahead feature is enabled */ private boolean mReadaheadEnabled; /** * Reference to precreated ReadAhead thread */ private ReadaheadThread mReadahead; /** * Reference to precreated BASTP Object */ private BastpUtil mBastpUtil; /** * Reference to Playcounts helper class */ private PlayCountsHelper mPlayCounts; @Override public void onCreate() { HandlerThread thread = new HandlerThread("PlaybackService", Process.THREAD_PRIORITY_DEFAULT); thread.start(); mTimeline = new SongTimeline(this); mTimeline.setCallback(this); int state = loadState(); mPlayCounts = new PlayCountsHelper(this); mMediaPlayer = getNewMediaPlayer(); mBastpUtil = new BastpUtil(); mReadahead = new ReadaheadThread(); mReadahead.start(); mNotificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); mAudioManager = (AudioManager) getSystemService(AUDIO_SERVICE); SharedPreferences settings = getSettings(this); settings.registerOnSharedPreferenceChangeListener(this); mNotificationMode = Integer.parseInt(settings.getString(PrefKeys.NOTIFICATION_MODE, "1")); mScrobble = settings.getBoolean(PrefKeys.SCROBBLE, false); mIdleTimeout = settings.getBoolean(PrefKeys.USE_IDLE_TIMEOUT, false) ? settings.getInt(PrefKeys.IDLE_TIMEOUT, 3600) : 0; Song.mCoverLoadMode = settings.getBoolean(PrefKeys.COVERLOADER_ANDROID, true) ? Song.mCoverLoadMode | Song.COVER_MODE_ANDROID : Song.mCoverLoadMode & ~(Song.COVER_MODE_ANDROID); Song.mCoverLoadMode = settings.getBoolean(PrefKeys.COVERLOADER_VANILLA, true) ? Song.mCoverLoadMode | Song.COVER_MODE_VANILLA : Song.mCoverLoadMode & ~(Song.COVER_MODE_VANILLA); Song.mCoverLoadMode = settings.getBoolean(PrefKeys.COVERLOADER_SHADOW, true) ? Song.mCoverLoadMode | Song.COVER_MODE_SHADOW : Song.mCoverLoadMode & ~(Song.COVER_MODE_SHADOW); mHeadsetOnly = settings.getBoolean(PrefKeys.HEADSET_ONLY, false); mStockBroadcast = settings.getBoolean(PrefKeys.STOCK_BROADCAST, false); mInvertNotification = settings.getBoolean(PrefKeys.NOTIFICATION_INVERTED_COLOR, false); mNotificationAction = createNotificationAction(settings); mHeadsetPause = getSettings(this).getBoolean(PrefKeys.HEADSET_PAUSE, true); mShakeAction = settings.getBoolean(PrefKeys.ENABLE_SHAKE, false) ? Action.getAction(settings, PrefKeys.SHAKE_ACTION, Action.NextSong) : Action.Nothing; mShakeThreshold = settings.getInt(PrefKeys.SHAKE_THRESHOLD, 80) / 10.0f; mReplayGainTrackEnabled = settings.getBoolean(PrefKeys.ENABLE_TRACK_REPLAYGAIN, false); mReplayGainAlbumEnabled = settings.getBoolean(PrefKeys.ENABLE_ALBUM_REPLAYGAIN, false); mReplayGainBump = settings.getInt(PrefKeys.REPLAYGAIN_BUMP, 75); /* seek bar is 150 -> 75 == middle == 0 */ mReplayGainUntaggedDeBump = settings.getInt(PrefKeys.REPLAYGAIN_UNTAGGED_DEBUMP, 150); /* seek bar is 150 -> == 0 */ mReadaheadEnabled = settings.getBoolean(PrefKeys.ENABLE_READAHEAD, false); PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); mWakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TeardropMusicLock"); mReceiver = new Receiver(); IntentFilter filter = new IntentFilter(); filter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY); filter.addAction(Intent.ACTION_SCREEN_ON); registerReceiver(mReceiver, filter); getContentResolver().registerContentObserver(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, mObserver); CompatIcs.registerRemote(this, mAudioManager); mLooper = thread.getLooper(); mHandler = new Handler(mLooper, this); initWidgets(); updateState(state); setCurrentSong(0, false); sInstance = this; synchronized (sWait) { sWait.notifyAll(); } mAccelFiltered = 0.0f; mAccelLast = SensorManager.GRAVITY_EARTH; setupSensor(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (intent != null) { String action = intent.getAction(); if (ACTION_TOGGLE_PLAYBACK.equals(action)) { playPause(); } else if (ACTION_TOGGLE_PLAYBACK_NOTIFICATION.equals(action)) { mForceNotificationVisible = true; synchronized (mStateLock) { if ((mState & FLAG_PLAYING) != 0) pause(); else play(); } } else if (ACTION_TOGGLE_PLAYBACK_DELAYED.equals(action)) { if (mHandler.hasMessages(CALL_GO, Integer.valueOf(0))) { mHandler.removeMessages(CALL_GO, Integer.valueOf(0)); Intent launch = new Intent(this, LibraryActivity.class); launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); launch.setAction(Intent.ACTION_MAIN); startActivity(launch); } else { mHandler.sendMessageDelayed(mHandler.obtainMessage(CALL_GO, 0, 0, Integer.valueOf(0)), 400); } } else if (ACTION_NEXT_SONG.equals(action)) { setCurrentSong(1, false); userActionTriggered(); } else if (ACTION_NEXT_SONG_AUTOPLAY.equals(action)) { setCurrentSong(1, false); play(); } else if (ACTION_NEXT_SONG_DELAYED.equals(action)) { if (mHandler.hasMessages(CALL_GO, Integer.valueOf(1))) { mHandler.removeMessages(CALL_GO, Integer.valueOf(1)); Intent launch = new Intent(this, LibraryActivity.class); launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); launch.setAction(Intent.ACTION_MAIN); startActivity(launch); } else { mHandler.sendMessageDelayed(mHandler.obtainMessage(CALL_GO, 1, 0, Integer.valueOf(1)), 400); } } else if (ACTION_PREVIOUS_SONG.equals(action)) { setCurrentSong(-1, false); userActionTriggered(); } else if (ACTION_REWIND_SONG.equals(action)) { /* only rewind song IF we played more than 2.5 sec (and song is longer than 5 sec) */ if (getPosition() > REWIND_AFTER_PLAYED_MS && getDuration() > REWIND_AFTER_PLAYED_MS * 2) { setCurrentSong(0, false); } else { setCurrentSong(-1, false); } play(); } else if (ACTION_PLAY.equals(action)) { play(); } else if (ACTION_PAUSE.equals(action)) { pause(); } else if (ACTION_CYCLE_REPEAT.equals(action)) { cycleFinishAction(); } else if (ACTION_CYCLE_SHUFFLE.equals(action)) { cycleShuffle(); } else if (ACTION_CLOSE_NOTIFICATION.equals(action)) { mForceNotificationVisible = false; pause(); stopForeground(true); // sometimes required to clear notification mNotificationManager.cancel(NOTIFICATION_ID); } MediaButtonReceiver.registerMediaButton(this); } return START_NOT_STICKY; } @Override public void onDestroy() { sInstance = null; mLooper.quit(); // clear the notification stopForeground(true); if (mMediaPlayer != null) { saveState(mMediaPlayer.getCurrentPosition()); //TODO what is this and why is it here but not in newer Vanilla versions? // Intent i = new Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION); // i.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mMediaPlayer.getAudioSessionId()); // i.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, getPackageName()); // sendBroadcast(i); mMediaPlayer.release(); mMediaPlayer = null; } MediaButtonReceiver.unregisterMediaButton(this); try { unregisterReceiver(mReceiver); } catch (IllegalArgumentException e) { // we haven't registered the receiver yet } if (mSensorManager != null && mShakeAction != Action.Nothing) mSensorManager.unregisterListener(this); if (mWakeLock != null && mWakeLock.isHeld()) mWakeLock.release(); super.onDestroy(); } /** * Returns a new MediaPlayer object */ private MediaPlayer getNewMediaPlayer() { MediaPlayer mp = new MediaPlayer(); mp.setAudioStreamType(AudioManager.STREAM_MUSIC); mp.setOnCompletionListener(this); mp.setOnErrorListener(this); mp.setOnPreparedListener(this); return mp; } public void prepareMediaPlayer(MediaPlayer mp, Song song) throws IOException { Date threeHoursAgo = new Date(new Date().getTime() - 10800000); if (song.isCloudSong && (song.dropboxLinkCreated == null || song.dropboxLinkCreated.before(threeHoursAgo))) { //FIXME some of this code is duplicated try { String path = LibraryActivity.mApi.media(song.dbPath, true).url; song.path = path; song.dropboxLinkCreated = new Date(); } catch (DropboxException e1) { Log.w("OrchidMP", "Failed to refresh a song's streaming link: " + e1.getMessage()); throw new IOException("Failed to refresh a song's streaming link."); } } mp.setDataSource(song.path); synchronized (sDurationRefreshLock) { mp.prepare(); } //TODO what is this and why is it here but not in newer Vanilla versions? // Intent intent = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION); // intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, mp.getAudioSessionId()); // intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, getPackageName()); // sendBroadcast(intent); applyReplayGain(mp, song); } /** * Make sure that the current ReplayGain volume matches * the (maybe just changed) user settings */ private void refreshReplayGainValues() { Song curSong = getSong(0); if (mMediaPlayer == null) return; if (curSong == null) return; applyReplayGain(mMediaPlayer, curSong); if (mPreparedMediaPlayer != null) { applyReplayGain(mPreparedMediaPlayer, getSong(1)); } } /** * Adjusts volume according to RG information. * * If rgTrack and rgAlbum are both null, the file which path points to (must be local) * will be checked for RG tags. If rgTrack or rgAlbum are provided, path is ignored. */ private void applyReplayGain(MediaPlayer mp, Song song) { float rgTrack, rgAlbum; if (song.rgTrack == null && song.rgAlbum == null) { float[] rg = getReplayGainValues(song.path); /* track, album */ rgTrack = rg[0]; rgAlbum = rg[1]; } else { rgTrack = song.rgTrack == null ? 0f : song.rgTrack; rgAlbum = song.rgAlbum == null ? 0f : song.rgAlbum; } float adjust = 0f; if (mReplayGainAlbumEnabled) { adjust = (rgTrack != 0 ? rgTrack : adjust); /* do we have track adjustment ? */ adjust = (rgAlbum != 0 ? rgAlbum : adjust); /* ..or, even better, album adj? */ } if (mReplayGainTrackEnabled || (mReplayGainAlbumEnabled && adjust == 0)) { adjust = (rgAlbum != 0 ? rgAlbum : adjust); /* do we have album adjustment ? */ adjust = (rgTrack != 0 ? rgTrack : adjust); /* ..or, even better, track adj? */ } if (adjust == 0) { /* No RG value found: decrease volume for untagged song if requested by user */ adjust = (mReplayGainUntaggedDeBump - 150) / 10f; } else { /* This song has some replay gain info, we are now going to apply the 'bump' value ** The preferences stores the raw value of the seekbar, that's 0-150 ** But we want -15 <-> +15, so 75 shall be zero */ adjust += 2 * (mReplayGainBump - 75) / 10f; /* 2* -> we want +-15, not +-7.5 */ } if (mReplayGainAlbumEnabled == false && mReplayGainTrackEnabled == false) { /* Feature is disabled: Make sure that we are going to 100% volume */ adjust = 0f; } float rg_result = ((float) Math.pow(10, (adjust / 20))) * mFadeOut; if (rg_result > 1.0f) { rg_result = 1.0f; /* android would IGNORE the change if this is > 1 and we would end up with the wrong volume */ } else if (rg_result < 0.0f) { rg_result = 0.0f; } mp.setVolume(rg_result, rg_result); } /** * Returns the (hopefully cached) replaygain * values of given file */ public float[] getReplayGainValues(String path) { return mBastpUtil.getReplayGainValues(path); } /** * Destroys any currently prepared MediaPlayer and * re-creates a newone if needed. */ private void triggerGaplessUpdate() { // Log.d("VanillaMusic", "triggering gapless update"); if (mMediaPlayerInitialized != true) return; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) return; /* setNextMediaPlayer is supported since JB */ if (mPreparedMediaPlayer != null) { /* an old prepared player exists and is * most likely invalid -> destroy it now */ mMediaPlayer.setNextMediaPlayer(null); //TODO: needs special treatment - current min API level is too low mPreparedMediaPlayer.release(); mPreparedMediaPlayer = null; // Log.d("VanillaMusic", "old prepared player destroyed"); } int fa = finishAction(mState); Song nextSong = getSong(1); if (nextSong != null && fa != SongTimeline.FINISH_REPEAT_CURRENT && fa != SongTimeline.FINISH_STOP_CURRENT && fa != SongTimeline.FINISH_RANDOM && !mTimeline.isEndOfQueue()) { try { mPreparedMediaPlayer = getNewMediaPlayer(); prepareMediaPlayer(mPreparedMediaPlayer, nextSong); mMediaPlayer.setNextMediaPlayer(mPreparedMediaPlayer); // Log.d("VanillaMusic", "New media player prepared as "+mPreparedMediaPlayer+" with path "+nextSong.path); } catch (IOException e) { Log.e("OrchidMP", "IOException", e); } } else { Log.d("OrchidMP", "Must not create new media player object"); } } /** * Stops or starts the readahead thread */ private void triggerReadAhead() { Song song = mCurrentSong; if (mReadaheadEnabled && (mState & FLAG_PLAYING) != 0 && song != null) { mReadahead.setSource(song.path); } else { mReadahead.pause(); } } /** * Return the SharedPreferences instance containing the PlaybackService * settings, creating it if necessary. */ public static SharedPreferences getSettings(Context context) { if (sSettings == null) sSettings = PreferenceManager.getDefaultSharedPreferences(context); return sSettings; } /** * Setup the accelerometer. */ private void setupSensor() { if (mShakeAction == Action.Nothing || (mState & FLAG_PLAYING) == 0) { if (mSensorManager != null) mSensorManager.unregisterListener(this); } else { if (mSensorManager == null) mSensorManager = (SensorManager) getSystemService(SENSOR_SERVICE); mSensorManager.registerListener(this, mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER), SensorManager.SENSOR_DELAY_UI); } } private void loadPreference(String key) { SharedPreferences settings = getSettings(this); if (PrefKeys.HEADSET_PAUSE.equals(key)) { mHeadsetPause = settings.getBoolean(PrefKeys.HEADSET_PAUSE, true); } else if (PrefKeys.NOTIFICATION_ACTION.equals(key)) { mNotificationAction = createNotificationAction(settings); updateNotification(); } else if (PrefKeys.NOTIFICATION_INVERTED_COLOR.equals(key)) { mInvertNotification = settings.getBoolean(PrefKeys.NOTIFICATION_INVERTED_COLOR, false); updateNotification(); } else if (PrefKeys.NOTIFICATION_MODE.equals(key)) { mNotificationMode = Integer.parseInt(settings.getString(PrefKeys.NOTIFICATION_MODE, "1")); // This is the only way to remove a notification created by // startForeground(), even if we are not currently in foreground // mode. stopForeground(true); updateNotification(); } else if (PrefKeys.SCROBBLE.equals(key)) { mScrobble = settings.getBoolean(PrefKeys.SCROBBLE, false); } else if (PrefKeys.MEDIA_BUTTON.equals(key) || PrefKeys.MEDIA_BUTTON_BEEP.equals(key)) { MediaButtonReceiver.reloadPreference(this); } else if (PrefKeys.USE_IDLE_TIMEOUT.equals(key) || PrefKeys.IDLE_TIMEOUT.equals(key)) { mIdleTimeout = settings.getBoolean(PrefKeys.USE_IDLE_TIMEOUT, false) ? settings.getInt(PrefKeys.IDLE_TIMEOUT, 3600) : 0; userActionTriggered(); } else if (PrefKeys.COVERLOADER_ANDROID.equals(key)) { Song.mCoverLoadMode = settings.getBoolean(PrefKeys.COVERLOADER_ANDROID, true) ? Song.mCoverLoadMode | Song.COVER_MODE_ANDROID : Song.mCoverLoadMode & ~(Song.COVER_MODE_ANDROID); Song.mFlushCoverCache = true; } else if (PrefKeys.COVERLOADER_VANILLA.equals(key)) { Song.mCoverLoadMode = settings.getBoolean(PrefKeys.COVERLOADER_VANILLA, true) ? Song.mCoverLoadMode | Song.COVER_MODE_VANILLA : Song.mCoverLoadMode & ~(Song.COVER_MODE_VANILLA); Song.mFlushCoverCache = true; } else if (PrefKeys.COVERLOADER_SHADOW.equals(key)) { Song.mCoverLoadMode = settings.getBoolean(PrefKeys.COVERLOADER_SHADOW, true) ? Song.mCoverLoadMode | Song.COVER_MODE_SHADOW : Song.mCoverLoadMode & ~(Song.COVER_MODE_SHADOW); Song.mFlushCoverCache = true; } else if (PrefKeys.NOTIFICATION_INVERTED_COLOR.equals(key)) { updateNotification(); } else if (PrefKeys.HEADSET_ONLY.equals(key)) { mHeadsetOnly = settings.getBoolean(key, false); if (mHeadsetOnly && isSpeakerOn()) unsetFlag(FLAG_PLAYING); } else if (PrefKeys.STOCK_BROADCAST.equals(key)) { mStockBroadcast = settings.getBoolean(key, false); } else if (PrefKeys.ENABLE_SHAKE.equals(key) || PrefKeys.SHAKE_ACTION.equals(key)) { mShakeAction = settings.getBoolean(PrefKeys.ENABLE_SHAKE, false) ? Action.getAction(settings, PrefKeys.SHAKE_ACTION, Action.NextSong) : Action.Nothing; setupSensor(); } else if (PrefKeys.SHAKE_THRESHOLD.equals(key)) { mShakeThreshold = settings.getInt(PrefKeys.SHAKE_THRESHOLD, 80) / 10.0f; } else if (PrefKeys.ENABLE_TRACK_REPLAYGAIN.equals(key)) { mReplayGainTrackEnabled = settings.getBoolean(PrefKeys.ENABLE_TRACK_REPLAYGAIN, false); refreshReplayGainValues(); } else if (PrefKeys.ENABLE_ALBUM_REPLAYGAIN.equals(key)) { mReplayGainAlbumEnabled = settings.getBoolean(PrefKeys.ENABLE_ALBUM_REPLAYGAIN, false); refreshReplayGainValues(); } else if (PrefKeys.REPLAYGAIN_BUMP.equals(key)) { mReplayGainBump = settings.getInt(PrefKeys.REPLAYGAIN_BUMP, 75); refreshReplayGainValues(); } else if (PrefKeys.REPLAYGAIN_UNTAGGED_DEBUMP.equals(key)) { mReplayGainUntaggedDeBump = settings.getInt(PrefKeys.REPLAYGAIN_UNTAGGED_DEBUMP, 150); refreshReplayGainValues(); } else if (PrefKeys.ENABLE_READAHEAD.equals(key)) { mReadaheadEnabled = settings.getBoolean(PrefKeys.ENABLE_READAHEAD, false); } /* Tell androids cloud-backup manager that we just changed our preferences */ (new BackupManager(this)).dataChanged(); } /** * Set a state flag. */ public void setFlag(int flag) { synchronized (mStateLock) { updateState(mState | flag); } } /** * Unset a state flag. */ public void unsetFlag(int flag) { synchronized (mStateLock) { updateState(mState & ~flag); } } /** * Return true if audio would play through the speaker. */ @SuppressWarnings("deprecation") private boolean isSpeakerOn() { // Android seems very intent on making this difficult to detect. In // Android 1.5, this worked great with AudioManager.getRouting(), // which definitively answered if audio would play through the speakers. // Android 2.0 deprecated this method and made it no longer function. // So this hacky alternative was created. But with Android 4.0, // isWiredHeadsetOn() was deprecated, though it still works. But for // how much longer? // // I'd like to remove this feature so I can avoid fighting Android to // keep it working, but some users seem to really like it. I think the // best solution to this problem is for Android to have separate media // volumes for speaker, headphones, etc. That way the speakers can be // muted system-wide. There is not much I can do about that here, // though. return !mAudioManager.isWiredHeadsetOn() && !mAudioManager.isBluetoothA2dpOn() && !mAudioManager.isBluetoothScoOn(); } /** * Modify the service state. * * @param state Union of PlaybackService.STATE_* flags * @return The new state */ private int updateState(int state) { if ((state & (FLAG_NO_MEDIA | FLAG_ERROR | FLAG_EMPTY_QUEUE)) != 0 || mHeadsetOnly && isSpeakerOn()) state &= ~FLAG_PLAYING; int oldState = mState; mState = state; if (state != oldState) { mHandler.sendMessage(mHandler.obtainMessage(PROCESS_STATE, oldState, state)); mHandler.sendMessage(mHandler.obtainMessage(BROADCAST_CHANGE, state, 0)); } return state; } private void processNewState(int oldState, int state) { int toggled = oldState ^ state; if ((toggled & FLAG_PLAYING) != 0) { if ((state & FLAG_PLAYING) != 0) { if (mMediaPlayerInitialized) mMediaPlayer.start(); if (mNotificationMode != NEVER) startForeground(NOTIFICATION_ID, createNotification(mCurrentSong, mState)); mAudioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); mHandler.removeMessages(RELEASE_WAKE_LOCK); try { if (mWakeLock != null && mWakeLock.isHeld() == false) mWakeLock.acquire(); } catch (SecurityException e) { // Don't have WAKE_LOCK permission } } else { if (mMediaPlayerInitialized) mMediaPlayer.pause(); if (mNotificationMode == ALWAYS || mForceNotificationVisible) { stopForeground(false); mNotificationManager.notify(NOTIFICATION_ID, createNotification(mCurrentSong, mState)); } else { stopForeground(true); } // Delay release of the wake lock. This allows the headset // button to continue to function for a short period after // pausing. mHandler.sendEmptyMessageDelayed(RELEASE_WAKE_LOCK, WAKE_LOCK_DELAY); } setupSensor(); } if ((toggled & FLAG_NO_MEDIA) != 0 && (state & FLAG_NO_MEDIA) != 0) { Song song = mCurrentSong; if (song != null && mMediaPlayerInitialized) { mPendingSeek = mMediaPlayer.getCurrentPosition(); mPendingSeekSong = song.id; } } if ((toggled & MASK_SHUFFLE) != 0) mTimeline.setShuffleMode(shuffleMode(state)); if ((toggled & MASK_FINISH) != 0) mTimeline.setFinishAction(finishAction(state)); triggerGaplessUpdate(); triggerReadAhead(); } private void broadcastChange(int state, Song song, long uptime) { if (state != -1) { ArrayList<PlaybackActivity> list = sActivities; for (int i = list.size(); --i != -1;) list.get(i).setState(uptime, state); } if (song != null) { ArrayList<PlaybackActivity> list = sActivities; for (int i = list.size(); --i != -1;) list.get(i).setSong(uptime, song); } updateWidgets(); CompatIcs.updateRemote(this, mCurrentSong, mState); if (mStockBroadcast) stockMusicBroadcast(); if (mScrobble) scrobble(); } /** * Check if there are any instances of each widget. */ private void initWidgets() { AppWidgetManager manager = AppWidgetManager.getInstance(this); OneCellWidget.checkEnabled(this, manager); FourSquareWidget.checkEnabled(this, manager); FourLongWidget.checkEnabled(this, manager); FourWhiteWidget.checkEnabled(this, manager); WidgetD.checkEnabled(this, manager); WidgetE.checkEnabled(this, manager); } /** * Update the widgets with the current song and state. */ private void updateWidgets() { AppWidgetManager manager = AppWidgetManager.getInstance(this); Song song = mCurrentSong; int state = mState; OneCellWidget.updateWidget(this, manager, song, state); FourLongWidget.updateWidget(this, manager, song, state); FourSquareWidget.updateWidget(this, manager, song, state); FourWhiteWidget.updateWidget(this, manager, song, state); WidgetD.updateWidget(this, manager, song, state); WidgetE.updateWidget(this, manager, song, state); } /** * Send a broadcast emulating that of the stock music player. */ private void stockMusicBroadcast() { Song song = mCurrentSong; Intent intent = new Intent("com.android.music.playstatechanged"); intent.putExtra("playing", (mState & FLAG_PLAYING) != 0); if (song != null) { intent.putExtra("track", song.title); intent.putExtra("album", song.album); intent.putExtra("artist", song.artist); intent.putExtra("songid", song.id); intent.putExtra("albumid", song.albumId); } sendBroadcast(intent); } private void scrobble() { Song song = mCurrentSong; Intent intent = new Intent("net.jjc1138.android.scrobbler.action.MUSIC_STATUS"); intent.putExtra("playing", (mState & FLAG_PLAYING) != 0); if (song != null) intent.putExtra("id", (int) song.id); sendBroadcast(intent); } private void updateNotification() { if ((mForceNotificationVisible || mNotificationMode == ALWAYS || mNotificationMode == WHEN_PLAYING && (mState & FLAG_PLAYING) != 0) && mCurrentSong != null) mNotificationManager.notify(NOTIFICATION_ID, createNotification(mCurrentSong, mState)); else mNotificationManager.cancel(NOTIFICATION_ID); } /** * Start playing if currently paused. * * @return The new state after this is called. */ public int play() { synchronized (mStateLock) { if ((mState & FLAG_EMPTY_QUEUE) != 0) { Toast.makeText(this, R.string.empty_queue_cant_play, Toast.LENGTH_SHORT).show(); } int state = updateState(mState | FLAG_PLAYING); userActionTriggered(); return state; } } /** * Pause if currently playing. * * @return The new state after this is called. */ public int pause() { synchronized (mStateLock) { int state = updateState(mState & ~FLAG_PLAYING); userActionTriggered(); return state; } } /** * If playing, pause. If paused, play. * * @return The new state after this is called. */ public int playPause() { mForceNotificationVisible = false; synchronized (mStateLock) { if ((mState & FLAG_PLAYING) != 0) return pause(); else return play(); } } /** * Change the end action (e.g. repeat, random). * * @param action The new action. One of SongTimeline.FINISH_*. * @return The new state after this is called. */ public int setFinishAction(int action) { synchronized (mStateLock) { return updateState(mState & ~MASK_FINISH | action << SHIFT_FINISH); } } /** * Cycle repeat mode. Disables random mode. * * @return The new state after this is called. */ public int cycleFinishAction() { synchronized (mStateLock) { int mode = finishAction(mState) + 1; if (mode > SongTimeline.FINISH_RANDOM) mode = SongTimeline.FINISH_STOP; return setFinishAction(mode); } } /** * Change the shuffle mode. * * @param mode The new mode. One of SongTimeline.SHUFFLE_*. * @return The new state after this is called. */ public int setShuffleMode(int mode) { synchronized (mStateLock) { return updateState(mState & ~MASK_SHUFFLE | mode << SHIFT_SHUFFLE); } } /** * Cycle shuffle mode. * * @return The new state after this is called. */ public int cycleShuffle() { synchronized (mStateLock) { int mode = shuffleMode(mState) + 1; if (mode > SongTimeline.SHUFFLE_ALBUMS) mode = SongTimeline.SHUFFLE_NONE; return setShuffleMode(mode); } } /** * Move to the next or previous song or album in the timeline. * * @param delta One of SongTimeline.SHIFT_*. 0 can also be passed to * initialize the current song with media player, notification, * broadcasts, etc. * @return The new current song */ private Song setCurrentSong(int delta, boolean forcePlayWhenReady) { if (mMediaPlayer == null) return null; if (mMediaPlayer.isPlaying()) mMediaPlayer.stop(); Song song; if (delta == 0) song = mTimeline.getSong(0); else song = mTimeline.shiftCurrentSong(delta); mCurrentSong = song; if (song == null || song.id == -1 || song.path == null) { if (MediaUtils.isSongAvailable(this, getContentResolver())) { int flag = FLAG_EMPTY_QUEUE; synchronized (mStateLock) { updateState((mState | flag) & ~FLAG_NO_MEDIA); } return null; } else { // we don't have any songs : / synchronized (mStateLock) { updateState((mState | FLAG_NO_MEDIA) & ~FLAG_EMPTY_QUEUE); } return null; } } else if ((mState & (FLAG_NO_MEDIA | FLAG_EMPTY_QUEUE)) != 0) { synchronized (mStateLock) { updateState(mState & ~(FLAG_EMPTY_QUEUE | FLAG_NO_MEDIA)); } } mHandler.removeMessages(PROCESS_SONG); mMediaPlayerInitialized = false; mHandler.sendMessage( mHandler.obtainMessage(PROCESS_SONG, forcePlayWhenReady ? FORCE_PLAYBACK : 0, 0, song)); mHandler.sendMessage(mHandler.obtainMessage(BROADCAST_CHANGE, -1, 0, song)); return song; } //this should be called on a worker thread private void processSong(Song song, boolean forcePlayWhenReady) { /* Save our 'current' state, as the try block may set the ERROR flag (which clears the PLAYING flag) */ boolean playing = (mState & FLAG_PLAYING) != 0; try { mMediaPlayerInitialized = false; mMediaPlayer.reset(); if (mPreparedMediaPlayer != null && mPreparedMediaPlayer.isPlaying()) { mMediaPlayer.release(); mMediaPlayer = mPreparedMediaPlayer; ArrayList<PlaybackActivity> list = sActivities; for (int i = list.size(); --i != -1;) list.get(i).displayCurrentSongDuration(mPreparedMediaPlayer.getDuration()); mPreparedMediaPlayer = null; } else if (song.path != null) { prepareMediaPlayer(mMediaPlayer, song); } mMediaPlayerInitialized = true; triggerGaplessUpdate(); triggerReadAhead(); if (mPendingSeek != 0 && mPendingSeekSong == song.id) { mMediaPlayer.seekTo(mPendingSeek); mPendingSeek = 0; } if (forcePlayWhenReady) { if ((mState & FLAG_ERROR) != 0) { mErrorMessage = null; updateState(mState & ~FLAG_ERROR); } play(); mMediaPlayer.start(); } else { if ((mState & FLAG_PLAYING) != 0) { mMediaPlayer.start(); } if ((mState & FLAG_ERROR) != 0) { mErrorMessage = null; updateState(mState & ~FLAG_ERROR); } } mSkipBroken = 0; /* File not broken, reset skip counter */ mRetryCurrent = 0; } catch (IOException e) { if (mRetryCurrent == 0) { Log.i("OrchidMP", "Failed to load song " + song.path + ", will retry once."); mRetryCurrent = 1; try { //TODO: maybe this should re-download all the song's metadata, not just refresh the url? in case it was modified? //better yet, refresh the song's metadata after it has started playing to keep things smooth String path = LibraryActivity.mApi.media(song.dbPath, true).url; //retry with refreshed streaming link song.path = path; song.dropboxLinkCreated = new Date(); processSong(song, playing); return; } catch (DropboxException e1) { Log.w("OrchidMP", "Failed to refresh a song's streaming link: " + e1.getMessage()); // too bad. skip song } } /* failed after 1 retry, so skip song */ //mErrorMessage = getResources().getString(R.string.song_load_failed, song.path); updateState(mState | FLAG_ERROR); //Toast.makeText(this, mErrorMessage, Toast.LENGTH_LONG).show(); Log.e("OrchidMP", "IOException", e); /* Automatically advance to next song IF we are currently playing or already did skip * something. This will stop after skipping 10 songs to avoid endless loops (queue full * of broken stuff */ if (!mTimeline.isEndOfQueue() && getSong(1) != null && (playing || (mSkipBroken > 0 && mSkipBroken < 10))) { mSkipBroken++; mRetryCurrent = 0; mHandler.sendMessageDelayed(mHandler.obtainMessage(SKIP_BROKEN_SONG, getTimelinePosition(), 0), 1000); } } updateNotification(); //mTimeline.purge(); } @Override public void onCompletion(MediaPlayer player) { // song was played in its entirety - increase its popularity significantly Song song = mTimeline.getSong(0); mPlayCounts.countSong(song, 5); if (finishAction(mState) == SongTimeline.FINISH_REPEAT_CURRENT) { setCurrentSong(0, false); } else if (finishAction(mState) == SongTimeline.FINISH_RANDOM) { //play a random song from the queue - other than the current one (unless it's the only one) unsetFlag(FLAG_PLAYING); if (mTimeline.getLength() == 1) { mTimeline.setCurrentQueuePosition(0); setCurrentSong(0, false); play(); } else { int target = new Random().nextInt(mTimeline.getLength() - 1); if (target >= mTimeline.getPosition()) target++; mTimeline.setCurrentQueuePosition(target); setCurrentSong(0, false); play(); } } else if (finishAction(mState) == SongTimeline.FINISH_STOP_CURRENT) { unsetFlag(FLAG_PLAYING); setCurrentSong(1, false); } else if (mTimeline.isEndOfQueue()) { unsetFlag(FLAG_PLAYING); } else { setCurrentSong(1, false); } } //this runs twice for the same song whenever a new song starts playing //TODO: find out why it runs twice @Override public void onPrepared(MediaPlayer mp) { if (shouldCountSongStart) { Song song = mTimeline.getSong(0); if (!song.isCloudSong) { mPlayCounts.countSong(song, 1); //increase the song's popularity slightly when it starts playing } } shouldCountSongStart = !shouldCountSongStart; if (mMediaPlayer == mp) { ArrayList<PlaybackActivity> list = sActivities; for (int i = list.size(); --i != -1;) list.get(i).displayCurrentSongDuration(mp.getDuration()); } } @Override public boolean onError(MediaPlayer player, int what, int extra) { Log.e("OrchidMP", "MediaPlayer error: " + what + ' ' + extra); return true; } /** * Returns the song <code>delta</code> places away from the current * position. * * @see SongTimeline#getSong(int) */ public Song getSong(int delta) { if (mTimeline == null) return null; if (delta == 0) return mCurrentSong; return mTimeline.getSong(delta); } private class Receiver extends BroadcastReceiver { @Override public void onReceive(Context content, Intent intent) { String action = intent.getAction(); if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(action)) { if (mHeadsetPause) unsetFlag(FLAG_PLAYING); } else if (Intent.ACTION_SCREEN_ON.equals(action)) { userActionTriggered(); } } } public void onMediaChange() { if (MediaUtils.isSongAvailable(this, getContentResolver())) { if ((mState & FLAG_NO_MEDIA) != 0) setCurrentSong(0, false); } else { setFlag(FLAG_NO_MEDIA); } ArrayList<PlaybackActivity> list = sActivities; for (int i = list.size(); --i != -1;) list.get(i).onMediaChange(); } @Override public void onSharedPreferenceChanged(SharedPreferences settings, String key) { loadPreference(key); } /** * Calls {@link PowerManager.WakeLock#release()} on mWakeLock. */ private static final int RELEASE_WAKE_LOCK = 1; /** * Run the given query and add the results to the timeline. * * obj is the QueryTask. arg1 is the add mode (one of SongTimeline.MODE_*) */ private static final int QUERY = 2; private static final int CLOUD_SONGS = 1337; /** * This message is sent with a delay specified by a user preference. After * this delay, assuming no new IDLE_TIMEOUT messages cancel it, playback * will be stopped. */ private static final int IDLE_TIMEOUT = 4; /** * Decrease the volume gradually over five seconds, pausing when 0 is * reached. * * arg1 should be the progress in the fade as a percentage, 1-100. */ private static final int FADE_OUT = 7; /** * If arg1 is 0, calls {@link PlaybackService#playPause()}. * Otherwise, calls PlaybackService#setCurrentSong with arg1. */ private static final int CALL_GO = 8; private static final int BROADCAST_CHANGE = 10; private static final int SAVE_STATE = 12; private static final int PROCESS_SONG = 13; private static final int PROCESS_STATE = 14; private static final int SKIP_BROKEN_SONG = 15; private static final int FORCE_PLAYBACK = 1337; @Override public boolean handleMessage(Message message) { switch (message.what) { case CALL_GO: if (message.arg1 == 0) playPause(); else setCurrentSong(message.arg1, false); break; case SAVE_STATE: // For unexpected terminations: crashes, task killers, etc. // In most cases onDestroy will handle this saveState(0); break; case PROCESS_SONG: processSong((Song) message.obj, (message.arg1 == FORCE_PLAYBACK)); break; case QUERY: runQuery((QueryTask) message.obj); break; case CLOUD_SONGS: runDropboxQuery((ArrayList<CloudSongMetadata>) message.obj, message.arg1); break; case IDLE_TIMEOUT: if ((mState & FLAG_PLAYING) != 0) { mHandler.sendMessage(mHandler.obtainMessage(FADE_OUT, 0)); } break; case FADE_OUT: if (mFadeOut <= 0.0f) { mIdleStart = SystemClock.elapsedRealtime(); unsetFlag(FLAG_PLAYING); } else { mFadeOut -= 0.01f; mHandler.sendMessageDelayed(mHandler.obtainMessage(FADE_OUT, 0), 50); } refreshReplayGainValues(); /* Updates the volume using the new mFadeOut value */ break; case PROCESS_STATE: processNewState(message.arg1, message.arg2); break; case BROADCAST_CHANGE: broadcastChange(message.arg1, (Song) message.obj, message.getWhen()); break; case RELEASE_WAKE_LOCK: if (mWakeLock != null && mWakeLock.isHeld()) mWakeLock.release(); break; case SKIP_BROKEN_SONG: /* Advance to next song if the user didn't already change. * But we are restoring the Playing state in ANY case as we are most * likely still stopped due to the error * Note: This is somewhat racy with user input but also is the - by far - simplest * solution */ if (getTimelinePosition() == message.arg1) { setCurrentSong(1, true); } break; default: return false; } return true; } /** * Returns the current service state. The state comprises several individual * flags. */ public int getState() { synchronized (mStateLock) { return mState; } } /** * Returns the current position in current song in milliseconds. */ public int getPosition() { if (!mMediaPlayerInitialized) return 0; return mMediaPlayer.getCurrentPosition(); } /** * Returns the song duration in milliseconds. */ public int getDuration() { if (!mMediaPlayerInitialized) return 0; return mMediaPlayer.getDuration(); } /** * Seek to a position in the current song. * * @param progress Proportion of song completed (where 1000 is the end of the song) */ public void seekToProgress(int progress) { if (!mMediaPlayerInitialized) return; long position = (long) mMediaPlayer.getDuration() * progress / 1000; mMediaPlayer.seekTo((int) position); } @Override public IBinder onBind(Intent intents) { return null; } @Override public void activeSongReplaced(int delta, Song song) { ArrayList<PlaybackActivity> list = sActivities; for (int i = list.size(); --i != -1;) list.get(i).replaceSong(delta, song); if (delta == 0) setCurrentSong(0, false); } /** * Delete all the songs in the given media set. Should be run on a * background thread. * * @param type One of the TYPE_* constants, excluding playlists. * @param id The MediaStore id of the media to delete. * @return The number of songs deleted. */ public int deleteMedia(int type, long id) { int count = 0; ContentResolver resolver = getContentResolver(); String[] projection = new String[] { MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DATA }; Cursor cursor = MediaUtils.buildQuery(type, id, projection, null).runQuery(resolver); if (cursor != null) { while (cursor.moveToNext()) { if (new File(cursor.getString(1)).delete()) { long songId = cursor.getLong(0); String where = MediaStore.Audio.Media._ID + '=' + songId; resolver.delete(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, where, null); mTimeline.removeSong(songId); ++count; } } cursor.close(); } return count; } /** * Move to next or previous song or album in the queue. * * @param delta One of SongTimeline.SHIFT_*. * @return The new current song. */ public Song shiftCurrentSong(int delta) { ArrayList<PlaybackActivity> list = sActivities; for (int i = list.size(); --i != -1;) list.get(i).displayLoadingMessage(); Song song = setCurrentSong(delta, false); userActionTriggered(); return song; } /** * Resets the idle timeout countdown. Should be called by a user action * has been triggered (new song chosen or playback toggled). * * If an idle fade out is currently in progress, aborts it and resets the * volume. */ public void userActionTriggered() { mHandler.removeMessages(FADE_OUT); mHandler.removeMessages(IDLE_TIMEOUT); if (mIdleTimeout != 0) mHandler.sendEmptyMessageDelayed(IDLE_TIMEOUT, mIdleTimeout * 1000); if (mFadeOut != 1.0f) { mFadeOut = 1.0f; refreshReplayGainValues(); } long idleStart = mIdleStart; if (idleStart != -1 && SystemClock.elapsedRealtime() - idleStart < IDLE_GRACE_PERIOD) { mIdleStart = -1; setFlag(FLAG_PLAYING); } } /** * Run the query and add the results to the timeline. Should be called in the * worker thread. * * @param query The query to run. */ public void runQuery(QueryTask query) { int count = mTimeline.addSongs(this, query); int text; switch (query.mode) { case SongTimeline.MODE_PLAY: case SongTimeline.MODE_PLAY_POS_FIRST: case SongTimeline.MODE_PLAY_ID_FIRST: text = R.plurals.playing; if (count != 0 && (mState & FLAG_PLAYING) == 0) setFlag(FLAG_PLAYING); break; case SongTimeline.MODE_PLAY_NEXT: case SongTimeline.MODE_ENQUEUE: case SongTimeline.MODE_ENQUEUE_ID_FIRST: case SongTimeline.MODE_ENQUEUE_POS_FIRST: text = R.plurals.enqueued; break; default: throw new IllegalArgumentException("Invalid add mode: " + query.mode); } Toast.makeText(this, getResources().getQuantityString(text, count, count), Toast.LENGTH_SHORT).show(); triggerGaplessUpdate(); } /** * Doesn't actually query anything, but so named because it's * a sister method of runQuery. Prepares Song instances and * adds them to the timeline. * * @param cloudSongs Metadata of the songs to add. */ private void runDropboxQuery(ArrayList<CloudSongMetadata> cloudSongs, int mode) { int count = mTimeline.addCloudSongs(cloudSongs, mode); int text; switch (mode) { case SongTimeline.MODE_PLAY: text = R.plurals.playing; if (count != 0 && (mState & FLAG_PLAYING) == 0) setFlag(FLAG_PLAYING); break; case SongTimeline.MODE_ENQUEUE: text = R.plurals.enqueued; break; default: throw new IllegalArgumentException("Invalid add mode: " + mode); } Toast.makeText(this, getResources().getQuantityString(text, count, count), Toast.LENGTH_SHORT).show(); triggerGaplessUpdate(); } /** * Run the query in the background and add the results to the timeline. * * @param query The query. */ public void addSongs(QueryTask query) { mHandler.sendMessage(mHandler.obtainMessage(QUERY, query)); } /** * Read provided song metadata and add the results to the timeline. * * @param cloudSongs The metadata for the songs. * @param mode How to add the songs to the timeline: {@link SongTimeline}.MODE_PLAY or {@link SongTimeline}.MODE_ENQUEUE. */ void addCloudSongs(ArrayList<CloudSongMetadata> cloudSongs, int mode) { mHandler.sendMessage(mHandler.obtainMessage(CLOUD_SONGS, mode, 0, cloudSongs)); } /** * Enqueues all the songs with the same album/artist/genre as the current * song. * * This will clear the queue and place the first song from the group after * the playing song. * * @param type The media type, one of MediaUtils.TYPE_ALBUM, TYPE_ARTIST, * or TYPE_GENRE */ public void enqueueFromCurrent(int type) { Song current = mCurrentSong; if (current == null) return; long id; switch (type) { /* case MediaUtils.TYPE_ARTIST: id = current.artistId; break; case MediaUtils.TYPE_ALBUM: id = current.albumId; break; case MediaUtils.TYPE_GENRE: id = MediaUtils.queryGenreForSong(getContentResolver(), current.id); break; */ default: throw new IllegalArgumentException("Unsupported media type: " + type); } /* String selection = "_id!=" + current.id; QueryTask query = MediaUtils.buildQuery(type, id, Song.FILLED_PROJECTION, selection); query.mode = SongTimeline.MODE_PLAY_NEXT; addSongs(query); */ } /** * Clear the song queue. */ public void clearQueue() { mTimeline.clearQueue(); triggerGaplessUpdate(); } //TODO: reduce redundancy - make one clearQueue method call the other /** * Clear the song queue and notify the callback. */ public void clearQueue(Callback callback) { mTimeline.clearQueue(callback); triggerGaplessUpdate(); } void randomlyReorderQueue(Callback callback) { mTimeline.randomlyReorderQueue(callback); triggerGaplessUpdate(); } /** * Return the error message set when FLAG_ERROR is set. */ public String getErrorMessage() { return mErrorMessage; } @Override public void timelineChanged() { mHandler.removeMessages(SAVE_STATE); mHandler.sendEmptyMessageDelayed(SAVE_STATE, 5000); } @Override public void positionInfoChanged() { ArrayList<PlaybackActivity> list = sActivities; for (int i = list.size(); --i != -1;) list.get(i).onPositionInfoChanged(); } private final ContentObserver mObserver = new ContentObserver(null) { @Override public void onChange(boolean selfChange) { MediaUtils.onMediaChange(); onMediaChange(); } }; /** * Return the PlaybackService instance, creating one if needed. */ public static PlaybackService get(Context context) { if (sInstance == null) { context.startService(new Intent(context, PlaybackService.class)); while (sInstance == null) { try { synchronized (sWait) { sWait.wait(); } } catch (InterruptedException ignored) { } } } return sInstance; } /** * Returns true if a PlaybackService instance is active. */ public static boolean hasInstance() { return sInstance != null; } /** * Add an Activity to the registered PlaybackActivities. * * @param activity The Activity to be added */ public static void addActivity(PlaybackActivity activity) { sActivities.add(activity); //FIXME Caused crashes when opening FullPlaybackActivity quickly after app startup, //before the PlaybackService could initialize its stuff, this is not currently an issue //because the UI does not allow opening FPA before PS is ready, but that may change synchronized (sDurationRefreshLock) { if (hasInstance() && sInstance.mMediaPlayer != null) { activity.displayCurrentSongDuration(sInstance.mMediaPlayer.getDuration()); } } } /** * Remove an Activity from the registered PlaybackActivities * * @param activity The Activity to be removed */ public static void removeActivity(PlaybackActivity activity) { sActivities.remove(activity); } /** * Initializes the service state, loading songs saved from the disk into the * song timeline. * * @return The loaded value for mState. */ public int loadState() { int state = 0; try { DataInputStream in = new DataInputStream(openFileInput(STATE_FILE)); if (in.readLong() == STATE_FILE_MAGIC && in.readInt() == STATE_VERSION) { mPendingSeek = in.readInt(); mPendingSeekSong = in.readLong(); mTimeline.readState(getSharedPreferences(PREFS_SAVED_SONGS, 0)); state |= mTimeline.getShuffleMode() << SHIFT_SHUFFLE; state |= mTimeline.getFinishAction() << SHIFT_FINISH; } in.close(); } catch (EOFException e) { Log.w("OrchidMP", "Failed to load state", e); } catch (IOException e) { Log.w("OrchidMP", "Failed to load state", e); } catch (JSONException e) { Log.w("OrchidMP", "Failed to load state", e); } return state; } /** * Save the service state to disk. * * @param pendingSeek The pendingSeek to store. Should be the current * MediaPlayer position or 0. */ public void saveState(int pendingSeek) { try { DataOutputStream out = new DataOutputStream(openFileOutput(STATE_FILE, 0)); Song song = mCurrentSong; out.writeLong(STATE_FILE_MAGIC); out.writeInt(STATE_VERSION); out.writeInt(pendingSeek); out.writeLong(song == null ? -1 : song.id); mTimeline.writeState(getSharedPreferences(PREFS_SAVED_SONGS, 0)); out.close(); } catch (IOException e) { Log.w("OrchidMP", "Failed to save state", e); } catch (JSONException e) { Log.w("OrchidMP", "Failed to save state", e); } } /** * Returns the shuffle mode for the given state. * * @param state The PlaybackService state to process. * @return The shuffle mode. One of SongTimeline.SHUFFLE_*. */ public static int shuffleMode(int state) { return (state & MASK_SHUFFLE) >> SHIFT_SHUFFLE; } /** * Returns the finish action for the given state. * * @param state The PlaybackService state to process. * @return The finish action. One of SongTimeline.FINISH_*. */ public static int finishAction(int state) { return (state & MASK_FINISH) >> SHIFT_FINISH; } /** * Create a PendingIntent for use with the notification. * * @param prefs Where to load the action preference from. */ public PendingIntent createNotificationAction(SharedPreferences prefs) { switch (Integer.parseInt(prefs.getString(PrefKeys.NOTIFICATION_ACTION, "0"))) { case NOT_ACTION_NEXT_SONG: { Intent intent = new Intent(this, PlaybackService.class); intent.setAction(PlaybackService.ACTION_NEXT_SONG_AUTOPLAY); return PendingIntent.getService(this, 0, intent, 0); } case NOT_ACTION_MINI_ACTIVITY: { Intent intent = new Intent(this, MiniPlaybackActivity.class); return PendingIntent.getActivity(this, 0, intent, 0); } default: Log.w("OrchidMP", "Unknown value for notification_action. Defaulting to 0."); // fall through case NOT_ACTION_MAIN_ACTIVITY: { Intent intent = new Intent(this, LibraryActivity.class); intent.setAction(Intent.ACTION_MAIN); return PendingIntent.getActivity(this, 0, intent, 0); } case NOT_ACTION_FULL_ACTIVITY: { Intent intent = new Intent(this, FullPlaybackActivity.class); intent.setAction(Intent.ACTION_MAIN); return PendingIntent.getActivity(this, 0, intent, 0); } } } /** * Create a song notification. Call through the NotificationManager to * display it. * * @param song The Song to display information about. * @param state The state. Determines whether to show paused or playing icon. */ public Notification createNotification(Song song, int state) { boolean playing = (state & FLAG_PLAYING) != 0; RemoteViews views = new RemoteViews(getPackageName(), R.layout.notification); RemoteViews expanded = new RemoteViews(getPackageName(), R.layout.notification_expanded); Bitmap cover = song.getCover(this); if (cover == null) { views.setImageViewResource(R.id.cover, R.drawable.fallback_cover); expanded.setImageViewResource(R.id.cover, R.drawable.fallback_cover); } else { views.setImageViewBitmap(R.id.cover, cover); expanded.setImageViewBitmap(R.id.cover, cover); } int playButton = getPlayButtonResource(playing); views.setImageViewResource(R.id.play_pause, playButton); expanded.setImageViewResource(R.id.play_pause, playButton); ComponentName service = new ComponentName(this, PlaybackService.class); Intent previous = new Intent(PlaybackService.ACTION_PREVIOUS_SONG); previous.setComponent(service); expanded.setOnClickPendingIntent(R.id.previous, PendingIntent.getService(this, 0, previous, 0)); Intent playPause = new Intent(PlaybackService.ACTION_TOGGLE_PLAYBACK_NOTIFICATION); playPause.setComponent(service); views.setOnClickPendingIntent(R.id.play_pause, PendingIntent.getService(this, 0, playPause, 0)); expanded.setOnClickPendingIntent(R.id.play_pause, PendingIntent.getService(this, 0, playPause, 0)); Intent next = new Intent(PlaybackService.ACTION_NEXT_SONG); next.setComponent(service); views.setOnClickPendingIntent(R.id.next, PendingIntent.getService(this, 0, next, 0)); expanded.setOnClickPendingIntent(R.id.next, PendingIntent.getService(this, 0, next, 0)); Intent close = new Intent(PlaybackService.ACTION_CLOSE_NOTIFICATION); close.setComponent(service); views.setOnClickPendingIntent(R.id.close, PendingIntent.getService(this, 0, close, 0)); expanded.setOnClickPendingIntent(R.id.close, PendingIntent.getService(this, 0, close, 0)); views.setTextViewText(R.id.title, song.title); views.setTextViewText(R.id.artist, song.artist); expanded.setTextViewText(R.id.title, song.title); expanded.setTextViewText(R.id.album, song.album); expanded.setTextViewText(R.id.artist, song.artist); Notification notification = new Notification(); notification.contentView = views; notification.icon = R.drawable.status_icon; notification.flags |= Notification.FLAG_ONGOING_EVENT; notification.contentIntent = mNotificationAction; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { // expanded view is available since 4.1 notification.bigContentView = expanded; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { notification.visibility = Notification.VISIBILITY_PUBLIC; } // if(mNotificationNag) { // if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // notification.priority = Notification.PRIORITY_MAX; // notification.vibrate = new long[0]; // needed to get headsup // } else { // notification.tickerText = song.title + " - " + song.artist; // } // } return notification; } private static int getPlayButtonResource(boolean playing) { int playButton = 0; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // Android >= 5.0 uses the dark version of this drawable playButton = playing ? R.drawable.widget_pause : R.drawable.widget_play; } else { playButton = playing ? R.drawable.pause : R.drawable.play; } return playButton; } public void onAudioFocusChange(int type) { Log.d("OrchidMP", "audio focus change: " + type); switch (type) { case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: mDuckedLoss = (mState & FLAG_PLAYING) != 0; unsetFlag(FLAG_PLAYING); break; case AudioManager.AUDIOFOCUS_LOSS: case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: mDuckedLoss = false; mForceNotificationVisible = true; unsetFlag(FLAG_PLAYING); break; case AudioManager.AUDIOFOCUS_GAIN: if (mDuckedLoss) { mDuckedLoss = false; setFlag(FLAG_PLAYING); } break; } } @Override public void onSensorChanged(SensorEvent se) { double x = se.values[0]; double y = se.values[1]; double z = se.values[2]; double accel = Math.sqrt(x * x + y * y + z * z); double delta = accel - mAccelLast; mAccelLast = accel; double filtered = mAccelFiltered * 0.9f + delta; mAccelFiltered = filtered; if (filtered > mShakeThreshold) { long now = SystemClock.elapsedRealtime(); if (now - mLastShakeTime > MIN_SHAKE_PERIOD) { mLastShakeTime = now; performAction(mShakeAction, null); } } } @Override public void onAccuracyChanged(Sensor sensor, int accuracy) { } /** * Execute the given action. * * @param action The action to execute. * @param receiver Optional. If non-null, update the PlaybackActivity with * new song or state from the executed action. The activity will still be * updated by the broadcast if not passed here; passing it just makes the * update immediate. */ public void performAction(Action action, PlaybackActivity receiver) { switch (action) { case Nothing: break; case Library: Intent intent = new Intent(this, LibraryActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); break; case PlayPause: { int state = playPause(); if (receiver != null) receiver.setState(state); break; } case NextSong: { Song song = shiftCurrentSong(SongTimeline.SHIFT_NEXT_SONG); if (receiver != null) receiver.setSong(song); break; } case PreviousSong: { Song song = shiftCurrentSong(SongTimeline.SHIFT_PREVIOUS_SONG); if (receiver != null) receiver.setSong(song); break; } case NextAlbum: { Song song = shiftCurrentSong(SongTimeline.SHIFT_NEXT_ALBUM); if (receiver != null) receiver.setSong(song); break; } case PreviousAlbum: { Song song = shiftCurrentSong(SongTimeline.SHIFT_PREVIOUS_ALBUM); if (receiver != null) receiver.setSong(song); break; } case Repeat: { int state = cycleFinishAction(); if (receiver != null) receiver.setState(state); break; } case Shuffle: { int state = cycleShuffle(); if (receiver != null) receiver.setState(state); break; } /* case EnqueueAlbum: //TODO: re-add these enqueueFromCurrent(MediaUtils.TYPE_ALBUM); break; case EnqueueArtist: enqueueFromCurrent(MediaUtils.TYPE_ARTIST); break; case EnqueueGenre: enqueueFromCurrent(MediaUtils.TYPE_GENRE); break; */ case ClearQueue: clearQueue(); Toast.makeText(this, R.string.queue_cleared, Toast.LENGTH_SHORT).show(); break; case ToggleControls: // Handled in FullPlaybackActivity.performAction break; default: throw new IllegalArgumentException("Invalid action: " + action); } } /** * Returns the position of the current song in the song timeline. */ public int getTimelinePosition() { return mTimeline.getPosition(); } /** * Returns the number of songs in the song timeline. */ public int getTimelineLength() { return mTimeline.getLength(); } /** * Returns 'Song' with given id from timeline */ public Song getSongByQueuePosition(int id) { return mTimeline.getSongByQueuePosition(id); } /** * Do a 'hard' jump to given queue position */ public void jumpToQueuePosition(int id) { ArrayList<PlaybackActivity> list = sActivities; for (int i = list.size(); --i != -1;) list.get(i).displayLoadingMessage(); mTimeline.setCurrentQueuePosition(id); setCurrentSong(0, false); play(); } /** * Do a 'hard' jump to a random queue position, other than the current one * if possible. */ public void jumpToRandomQueuePosition() { int target; if (mTimeline.getLength() == 1) { target = 0; } else { target = new Random().nextInt(mTimeline.getLength() - 1); if (target == mTimeline.getPosition()) target++; } mTimeline.setCurrentQueuePosition(target); setCurrentSong(0, false); play(); } }