Blog Infos
Author
Published
Topics
, , , ,
Published

 

In my previous article, we walked through setting up a Node.js media server and building an Android client to play an RTMP stream developing kind of both server and client.

Read The 1st Part here

While that setup worked for some of you, like me, many might have run into a hard wall while trying to play a raw, low-latency live RTMP stream.
The player would buffer indefinitely or fail to connect, even though the stream was confirmed to be active.

So, what was the missing piece in our original ExoPlayer implementation?

The answer lies in the evolution of ExoPlayer and how it distinguishes between on-demand media and true live streams.
When I tried the same stream using a direct FFmpeg command or by switching to an HLS URL, it worked.

Problem: Treating a Live Stream Like a File

In the original code, we used ProgressiveMediaSource. Let’s look at that snippet again:

// The original, non-working approach for LIVE streams
private fun playVideo() {
    val videoSource = ProgressiveMediaSource.Factory(RtmpDataSource.Factory())
        .createMediaSource(MediaItem.fromUri(Uri.parse(url)))
    exoPlayer.setMediaSource(videoSource)
}

ProgressiveMediaSource is designed for media files that can be downloaded progressively from a server—think standard MP4 or MP3 files. It assumes it can buffer the content from start to finish.

live RTMP stream, however, has no “finish.” It’s a continuous flow of data. Using a source designed for files on a live stream causes ExoPlayer to get stuck, waiting for an end that will never come.

First, we need to test whether it’s actually streaming or not?

To play a RTMP stream using FFmpeg use this on Linux or Mac terminal

ffplay -fflags nobuffer -flags low_delay -framedrop -probesize 32 -analyzeduration 0 -flush_packets 1 -rtmp_live live rtmp://URL:PORT/live/stream

This is why the FFmpeg approach worked. FFmpeg was given specific flags (-fflags nobuffer-flags low_delay) that states “This is a low-latency live stream, and don’t wait to buffer it.”

Our original ExoPlayer code was missing the equivalent instructions.
I found out 2 approaches to this solution.

Solution 0: Use this library:
Solution 1: RTMP-HLS Streaming Approach

While we’ve fixed our RTMP implementation, it’s worth asking: is RTMP the best choice for client-side playback today? For many, the answer is no.

HLS (HTTP Live Streaming) has become the industry standard for a reason. It works over standard HTTP ports, making it firewall-friendly, and it’s natively supported on virtually all modern devices.

This method uses the mobile-ffmpeg library where ‘AAR’ file can be downloaded from HERE, as the current library is now archived.

What does it do?

It executes a FFmpeg process in the background, which reads the RTMP stream and writes streams of HLS files (.m3u8 playlist and .ts segments) to the app’s local storage. ExoPlayer then reads these local files.

media3 = "1.8.0"

androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx-media3-datasource-rtmp = { module = "androidx.media3:media3-datasource-rtmp", version.ref = "media3" }

    implementation(libs.androidx.media3.datasource.rtmp)
    implementation(libs.androidx.media3.exoplayer)
    implementation(libs.androidx.media3.exoplayer.hls)
    implementation(libs.androidx.media3.ui)
    implementation(files("libs/mobile-ffmpeg.aar"))

Pros:

  • Works with any RTMP stream, regardless of server configuration.
  • Gives you full control over the transcoding parameters.

Cons:

  • High CPU and battery consumption on the client device.
  • Adds significant complexity (managing background processes, file I/O, timing).
  • Not suitable for long-running streams on battery-powered devices.
Steps to follow :
  1. Create a directory where you will store your stream.m3u8 and ts files
  2. Start Transcoding or the process of conversion from RTMP to HLS

 

private lateinit var hlsDirectory: File
private val hlsFileName = "stream.m3u8"
private lateinit var hlsFile: File
private val localHlsUri by lazy { Uri.fromFile(hlsFile) }

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        val hlsDirectory = File(requireContext().filesDir, "hls")
        hlsFile = File(hlsDirectory, hlsFileName)

        if (!hlsDirectory.exists()) {
            hlsDirectory.mkdirs()
        } else {
            // Clean up old files from previous sessions
            hlsDirectory.listFiles()?.forEach { it.delete() }
        }

        // 2. Start the FFmpeg process to convert RTMP to HLS
        startFfmpegTranscoding()
}

 

 

