Tracking Video Events in Android TV (with MediaPlayer)

When it comes to TV apps, one thing you can’t do enough is testing your video players. Unlike consumer phones, people’s expectations from TV apps are quite high. These apps don’t update too often and the main experience is mostly high quality video playback. As developers with high standards, we cannot tolerate any hiccups in our video players. In this post, we are going to learn how to do that by using your favorite testing platform.

Android TV Blog Post Series

These posts build on top of each other. If you plan to learn other cool stuff as well, feel free to take a look.

  1. Testing Android TV Apps
  2. Tracking Video Events in Android TV (with MediaPlayer)
  3. ExoPlayer Analytics for Android TV

What we want to accomplish

We are going to implement a wrapper which behind the scenes will send video playback events to an analytics server.

Don’t worry! The abstraction we are going to create will be able to work with any tracking mechanism other than TestFairy. All you will have to do is changing 5 lines if  TestFairy is not suitable to your needs.

Here is a list of our goals:

  1. Support for MediaPlayer from Android SDK
  2. Support for MediaPlayerAdapter from leanback support libraries
  3. A one liner API that looks like wrapper = wrap(mediaPlayer) or wrapper = wrap(mediaPlayerAdapter)
  4. Sending analytic events for buffering and playback events
  5. Keeping track of play/pause/error states
  6. Not breaking existing functionality with MediaPlayer and MediaPlayerAdapter classes
  7. Exposing existing public API from the wrapped classes to allow further extensions by users

“Hello TestFairyMediaPlayerGlue!”

Sorry for the obvious name 🙂

Let’s create some compile time unit tests for each scenario. We will compile these once we finish our implementations.

Test for MediaPlayer:

// Find/create a media player
android.media.MediaPlayer myMediaPlayer = ...;

// Initialize
TestFairyMediaPlayerGlue.PlayerWrapper wrapper = TestFairyMediaPlayerGlue.createByWrapping(myMediaPlayer);
// use wrapper to configure further listeners and behavior

Test for MediaPlayerAdapter:

// Find/create a media player adapter
MediaPlayerAdapter myPlayerAdapter = ...;

// Initialize
TestFairyMediaPlayerGlue.PlayerAdapterWrapper wrapper = TestFairyMediaPlayerGlue.createByWrapping(myPlayerAdapter);
// use wrapper to configure further listeners and behavior

Right immediately, these tests tell us:

  1. There is a factory named TestFairyMediaPlayerGlue.createByWrapping(MediaPlayer) we need to implement.
  2. There is another factory named TestFairyMediaPlayerGlue.createByWrapping(MediaPlayerAdapter) .
  3. There is a consumer interface named TestFairyMediaPlayerGlue.PlayerWrapper.
  4. There is another consumer interface named TestFairyMediaPlayerGlue.PlayerAdapterWrapper.
  5. Item 1 and 2 do probably share some of their functionalities. Let’s define them.

The first step is creating a class named TestFairyMediaPlayerGlue.

Then the factories.

// TODO : implement PlayerWrapper
public static PlayerWrapper createByWrapping(@NonNull final MediaPlayer mediaPlayer) {
    if (mediaPlayer == null) {
        throw new NullPointerException("MediaPlayer cannot be null.");
    }

    // TODO : Do player specific stuff, implement wrapper

    return wrapper;
}

// TODO : implement PlayerAdapterWrapper
public static PlayerAdapterWrapper createByWrapping(@NonNull final MediaPlayerAdapter playerAdapter) {
    if (playerAdapter == null) {
        throw new NullPointerException("MediaPlayerAdapter cannot be null.");
    }

    // TODO : Do adapter specific stuff, implement wrapper

    return wrapper;
}

We could define a strategy pattern to decouple the wrapper internals from the factory but we find that there aren’t that many video players for Android TV out there. The complete implementation is still small enough to fit in a single java file after all. It is also safe to not expect from people to use different kind of players in different parts of their TV apps. Thus, if we wanted to implement a new factory for another video player such as Google’s famous ExoPlayer, we would probably put it in a java file of its own and remove anything related to MediaPlayer and leanback support library inside the implementation. This post will therefore leave usage of such design patterns as an excersice to the reader.

