MediaSession & MediaController
– Android TV app hands on Tutorial 9

NowPlayingCard

Video Controls implementation with MediaSession

The sample implementation is done in Google’s latest sample Android TV application. AOSP sample application implementation does not have MediaSession implementation yet (in API 21, 22).

In previous chapter, I explained that following Video controls are needed to be impmented.

  1. Action’s UI update part (done in previous chapter)
  2. Video control part (done in previous chapter) MediaSession implementation, Video control via MediaController’s TransportControls (this chapter)
  3. – MediaSession can handle the action when user presses TV remote controller’s video control button.
    – It allows other activity to inherite video control. Especially LeanbackLauncher, Home display, can play video in background.  
  4. set MediaMetadata to MediaSession (this chapter)
    – “Now playing card” will appear at the top of recommendation row.

In this chapter, we proceed to implement Video controls using MediaSession. We can pass VideoView control to LeanbackLauncher by using MediaSession, which results to achieve playing video background in LeanbackLauncher. 

For 3, we create MediaSession in PlaybackOverlayActivity, and control it from MediaController in PlaybackOverlayFragment.

For 4, MediaSession’s Metadata is updated using MediaMetadata & PlaybackState class to update “Now playing card”.

I recommend you to read Displaying a Now Playing Card, for official explanation.

This chapter’s implementation is almost independent with previous chapter’s implementation. Before implementing MediaSession, I will implement requestVisibleBehind method, so that we can play video in background of LeanbackLauncher app.

Implement requestVisibleBehind

This method is added in API 21 (Lolipop), AOSP explanation explains that

If this call is successful then the activity will remain visible after onPause() is called, and is allowed to continue playing media in the background.

Sample implementation is as follows.

    @Override
    public void onPause() {
        super.onPause();
        if (!requestVisibleBehind(true)) {
            // Try to play behind launcher, but if it fails, stop playback.
            mPlaybackController.playPause(false);
        }
    }

After this implementation, when you play video contents in your application and press “Home” key to go back LeanbackLauncher, the video is remain playing in background. .

Class structure of this chapter

We handle 3 classes in this chapter.

  • PlaybackOverlayActivity    
     – manage lifecycle, pass intent information to PlaybackController
     – MediaSession’s life time is linked with activity
  • PlaybackOverlayFragment
     – handle UI of PlaybackControlsRow
     – MediaController Callback function is used to update UI according to the current playback state
  • PlaybackController              
     – manage video playback
     – video control functions
     – MediaSessionCallback for receiving Video control key from TV remote controller

Create & release MediaSession

So far, we cannot control this video by using video control key in remote controller. Let’s implement MediaSession to define the action for each video control key in remote controller. At first, we create MediaSession in Constructor of PlaybackController, which is invoked by PlaybackOverlayActivity.

    public PlaybackController(Activity activity) {
        mActivity = activity;
        // mVideoView = (VideoView) activity.findViewById(VIDEO_VIEW_RESOURCE_ID);
        createMediaSession(mActivity);
    }

    private void createMediaSession(Activity activity) {
        if (mSession == null) {
            mSession = new MediaSession(activity, MEDIA_SESSION_TAG);
            mMediaSessionCallback = new MediaSessionCallback();
            mSession.setCallback(mMediaSessionCallback);
            mSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS |
                    MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS);

            mSession.setActive(true);
            activity.setMediaController(new MediaController(activity, mSession.getSessionToken()));
        }
    }

We can set callback, MediaSessionCallback, to MediaSession. It defines the precise behavior of each video control button which will be explained later.  

setFlags method with the argument FLAG_HANDLES_MEDIA_BUTTONS & FLAG_HANDLES_TRANSPORT_CONTROLS are necessary to enable remote controller key to control video.

After create, we must release MediaSession when it finishes.

    public void releaseMediaSession() {
        if(mSession != null) {
            mSession.release();
        }
    }

Video control functions

