Implement video playback in a scrolling list (ListView & RecyclerView)

This article will explain how to implement video playback in the list. Similar to the effects of popular apps such as  Facebook, Instagram or Magisto :

Facebook:

Magisto:

Instagram:

This article is based on an open source project:  VideoPlayerManager

All the code and examples are there. This article will skip many things. So if you really want to understand how it works, it is best to download the source code and read this article together with the source code. But even without looking at the source code, this article can help you understand what we are doing.

Two questions

To achieve the functions we need, we must solve two problems:

1. We need to manage video playback. In Android, we have a MediaPlayer.class that works with SurfaceView to play videos. But it has many flaws. We can't use ordinary VideoView in the list.

VideoView inherits from SurfaceView, and SurfaceView does not have a UI synchronization buffer. This leads to the fact that when the list is scrolling, the video being played needs to keep up with the scrolling pace. There is a sync buffer in TextureView, but there is no TextureView-based VideoView in Android SDK version 15.

So we need a View that inherits from TextureView and works with Android MediaPlayer. Almost all methods in MediaPlayer (prepare, start, stop, etc...) call local methods related to the hardware. When doing work longer than 16ms (inevitably), the hardware will be very tricky and then we will see a list of stuck. This is why we need to call them from a background thread.

2. We also need to know which View in the scrolling list is currently active to switch the playing video. So we need to track scrolling and define the view with the largest visible range.

Manage video playback

Our goal is to provide the following features:

Suppose the video is playing. The user scrolls through the list, and a new item replaces the currently playing item to become the view with the largest visible range. So now we need to stop the current video and start a new video.

The main function is: stop the previous play, and start a new play only after the old one is stopped.

The following is an example: when you press the thumbnail of a video-the currently playing video stops playing and another video starts playing.

 

VideoPlayerView

The first thing we need to do is to implement a VideoView based on TextureView . We cannot use VideoView in a scrolling list. This is because if the user scrolls the list during playback, the rendering of the video will be chaotic.

I will divide this task into several parts:

1. Create a ScalableTextureView , which is a subclass of TextureView, and it also knows how to adjust SurfaceTexture (video playback is running on SurfaceTexture), and provides several options similar to ImageView scaleType.

public enum ScaleType {
    CENTER_CROP, TOP, BOTTOM, FILL
}

2. Create a VideoPlayerView, which is a subclass of ScalableTextureView and contains all functions related to MediaPlayer.class. This custom view encapsulates MediaPlayer.class and provides an API very similar to VideoView. It has all the methods of MediaPlayer: setDataSource, prepare, start, stop, pause, reset, release.

Video Player Manager and Messages Handler Thread

Video Playback Manager and MessagesHandlerThread work together and are responsible for calling methods of MediaPlayer. We need to call methods such as prepare(), start(), etc. in a separate thread because they are directly related to the hardware of the device. We have also done calling MediaPlayer.reset() in the UI thread, but the player has a problem, and this method blocks the UI thread for almost 4 minutes! This is why we don't have to use asynchronous MediaPlayer.prepareAsync, but use synchronous MediaPlayer.prepare. We let everything be done in a separate thread.

As for the process of starting a new playback, here are the steps that MediaPlayer will do:

  1. Stop the previous playback. Call the MediaPlayer.stop() method to complete.

  2. Call the MediaPlayer.reset() method to reset MediaPlayer. The reason for this is that in the scrolling list, the view may be reused, and we hope that all resources can be released.

  3. Call MediaPlayer.release() method to release MediaPlayer

  4. Clear the instance of MediaPlayer. When a new video should be played, a new MediaPlayer instance will be created.

  5. Create a MediaPlayer instance for the view with the largest visible range.

  6. Call MediaPlayer.setDataSource(String url) to set the data source for the new MediaPlayer.

  7. Call MediaPlayer.prepare(), there is no need to call asynchronous MediaPlayer.prepareAsync() here.

  8. Call MediaPlayer.start()

  9. Wait for the actual video to start.

All these operations are encapsulated in a Message processed in a separate thread. If this is a Stop message, VideoPlayerView.stop() will be called, and it will eventually call MediaPlayer.stop().

We need custom messages because this way we can set the current state. We can know whether it is stopping or has stopped or other states. It helps us control what message is currently being processed, and if necessary, we can do something with it, for example, start a new playback.

/**
 * This PlayerMessage calls {@link MediaPlayer#stop()} on the instance that is used inside {@link VideoPlayerView}
 */