What to use from the Android SDK

MediaPlayer already supports the events below out of box. The wrapper for them will be quite straight forward.

void setOnBufferingUpdateListener(MediaPlayer.OnBufferingUpdateListener onBufferingUpdateListener);

void setOnCompletionListener(MediaPlayer.OnCompletionListener onCompletionListener);

void setOnErrorListener(MediaPlayer.OnErrorListener onErrorListener);

void setOnMediaTimeDiscontinuityListener(MediaPlayer.OnMediaTimeDiscontinuityListener onMediaTimeDiscontinuityListener);

void setOnSeekCompleteListener(MediaPlayer.OnSeekCompleteListener onSeekCompleteListener);

Unfortunately, MediaPlayer doesn’t ship a playback event that is triggered whenever current play percentage changes. We need to implement our own. Making it optional (default is turned on) is a good idea in order not to spam our events server.

void trackPlaybackPosition();

void untrackPlaybackPosition();

Finally, we will allow the bridge to TestFairy to be overriden as we mentioned earlier.

TestFairyBridge getTestFairyBridge();

void setTestFairyBridge(TestFairyBridge bridge);

Here is our final wrapper interface for MediaPlayer.

public interface PlayerWrapper {
    void setOnBufferingUpdateListener(MediaPlayer.OnBufferingUpdateListener onBufferingUpdateListener);

    void setOnCompletionListener(MediaPlayer.OnCompletionListener onCompletionListener);

    void setOnErrorListener(MediaPlayer.OnErrorListener onErrorListener);

    void setOnMediaTimeDiscontinuityListener(MediaPlayer.OnMediaTimeDiscontinuityListener onMediaTimeDiscontinuityListener);

    void setOnSeekCompleteListener(MediaPlayer.OnSeekCompleteListener onSeekCompleteListener);

    void trackPlaybackPosition();

    void untrackPlaybackPosition();

    TestFairyBridge getTestFairyBridge();

    void setTestFairyBridge(TestFairyBridge bridge);
}

We can instantiate an anonymous class which will intercept all these events, send them to TestFairy, and then republish all of it to the wrapped listeners.

What to use from the leanback library

Luckily, MediaPlayerAdapter already has a callback api named setCallback(PlayerAdapter.Callback) that provides all the events we need to listen. We will wrap that and add the extra configuration we defined earlier to be consistent accors different usages.

public interface PlayerAdapterWrapper {
    void setCallbacks(PlayerAdapter.Callback callbacks);

    void trackPlaybackPosition();

    void untrackPlaybackPosition();

    TestFairyBridge getTestFairyBridge();

    void setTestFairyBridge(TestFairyBridge bridge);
}

The Bridge

The TestFairy bridge will represent each event with a single callback method.

public interface TestFairyBridge {
    void onBufferingUpdate(int percent);

    void onPlaybackStateChange(boolean isPlaying);

    void onPlaybackPositionUpdate(int percent);

    void onComplete();

    void onError(int reason, Object extra);
}

Our glue will provide a default implementation just in case.

The Glue

The implementers of these interfaces will be created by a single factory class named TestFairyMediaPlayerGlue. Each time we request a wrapper, this factory will temporarily instantiate itself to configure the returned wrappers. Keeping track of how we created our temporary factory will help us share some of the functionality in both scenarios while isolating the non-shared state.

Let’s define these scenarios as abstract classes.

private static abstract class CreatedFrom {
        protected abstract void registerCurrentPositionTracker(final Runnable runnable);

        protected abstract void unRegisterCurrentPositionTracker();

        private abstract static class FromMediaPlayer extends CreatedFrom {
            private MediaPlayer.OnBufferingUpdateListener onBufferingUpdateListener;
            private MediaPlayer.OnCompletionListener onCompletionListener;
            private MediaPlayer.OnErrorListener onErrorListener;
            private MediaPlayer.OnMediaTimeDiscontinuityListener onMediaTimeDiscontinuityListener;
            private MediaPlayer.OnSeekCompleteListener onSeekCompleteListener;
        }