Video control functions are implemented in MediaSessionCallback class. As name suggests, each video control action is implemented in corresponding callback function. This callback is called from 2 ways, which is “remote controller media key” or “UI video control button in PlaybackControlsRow”.

    public void playPause(boolean doPlay) {

        if (mCurrentPlaybackState == PlaybackState.STATE_NONE) {
            /* Callbacks for mVideoView */
            setupCallbacks();
        }

        //if (doPlay && mCurrentPlaybackState != PlaybackState.STATE_PLAYING) {
        if (doPlay) { // Play
            Log.d(TAG, "playPause: play");
            if(mCurrentPlaybackState == PlaybackState.STATE_PLAYING) {
                /* if current state is already playing, do nothing */
                return;
            } else {
                mCurrentPlaybackState = PlaybackState.STATE_PLAYING;
                mVideoView.start();
                mStartTimeMillis = System.currentTimeMillis();
            }
        } else { // Pause
            Log.d(TAG, "playPause: pause");
            if(mCurrentPlaybackState == PlaybackState.STATE_PAUSED) {
                /* if current state is already paused, do nothing */
                return;
            } else {
                mCurrentPlaybackState = PlaybackState.STATE_PAUSED;
            }
            setPosition(mVideoView.getCurrentPosition());
            mVideoView.pause();

        }

        updatePlaybackState();
    }

    public void fastForward() {
        if (mDuration != -1) {
            // Fast forward 10 seconds.
            setPosition(getCurrentPosition() + (10 * 1000));
            mVideoView.seekTo(mPosition);
        }

    }

    public void rewind() {
        // rewind 10 seconds
        setPosition(getCurrentPosition() - (10 * 1000));
        mVideoView.seekTo(mPosition);
    }


    private class MediaSessionCallback extends MediaSession.Callback {
        @Override
        public void onPlay() {
            playPause(true);
        }

        @Override
        public void onPause() {
            playPause(false);
        }

        @Override
        public void onSkipToNext() {
            if (++mCurrentItem >= mItems.size()) { // Current Item is set to next here
                mCurrentItem = 0;
            }

            Movie movie = mItems.get(mCurrentItem);
            //Movie movie = VideoProvider.getMovieById(mediaId);
            if (movie != null) {
                setVideoPath(movie.getVideoUrl());
                //mCurrentPlaybackState = PlaybackState.STATE_PAUSED;
                //updateMetadata(movie);
                updateMetadata();
                playPause(mCurrentPlaybackState == PlaybackState.STATE_PLAYING);
            } else {
                Log.e(TAG, "onSkipToNext movie is null!");
            }

        }


        @Override
        public void onSkipToPrevious() {
            if (--mCurrentItem < 0) { // Current Item is set to previous here
                mCurrentItem = mItems.size()-1;
            }

            Movie movie = mItems.get(mCurrentItem);
            //Movie movie = VideoProvider.getMovieById(mediaId);
            if (movie != null) {
                setVideoPath(movie.getVideoUrl());
                //mCurrentPlaybackState = PlaybackState.STATE_PAUSED;
                updateMetadata();
                playPause(mCurrentPlaybackState == PlaybackState.STATE_PLAYING);
            } else {
                Log.e(TAG, "onSkipToPrevious movie is null!");
            }
        }

        @Override
        public void onPlayFromMediaId(String mediaId, Bundle extras) {
            mCurrentItem = Integer.parseInt(mediaId);
            Movie movie = mItems.get(mCurrentItem);
            //Movie movie = VideoProvider.getMovieById(mediaId);
            if (movie != null) {
                setVideoPath(movie.getVideoUrl());
                // mCurrentPlaybackState = PlaybackState.STATE_PAUSED;
                // updateMetadata(movie);
                updateMetadata();
                playPause(mCurrentPlaybackState == PlaybackState.STATE_PLAYING);
            }
        }

        @Override
        public void onSeekTo(long pos) {
            setPosition((int) pos);
            mVideoView.seekTo(mPosition);
            updatePlaybackState();
        }

        @Override
        public void onFastForward() {
            fastForward();
        }

        @Override
        public void onRewind() {
            rewind();
        }
    }

Video control by remote controller key

To enable video control from remote controller, we need to explicitly set available actions to MediaSession. Available actions are defined in PlaybackState class, sample implementation is below.

    private void updatePlaybackState() {
        PlaybackState.Builder stateBuilder = new PlaybackState.Builder()
                .setActions(getAvailableActions());
        int state = PlaybackState.STATE_PLAYING;
        if (mCurrentPlaybackState == PlaybackState.STATE_PAUSED || mCurrentPlaybackState == PlaybackState.STATE_NONE) {
            state = PlaybackState.STATE_PAUSED;
        }
        stateBuilder.setState(state, getCurrentPosition(), 1.0f);
        mSession.setPlaybackState(stateBuilder.build());
    }

    private long getAvailableActions() {
        long actions = PlaybackState.ACTION_PLAY |
                PlaybackState.ACTION_PAUSE |
                PlaybackState.ACTION_PLAY_PAUSE |
                PlaybackState.ACTION_REWIND |
                PlaybackState.ACTION_FAST_FORWARD |
                PlaybackState.ACTION_SKIP_TO_PREVIOUS |
                PlaybackState.ACTION_SKIP_TO_NEXT |
                PlaybackState.ACTION_PLAY_FROM_MEDIA_ID |
                PlaybackState.ACTION_PLAY_FROM_SEARCH;
        return actions;
    }

In this example, available actions are decided in getAvailableActions method, by adding actions using logical disjunction.

Video control from UI – MediaController.getTransportControls