private fun startFfmpegTranscoding() {
        val ffmpegCommand =
            "-i $rtmpInputUrl -c:v copy -c:a copy -f hls -hls_time 2 -hls_list_size 4 -hls_flags delete_segments ${hlsFile.absolutePath}"

        Log.d("HLSPlayerFragment", "Executing FFmpeg command: $ffmpegCommand")

        viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
            val result = FFmpeg.execute(ffmpegCommand)
            if (result == Config.RETURN_CODE_SUCCESS) {
                Log.i("HLSPlayerFragment", "FFmpeg process completed successfully.")
            } else {
                Log.e("HLSPlayerFragment", "FFmpeg process failed with rc=$result.")
                Config.printLastCommandOutput(Log.INFO)
            }
        }

        // Wait a few seconds for FFmpeg to create the initial .m3u8 file
        // before trying to initialize the player.
        viewLifecycleOwner.lifecycleScope.launch {
            kotlinx.coroutines.delay(5000)
            initializePlayer()
        }
    }

    @SuppressLint("UnsafeOptInUsageError")
    private fun initializePlayer() {
        if (player != null || context == null) return

        Log.d("HLSPlayerFragment", "Initializing player with URI: $localHlsUri")

        val dataSourceFactory = DefaultDataSource.Factory(requireContext())
        val mediaItem = MediaItem.fromUri(localHlsUri)

        val hlsMediaSource = HlsMediaSource.Factory(dataSourceFactory)
            .createMediaSource(mediaItem)

        player = ExoPlayer.Builder(requireContext()).build().also { exoPlayer ->
            binding.playerView.player = exoPlayer
            exoPlayer.setMediaSource(hlsMediaSource)
            exoPlayer.playWhenReady = true
            exoPlayer.prepare()
        }
    }

    private fun releaseResources() {
        player?.release()
        player = null

        FFmpeg.cancel()
        Log.d("HLSPlayerFragment", "All resources released.")
    }

    override fun onDestroyView() {
        super.onDestroyView()
        releaseResources()
        _binding = null
    }

 

Solution 1: Configuring for the Live Edge

The modern solution, using AndroidX Media3 (the successor to ExoPlayer2), is to configure MediaItem it with the properties of a live broadcast. This tells ExoPlayer how to handle the stream correctly—by staying close to the “live edge” (the most recently broadcast data) instead of trying to buffer the whole thing.

Here is the corrected code using LiveConfiguration

// The new, working approach for LIVE streams
private fun initializePlayer(rtmpUrl: String) {
    player = ExoPlayer.Builder(requireContext())
        .build()
        .also { exoPlayer ->
            binding.playerView.player = exoPlayer
            val mediaItem = MediaItem.Builder()
                .setUri(rtmpUrl)
                .setLiveConfiguration(
                    // These are the magic instructions!
                    MediaItem.LiveConfiguration.Builder()
                        .setMaxPlaybackSpeed(1.02f)
                        .setTargetOffsetMs(500) // Aim for 500ms latency
                        .build()
                )
                .build()
            exoPlayer.setMediaItem(mediaItem)
            exoPlayer.prepare()
        }
}

The key difference is the call to .setLiveConfiguration(). This is the modern ExoPlayer equivalent of the low-level FFmpeg flags.

  • setTargetOffsetMs(500): This tells the player to try and stay 500 milliseconds behind the live broadcast. It creates a small buffer to handle network jitter without introducing significant delay.
  • setMaxPlaybackSpeed(1.02f): If the player falls behind, it’s allowed to speed up playback slightly (by 2%) to catch up to the target offset.

By providing this configuration, we give ExoPlayer the context it was missing. It now understands it’s dealing with a live source and can manage its buffer and playback speed dynamically to provide a smooth, low-latency experience.

Full code segment!

 

