목차
music player 를 이용할 일이 있어 소스를 좀 분석해 보려 한다.
소스는 아래 소스로 분석하려 한다. 대체로 주석이 잘 달려 있다.
- vanilla-player source : vanilla-music/vanilla · GitHub
여기서 player service 는 startService() 를 이용하고 있다.
소스 분석을 하는 소스는 아래 github 에 남겨놓는다.
play
player 의 play 버튼을 누를 때를 중심으로 생각해 보자.
이 때 play 버튼에 대한 event 가 발생하고, 이 event 가 발생할 때 Service 를 통해 MediaPlayer.start 를 호출하면 된다.
이 동작이 어떤식으로 구현되어 있는지를 살펴보자.
PlaybackActivity#onClick()
activity 에서 click 을 할 때의 동작은 아래와 같다.- PlaybackActivity#onClick()
- PlaybackActivity#onClick: case R.id.play_pause: playPause()
- PlaybackActivity#playPause : int state = service.playPause();
- PlaybackActivity#playPause : setState(state);
service#playPause()
vanilla music player 에서 service 는 자체적으로 handler 를 사용한다. 그래서 자신이 해야할 일을 직접적으로 호출하기 보다는 message 를 날리는 작업으로 대체한다. 그리고 이 message 들을 처리하는 handler(handleMessage()) 를 구현해서 일을 처리하고 있다.이것은 아마도 여러 activity 에서 하나의 service 를 쓰기 때문에, 여러 activity 에서 오는 작업들을 처리하기 위한 방법으로 사용된 듯 하다.
이런 이유로 service.playPause() 안에서 직접적으로 MediaPlayer.start() 를 호출하지 않고, 아래 2 개의 event 를 발생시킨다.
- PROCESS_STATE event : 여기서 MediaPlayer 를 play 하고, notification 을 만든다.
- BROADCAST_CHANGE event : 여기서 widget 이나 Activity 의 상태를 업데이트 하게 된다.
이 때 activity 의 setState 가 호출된다. 이런 이유로 PlaybackActivity#setState 는 runOnUiThread 를 사용하게 된다.
이 소스에서 이 MediaPlayer 는 Service 로 되어 있다.(PlayerService) 이 PlayerService 에서 start를 호출하면 play 는 완성된다.
// PlaybackActivity public void onCreate(Bundle state) { super.onCreate(state); PlaybackService.addActivity(this); ... } public void onDestroy() { PlaybackService.removeActivity(this); mLooper.quit(); super.onDestroy(); } // 여러 activity 에서 하나의 service 를 사용하기 때문에 activity 정보를 service 로 넘기는 작업을 하게 된다. public void onStart() { super.onStart(); if (PlaybackService.hasInstance()) onServiceReady(); else startService(new Intent(this, PlaybackService.class)); ... } // 여러 activity 에서 하나의 service 를 사용하기 때문에 이미 만들어져 있는 경우와 처음으로 service 를 시작하는 부분이 필요하다. public void onResume(){ ... if (PlaybackService.hasInstance()) { PlaybackService service = PlaybackService.get(this); service.userActionTriggered(); } } public void onClick(View view) { switch (view.getId()) { case R.id.next: shiftCurrentSong(SongTimeline.SHIFT_NEXT_SONG); break; case R.id.play_pause: playPause(); break; case R.id.previous: shiftCurrentSong(SongTimeline.SHIFT_PREVIOUS_SONG); break; case R.id.end_action: cycleFinishAction(); break; case R.id.shuffle: cycleShuffle(); break; } } // music player action 의 시작점이라고 보면 된다. public void shiftCurrentSong(int delta) { setSong(PlaybackService.get(this).shiftCurrentSong(delta)); } public void playPause() { PlaybackService service = PlaybackService.get(this); int state = service.playPause(); if ((state & PlaybackService.FLAG_ERROR) != 0) // error occurs showToast(service.getErrorMessage(), Toast.LENGTH_LONG); setState(state); } protected void setState(final int state) { mLastStateEvent = SystemClock.uptimeMillis(); if (mState != state) { final int toggled = mState ^ state; mState = state; // This is run by the Service, so runOnUiThread is needed runOnUiThread(new Runnable() { @Override public void run() { onStateChange(state, toggled); } }); } } protected void onStateChange(int state, int toggled) { // change the button image depending on state if ((toggled & PlaybackService.FLAG_PLAYING) != 0 && mPlayPauseButton != null) { mPlayPauseButton.setImageResource((state & PlaybackService.FLAG_PLAYING) == 0 ? R.drawable.play : R.drawable.pause); } if ((toggled & PlaybackService.MASK_FINISH) != 0 && mEndButton != null) { mEndButton.setImageResource(SongTimeline.FINISH_ICONS[PlaybackService.finishAction(state)]); } if ((toggled & PlaybackService.MASK_SHUFFLE) != 0 && mShuffleButton != null) { mShuffleButton.setImageResource(SongTimeline.SHUFFLE_ICONS[PlaybackService.shuffleMode(state)]); } } // state 에 맞는 image 를 set 해준다.
// PlaybackService public static boolean hasInstance() { return sInstance != null; } public static PlaybackService get(Context context){ if (sInstance == null){ context.startService(new Intent(context, PlaybackService.class)); ... } ... } /** * 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 actually in progress, aborts it and resets the * volume. */ public void userActionTriggered() { // + user action will be started, so prepare to play 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); } } /** * 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) { Song song = setCurrentSong(delta); userActionTriggered(); return song; } /** * 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(); } } public int pause() { synchronized (mStateLock) { int state = updateState(mState & ~FLAG_PLAYING); userActionTriggered(); return state; } } public int play() { synchronized (mStateLock) { // If queue is empty if ((mState & FLAG_EMPTY_QUEUE) != 0) { setFinishAction(SongTimeline.FINISH_RANDOM); setCurrentSong(0); Toast.makeText(this, R.string.random_enabling, Toast.LENGTH_SHORT).show(); } int state = updateState(mState | FLAG_PLAYING); userActionTriggered(); return state; } }
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="ch.blinkenlights.android.vanilla" android:versionName="1.0.30" android:versionCode="1030" android:installLocation="auto"> ... <application android:icon="@drawable/icon" android:label="@string/app_name"> ... <activity android:name="PlaylistActivity" android:launchMode="singleTask" /> ... <service android:name="PlaybackService"> <intent-filter> <action android:name="ch.blinkenlights.android.vanilla.action.PLAY" /> <action android:name="ch.blinkenlights.android.vanilla.action.PAUSE" /> <action android:name="ch.blinkenlights.android.vanilla.action.TOGGLE_PLAYBACK" /> <action android:name="ch.blinkenlights.android.vanilla.action.NEXT_SONG" /> <action android:name="ch.blinkenlights.android.vanilla.action.PREVIOUS_SONG" /> </intent-filter> </service> ... </application> </manifest>
// PlayerService @Override public void onCreate() { HandlerThread thread = new HandlerThread("PlaybackService", Process.THREAD_PRIORITY_DEFAULT); thread.start(); ... int state = loadState(); ... // Get MediaPlayers mMediaPlayer = getNewMediaPlayer(); mPreparedMediaPlayer = getNewMediaPlayer(); // We only have a single audio session mPreparedMediaPlayer.setAudioSessionId(mMediaPlayer.getAudioSessionId()); mBastpUtil = new BastpUtil(); mReadahead = new ReadaheadThread(); mReadahead.start(); mNotificationManager = (NotificationManager)getSystemService(NOTIFICATION_SERVICE); // get Audio Service mAudioManager = (AudioManager)getSystemService(AUDIO_SERVICE); // set settings values from Preference SharedPreferences settings = getSettings(this); ... mReadaheadEnabled = settings.getBoolean(PrefKeys.ENABLE_READAHEAD, false); PowerManager powerManager = (PowerManager)getSystemService(POWER_SERVICE); mWakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "VanillaMusicLock"); // Case for unplugged the headset & screen on mReceiver = new Receiver(); IntentFilter filter = new IntentFilter(); filter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY); filter.addAction(Intent.ACTION_SCREEN_ON); registerReceiver(mReceiver, filter); // observe if the new audio file is added getContentResolver().registerContentObserver(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, mObserver); // To show the player on lock screen RemoteControl.registerRemote(this, mAudioManager); mLooper = thread.getLooper(); mHandler = new Handler(mLooper, this); ... updateState(state); setCurrentSong(0); sInstance = this; synchronized (sWait) { sWait.notifyAll(); // wake up all the threads which want to use this lock } ... } // music player 에서 사용할 요소들을 setting 한다. /** * 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(in); state |= mTimeline.getShuffleMode() << SHIFT_SHUFFLE; state |= mTimeline.getFinishAction() << SHIFT_FINISH; } in.close(); } catch (EOFException e) { Log.w("VanillaMusic", "Failed to load state", e); } catch (IOException e) { Log.w("VanillaMusic", "Failed to load state", e); } return state; } // player 를 사용하고 정지했을 때 그 이전의 상태(state) 를 file 에 저장하고, 다시 불러와서 set 하는 구조인 듯 하다. public int onStartCommand(Intent intent, int flags, int startId) { // START_STICKY needs to check null but for START_NOT_STICKY cannot sure // @see http://csjung.tistory.com/132 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)) { ... } else if (ACTION_NEXT_SONG.equals(action)) { ... } else if (ACTION_NEXT_SONG_AUTOPLAY.equals(action)) { ... } else if (ACTION_NEXT_SONG_DELAYED.equals(action)) { ... } else if (ACTION_PREVIOUS_SONG.equals(action)) { ... } else if (ACTION_REWIND_SONG.equals(action)) { ... } else if (ACTION_PLAY.equals(action)) { play(); } else if (ACTION_PAUSE.equals(action)) { pause(); } else if (ACTION_CYCLE_REPEAT.equals(action)) { ... } else if (ACTION_CYCLE_SHUFFLE.equals(action)) { ... } else if (ACTION_CLOSE_NOTIFICATION.equals(action)) { ... } MediaButtonReceiver.registerMediaButton(this); } return START_NOT_STICKY; } // 여러 action 에 대한 처리를 해준다. @Override public void onDestroy() { sInstance = null; mLooper.quit(); // clear the notification stopForeground(true); // defer wakelock and close audioFX enterSleepState(); if (mMediaPlayer != null) { mMediaPlayer.release(); mMediaPlayer = null; } if (mPreparedMediaPlayer != null) { mPreparedMediaPlayer.release(); mPreparedMediaPlayer = null; } MediaButtonReceiver.unregisterMediaButton(this); try { unregisterReceiver(mReceiver); } catch (IllegalArgumentException e) { // we haven't registered the receiver yet } if (mSensorManager != null) mSensorManager.unregisterListener(this); super.onDestroy(); } /** * Returns a new MediaPlayer object */ private VanillaMediaPlayer getNewMediaPlayer() { VanillaMediaPlayer mp = new VanillaMediaPlayer(this); mp.setAudioStreamType(AudioManager.STREAM_MUSIC); mp.setOnCompletionListener(this); mp.setOnErrorListener(this); return mp; } /** * 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; }
Handler.Callback
public final class PlaybackService extends Service implements Handler.Callback public void onCreate(){ ... mHandler = new Handler(mLooper, this); ... } // 여기서 Handler.Callback 을 set 하고, /** * Releases mWakeLock and closes any open AudioFx sessions */ private static final int ENTER_SLEEP_STATE = 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; /** * 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 {@link PlaybackService#setCurrentSong(int)} 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 GAPLESS_UPDATE = 16; public boolean handleMessage(Message message) { switch (message.what) { case CALL_GO: if (message.arg1 == 0) playPause(); else setCurrentSong(message.arg1); 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); break; case QUERY: runQuery((QueryTask)message.obj); 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 ENTER_SLEEP_STATE: enterSleepState(); 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); } // Optimistically claim to have recovered from this error mErrorMessage = null; unsetFlag(FLAG_ERROR); mHandler.sendMessage(mHandler.obtainMessage(CALL_GO, 0, 0)); break; case GAPLESS_UPDATE: triggerGaplessUpdate(); break; default: return false; // get another message } return true; } // PlaybackService 라는 이름의 Thread 와 communicate 하는 부분들, service 내에서 작업들을 queue 에 넣어 하나씩 처리하는 방법을 위해 쓰이는 듯 하다.
private void processSong(Song song) { /* 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.isPlaying()) { // The prepared media player is playing as the previous song // reched its end 'naturally' (-> gapless) // We can now swap mPreparedMediaPlayer and mMediaPlayer VanillaMediaPlayer tmpPlayer = mMediaPlayer; mMediaPlayer = mPreparedMediaPlayer; mPreparedMediaPlayer = tmpPlayer; // this was mMediaPlayer and is in reset() state Log.v("VanillaMusic", "Swapped media players"); } else if(song.path != null) { prepareMediaPlayer(mMediaPlayer, song.path); } mMediaPlayerInitialized = true; // Cancel any pending gapless updates and re-send them mHandler.removeMessages(GAPLESS_UPDATE); mHandler.sendEmptyMessage(GAPLESS_UPDATE); if (mPendingSeek != 0 && mPendingSeekSong == song.id) { mMediaPlayer.seekTo(mPendingSeek); mPendingSeek = 0; } 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 */ } catch (IOException e) { mErrorMessage = getResources().getString(R.string.song_load_failed, song.path); updateState(mState | FLAG_ERROR); Toast.makeText(this, mErrorMessage, Toast.LENGTH_LONG).show(); Log.e("VanillaMusic", "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() == false && getSong(1) != null && (playing || (mSkipBroken > 0 && mSkipBroken < 10))) { mSkipBroken++; mHandler.sendMessageDelayed(mHandler.obtainMessage(SKIP_BROKEN_SONG, getTimelinePosition(), 0), 1000); } } updateNotification(); mTimeline.purge(); } // 여기서 MediaPlayer.start() 가 실행된다.
Notification
notification 동작은 여기를 보면 이해가 쉬울 것이다. 여기서는 notification 의 play/pause 버튼의 구현 모습만 확인하자.@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();
}
}
...
}
...
}
public Notification createNotification(Song song, int state)
{
...
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));
...
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;
}
...
return notification;
}
See Also
Media Browser Related sources
- https://github.com/googlesamples/android-UniversalMusicPlayer
- googlecast/CastCompanionLibrary-android · GitHub
Reference
- drcarter의 HelloWorld! :: [Android] RemoteControlClient 활용하기.
- 돌고래꿈 :: Java 동기화의 이해(synchronized, wait, notify, notifyAll)
댓글 없음:
댓글 쓰기