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.
- Testing Android TV Apps
- Tracking Video Events in Android TV (with MediaPlayer)
- 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:
- Support for
MediaPlayer
from Android SDK - Support for
MediaPlayerAdapter
from leanback support libraries - A one liner API that looks like
wrapper = wrap(mediaPlayer)
orwrapper = wrap(mediaPlayerAdapter)
- Sending analytic events for buffering and playback events
- Keeping track of play/pause/error states
- Not breaking existing functionality with
MediaPlayer
andMediaPlayerAdapter
classes - 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:
- There is a factory namedÂ
TestFairyMediaPlayerGlue.createByWrapping(MediaPlayer)
we need to implement. - There is another factory namedÂ
TestFairyMediaPlayerGlue.createByWrapping(MediaPlayerAdapter)
. - There is a consumer interface namedÂ
TestFairyMediaPlayerGlue.PlayerWrapper
. - There is another consumer interface namedÂ
TestFairyMediaPlayerGlue.PlayerAdapterWrapper
. - 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
- Photo by Andres Jasso on Unsplash