public class Stop extends PlayerMessage {
    public Stop(VideoPlayerView videoView, VideoPlayerManagerCallback callback) {
        super(videoView, callback);
    }
    @Override
    protected void performAction(VideoPlayerView currentPlayer) {
        currentPlayer.stop();
    }
    @Override
    protected PlayerMessageState stateBefore() {
        return PlayerMessageState.STOPPING;
    }
    @Override
    protected PlayerMessageState stateAfter() {
        return PlayerMessageState.STOPPED;
    }
}

If we need to start a new playback, we only need to call a method in VideoPlayerManager. It adds the following message combinations to MessagesHandlerThread.

// pause the queue processing and check current state
// if current state is "started" then stop old playback
mPlayerHandler.addMessage(new Stop(mCurrentPlayer, this));
mPlayerHandler.addMessage(new Reset(mCurrentPlayer, this));
mPlayerHandler.addMessage(new Release(mCurrentPlayer, this));
mPlayerHandler.addMessage(new ClearPlayerInstance(mCurrentPlayer, this));// set new video player view
mPlayerHandler.addMessage(new SetNewViewForPlayback(newVideoPlayerView, this));
// start new playback
mPlayerHandler.addMessages(Arrays.asList(
        new CreateNewPlayerInstance(videoPlayerView, this),
        new SetAssetsDataSourceMessage(videoPlayerView, assetFileDescriptor, this), // I use local file for demo
        new Prepare(videoPlayerView, this),
        new Start(videoPlayerView, this)
));
// resume queue processing

The operation of the message is synchronous, so we can pause the processing of the queue at any time, such as:

The current video is in the ready state (MedaiPlayer.prepare() is called, MediaPlayer.start() is waiting in the queue), the user scrolls the table, so we need to start playing the video on a new view. In this case, we:

  1. Pause processing of the queue

  2. Remove all pending messages

  3. Send "Stop", "Reset", "Release", "Clear Player instance" to the queue. They will be called as soon as we return from "Prepare".

  4. Send "Create new Media Player instance", "Set Current Media Player" (this message changes the MediaPlayer object that executes messages), "Set data source", "Prepare", "Start" messages. These messages will start the video playback on the new view.

Okay, so we have a tool to run video playback according to our needs: stop the previous one and then display the next one.

Here is the gradle dependency of the library:

dependencies {
    compile 'com.github.danylovolokh:video-player-manager:0.2.0'
}

Identify the view with the largest visible range in the list.List Visibility Utils

The first problem is to manage the playback of videos. The second problem is to track which item has the largest visible range and switch the playback to that view.

There is an interface called ListItemsVisibilityCalculator and its implementation SingleListViewItemActiveCalculator does this job.

In order to calculate the visibility of items in the list, the model class used in the adapter must implement the ListItem interface.

/**
 * A general interface for list items.
 * This interface is used by {@link ListItemsVisibilityCalculator}
 *
 * @author danylo.volokh
 */
public interface ListItem {
    /**
     * When this method is called, the implementation should provide a
     * visibility percents in range 0 - 100 %
     * @param view the view which visibility percent should be
     * calculated.
     * Note: visibility doesn't have to depend on the visibility of a
     * full view. 
     * It might be calculated by calculating the visibility of any
     * inner View
     *
     * @return percents of visibility
     */
    int getVisibilityPercents(View view);
    /**
     * When view visibility become bigger than "current active" view
     * visibility then the new view becomes active.
     * This method is called
     */
    void setActive(View newActiveView, int newActiveViewPosition);
    /**
     * There might be a case when not only new view becomes active,
     * but also when no view is active.
     * When view should stop being active this method is called
     */
    void deactivate(View currentView, int position);
}

ListItemsVisibilityCalculator tracks the direction of scrolling and calculates the visibility of items at runtime. The visibility of an item may depend on any view in a single item in the list. It is up to you to implement the getVisibilityPercents() method.

There is a default implementation in the sample demo app:

/**
 * This method calculates visibility percentage of currentView.
 * This method works correctly when currentView is smaller then it's enclosure.
 * @param currentView - view which visibility should be calculated
 * @return currentView visibility percents
 */
@Override
public int getVisibilityPercents(View currentView) {
    int percents = 100;
    currentView.getLocalVisibleRect(mCurrentViewRect);
    int height = currentView.getHeight();
    if(viewIsPartiallyHiddenTop()){
        // view is partially hidden behind the top edge
    percents = (height - mCurrentViewRect.top) * 100 / height;
    } else if(viewIsPartiallyHiddenBottom(height)){
        percents = mCurrentViewRect.bottom * 100 / height;
    }
    return percents;
}

Every view needs to know how to calculate its visible percentage. When scrolling occurs, SingleListViewItemActiveCalculator will request this value from each view, so the implementation here cannot be too complicated.

When the visibility of a neighbor exceeds the currently active item, the setActive method will be called. At this moment, the playback should be switched.