        private abstract static class FromMediaPlayerAdapter extends CreatedFrom {
            private PlayerAdapter.Callback playerAdapterListenerCallbacks;
        }
    }
}

Remember that MediaPlayer still needs our help to support playback position tracking. This will pollute FromMediaPlayer a little but no worries, it is just a few lines.

private static abstract class CreatedFrom {
    protected abstract void registerCurrentPositionTracker(final Runnable runnable);

    protected abstract void unRegisterCurrentPositionTracker();

    private abstract static class FromMediaPlayer extends CreatedFrom {
        private MediaPlayer.OnBufferingUpdateListener onBufferingUpdateListener;
        private MediaPlayer.OnCompletionListener onCompletionListener;
        private MediaPlayer.OnErrorListener onErrorListener;
        private MediaPlayer.OnMediaTimeDiscontinuityListener onMediaTimeDiscontinuityListener;
        private MediaPlayer.OnSeekCompleteListener onSeekCompleteListener;

        private static Runnable createPositionTracker(final MediaPlayer mediaPlayer, final TestFairyMediaPlayerGlue factory) {
            return new Runnable() {
                @Override
                public void run() {
                    // TODO : implement a single event dispatch for playback progress
                }
            };
        }

        // TODO : wrap a player to be able to periodically query for playback progress
        protected abstract MediaPlayer getMediaPlayer();
    }

    private abstract static class FromMediaPlayerAdapter extends CreatedFrom {
        private PlayerAdapter.Callback playerAdapterListenerCallbacks;
    }
}

Now that our public *Wrapper interfaces and private states named conveniently as CreatedFrom are defined, we can start filling the very first TODO notes we created earlier.


Here is the shared factory state:

ublic final class TestFairyMediaPlayerGlue {

    // State
    private final CreatedFrom createdFrom;
    private TestFairyBridge testFairyBridge;
    //////////////////////////////////////////////////////////////

    // Private constructor for internal use, includes bridging logic
    private TestFairyMediaPlayerGlue(CreatedFrom createdFrom) {
        // Source of creation (player or adapter or some other creation mechanism)
        this.createdFrom = createdFrom;

        // Default bridge, can be overridden with a setter
        this.testFairyBridge = new TestFairyBridge() {
            @Override
            public void onBufferingUpdate(int percent) {
                TestFairy.addEvent(String.format("Video Buffering: %d%%", percent));
            }

            @Override
            public void onPlaybackStateChange(boolean isPlaying) {
                TestFairy.addEvent(String.format("Video is %s", isPlaying ? "playing" : "paused"));
            }

            @Override
            public void onPlaybackPositionUpdate(int percent) {
                TestFairy.addEvent(String.format("Video Position: %d%%", percent));
            }

            @Override
            public void onComplete() {
                TestFairy.addEvent("Video complete");
            }

            @Override
            public void onError(int reason, Object extra) {
                TestFairy.addEvent(String.format("Video Error - Reason: %d - Extra: %s", reason, extra != null ? extra.toString() : "null"));
            }
        };
    }
    //////////////////////////////////////////////////////////////

Modify it if you plan to use tracking services other than TestFairy.


The private states for MediaPlayer:

// Factory
public static PlayerWrapper createByWrapping(@NonNull final MediaPlayer mediaPlayer) {
    if (mediaPlayer == null) {
        throw new NullPointerException("MediaPlayer cannot be null.");
    }

    final TestFairyMediaPlayerGlue listener = new TestFairyMediaPlayerGlue(new CreatedFrom.FromMediaPlayer() {
        private final Handler handler = new Handler();
        private Runnable currentPositionTracker;

        @Override
        protected void registerCurrentPositionTracker(final Runnable runnable) {
            // TODO : Run given runnable every few seconds to keep track of playback progress
        }

        @Override
        protected void unRegisterCurrentPositionTracker() {
            // TODO : unregisted any previously registered tracker
        }

        // TODO : use this to ask for the player in the tracker
        @Override
        protected MediaPlayer getMediaPlayer() {
            return mediaPlayer;
        }
    });

    final CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) listener.createdFrom;
    castedCreationMethod.registerCurrentPositionTracker(CreatedFrom.FromMediaPlayer.createPositionTracker(mediaPlayer, listener));

    final PlayerWrapperImpl playerWrapper = listener.createPlayerWrapper();

    mediaPlayer.setOnBufferingUpdateListener(playerWrapper);
    mediaPlayer.setOnCompletionListener(playerWrapper);
    mediaPlayer.setOnErrorListener(playerWrapper);
    mediaPlayer.setOnMediaTimeDiscontinuityListener(playerWrapper);
    mediaPlayer.setOnSeekCompleteListener(playerWrapper);

    if (TestFairy.getSessionUrl() == null) {
        Log.w("TestFairyMediaPlayerGlue", "Media player events will not be sent unless you call TestFairy.begin()");
    }

    return playerWrapper;
}

