I started the weekend with a simple idea:

“I’ll tweak an open source IPTV Flutter project a bit and call it a day.”

Yeah… no.


The project was using Better Player.
At first glance, everything looked fine:

  • streams were playing
  • DASH (.mpd) worked
  • even DRM (ClearKey) was supported

So naturally, I assumed the hard parts were already solved.

They weren’t.


The First Problem

The stream had multiple audio tracks.

The player had… one.

No selector.
No API.
No indication that multiple tracks even existed.

Just:

“Here’s your audio. Enjoy.”


Trying Alternatives (Spoiler: Didn’t Work)

First stop: video_player.

  • simple
  • official
  • useless for this case

No real support for:

  • advanced DASH
  • DRM
  • track selection

Then I tried media_kit.

This one looked promising:

  • multiple audio tracks ✔️
  • subtitles ✔️
  • clean API ✔️

But then:

DRM: nope.

And not “kind of no”.
More like:

“This is not part of the stack.”

Because on Android, DRM lives in a completely different world.


The Realization

At that point it became obvious:

If I want proper DASH + DRM, I’m using ExoPlayer.

And Better Player… was already using it.


So the question changed from:

“Which library should I use?”

to:

“What is this library hiding from me?”


The Hack (Yes, Reflection)

val pluginClass = Class.forName("com.sarthak.better_player_enhanced.BetterPlayerPlugin")
val videoPlayersField = pluginClass.getDeclaredField("videoPlayers")
videoPlayersField.isAccessible = true

val videoPlayers = videoPlayersField.get(pluginInstance) as LongSparseArray<Any>
val betterPlayer = videoPlayers[textureId]

val exoPlayerField = betterPlayer.javaClass.getDeclaredField("exoPlayer")
exoPlayerField.isAccessible = true

val exoPlayer = exoPlayerField.get(betterPlayer) as ExoPlayer

Yes, it’s reflection.
Yes, it’s ugly.
Yes, it works.


Suddenly… Audio Tracks Exist

Once I had the real ExoPlayer:

exoPlayer.currentTracks.groups
    .filter { it.type == C.TRACK_TYPE_AUDIO }

And just like that:

👉 all audio tracks showed up

They were always there. Better Player just didn’t expose them.


Selecting one was straightforward:

trackSelector.parameters = trackSelector.buildUponParameters()
    .setOverrideForType(
        TrackSelectionOverride(group.mediaTrackGroup, 0)
    )
    .build()

Done.


Wiring It Through MainActivity

I exposed this through a MethodChannel.

Now Flutter can:

  • get audio tracks
  • get subtitle tracks
  • select tracks
  • listen for subtitle cues

Example:

when (call.method) {

    "getAudioTracks" -> {
        val tracks = exoPlayer.currentTracks.groups
            .filter { it.type == C.TRACK_TYPE_AUDIO }
            .mapIndexed { i, group ->
                val fmt = group.getTrackFormat(0)
                mapOf(
                    "groupIndex" to i,
                    "language" to (fmt.language ?: "und"),
                    "label" to (fmt.label ?: "Audio $i")
                )
            }
        result.success(tracks)
    }

    "selectAudioTrack" -> {
        val groupIndex = call.argument<Int>("groupIndex")!!
        val group = audioGroups[groupIndex]

        trackSelector.parameters = trackSelector.buildUponParameters()
            .setOverrideForType(
                TrackSelectionOverride(group.mediaTrackGroup, 0)
            )
            .build()

        result.success(null)
    }
}

Flutter Side (Still Using Better Player)

Here’s the funny part:

I didn’t remove Better Player.

_betterPlayerController = BetterPlayerController(config);

_betterPlayerController!.setupDataSource(
  BetterPlayerDataSource(
    BetterPlayerDataSourceType.network,
    url,
    videoFormat: BetterPlayerVideoFormat.dash,
    drmConfiguration: BetterPlayerDrmConfiguration(
      drmType: BetterPlayerDrmType.clearKey,
      clearKey: jwk,
    ),
  ),
);

It still handles:

  • rendering
  • lifecycle
  • texture management

But now I can fetch native tracks:

final audios = await _betterPlayerController!.getNativeAudioTracks();
final subs = await _betterPlayerController!.getNativeSubtitleTracks();

And render them:

ListTile(
  title: Text(track['label']),
  onTap: () {
    _betterPlayerController!
        .selectNativeAudioTrack(track['groupIndex']);
  },
)

Subtitles (Custom Rendering)

Better Player wasn’t handling subtitles well either.

So I listened directly from ExoPlayer:

override fun onCues(cueGroup: CueGroup) {
    val text = cueGroup.cues
        .mapNotNull { it.text?.toString() }
        .joinToString("\n")

    channel.invokeMethod("onSubtitleCue", text)
}

Then rendered them in Flutter:

if (_subtitlesActive && _currentSubtitle.isNotEmpty)
  Positioned(
    bottom: 60,
    child: Text(_currentSubtitle),
  )

What This Became

Without really planning it, I ended up with:

Flutter UI
   
Better Player (render + lifecycle)
   
ExoPlayer (actual control)

The Takeaway

I didn’t:

  • switch frameworks
  • rewrite everything in Kotlin
  • abandon Flutter

I just:

stopped treating the plugin as a black box


Better Player isn’t bad. But its abstraction:

  • hides too much
  • simplifies too aggressively
  • breaks down in complex scenarios

Conclusion

Sometimes the problem isn’t the tool.

It’s how much of it you’re allowed to see.

And when you need more:

you either switch tools…
or go deeper.

I went deeper.

So far… it works 😄

The Result

  • Multi-audio ✔️
  • Subtitles ✔️
  • DRM ✔️

Without replacing Better Player.