At Patreon, video and audio playback are core parts of the fan experience. Whether it’s watching creator content or listening to podcasts, media reliability directly impacts how creators connect with their fans.
In 2024, our Android team set out to modernize the foundation of our playback system by migrating from ExoPlayer2 (now deprecated) to Google’s new Media3 library. We saw this as the first step toward improving playback performance and stability, and we didn’t want to optimize an outdated stack when significant wins could come from upgrading alone.
The original goal: migration-in-place
Initially, we set our sights on a simple migration-in-place, avoiding any large refactors or architect…
At Patreon, video and audio playback are core parts of the fan experience. Whether it’s watching creator content or listening to podcasts, media reliability directly impacts how creators connect with their fans.
In 2024, our Android team set out to modernize the foundation of our playback system by migrating from ExoPlayer2 (now deprecated) to Google’s new Media3 library. We saw this as the first step toward improving playback performance and stability, and we didn’t want to optimize an outdated stack when significant wins could come from upgrading alone.
The original goal: migration-in-place
Initially, we set our sights on a simple migration-in-place, avoiding any large refactors or architecture changes. We began by following Google’s Media3 Migration Guide that encouraged engineers to use an automated migration script to handle renaming imports and perform basic migration steps from old to new APIs. We downloaded the script, ran it on our codebase, and saw fewer errors than we thought we’d see.
Given the error count wasn’t high, we thought manually fixing them would be simple. However, as we dug into the errors, we noticed a recurring theme: our existing architecture was constantly fighting against Media3’s expectations. This meant that the short list of errors that existed after running the migration script were surprisingly difficult to stamp out, and attempting to fix them only revealed further issues.
To give some examples: we built our media notifications manually, using old Compat classes and a notification manager, while Media3 preferred to build its media notifications directly from the player’s MediaItem. We created new ExoPlayer instances for every new video to be played, but Media3 suggests that you only have a single ExoPlayer instance for all long-running content. We managed background playback with extremely minimal use of a MediaSession, but Media3 recommends a player service that has MediaSession at the core of everything it does. There were other examples, big and small, but the disparity between our architecture and Google’s new best practices was stark.


Beyond this, our app often had two separate solutions with totally different architectures for every playback-related problem, a byproduct of Patreon shipping native audio long before it shipped native video. This made the migration even more difficult, since some migration steps would go perfectly smoothly with our audio stack, but felt impossible with our video stack, or vice versa.
Pivoting to a full rewrite
We time-boxed our migration investigation to about a week, and after struggling to conceive of any migration-in-place that would amount to a cleaner, better stack, we decided that the right path forward was to rewrite our media service entirely.
This decision came down to a few key points:
Media3 recommends an architecture that is entirely different than our own
Our existing architecture was deeply inconsistent between audio and video
Our existing architecture was fragile and difficult to reason about
Overall, we felt that a rewrite would both modernize our stack and eliminate countless small issues caused by spaghetti code, architectural inconsistencies, and unhandled corner cases that we believed would take even longer to fix in place.
The rewrite would be extensive, encompassing all classes below the app-layer repositories that handled user playback requests (e.g. “play/pause Patreon post with ID: 2”); these could remain untouched since they didn’t interface directly with any ExoPlayer components.
Everything else, however, including our ExoPlayer wrappers, media analytics handlers, background playback Service, media notification management, custom media notification actions, Android Auto MediaLibrary, and media repositories, needed to be removed and re-architected.
Because the project had initially been pitched as ‘migration in place,’ getting leadership to approve a larger effort required some convincing. We emphasized the need for a strong, non-deprecated foundation for core functionality like playback, and we promised that the rewritten stack would massively increase developer velocity in the future, eventually outpacing any improvements we could make in the short-term. Leadership supported our recommendation, and so we began to rewrite our entire playback stack using Media3–with a few specific principles to guide the rewrite:
1. Designing a cleaner architecture
Because our old stack had poor separation of concerns, used outdated classes, and was mostly untested (and therefore difficult to onboard onto), we wanted the new stack to leverage modern architecture patterns, dependency injection, modularization, and unit testing to ensure that it would stand the test of time and be legible to new engineers.
2. Rolling out carefully with metrics
We wanted to ensure that we could roll out our new media stack slowly alongside the existing stack.
This would allow us to check for metric changes (error rate, playback “smoothness”, buffer rate, etc.) between the old and new stacks. Plus, it would help ensure that we impacted a smaller number of users as we worked out the kinks in our new implementation prior to a general release.
3. Building with testability
In contrast to our existing code, we wanted our new code to be covered by comprehensive unit tests that would allow for us to make future improvements or tweaks with confidence and clarity.
Changes to our old stack required lots of manual regression testing to ensure that both audio and video remained functional, so our hope was that a suite of unit tests would speed up development and also help serve as implicit documentation for our new architecture and its expectations.
Rewrite methodology
Now that we had a clean slate to re-envision our media architecture, the team aimed to match Google’s best practices from the start.
Google’s Media3 documentation, however, is relatively sparse given the complexity and potential scale of playback functionality on Android. Also, Google often limits its documentation and demos to the most simple possible playback UXs. Coupled with the many outstanding androidx/media GitHub issues, this left us without a perfect plan to reference. Even apps like YouTube and Spotify encounter playback quirks, pointing to the complexity of media handling on Android.
We decided to base the core of our new architecture off of the simple diagram provided in the “background playback” documentation, and we ended up with the following:

In our legacy implementation, our app module directly created, handled, and observed its own ExoPlayer instances, allowing components at almost all layers to interact with them semi-directly.
This new approach, however, completely sequesters a single ExoPlayer instance within a Service and MediaSession, only allowing the app layer to interact with it via a MediaController (wrapped in our custom PlaybackController).
We chose this approach since it allowed for some key simplifications:
app layer doesn’t need to know about ExoPlayer or MediaSession details
A single ExoPlayer means we don’t need to be concerned about players competing for resources or holding them for too long
In Media3, using a MediaController to connect to a playback Service makes background playback and media notifications automatic (outside of some small custom options)
We also added some of our own constraints that we felt would improve the architecture:
All player/playback logic should live in a separate module (playerService)
This module should only expose a single class to our app, a custom component called PlaybackController that is used for all playback functionality
Where possible, our app layer modules should avoid depending on Media3 classes like Player or ExoPlayer.
Wrappers (upon wrappers (upon wrappers))
Though the Media3 and ExoPlayer2 libraries share countless class names, they have fully separate packages, which means that allowing them to exist simultaneously within the same codebase (or within the same class/interface) requires some ingenuity.
Many of our app layer classes relied, for example, on ExoPlayer2’s Player interface, which doesn’t play nicely with Media3’s class of the same name. These app layer classes sat outside of those we were rewriting from scratch, meaning that we needed a solution that allowed us to have these rely on both the ExoPlayer2 and Media3 version of the Player interface without causing fracturing or duplicating code.
To do so, we employed a wrapper interface, aptly named PlayerWrapper. This interface was a simple scaffold that duplicated the public APIs of the Media3 and ExoPlayer2 Player interfaces. Then, its implementations, such as ExoPlayer2PlayerWrapper, simply delegated back to the underlying player. For cases where the public API diverged between Media3 and ExoPlayer2, the alternative implementation would just no-op.
This also gave us the freedom to delegate to our custom PlaybackController through a PlaybackControllerPlayerWrapper, which in turn delegated from a MediaController down to our playback Service, allowing our app layer to remain completely ignorant of the underlying Service, MediaSession, and ExoPlayer logic.
If a class needed to rely directly on a concrete Media3 or ExoPlayer2 class, such as Compose PlayerView does with Player, we’d just make a wrapper around that class as well. We would end up with something like a PlayerViewWrapper—which would depend on a PlayerWrapper.