// Wrapper creation
private static abstract class PlayerWrapperImpl implements PlayerWrapper, MediaPlayer.OnBufferingUpdateListener, MediaPlayer.OnCompletionListener,
        MediaPlayer.OnErrorListener, MediaPlayer.OnMediaTimeDiscontinuityListener, MediaPlayer.OnSeekCompleteListener {
}

private PlayerWrapperImpl createPlayerWrapper() {
    return new PlayerWrapperImpl() {

        private int lastKnownBufferingPercent = -1;

        @Override
        public void onBufferingUpdate(MediaPlayer mp, int percent) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                if (castedCreationMethod.onBufferingUpdateListener != null) {
                    castedCreationMethod.onBufferingUpdateListener.onBufferingUpdate(mp, percent);
                }
            }

            if (lastKnownBufferingPercent != percent && testFairyBridge != null) {
                testFairyBridge.onBufferingUpdate(percent);
            }
            lastKnownBufferingPercent = percent;
        }

        @Override
        public void onCompletion(MediaPlayer mp) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                if (castedCreationMethod.onCompletionListener != null) {
                    castedCreationMethod.onCompletionListener.onCompletion(mp);
                }
            }

            if (testFairyBridge != null) {
                testFairyBridge.onComplete();
            }
        }

        @Override
        public boolean onError(MediaPlayer mp, int what, int extra) {
            boolean onErrorResult = false;

            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                if (castedCreationMethod.onErrorListener != null) {
                    onErrorResult = castedCreationMethod.onErrorListener.onError(mp, what, extra);
                }
            }

            if (testFairyBridge != null) {
                testFairyBridge.onError(what, extra);
            }

            return onErrorResult;
        }

        private boolean lastKnownPlaybackStateIsPlaying = false;

        @Override
        public void onMediaTimeDiscontinuity(MediaPlayer mp, MediaTimestamp mts) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                if (castedCreationMethod.onMediaTimeDiscontinuityListener != null) {
                    castedCreationMethod.onMediaTimeDiscontinuityListener.onMediaTimeDiscontinuity(mp, mts);
                }
            }

            if (mp.isPlaying() != lastKnownPlaybackStateIsPlaying && testFairyBridge != null) {
                testFairyBridge.onPlaybackStateChange(mp.isPlaying());
            }
            lastKnownPlaybackStateIsPlaying = mp.isPlaying();
        }

        @Override
        public void onSeekComplete(MediaPlayer mp) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                if (castedCreationMethod.onSeekCompleteListener != null) {
                    castedCreationMethod.onSeekCompleteListener.onSeekComplete(mp);
                }
            }
        }

        @Override
        public void setOnBufferingUpdateListener(MediaPlayer.OnBufferingUpdateListener onBufferingUpdateListener) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                castedCreationMethod.onBufferingUpdateListener = onBufferingUpdateListener;
            }
        }

        @Override
        public void setOnCompletionListener(MediaPlayer.OnCompletionListener onCompletionListener) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                castedCreationMethod.onCompletionListener = onCompletionListener;
            }
        }

        @Override
        public void setOnErrorListener(MediaPlayer.OnErrorListener onErrorListener) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                castedCreationMethod.onErrorListener = onErrorListener;
            }
        }

        @Override
        public void setOnMediaTimeDiscontinuityListener(MediaPlayer.OnMediaTimeDiscontinuityListener onMediaTimeDiscontinuityListener) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                castedCreationMethod.onMediaTimeDiscontinuityListener = onMediaTimeDiscontinuityListener;
            }
        }

        @Override
        public void setOnSeekCompleteListener(MediaPlayer.OnSeekCompleteListener onSeekCompleteListener) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                castedCreationMethod.onSeekCompleteListener = onSeekCompleteListener;
            }
        }

        @Override
        public void trackPlaybackPosition() {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayer) {
                CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) createdFrom;

                TestFairyMediaPlayerGlue.this.createdFrom.registerCurrentPositionTracker(
                        CreatedFrom.FromMediaPlayer.createPositionTracker(
                                castedCreationMethod.getMediaPlayer(),
                                TestFairyMediaPlayerGlue.this
                        )
                );
            }
        }

        @Override
        public void untrackPlaybackPosition() {
            TestFairyMediaPlayerGlue.this.createdFrom.unRegisterCurrentPositionTracker();
        }

        @Override
        public void setTestFairyBridge(TestFairyBridge bridge) {
            testFairyBridge = bridge;
        }

        @Override
        public TestFairyBridge getTestFairyBridge() {
            return testFairyBridge;
        }
    };
}