There is also an ItemsPositionGetter that acts as an adapter between ListItemsVisibilityCalculator and ListView or RecyclerView. In this way, ListItemsVisibilityCalculator does not need to know whether this is a ListView or a RecyclerView. It just does its own job. But it needs to know some information provided by ItemsPositionGetter:

/**
 * This class is an API for {@link ListItemsVisibilityCalculator}
 * Using this class is can access all the data from RecyclerView / 
 * ListView
 *
 * There is two different implementations for ListView and for 
 * RecyclerView.
 * RecyclerView introduced LayoutManager that's why some of data moved
 * there
 *
 * Created by danylo.volokh on 9/20/2015.
 */
public interface ItemsPositionGetter {
 
   View getChildAt(int position);
    int indexOfChild(View view);
    int getChildCount();
    int getLastVisiblePosition();
    int getFirstVisiblePosition();
}

Considering the principle of separation of business logic and model, putting that logic in the model is a bit messy. But some modifications may be able to achieve separation. But although it is not so good-looking now, it still runs without problems.

The following is the rendering:

The following is the gradle dependency of this library:

dependencies {
    compile 'com.github.danylovolokh:list-visibility-utils:0.2.0'
}

Combination of Video Player Manager and List Visibility Utils to implement video playback in the scrolling list.

Now we have two libraries that can solve all our problems. Let us combine them to achieve the functions we need.

Here is the code taken from the fragment that uses RecyclerView:

1. Initialize ListItemsVisibilityCalculator and pass a reference to the list to it.

/**
 * Only the one (most visible) view should be active (and playing).
 * To calculate visibility of views we use {@link SingleListViewItemActiveCalculator}
 */
private final ListItemsVisibilityCalculator mVideoVisibilityCalculator = new SingleListViewItemActiveCalculator(
new DefaultSingleItemCalculatorCallback(), mList);

DefaultSingleItemCalculatorCallback just calls the ListItem.setActive method when the active view changes, but you can override it yourself and do what you want:

/**
 * Methods of this callback will be called when new active item is found {@link Callback#activateNewCurrentItem(ListItem, View, int)}
 * or when there is no active item {@link Callback#deactivateCurrentItem(ListItem, View, int)} - this might happen when user scrolls really fast
 */
public interface Callback<T extends ListItem>{
    void activateNewCurrentItem(T item, View view, int position);
    void deactivateCurrentItem(T item, View view, int position);
}

2. Initialize VideoPlayerManager.

/**
 * Here we use {@link SingleVideoPlayerManager}, which means that only one video playback is possible.
 */
private final VideoPlayerManager<MetaData> mVideoPlayerManager = new SingleVideoPlayerManager(new PlayerItemChangeListener() {
    @Override
    public void onPlayerItemChanged(MetaData metaData) {
    }
});

3. Set on scroll listener for RecyclerView and pass scroll events to list visibility utils.

@Override
public void onScrollStateChanged(RecyclerView view, int scrollState) {
 mScrollState = scrollState;
 if(scrollState == RecyclerView.SCROLL_STATE_IDLE && mList.isEmpty()){
 mVideoVisibilityCalculator.onScrollStateIdle(
          mItemsPositionGetter,
          mLayoutManager.findFirstVisibleItemPosition(),
          mLayoutManager.findLastVisibleItemPosition());
 }
 }
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
 if(!mList.isEmpty()){
   mVideoVisibilityCalculator.onScroll(
         mItemsPositionGetter,
         mLayoutManager.findFirstVisibleItemPosition(),
         mLayoutManager.findLastVisibleItemPosition() -
         mLayoutManager.findFirstVisibleItemPosition() + 1,
         mScrollState);
 }
}
});

4. Create ItemsPositionGetter

ItemsPositionGetter mItemsPositionGetter = 
new RecyclerViewItemPositionGetter(mLayoutManager, mRecyclerView);

5. At the same time, we call a method in onResume so that we can start calculating the item with the largest visible range immediately when we turn on the screen

@Override
public void onResume() {
    super.onResume();
    if(!mList.isEmpty()){
        // need to call this method from list view handler in order to have filled list
        mRecyclerView.post(new Runnable() {
            @Override
            public void run() {
                mVideoVisibilityCalculator.onScrollStateIdle(
                        mItemsPositionGetter,
                        mLayoutManager.findFirstVisibleItemPosition(),
                        mLayoutManager.findLastVisibleItemPosition());
            }
        });
    }
}

This way we get a set of videos that are playing in the list.

In general, this is just an explanation of the most important part. There is more code in the sample app: https://github.com/danylovolokh/VideoPlayerManager

 

Guess you like

Origin blog.csdn.net/qq_39477770/article/details/109180859