/* * SicMu Player - Lightweight music player for Android * Copyright (C) 2015 Mathieu Souchaud * * 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 souch.smp; import android.app.Notification; import android.app.PendingIntent; import android.app.Service; import android.content.ComponentName; import android.content.ContentUris; import android.content.Context; import android.content.Intent; 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.net.Uri; import android.os.Binder; import android.os.Build; import android.os.IBinder; import android.os.PowerManager; import android.util.Log; import android.widget.Toast; import java.util.Timer; import java.util.TimerTask; public class MusicService extends Service implements MediaPlayer.OnPreparedListener, MediaPlayer.OnErrorListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnSeekCompleteListener, AudioManager.OnAudioFocusChangeListener, SensorEventListener { // drive app from hardware key (from MediaButtonIntentReceiver) public static final String SERVICECMD = "souch.smp.musicservicecommand"; public static final String CMDNAME = "command"; public static final String CMDTOGGLEPAUSE = "togglepause"; public static final String CMDSTOP = "stop"; public static final String CMDPAUSE = "pause"; public static final String CMDPLAY = "play"; public static final String CMDPREVIOUS = "previous"; public static final String CMDNEXT = "next"; // drive the app from another app public static final String TOGGLEPAUSE_ACTION = "souch.smp.musicservicecommand.togglepause"; public static final String PAUSE_ACTION = "souch.smp.musicservicecommand.pause"; public static final String PREVIOUS_ACTION = "souch.smp.musicservicecommand.previous"; public static final String NEXT_ACTION = "souch.smp.musicservicecommand.next"; private Parameters params; private MediaPlayer player; private Rows rows; // seek to last song pos on startup in millisec // if -1: disabled (do not seek to on startup) private int savedSongPos; // need for focus private boolean wasPlaying; // sthg happened and the Main do not know it: a song has finish to play, another app gain focus, ... private boolean changed; // useful only for buggy android seek private int seekPosBug; // a notification has been launched private boolean foreground; private static final int NOTIFY_ID = 1; private boolean mainIsVisible; public void setMainIsVisible(boolean visible) { mainIsVisible = visible; } private final IBinder musicBind = new MusicBinder(); private ComponentName remoteControlResponder; private boolean hasAudioFocus; private AudioManager audioManager; // current state of the MediaPlayer private PlayerState state; // set to false if seekTo() has been called but the seek is still not done private boolean seekFinished; private SensorManager sensorManager; private Sensor accelerometer; private long lastUpdate; private boolean enableShake; private float shakeThreshold; private final int MIN_SHAKE_PERIOD = 1000 * 1000 * 1000; private double accelLast; private double accelCurrent; private double accel; private Scrobble scrobble; public Rows getRows() { return rows; } public boolean getChanged() { boolean hasChanged = changed; changed = false; return hasChanged; } public void setChanged() { changed = true; } /*** SERVICE ***/ public void onCreate() { Log.d("MusicService", "onCreate()"); super.onCreate(); state = new PlayerState(); changed = false; seekFinished = true; seekPosBug = -1; wasPlaying = false; player = null; remoteControlResponder = null; audioManager = null; hasAudioFocus = false; params = new ParametersImpl(this); rows = new Rows(getContentResolver(), params, getResources()); restore(); remoteControlResponder = new ComponentName(getPackageName(), MediaButtonIntentReceiver.class.getName()); audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); audioManager.registerMediaButtonEventReceiver(remoteControlResponder); foreground = false; mainIsVisible = false; scrobble = new Scrobble(rows, params, getApplicationContext()); } public class MusicBinder extends Binder { MusicService getService() { return MusicService.this; } } @Override public IBinder onBind(Intent arg0) { return musicBind; } @Override public void onDestroy() { Log.d("MusicService", "onDestroy"); save(); rows.save(); releaseAudio(); if (!params.getMediaButtonStartAppShake()) audioManager.unregisterMediaButtonEventReceiver(remoteControlResponder); } /*** PLAYER ***/ // create AudioManager and MediaPlayer at the last moment // this func assure they are initialized private MediaPlayer getPlayer() { seekPosBug = -1; if (!hasAudioFocus) { int result = audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { hasAudioFocus = true; } else { Toast.makeText(getApplicationContext(), getResources().getString(R.string.focus_error), Toast.LENGTH_LONG).show(); } } if (player == null) { player = new MediaPlayer(); //set player properties player.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK); player.setAudioStreamType(AudioManager.STREAM_MUSIC); player.setOnPreparedListener(this); player.setOnCompletionListener(this); player.setOnErrorListener(this); player.setOnSeekCompleteListener(this); } return player; } private void releaseAudio() { if (params.getSaveSongPos() && player != null && state.getState() != PlayerState.Nope && state.getState() != PlayerState.Idle && state.getState() != PlayerState.Error) { params.setSongPos(player.getCurrentPosition()); } state.setState(PlayerState.Nope); seekFinished = true; changed = true; wasPlaying = false; scrobble.send(Scrobble.SCROBBLE_COMPLETE); if (player != null) { if (player.isPlaying()) { player.stop(); } player.release(); player = null; } if (hasAudioFocus) { audioManager.abandonAudioFocus(this); hasAudioFocus = false; } stopSensor(); stopNotification(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { handleCommand(intent); // show the notification if MusicService has been started from the MediaButtonIntentReceiver if (!mainIsVisible && !foreground && changed && isInState(PlayerState.Started)) startNotification(); return super.onStartCommand(intent, flags, startId); } private void handleCommand(Intent intent) { if (intent == null) return; String action = intent.getAction(); String cmd = intent.getStringExtra("command"); Log.d("MusicService", "intentReceiver.onReceive" + action + " / " + cmd); if (CMDNEXT.equals(cmd) || NEXT_ACTION.equals(action)) { playNext(); changed = true; } else if (CMDPREVIOUS.equals(cmd) || PREVIOUS_ACTION.equals(action)) { playPrev(); changed = true; } else if (CMDTOGGLEPAUSE.equals(cmd) || TOGGLEPAUSE_ACTION.equals(action)) { if (isInState(PlayerState.Started)) { pause(); } else { if (isInState(PlayerState.Paused)) start(); else playSong(); } changed = true; } else if (CMDSTOP.equals(cmd) || CMDPAUSE.equals(cmd) || PAUSE_ACTION.equals(action)) { if (isInState(PlayerState.Started)) { pause(); changed = true; } } else if (CMDPLAY.equals(cmd)) { if (isInState(PlayerState.Paused)) start(); else playSong(); changed = true; } } @Override public void onAudioFocusChange(int focusChange) { switch (focusChange) { case AudioManager.AUDIOFOCUS_GAIN: // resume playback if (wasPlaying) { start(); changed = true; } //player.setVolume(1.0f, 1.0f); break; case AudioManager.AUDIOFOCUS_LOSS: // Lost focus for an unbounded amount of time: stop playback and release media player releaseAudio(); break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: // Lost focus for a short time, but we have to stop // playback. We don't release the media player because playback // is likely to resume case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: // Lost focus for a short time, but it's ok to keep playing // at an attenuated level if (getPlayer().isPlaying()) { //player.setVolume(0.1f, 0.1f); pause(); wasPlaying = true; changed = true; } else { wasPlaying = false; stopSensor(); } break; } } private int seekPosNbLoop; private final int seekPosMaxLoop = 15; @Override public void onSeekComplete(MediaPlayer mp) { // on a 4.1 phone no bug : calling getCurrentPosition now gives the new seeked position // on My 2.3.6 phone, the phone seems bugged : calling now getCurrentPosition gives // last position. So wait the seekpos goes after the asked seekpos. if(seekPosBug != -1) { // todo: make it thread safe? seekPosNbLoop = seekPosMaxLoop; final Timer seekPosTimer = new Timer(); seekPosTimer.schedule(new TimerTask() { @Override public void run() { if (seekPosNbLoop-- > 0 || getCurrentPosition() >= seekPosBug) { seekFinished = true; seekPosBug = -1; seekPosTimer.cancel(); } } }, 300); } else { seekFinished = true; } Log.d("MusicService", "onSeekComplete setProgress" + RowSong.secondsToMinutes(getCurrentPosition())); } public void playSong() { RowSong rowSong = rows.getCurrSong(); if (rowSong == null) return; startSensor(); getPlayer().reset(); state.setState(PlayerState.Idle); // get id long currSong = rowSong.getID(); // set uri Uri trackUri = ContentUris.withAppendedId( android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, currSong); try{ getPlayer().setDataSource(getApplicationContext(), trackUri); } catch(Exception e){ Log.e("MUSIC SERVICE", "Error setting data source", e); state.setState(PlayerState.Error); // todo: improve error handling return; } state.setState(PlayerState.Initialized); getPlayer().prepareAsync(); state.setState(PlayerState.Preparing); } @Override public void onCompletion(MediaPlayer mp) { state.setState(PlayerState.PlaybackCompleted); changed = true; // loop only to same track if not asked to change track (i.e. loop only on completion) if (rows.getRepeatMode() == RepeatMode.REPEAT_ONE) playSame(); else playNext(); } @Override public boolean onError(MediaPlayer mp, int what, int extra) { // todo: check if this func is ok /* mp.reset(); state.setState(PlayerState.Idle); */ return false; } @Override public void onPrepared(MediaPlayer mp) { // if a songPos has been stored if (savedSongPos > 0) { // seek to it if (savedSongPos < mp.getDuration()) mp.seekTo(savedSongPos); // reset songPos params.setSongPos(0); savedSongPos = 0; } // start playback mp.start(); state.setState(PlayerState.Started); scrobble.send(Scrobble.SCROBBLE_COMPLETE); scrobble.send(Scrobble.SCROBBLE_START); } /*** PLAY ACTION ***/ // get curr position in second public int getCurrentPosition(){ if(player == null) return 0; return player.getCurrentPosition() / 1000; } // get song total duration in second public int getDuration(){ if(player == null) return 0; return player.getDuration() / 1000; } // move to this song genuinePos in second public void seekTo(int posn){ if(player == null) return; seekFinished = false; int gotoPos = posn * 1000; if(Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) seekPosBug = gotoPos; player.seekTo(gotoPos); } public boolean getSeekFinished() { return seekFinished; } // unpause public void start() { getPlayer().start(); state.setState(PlayerState.Started); startSensor(); scrobble.send(Scrobble.SCROBBLE_RESUME); } public void pause() { if(player == null) return; player.pause(); state.setState(PlayerState.Paused); stopSensor(); scrobble.send(Scrobble.SCROBBLE_PAUSE); if(foreground) stopNotification(); } public void playPrev() { if (params.getShuffle()) rows.moveToRandomSongBack(); else rows.moveToPrevSong(); if(foreground) startNotification(); playSong(); } public void playNext() { if (params.getShuffle()) rows.moveToRandomSong(); else rows.moveToNextSong(); if(foreground) startNotification(); playSong(); } public void playPrevGroup() { if (params.getShuffle()) rows.moveToRandomSongBack(); else rows.moveToPrevGroup(); if(foreground) startNotification(); playSong(); } public void playNextGroup() { if (params.getShuffle()) rows.moveToRandomSong(); else rows.moveToNextGroup(); if(foreground) startNotification(); playSong(); } public void playSame() { if(foreground) startNotification(); playSong(); } /*** STATE ***/ public boolean isInState(int states) { return state.compare(states); } // !playingStopped == playingLaunched || playingPaused public boolean playingLaunched() { final int states = PlayerState.Initialized | PlayerState.Idle | PlayerState.PlaybackCompleted | PlayerState.Prepared | PlayerState.Preparing | PlayerState.Started; return state.compare(states); } public boolean playingStopped() { final int states = PlayerState.Nope | PlayerState.Error | PlayerState.End; return state.compare(states); } public boolean playingPaused() { return state.compare(PlayerState.Paused); } public boolean isPlaying() { return player != null && player.isPlaying(); } /*** NOTIFICATION ***/ public void startNotification() { RowSong rowSong = rows.getCurrSong(); if(rowSong == null) return; Intent notificationIntent = new Intent(this, Main.class); notificationIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); PendingIntent pendInt = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT); Notification notification = new Notification(R.drawable.ic_actionbar_launcher_anim, rowSong.getTitle(), System.currentTimeMillis()); notification.setLatestEventInfo(this, getResources().getString(R.string.app_playing), rowSong.getTitle() + " - " + rowSong.getArtist(), pendInt); startForeground(NOTIFY_ID, notification); foreground = true; } public void stopNotification() { if(foreground) stopForeground(true); foreground = false; } /*** PREFERENCES ***/ private void restore() { enableShake = params.getEnableShake(); shakeThreshold = params.getShakeThreshold() / 10; if (params.getSaveSongPos()) savedSongPos = params.getSongPos(); else savedSongPos = -1; } private void save() { params.setEnableShake(enableShake); } /*** SENSORS ***/ @Override public void onSensorChanged(SensorEvent event) { if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) { getAccelerometer(event); } } private void getAccelerometer(SensorEvent event) { float[] values = event.values; // Movement float x = values[0]; float y = values[1]; float z = values[2]; // algo found here : http://stackoverflow.com/questions/2317428/android-i-want-to-shake-it accelLast = accelCurrent; accelCurrent = Math.sqrt((double) (x*x + y*y + z*z)); double delta = accelCurrent - accelLast; accel = accel * 0.9f + delta; // perform low-cut filter if (accel > shakeThreshold) { final long actualTime = event.timestamp; if (actualTime - lastUpdate < MIN_SHAKE_PERIOD) { return; } lastUpdate = actualTime; Log.d("MusicService", "Device was shuffed. Acceleration: " + String.format("%.1f", accel) + " x: " + String.format("%.1f", x*x) + " y: " + String.format("%.1f", y*y) + " z: " + String.format("%.1f", z*z)); // goes to next song if(playingLaunched()) { playNext(); changed = true; } } } @Override public void onAccuracyChanged(Sensor sensor, int accuracy) {} public void setEnableShake(boolean shake) { enableShake = shake; if(enableShake) startSensor(); else stopSensor(); params.setEnableShake(enableShake); } public boolean getEnableShake() { return enableShake; } public void setShakeThreshold(float threshold) { shakeThreshold = threshold / 10; } // can be called twice private void startSensor() { if(enableShake && sensorManager == null) { accelLast = SensorManager.GRAVITY_EARTH; accel = 0.00f; accelCurrent = SensorManager.GRAVITY_EARTH; sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE); accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL); lastUpdate = System.currentTimeMillis(); } } // can be called twice private void stopSensor() { if(sensorManager != null) { sensorManager.unregisterListener(this); sensorManager = null; accelerometer = null; } } }