Note that many of the overriden listeners delegate their events to the wrapped ones. We decorate some of these delegations to be able to dispatch our analytic events.


Finally, here is the one for the adapter:

// Factory
public static PlayerAdapterWrapper createByWrapping(@NonNull final MediaPlayerAdapter playerAdapter) {
    if (playerAdapter == null) {
        throw new NullPointerException("MediaPlayerAdapter cannot be null.");
    }

    final TestFairyMediaPlayerGlue listener = new TestFairyMediaPlayerGlue(new CreatedFrom.FromMediaPlayerAdapter() {
        @Override
        protected void registerCurrentPositionTracker(Runnable _) {
            unRegisterCurrentPositionTracker();
            playerAdapter.setProgressUpdatingEnabled(true);
        }

        @Override
        protected void unRegisterCurrentPositionTracker() {
            playerAdapter.setProgressUpdatingEnabled(false);
        }
    });

    final PlayerAdapterWrapperImpl callbacksWrapper = listener.createPlayerAdapterCallbacksWrapper(playerAdapter.getCallback());

    playerAdapter.setCallback(callbacksWrapper);

    if (TestFairy.getSessionUrl() == null) {
        Log.w("TestFairyMediaPlayerGlue", "Media player events will not be sent unless you call TestFairy.begin()");
    }

    return callbacksWrapper;
}

// Wrapper creation
private static abstract class PlayerAdapterWrapperImpl extends PlayerAdapter.Callback implements PlayerAdapterWrapper {
}