To control MediaSession from PlaybackControlsRow in VideoDetailsFragment, we use MediaController. The MediaController is created in the constructor of PlaybackController and it owns token of MediaSession.

When user clicks video control button, it will invoke MediaSessionCallback method using MediaController.getTransportControls() method.

        /* onClick */
        playbackControlsRowPresenter.setOnActionClickedListener(new OnActionClickedListener() {
            public void onActionClicked(Action action) {
                if (action.getId() == mPlayPauseAction.getId()) {
                    /* PlayPause action */
                    if (mPlayPauseAction.getIndex() == PlaybackControlsRow.PlayPauseAction.PLAY) {
                        mMediaController.getTransportControls().play();
                    } else if (mPlayPauseAction.getIndex() == PlaybackControlsRow.PlayPauseAction.PAUSE) {
                        mMediaController.getTransportControls().pause();
                    }
                } else if (action.getId() == mSkipNextAction.getId()) {
                    /* SkipNext action */
                    mMediaController.getTransportControls().skipToNext();
                } else if (action.getId() == mSkipPreviousAction.getId()) {
                    /* SkipPrevious action */
                    mMediaController.getTransportControls().skipToPrevious();
                } else if (action.getId() == mFastForwardAction.getId()) {
                    /* FastForward action  */
                    mMediaController.getTransportControls().fastForward();
                } else if (action.getId() == mRewindAction.getId()) {
                    /* Rewind action */
                    mMediaController.getTransportControls().rewind();
                }
        }

Video control part is done. However, we need to update UI of PlayControlsRow depending according to the video control.

Updating UI of VideoDetailsFragment 

We need to change the UI when video control action has executed, and video playback status has changed. We can get this event by using MediaController’s callback function. 2 callback methods are introduced in the following. 

  • onPlaybackStateChanged
  • onMetadataChanged

To use these callback methods, you can make subclass which extends MediaController.Callback class, and override these methods. To use this class, we can call MediaController’s registerCallback/unregisterCallback method to get event of MediaController.

    @Override
    public void onAttach(Activity activity) {
        super.onAttach(activity);
        mMediaController = getActivity().getMediaController();
        Log.d(TAG, "register callback of mediaController");
        if(mMediaController == null){
            Log.e(TAG, "mMediaController is null");
        }
        mMediaController.registerCallback(mMediaControllerCallback);

    }

    @Override
    public void onDetach() {
        if (mMediaController != null) {
            Log.d(TAG, "unregister callback of mediaController");
            mMediaController.unregisterCallback(mMediaControllerCallback);
        }
        super.onDetach();
    }

    private class MediaControllerCallback extends MediaController.Callback {
        @Override
        public void onPlaybackStateChanged(final PlaybackState state) {
            Log.d(TAG, "playback state changed: " + state.toString());
        }

        @Override
        public void onMetadataChanged(final MediaMetadata metadata) {
            Log.d(TAG, "received update of media metadata");
        }
    }

Updating video control icons in onPlaybackStateChanged 

Update PlaybackState

PlaybackState is updated in PlaybackController.

    private void updatePlaybackState() {
        PlaybackState.Builder stateBuilder = new PlaybackState.Builder()
                .setActions(getAvailableActions());
        int state = PlaybackState.STATE_PLAYING;
        if (mCurrentPlaybackState == PlaybackState.STATE_PAUSED || mCurrentPlaybackState == PlaybackState.STATE_NONE) {
            state = PlaybackState.STATE_PAUSED;
        }
        // stateBuilder.setState(state, mPosition, 1.0f);
        stateBuilder.setState(state, getCurrentPosition(), 1.0f);
        mSession.setPlaybackState(stateBuilder.build());
    }

For example, it will be called in playPause method. When user start play video state will change from STATE_PLAYING to STATE_PAUSED, or vice versa. PlaybackState update is set (notified) to MediaSession.

Callback

When PlaybackState has changed by setPlaybackState above, this event can be received with onPlaybackStateChanged callback. We can update play/pause icon in PlaybackControlsRow.

    private class MediaControllerCallback extends MediaController.Callback {
        @Override
        public void onPlaybackStateChanged(final PlaybackState state) {
            Log.d(TAG, "playback state changed: " + state.toString());
            mHandler.post(new Runnable() {
                @Override
                public void run() {
                    if (state.getState() == PlaybackState.STATE_PLAYING) {
                        mPlaybackController.setCurrentPlaybackState(PlaybackState.STATE_PLAYING);
                        startProgressAutomation();
                        // setFadingEnabled(false);
                        mPlayPauseAction.setIndex(PlaybackControlsRow.PlayPauseAction.PAUSE);
                        mPlayPauseAction.setIcon(mPlayPauseAction.getDrawable(PlaybackControlsRow.PlayPauseAction.PAUSE));
                        notifyChanged(mPlayPauseAction);
                    } else if (state.getState() == PlaybackState.STATE_PAUSED) {
                        mPlaybackController.setCurrentPlaybackState(PlaybackState.STATE_PAUSED);
                        // setFadingEnabled(false);
                        mPlayPauseAction.setIndex(PlaybackControlsRow.PlayPauseAction.PLAY);
                        mPlayPauseAction.setIcon(mPlayPauseAction.getDrawable(PlaybackControlsRow.PlayPauseAction.PLAY));
                        notifyChanged(mPlayPauseAction);
                    }

                    int currentTime = (int) state.getPosition();
                    mPlaybackControlsRow.setCurrentTime(currentTime);
                    // mPlaybackControlsRow.setBufferedProgress(currentTime + SIMULATED_BUFFERED_TIME);
                    mPlaybackControlsRow.setBufferedProgress(mPlaybackController.calcBufferedTime(currentTime));

                }
            });
        }

        ...
    }

update Media information in onMetadataChanged

Update MediaMetadata

MediaMetadata class is used to set metadata information of the video. We can set each attribute of metadata by using put method in MediaMetadata.Builder. Again, MediaMetadata update is set (notified) to MediaSession.

    public void updateMetadata(Movie movie) {
        final MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder();

        String title = movie.getTitle().replace("_", " -");

        metadataBuilder.putString(MediaMetadata.METADATA_KEY_MEDIA_ID, Long.toString(movie.getId()));
        metadataBuilder.putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, title);
        metadataBuilder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, movie.getStudio());
        metadataBuilder.putString(MediaMetadata.METADATA_KEY_DISPLAY_DESCRIPTION, movie.getDescription());
        metadataBuilder.putString(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI, movie.getCardImageUrl());
        metadataBuilder.putLong(MediaMetadata.METADATA_KEY_DURATION, mDuration);

        // And at minimum the title and artist for legacy support
        metadataBuilder.putString(MediaMetadata.METADATA_KEY_TITLE, title);
        metadataBuilder.putString(MediaMetadata.METADATA_KEY_ARTIST, movie.getStudio());

        Glide.with(mActivity)
                .load(Uri.parse(movie.getCardImageUrl()))
                .asBitmap()
                .into(new SimpleTarget<Bitmap>(500, 500) {
                    @Override
                    public void onResourceReady(Bitmap bitmap, GlideAnimation anim) {

                        metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ART, bitmap);
                        mSession.setMetadata(metadataBuilder.build());
                    }
                });
    }