class PlayerFragment : Fragment() {
    private var _binding: FragmentPlayerBinding? = null
    private val binding get() = _binding!!
    private var player: ExoPlayer? = null
    private var playWhenReady = true
    private var currentItem = 0
    private var playbackPosition = 0L
    lateinit var rtmpInputUrl: String
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentPlayerBinding.inflate(inflater, container, false)
        return binding.root
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        rtmpInputUrl = arguments?.getString(ARG_URL) ?: ""
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.loadButton.setOnClickListener {
            if (rtmpInputUrl.isNotEmpty()) {
                releasePlayer()
                initializePlayer(rtmpInputUrl)
            } else {
                Toast.makeText(context, "Please enter a URL", Toast.LENGTH_SHORT).show()
            }
        }
    }
    private fun initializePlayer(rtmpUrl: String) {
        player = ExoPlayer.Builder(requireContext())
            .build()
            .also { exoPlayer ->
                binding.playerView.player = exoPlayer
                val mediaItem = MediaItem.Builder()
                    .setUri(rtmpUrl)
                    .setLiveConfiguration(
                        MediaItem.LiveConfiguration.Builder()
                            .setMaxPlaybackSpeed(1.02f)
                            .setTargetOffsetMs(500)
                            .build()
                    )
                    .build()
                exoPlayer.setMediaItem(mediaItem)
                exoPlayer.playWhenReady = playWhenReady
                exoPlayer.seekTo(currentItem, playbackPosition)
                exoPlayer.prepare()
            }
    }
    private fun releasePlayer() {
        player?.let { exoPlayer ->
            playbackPosition = exoPlayer.currentPosition
            currentItem = exoPlayer.currentMediaItemIndex
            playWhenReady = exoPlayer.playWhenReady
            exoPlayer.release()
        }
        player = null
    }
    override fun onStart() {
        super.onStart()
        val url = binding.rtmpUrlEditText.text.toString().trim()
        if (url.isNotEmpty()) {
            initializePlayer(url)
        }
    }
    override fun onResume() {
        super.onResume()
        if (player == null) {
            val url = binding.rtmpUrlEditText.text.toString().trim()
            if (url.isNotEmpty()) {
                initializePlayer(url)
            }
        }
    }
    override fun onPause() {
        super.onPause()
        releasePlayer()
    }
    override fun onStop() {
        super.onStop()
        releasePlayer()
    }
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
    companion object {
        private const val ARG_URL = "url"
        @JvmStatic
        fun newInstance(url: String) =
            HLSPlayerFragment().apply {
                arguments = Bundle().apply {
                    putString(ARG_URL, url)
                }
            }
    }
}

 

 

<androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:background="@color/white"
        android:layout_height="match_parent">

       <EditText
            android:id="@+id/rtmpUrlEditText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:autofillHints="textUri"
            android:hint="rtmp://127.0.0.1:1935/stream"
            android:inputType="textUri"
            android:text="rtmp://172.20.33.176/live/stream"
            app:layout_constraintEnd_toStartOf="@+id/loadButton"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
        <Button
            android:id="@+id/loadButton"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="8dp"
            android:layout_marginEnd="8dp"
            android:text="Load Stream"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
        <androidx.media3.ui.PlayerView
            android:id="@+id/player_view"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginTop="8dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/rtmpUrlEditText" />
    </androidx.constraintlayout.widget.ConstraintLayout>

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Key Takeaways
  1. Not All Media Sources Are Equal: For on-demand video files (like MP4s over HTTP), it ProgressiveMediaSource is fine. For adaptive streams, you’d use HlsMediaSource or DashMediaSource. For live RTMP, you must configure the MediaItem for a live broadcast.
  2. Use setLiveConfiguration for Live Streams: This is the way in Media3/ExoPlayer to handle live content. It provides the player with the necessary strategy to manage latency and buffering, replacing the need for manual, low-level flags.
Conclusion

With these steps completed, we’ve successfully integrated ExoPlayer2 into your Android application, creating a fully functional RTMP client.

Now your Android application is equipped to stream RTMP content effortlessly. Feel free to run your app and explore the seamless RTMP video streaming capabilities on your Android device.

About the Author

I’m just a passionate Android developer who loves finding creative and elegant solutions to complex problems. Feel free to reach out on LinkedIn to chat about Android development and more.

This article was previously published on proandroiddev.com

Menu