private PlayerAdapterWrapperImpl createPlayerAdapterCallbacksWrapper(final PlayerAdapter.Callback originalCallbacks) {
    return new PlayerAdapterWrapperImpl() {

        private boolean lastKnownPlaybackStateIsPlaying = false;

        @Override
        public void onPlayStateChanged(PlayerAdapter adapter) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks.onPlayStateChanged(adapter);
                }

                originalCallbacks.onPlayStateChanged(adapter);
            }


            if (adapter.isPlaying() != lastKnownPlaybackStateIsPlaying && testFairyBridge != null) {
                testFairyBridge.onPlaybackStateChange(adapter.isPlaying());
            }
            lastKnownPlaybackStateIsPlaying = adapter.isPlaying();
        }

        @Override
        public void onPreparedStateChanged(PlayerAdapter adapter) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks.onPreparedStateChanged(adapter);
                }

                originalCallbacks.onPreparedStateChanged(adapter);
            }
        }

        @Override
        public void onPlayCompleted(PlayerAdapter adapter) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks.onPlayCompleted(adapter);
                }

                originalCallbacks.onPlayCompleted(adapter);
            }

            if (testFairyBridge != null) {
                testFairyBridge.onComplete();
            }
        }

        private int lastKnownPlaybackPercent = -1;
        @Override
        public void onCurrentPositionChanged(PlayerAdapter adapter) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks.onCurrentPositionChanged(adapter);
                }

                originalCallbacks.onCurrentPositionChanged(adapter);
            }

            if (adapter.getDuration() != 0) {
                long currentPosition = adapter.getCurrentPosition();
                long percent = (currentPosition * 100) / adapter.getDuration();

                if (lastKnownPlaybackPercent != percent && testFairyBridge != null) {
                    testFairyBridge.onPlaybackPositionUpdate((int) percent);
                }
                lastKnownPlaybackPercent = (int) percent;
            }
        }

        private int lastKnownBufferingPercent = -1;

        @Override
        public void onBufferedPositionChanged(PlayerAdapter adapter) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks.onBufferedPositionChanged(adapter);
                }

                originalCallbacks.onBufferedPositionChanged(adapter);
            }

            if (adapter.getDuration() != 0) {
                long currentPosition = adapter.getBufferedPosition();
                long percent = (currentPosition * 100) / adapter.getDuration();

                if (lastKnownBufferingPercent != percent && testFairyBridge != null) {
                    testFairyBridge.onBufferingUpdate((int) percent);
                }
                lastKnownBufferingPercent = (int) percent;
            }
        }

        @Override
        public void onDurationChanged(PlayerAdapter adapter) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks.onDurationChanged(adapter);
                }

                originalCallbacks.onDurationChanged(adapter);
            }
        }

        @Override
        public void onVideoSizeChanged(PlayerAdapter adapter, int width, int height) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks.onVideoSizeChanged(adapter, width, height);
                }

                originalCallbacks.onVideoSizeChanged(adapter, width, height);
            }
        }

        @Override
        public void onError(PlayerAdapter adapter, int errorCode, String errorMessage) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks.onError(adapter, errorCode, errorMessage);
                }

                originalCallbacks.onError(adapter, errorCode, errorMessage);
            }

            if (testFairyBridge != null) {
                testFairyBridge.onError(errorCode, errorMessage);
            }

        }

        @Override
        public void onBufferingStateChanged(PlayerAdapter adapter, boolean start) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks.onBufferingStateChanged(adapter, start);
                }

                originalCallbacks.onBufferingStateChanged(adapter, start);
            }
        }

        @Override
        public void onMetadataChanged(PlayerAdapter adapter) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks.onMetadataChanged(adapter);
                }

                originalCallbacks.onMetadataChanged(adapter);
            }
        }

        @Override
        public void setCallbacks(PlayerAdapter.Callback callbacks) {
            if (createdFrom instanceof CreatedFrom.FromMediaPlayerAdapter) {
                CreatedFrom.FromMediaPlayerAdapter castedCreationMethod = (CreatedFrom.FromMediaPlayerAdapter) createdFrom;

                if (castedCreationMethod.playerAdapterListenerCallbacks != null) {
                    castedCreationMethod.playerAdapterListenerCallbacks = callbacks;
                }
            }
        }

        @Override
        public void trackPlaybackPosition() {
            TestFairyMediaPlayerGlue.this.createdFrom.registerCurrentPositionTracker(null);
        }

        @Override
        public void untrackPlaybackPosition() {
            TestFairyMediaPlayerGlue.this.createdFrom.unRegisterCurrentPositionTracker();
        }

        @Override
        public void setTestFairyBridge(TestFairyBridge bridge) {
            testFairyBridge = bridge;
        }

        @Override
        public TestFairyBridge getTestFairyBridge() {
            return testFairyBridge;
        }
    };
}

Note that we still keep a reference to the original callbacks before overriding them to be able to keep all the functionality bundled with the adapter. Not doing so would break the existing code in our wrapped MediaPlayerAdapter users.

