It started with a simple request from the content team:
“Let’s push 50 FPS for videos, smoother playback, better experience.”
Reasonable enough. But once we rolled it out, a lot of users started complains about the videos stuck at 480p on multiple mid-range devices. No errors. No crashes. Just lower quality than expected.
The playback logs told the story:
tracks [eventTime=38397.41, mediaPos=30.00, window=0, period=0
group [
[ ] Track:0, id=1, mimeType=video/avc, container=video/mp4, bitrate=8801587, codecs=avc1.64002a, drm=[playready,widevine,cenc], res=1920x1080, fps=50.0, supported=NO_EXCEEDS_CAPABILITIES
[ ] Track:1, id=2, mimeType=video/avc, container=video/mp4, bitrate=4766387, codecs=avc1.640020, drm=[playready,widevine,cenc], res=1280x720, fps=50.0, supported=NO_EXCEEDS_CAPABILITIES
[X] Track:2, id=3, mimeType=video/avc, container=video/mp4, bitrate=1538459, codecs=avc1.64001f, drm=[playready,widevine,cenc], res=854x480, fps=30.0, supported=YES
[X] Track:3, id=4, mimeType=video/avc, container=video/mp4, bitrate=845578, codecs=avc1.64001e, drm=[playready,widevine,cenc], res=640x360, fps=30.0, supported=YES
]
That NO_EXCEEDS_CAPABILITIES line is the culprit.
Media3 quietly decided our HD 50 FPS tracks “exceeded device capabilities.” So playback continued but capped at 480p. No error messages. No warnings. Just silently limited quality.
The Invisible Quality Wall
This happens because Media3’s Adaptive Bitrate Streaming (ABR) system makes conservative assumptions about hardware decoding.
It examines each device’s decoders and, when something looks borderline like 1080p @ 50 FPS H.264 on a mid-range chip it marks the track as FORMAT_EXCEEDS_CAPABILITIES.
From that point on, that track simply isn’t considered. The device might actually handle it fine, but Media3 won’t even try.
So users on perfectly capable devices get downgraded playback for “their own safety,” while higher-end devices play the same stream flawlessly.
The Tempting Shortcut That Breaks Everything
The first instinct is to bypass the selector entirely:
trackSelector.parameters = trackSelector.buildUponParameters()
.setOverrideForType(trackSelection)
.build()
That works temporarily. Now all tracks, including HD, are selectable. But you just disabled everything intelligent about Media3.
Here’s what gets thrown out:
- Network-based adaptation
- Battery-aware quality throttling
- Bitrate and data saver constraints
- Custom viewport and scaling rules
- Bandwidth-based ABR logic
You’ve replaced a smart adaptive system with a blunt-force selector. Sure, users can watch HD now, but they’ll also buffer endlessly on slow networks and drain battery faster than ever.
The Surgical Fix: LenientVideoTrackSelector
Instead of nuking the logic, we wanted to negotiate with it.
The goal: tell Media3, “Hey, maybe this format exceeds capabilities but let’s give it a chance.”
That’s what the custom LenientVideoTrackSelector does. It extends DefaultTrackSelector and intercepts the video track capability check, converting only the restrictive FORMAT_EXCEEDS_CAPABILITIES flag to FORMAT_HANDLED. All other capability flags remain untouched.
Here’s the complete implementation:
class LenientVideoTrackSelector(
context: Context,
trackSelectionFactory: ExoTrackSelection.Factory = AdaptiveTrackSelection.Factory()
) : DefaultTrackSelector(context, trackSelectionFactory) {
override fun selectVideoTrack(
mappedTrackInfo: MappedTrackInfo,
rendererFormatSupports: Array<Array<IntArray>>,
mixedMimeTypeSupports: IntArray,
params: Parameters,
selectedAudioLanguage: String?
): Pair<ExoTrackSelection.Definition, Int>? {
val rendererIndex = (0 until mappedTrackInfo.rendererCount)
.find { mappedTrackInfo.getRendererType(it) == C.TRACK_TYPE_VIDEO }
if (rendererIndex != null) {
val groups = mappedTrackInfo.getTrackGroups(rendererIndex)
val supports = (0 until groups.length).map { groupIndex ->
val tracks = groups[groupIndex]
(0 until tracks.length).map { trackIndex ->
val supportFlags = rendererFormatSupports[rendererIndex][groupIndex][trackIndex]
val supportLevel = RendererCapabilities.getFormatSupport(supportFlags)
when (supportLevel) {
FORMAT_EXCEEDS_CAPABILITIES -> {
// Convert exceeds capabilities to handled, preserving other flags
val adaptiveSupport = RendererCapabilities.getAdaptiveSupport(supportFlags)
val tunnelingSupport = RendererCapabilities.getTunnelingSupport(supportFlags)
val hardwareSupport = RendererCapabilities.getHardwareAccelerationSupport(supportFlags)
val decoderSupport = RendererCapabilities.getDecoderSupport(supportFlags)
val audioOffloadSupport = RendererCapabilities.getAudioOffloadSupport(supportFlags)
RendererCapabilities.create(
/* formatSupport = */
FORMAT_HANDLED,
/* adaptiveSupport = */
adaptiveSupport,
/* tunnelingSupport = */
tunnelingSupport,
/* hardwareAccelerationSupport = */
hardwareSupport,
/* decoderSupport = */
decoderSupport,
/* audioOffloadSupport = */
audioOffloadSupport
)
}
else -> supportFlags
}
}.toTypedArray().toIntArray()
}.toTypedArray()
rendererFormatSupports[rendererIndex] = supports
}
return super.selectVideoTrack(
mappedTrackInfo,
rendererFormatSupports,
mixedMimeTypeSupports,
params,
selectedAudioLanguage
)
}
}
This tiny tweak acts like a permission slip. Media3’s normal rules stay intact; we just loosen the “too strict” ones.
Why This Works
Media3’s capability system isn’t a single yes/no. It’s a multi-layered decision model:
- Format Support — can the codec decode it at all?
- Adaptive Support — can it switch qualities smoothly?
- Hardware Acceleration — GPU or software decoding?
- Tunneling — can video/audio sync efficiently?
- Decoder Support — which decoder is assigned?
When Media3 flags FORMAT_EXCEEDS_CAPABILITIES, it’s playing safe: “this might stutter.”
By reclassifying that flag to FORMAT_HANDLED, we tell the selector: “Include this in consideration. Let ABR decide dynamically.”
We keep all adaptive decisions network, viewport, power saving but expand what’s considered “possible.”
What’s Preserved vs. What Changes
Still working after using LenientVideoTrackSelector:
- Network bandwidth adaptation ✅
- Battery-aware selection ✅
- Custom viewport constraints ✅
- Maximum bitrate and data saver ✅
- All custom parameters ✅
What changes:
- Tracks previously ignored (
FORMAT_EXCEEDS_CAPABILITIES) are now available ✅ - ABR can evaluate them normally ✅
- Users can select or stream higher qualities ✅
In short, you retain every smart safeguard and simply open up the choices.
Tradeoffs and Real-World Results
No solution is free.
Some low-end devices might stutter or briefly drop frames when forced to decode high-frame-rate HD. Some may even fall back to software decoding.
But for most mid-range hardware, the result is smooth playback and a huge drop in “blurry video” complaints. Users prefer having the option even if it occasionally struggles, over being silently limited forever.
The change gave us happier users, fewer false quality complaints, and no loss in stability.
The Bigger Picture: Pragmatism Over Purism
Media3’s default behavior is technically correct it protects users from bad playback. But “technically correct” isn’t always user-correct.
Real-world users don’t care about “hardware decoder capabilities.” They care about why their video looks worse than their friend’s. When frameworks err too conservatively, they risk harming perception of your app, not improving it.
LenientVideoTrackSelector strikes a balance: it respects Media3’s architecture while giving capable devices the freedom to try more.
TL;DR
Media3 often blocks HD or 50 FPS tracks using FORMAT_EXCEEDS_CAPABILITIES, especially on mid-tier devices.
The naive fix (setOverrideForType()) unlocks HD but breaks every adaptive safeguard.LenientVideoTrackSelector gently replaces only that one restrictive flag preserving ABR, battery logic, and custom parameters while letting capable devices stream higher quality.
Sometimes the best engineering isn’t about rewriting the system it’s about teaching it to trust the device a little more.

Leave a comment