Dynamic playlists with ExoPlayer
Ever wanted to support media playlists in your Android app, where users can add and remove playlist items arbitrarily during playback? Now you can!
From ExoPlayer 2.8 onward we’ve updated the ConcatenatingMediaSource
with dynamic playlist functionality. On the surface the new media source has a very simple and straightforward interface:
addMediaSource(mediaSource)
appends a new media source at the end of the playlist.addMediaSource(index, mediaSource)
inserts a new media source at the specified index in the playlist.addMediaSources(Collection<MediaSource>)
bulk appends a whole set of new media sources at the end of the playlist.addMediaSources(index, Collection<MediaSource>)
bulk inserts a set of new media sources at the specified index in the playlist.removeMediaSource(index)
removes the media source at the given index.moveMediaSource(fromIndex, toIndex)
moves an existing media source within the playlist. This is useful as you do not have to create a newMediaSource
object. And you can also move around the currently playing item without disrupting playback.getMediaSource(index)
allows you to access the media source at the given index.getSize()
returns the current playlist length.
You can call any of these methods before and after playback has started, no matter which item is currently playing. Access to these methods is thread-safe. And you can also rest assured that ExoPlayer pre-buffers the next playlist items to ensure gapless playback. As always, you will be notified when playback transitions to a new element by a call to eventListener.onPositionDiscontinuity
.
So, if you are eager to include the new playlist capabilities into your app, stop reading now, update to the latest ExoPlayer release and start coding :). If you are interested in some implementation details and why this was not part of ExoPlayer all along, please continue reading.
To understand the difficulties involved in supporting dynamically changing media sources, we need to step back a bit and see how ExoPlayer works with media sources internally. There are five classes and three threads involved (and a nice figure down below to put everything in context):
- Your app running on the app thread, which gives commands to the player (such as “prepare media source”).
- The player, which receives these commands and forwards them to the relevant instances on its own playback thread.
- The media source which handles access to the actual media. It gets created on the app thread, loads information on separate loader threads and communicates with the player on the playback thread.
- A timeline which exposes the structure of the media (duration, number of items, etc.). Timelines are immutable and thus accessible on all threads.
- A media period which handles all buffering and playback of media data. It gets created by the media source on the playback thread, but does the actual loading on a loader thread.
One issue in such a multi-threaded environment is that the different threads may have temporarily different views of the player’s state. Hence when a command is issued by one thread and handled by another, it’s necessary for the second thread to handle that command in a way that’s consistent with the first thread’s view of the player’s state when the command was issued.
Instantly reflecting state changes in all threads and being able to wait until a asynchronous process finished properly are two competing desires which need to be brought together when implementing dynamic playlist changes into the process described above. We have three such conundrums:
- Lazily updating the master playlist on the playback thread while having a instantly updated playlist on the app thread.
- Lazily waiting for new timeline information while keeping the timeline consistent with the playlist.
- Being able to create new periods based on the playlist while waiting for the lazy timeline information.
In general, the solutions are very similar. We use mocking instances of the objects we are waiting for. These mocking instances are created immediately and try to mock the lazy result of the operation as far as possible. Thus, the state of the whole system gets updated instantly to prevent ambiguous situations. Once the actual result of the lazy operation becomes available, the mocking instance stops mocking and starts forwarding all calls to the actual instance.
Updating the playlist
The “master” playlist is the one on the playback thread as this is where we perform all the operations. However, this has two drawbacks: Firstly, we want to be able to change the playlist before the playback thread even gets created (which happens when we call player.prepare(mediaSource)
). Secondly, we want the publicly visible playlist information (e.g., getSize()
) to immediately reflect our changes. Therefore, we keep a second (mocked) playlist on the app thread which always gets updated immediately and is used to query playlist information. As soon as the master playlist is available, all operations that have been applied to the mocked playlist are forwarded to the playback thread to update the master playlist.
Keeping the timeline in sync with the playlist
Whenever the playlist is changed, we need a new timeline to reflect this new media structure. Sometimes, we need to load the start of the newly added media to obtain the information required to fully update the timeline. However, it is also vital that the timeline immediately reflects all changes to the playlist. For example, if the app adds a new source at index i
and then immediately seeks to this index i
, the expectation clearly is that the new source will be played. If the player is not aware of this change yet because it still has the old timeline information, it will seek to another item instead (to the one which had the index i
before). That is why immediate timeline updates are of crucial importance.
To solve this problem, we immediately trigger a timeline update with a new mocked timeline element when the master playlist changes. This mocked timeline element has an unknown duration and is marked as “dynamic” to tell the player that it should expect changes to its properties. Even if this is information is not very helpful, it at least ensures that all timeline indices are kept in sync with the playlist. As soon as the playlist element is prepared, the mocked timeline element returns the actual properties of the underlying media. For the app, this results in a second timeline update.
Creating periods while waiting for timeline updates
The third problem is actually created by the solution to the second problem. Imagine we immediately want to play a newly inserted playlist element. Then the player asks the media source to create a new media period based on the information of the mocked timeline element. But the media source can’t fulfill this request yet as it still waits for the actual media information.
Again, we employ the idea of a mocked media period which gets created when the player requests a media period for a mocked timeline element. Void of any real media, the mocked media period will forever stay in a buffering mode telling the player that its media is not ready to be played yet. Once the timeline got updated with real media information, the mocked media period creates the actual media period itself and forwards all calls to this wrapped instance.
In summary, this blog post described our new ConcatenatingMediaSource
features to add and remove playlist elements on the fly. We also dived a bit deeper into the implementation details to see why such an implementation is not trivial. For questions and bug reports, please use our issue tracker on GitHub.