Missing Pieces

The adapter test is ready for compilation but we still lack some functionality in the player’s. CreatedFrom.FromMediaPlayer have to define how it queries the player for current progress.

public static PlayerWrapper createByWrapping(@NonNull final MediaPlayer mediaPlayer) {
        if (mediaPlayer == null) {
            throw new NullPointerException("MediaPlayer cannot be null.");
        }

        final TestFairyMediaPlayerGlue listener = new TestFairyMediaPlayerGlue(new CreatedFrom.FromMediaPlayer() {
            private final Handler handler = new Handler();
            private Runnable currentPositionTracker;

            @Override
            protected void registerCurrentPositionTracker(final Runnable runnable) {
                unRegisterCurrentPositionTracker();

                // Wrap given runnable to call it periodically
                currentPositionTracker = new Runnable() {
                    @Override
                    public void run() {
                        runnable.run();
                        handler.postDelayed(currentPositionTracker, 1000);
                    }
                };

                // Ask for current position every second (1000ms)
                handler.postDelayed(currentPositionTracker, 1000);
            }

            @Override
            protected void unRegisterCurrentPositionTracker() {
                if (currentPositionTracker != null) {
                    handler.removeCallbacks(currentPositionTracker);
                    currentPositionTracker = null;
                }
            }

            @Override
            protected MediaPlayer getMediaPlayer() {
                return mediaPlayer;
            }
        });

        final CreatedFrom.FromMediaPlayer castedCreationMethod = (CreatedFrom.FromMediaPlayer) listener.createdFrom;
        castedCreationMethod.registerCurrentPositionTracker(CreatedFrom.FromMediaPlayer.createPositionTracker(mediaPlayer, listener));

        final PlayerWrapperImpl playerWrapper = listener.createPlayerWrapper();

        mediaPlayer.setOnBufferingUpdateListener(playerWrapper);
        mediaPlayer.setOnCompletionListener(playerWrapper);
        mediaPlayer.setOnErrorListener(playerWrapper);
        mediaPlayer.setOnMediaTimeDiscontinuityListener(playerWrapper);
        mediaPlayer.setOnSeekCompleteListener(playerWrapper);

        if (TestFairy.getSessionUrl() == null) {
            Log.w("TestFairyMediaPlayerGlue", "Media player events will not be sent unless you call TestFairy.begin()");
        }

        return playerWrapper;
    }

And the given tracker should convert timestamps to percentages for simpler events.

private abstract static class FromMediaPlayer extends CreatedFrom {
    private MediaPlayer.OnBufferingUpdateListener onBufferingUpdateListener;
    private MediaPlayer.OnCompletionListener onCompletionListener;
    private MediaPlayer.OnErrorListener onErrorListener;
    private MediaPlayer.OnMediaTimeDiscontinuityListener onMediaTimeDiscontinuityListener;
    private MediaPlayer.OnSeekCompleteListener onSeekCompleteListener;

    private static Runnable createPositionTracker(final MediaPlayer mediaPlayer, final TestFairyMediaPlayerGlue listener) {
        return new Runnable() {
            private int lastKnownPlaybackPercent = -1;

            @Override
            public void run() {
                if (mediaPlayer.getDuration() != 0) {
                    int currentPosition = mediaPlayer.getCurrentPosition();
                    int percent = (currentPosition * 100) / mediaPlayer.getDuration();

                    if (lastKnownPlaybackPercent != percent && listener.testFairyBridge != null) {
                        listener.testFairyBridge.onPlaybackPositionUpdate(percent);
                    }
                    lastKnownPlaybackPercent = percent;
                }
            }
        };
    }

    protected abstract MediaPlayer getMediaPlayer();
}

Note that this is the same time conversion logic used in the buffering events.

The Result

You can find the complete implementation here. Put it in your project and see it in action by running our previously created unit tests.

If we’ve done everything correctly, our sessions in the TestFairy dashboard should look like this:

Let us know if you find any errors. Next time, we will do the same for Google’s ExoPlayer.

Credits