While this was tedious to set up, it allowed all of our app layer classes to drop their direct reliance on the ExoPlayer2 library, and instead they relied only on our internal Wrapper classes. This gave us the ability to add new implementations freely without having to update the app layer beyond some basic naming changes. Once the transition from ExoPlayer2 to Media3 was complete, these classes could be dropped in favor of the concrete Media3 classes once again, or they could be kept around to retain this flexibility.
Some of our wrappers ended did up being helpful outside of their original purpose. For example, while most of our media playback needed to be background-able (and therefore required a Service and MediaController), we discovered that some playback should be quick and local. For those cases, we were able to add another PlayerWrapper implementation (SimpleExoPlayerWrapper) that delegated to a fresh ExoPlayer instead of relying on our PlaybackController.
Despite the boilerplate, this wrapper-based approach gave us long-term flexibility and insulation from future media library changes. In general, we’ve found a lot of success in wrapping or shimming Google classes, since minimizing our app’s reliance on libraries outside of our control facilitates easier testing, easier refactoring, and better separation of concerns.
Dependency injection and feature flags
While these wrappers are neat on their own, they can be cumbersome to manage without a feature flag and some Hilt dependency injection magic to abstract the complexities of picking between various wrapper implementations.
To start, we added our primary feature flag that served as the source of truth for picking between the old and new stack. We also added a @MediaRewrite Hilt qualifier to allow us to qualify bindings that provided implementations from the new stack.
In combination with the wrappers, this allowed us to create classes that moved smoothly between the old and new stacks at the flip of a flag.
For example, here’s a snippet from our PatreonPlayerView:

Similarly, for classes that provided some long-running functionality in the background, we used these tools to kick them off only when the rewrite was enabled.
For example, we used a class called AudioMediaServiceBridge to bridge between our app-layer audio repositories and our PlaybackController, and we provided it into our set of AppStartupListeners like this:

We also used this system to dynamically enable or disable Android manifest entries for our different Services depending on which stack was being used. This was necessary since the ExoPlayer2 and Media3 services had overlapping intent filters that could cause problems if they existed simultaneously.
Results of the migration
Upon rolling out our new stack, we saw user complaints about media playback reduced by 85%, and we made major gains in our core performance metrics, increasing reliability and playback quality across the board. We saw a ~2% decrease in playback failure rates, brought our video startup time from 1.5s to below 1 second, and made major reductions in rebuffer rates.
Removing the spaghetti code and cruft of our old stack also let us eliminate some tricky playback bugs, improve our media notification handling (using custom actions and Media3’s new automatic notification handling), and improve our Android Auto functionality thanks to the benefits offered by MediaLibraryService.
The testability and cleanliness of the stack has made it easier to iterate upon, and we recently expanded our functionality to allow video downloads using Media3’s built-in downloading functionality. While this was possible with ExoPlayer2, the direct support for downloading offered by Media3 simplified this process and gave us a solid platform to build upon.
There were some drawbacks, however, as Media3 is still developing and maturing. We saw increased crash rates when attempting to queue media in the background (that was fixed with Media3 v.1.6), alongside other Media3-specific issues that required diligent monitoring of GitHub issue threads to solve.
Overall, we saw many immediate wins, and the improved architecture and code quality have given us long term benefits as well.
Key lessons
Plan for library differences early. While Media3 is built on ExoPlayer’s foundation, the API changes are non-trivial. Understanding how core concepts like Player, MediaItem, and MediaSession evolved in the context of Google’s new recommended architecture helped us avoid surprises mid-migration.
Don’t underestimate playback analytics. Our existing ExoPlayer2 metrics pipelines broke in subtle ways under Media3. Investing in updated instrumentation early paid off when debugging user-reported playback issues later.
Deprecations are opportunities. Migrating under the pressure of a deprecated dependency forced us to re-examine architectural choices that had accumulated over time, resulting in a cleaner, more maintainable playback stack.
Acknowledgements
Stevie Kideckel, for co-authoring this rewrite and being the wizard who casted our Dagger magic 🪄
Chris Eppstein for believing in this project and enabling us to prioritize craft over speed ✨
About the author
Cole Smith is an Android engineer who is passionate about clean architecture and user happiness. If they’re not coding, you can find Cole playing Old School RuneScape (RSN: MLGNaruto117).
If you have questions for Cole, leave them in the comments below.
Join the team
Interested in joining Patreon? We’re hiring! Browse our open positions here.