By setting correct MediaMetadata to MediaSession, Android TV will show Now Playing Card on LeanbackLauncher, which will be explained again later.

Callback

When MediaMetadata has changed by setMetadata above, this event can be received with onMetadataChanged callback. We can update item value of PlaybackControlsRow.

    private class MediaControllerCallback extends MediaController.Callback {

        ...

        @Override
        public void onMetadataChanged(final MediaMetadata metadata) {
            Log.d(TAG, "received update of media metadata");
                    updateMovieView(
                            metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE),
                            metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE),
                            metadata.getString(MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI),
                            metadata.getLong(MediaMetadata.METADATA_KEY_DURATION)
                    );
        }
    }

    private void updateMovieView(String title, String studio, String cardImageUrl, long duration) {
        Log.d(TAG, "updateMovieView");

        if (mPlaybackControlsRow.getItem() != null) {
            Movie item = (Movie) mPlaybackControlsRow.getItem();
            item.setTitle(title);
            item.setStudio(studio);
        } else {
            Log.e(TAG, "mPlaybackControlsRow.getItem is null!");
        }
        mPlaybackControlsRow.setTotalTime((int) duration);
        mPlaybackControlsRow.setCurrentTime(0);
        mPlaybackControlsRow.setBufferedProgress(0);
        mRowsAdapter.notifyArrayItemRangeChanged(0, mRowsAdapter.size());

        // Show the video card image if there is enough room in the UI for it.
        // If you have many primary actions, you may not have enough room.
        if (SHOW_IMAGE) {
            mPlaybackControlsRowTarget = new PicassoPlaybackControlsRowTarget(mPlaybackControlsRow);
            updateVideoImage(cardImageUrl);
        }
    }


Build and run

Now video playcontrol is possible from both clicking button on PlayControlsRow and pushing media key in remote controller. In the above video, I’m controlling video contents via remote controller and not using UI on the screen 🙂

Now Playing Card

NowPlayingCard
Now Playing Card on Leanback Launcher screen.

If the MediaMetadata is correctly set to MediaSession, Now Playing Card will appear to the LeanbackLauncher (Home display). It notifies information of current playing media to user. Also, Now Playing Card enables user to go back to your app to control video (pause/go to next video etc). 

Reference

Source code is on github.

Leave a Comment

Your email address will not be published. Required fields are marked *