[컴][안드로이드] vanilla Music player 소스 분석 - play / pause






music player 를 이용할 일이 있어 소스를 좀 분석해 보려 한다.
소스는 아래 소스로 분석하려 한다. 대체로 주석이 잘 달려 있다.

여기서 player service 는 startService() 를 이용하고 있다.


소스 분석을 하는 소스는 아래 github 에 남겨놓는다.




play


player 의 play 버튼을 누를 때를 중심으로 생각해 보자.

이 때 play 버튼에 대한 event 가 발생하고, 이 event 가 발생할 때 Service 를 통해 MediaPlayer.start 를 호출하면 된다.

이 동작이 어떤식으로 구현되어 있는지를 살펴보자.

PlaybackActivity#onClick()

activity 에서 click 을 할 때의 동작은 아래와 같다.
  1. PlaybackActivity#onClick()
  2. PlaybackActivity#onClick: case R.id.play_pause: playPause()
  3. PlaybackActivity#playPause : int state = service.playPause();
  4. PlaybackActivity#playPause : setState(state);

service#playPause()

vanilla music player 에서 service 는 자체적으로 handler 를 사용한다. 그래서 자신이 해야할 일을 직접적으로 호출하기 보다는 message 를 날리는 작업으로 대체한다. 그리고 이 message 들을 처리하는 handler(handleMessage()) 를 구현해서 일을 처리하고 있다.

이것은 아마도 여러 activity 에서 하나의 service 를 쓰기 때문에, 여러 activity 에서 오는 작업들을 처리하기 위한 방법으로 사용된 듯 하다.


이런 이유로 service.playPause() 안에서 직접적으로 MediaPlayer.start() 를 호출하지 않고, 아래 2 개의 event 를 발생시킨다.
  1. PROCESS_STATE event : 여기서 MediaPlayer 를 play 하고, notification 을 만든다.
  2. BROADCAST_CHANGE event : 여기서 widget 이나 Activity 의 상태를 업데이트 하게 된다.
    이 때 activity 의 setState 가 호출된다. 이런 이유로 PlaybackActivity#setState 는 runOnUiThread 를 사용하게 된다.
참고로 MediaPlayer 의 control 은 위의 2개의 event 로 거의 이루어진다.


이 소스에서 이 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

  1. https://github.com/googlesamples/android-UniversalMusicPlayer
  2. googlecast/CastCompanionLibrary-android · GitHub





댓글 없음:

댓글 쓰기