跪拜 Guibai
← Back to the summary

A libVLC Wrapper for H.265 IoT Live Streams on Android

Simple Encapsulation of an Android Live Video Player

EasyVlcPlayer Utility Class Analysis and Documentation

An Android video player encapsulation based on libVLC, specifically designed for playing RTSP/RTMP/HTTPS live video streams in IoT scenarios.


1. Dependency Import:

implementation 'org.videolan.android:libvlc-all:3.6.5'

2. EasyVlcPlayer Complete Code:

package com.xx.xx.iot.webrtc

import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.TextureView
import androidx.core.net.toUri
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.util.VLCVideoLayout

/**
 * @author: smile
 * @time: 2025/11/18 19:02
 * @description: vlc player
 **/
class EasyVlcPlayer(private val context: Context) {
    // VLC core components
    private var libVLC: LibVLC ? =null
    private var mediaPlayer: MediaPlayer ?= null
    // Playback state callback
    private var listener: OnPlayListener? = null
    private val progressHandler = Handler(Looper.getMainLooper())
    private var progressRunnable: Runnable? = null // Progress update task
    private var isInitialized = false
    private val TAG = EasyVlcPlayer::class.java.name
    private var surface: VLCVideoLayout? = null

    // Playback state listener interface
    interface OnPlayListener {
        fun onPrepared() // Preparation complete
        fun onError(message: String) // Error callback
        fun onStopped() // Stop callback
        fun onProgress(currentPosition: Long, totalDuration: Long)
    }

    fun setOnPlayListener(listener: OnPlayListener) {
        this.listener = listener
    }

    fun isInitialized(): Boolean = isInitialized

    // Initialize VLC
    fun init() = try {
        // Configure VLC parameters
        val options = mutableListOf(
            "--no-video-title-show",//Hide video title bar
            "--network-caching=800",//Network buffer time
            "--demux=hevc",//Force H.265 demuxing
            "--codec=hevc",//Force H.265 decoding
            "--http-reconnect",//Enable HTTP auto-reconnect on disconnect
            "--vout=android_display",//Specify video output module as Android native display module
            "--live-caching=800",//Set live stream specific cache time
            "-vvv",// Keep logs for easy debugging (remove when publishing)
            "--hevc-fps=20",//Set frame rate to 20fps
            "--avcodec-skiploopfilter=all", // Reduce decoder pressure
            "--android-display-chroma=RV32", // Specify color format
            "--avcodec-hw=none", // Disable hardware decoding
            "--video-on-top",
        )
        libVLC = LibVLC(context, options)
        mediaPlayer = MediaPlayer(libVLC)
        mediaPlayer?.spuTrack = -1 // Disable subtitles
        isInitialized = true
    } catch (e: Exception) {
        listener?.onError("VLC initialization failed: ${e.message}")
        isInitialized = false
    }

    // Set playback container
    fun setSurface(vlcVideoLayout: VLCVideoLayout) {
        if (vlcVideoLayout.width == 0 || vlcVideoLayout.height == 0) {
            Log.d(TAG, "Surface not ready (width/height is 0), delaying binding")
            vlcVideoLayout.post {
                this.surface = vlcVideoLayout
                Log.d(TAG, "Surface delayed binding complete")
            }
            return
        }
        this.surface = vlcVideoLayout
        mediaPlayer?.attachViews(vlcVideoLayout, null, false, false)
    }

    // Play RTSP/RTMP stream
    fun play(streamUrl: String) {
        try {
            if (!isInitialized) {
                listener?.onError("Player not initialized")
                return
            }
            if (surface == null) {
                listener?.onError("Playback control not bound")
                return
            }
            try {
                val uri = streamUrl.toUri()
                    .buildUpon()
                    .scheme("https") // Force HTTPS protocol
                    .build()
                val media = Media(libVLC, uri)
                mediaPlayer?.let {
                    it.media = media
                    media.release()
                    it.play()
                    it.setEventListener { event ->
                        when (event.type) {
                            MediaPlayer.Event.Playing -> Log.d(TAG,"Preparation complete")
                            MediaPlayer.Event.EncounteredError -> listener?.onError("Playback error: ${it.media?.uri}")
                            MediaPlayer.Event.EndReached,
                            MediaPlayer.Event.Stopped -> {
                                stop()
                                listener?.onStopped()
                            }
                        }
                    }
                }

            } catch (e: Exception) {
                listener?.onError("Playback failed: ${e.message}")
            }
        } catch (e: Exception) {
            listener?.onError("Playback failed: ${e.message}")
        }
    }

    fun seekTo(position: Long) {
        mediaPlayer?.time = position
    }


    fun getTotalDuration(): Long {
        return mediaPlayer?.length ?: 0L
    }

    private fun stopProgressUpdate() {
        progressRunnable?.let { progressHandler.removeCallbacks(it) }
    }

    /**
     * Pause rendering
     */
    fun pauseRender() {
        mediaPlayer?.let {
            if (it.isPlaying) {
                it.pause() // Pause decoding first
            }
            it.detachViews()
            stopProgressUpdate()
            Log.d(TAG, "Rendering paused, BufferQueue released")
        }
    }

    /**
     * Resume rendering (call when switching to foreground/unlocking: rebuild rendering pipeline)
     */
    fun resumeRender() {
        val currentSurface = surface ?: run {
            listener?.onError("Resume rendering failed: Playback control not bound")
            return
        }
        if (currentSurface.width > 0 && currentSurface.height > 0) {
            mediaPlayer?.let { player ->
                player.attachViews(currentSurface, null, false, false) // Re-bind Surface
                if (!player.isPlaying) {
                    player.play()
                }
                Log.d(TAG, "Rendering resumed, BufferQueue rebuilt")
            }
        } else {
            currentSurface.postDelayed({
                resumeRender()
            }, 300)
            Log.w(TAG, "Surface not ready, delaying rendering resume")
        }
    }

    // Pause playback
    fun pause() {
        mediaPlayer?.let {
            if (it.isPlaying) {
                it.pause()
                stopProgressUpdate()
            }
        }
    }

    // Resume playback
    fun resume() {
        if (mediaPlayer == null || !isInitialized) {
            listener?.onError("Player not initialized")
            return
        }
        val currentSurface = surface ?: run {
            listener?.onError("Playback control not bound")
            return
        }
        if (currentSurface.width > 0 && currentSurface.height > 0) {
            mediaPlayer?.let { player ->
                if (!player.isPlaying) {
                    player.detachViews()
                    player.attachViews(currentSurface, null, false, false)
                    player.play() // Resume playback
                    Log.d(TAG, "Playback resumed successfully, Surface dimensions: ${currentSurface.width}x${currentSurface.height}")
                }
            }
        } else {
            currentSurface.postDelayed({
                resume()
            }, 300)
            Log.w(TAG, "Surface not ready (dimensions: ${currentSurface.width}x${currentSurface.height}), delaying playback resume")
        }
    }

    // Stop playback
    fun stop() {
        mediaPlayer?.stop()
        stopProgressUpdate()
    }

    // Release resources
    fun release() {
        stopProgressUpdate()
        progressHandler.removeCallbacksAndMessages(null)
        mediaPlayer?.stop()
        mediaPlayer?.release()
        libVLC?.release()
        libVLC = null
        mediaPlayer = null
        surface = null
        listener = null
        isInitialized = false
    }

    fun detachSurface() {
        mediaPlayer?.detachViews()
    }
}

3. Class Overview

Item Description
Package Path com.x.x.iot.webrtc
Author smile
Core Dependency org.videolan.libvlc (VLC Android SDK)
Main Purpose Play real-time video streams pushed by devices (H.265/HEVC encoding)
Typical Scenario Remote video monitoring for robots/IoT devices, live playback

Design Positioning

EasyVlcPlayer is a lightweight encapsulation of libVLC, focusing on:


4. Core Members

4.1 Properties

Property Type Description
libVLC LibVLC? VLC core engine instance
mediaPlayer MediaPlayer? VLC media player instance
surface VLCVideoLayout? Video rendering container (Android Surface)
listener OnPlayListener? Playback state callback listener
progressHandler Handler Main thread Handler for progress updates
progressRunnable Runnable? Progress update scheduled task
isInitialized Boolean Initialization state flag

4.2 Callback Interface

interface OnPlayListener {
    fun onPrepared()                          // Preparation complete
    fun onError(message: String)              // Error callback
    fun onStopped()                           // Stop callback
    fun onProgress(currentPosition: Long,     // Progress callback
                     totalDuration: Long)
}

5. Method Details

5.1 Initialization Process

fun init()

Execution Steps:

1. Configure VLC startup parameters (MutableList<String>)
   ├── --no-video-title-show          → Hide video title bar
   ├── --network-caching=800          → Network buffer 800ms
   ├── --demux=hevc                   → Force H.265 demuxing
   ├── --codec=hevc                   → Force H.265 decoding
   ├── --http-reconnect               → HTTP auto-reconnect on disconnect
   ├── --vout=android_display         → Android native display module
   ├── --live-caching=800             → Live stream cache 800ms
   ├── -vvv                           → Verbose logs (remove when publishing)
   ├── --hevc-fps=20                  → Limit frame rate to 20fps
   ├── --avcodec-skiploopfilter=all   → Skip loop filter, reduce decoding pressure
   ├── --android-display-chroma=RV32  → Color format RV32
   ├── --avcodec-hw=none              → Disable hardware decoding (compatibility priority)
   └── --video-on-top                 → Video window on top

2. libVLC = LibVLC(context, options)
3. mediaPlayer = MediaPlayer(libVLC)
4. mediaPlayer?.spuTrack = -1        → Disable subtitle track
5. isInitialized = true

Exception Handling:


5.2 Surface Binding

fun setSurface(vlcVideoLayout: VLCVideoLayout)

Logic Branches:

Condition Handling
width == 0 || height == 0 Surface not ready, delay binding via post { }
width > 0 && height > 0 Bind immediately: mediaPlayer?.attachViews(vlcVideoLayout, null, false, false)

Note: attachViews() must be called when the Surface is valid, otherwise rendering will fail.


5.3 Play Video Stream

fun play(streamUrl: String)

Complete Flow:

1. Pre-checks
   ├── !isInitialized → onError("Player not initialized") → return
   └── surface == null → onError("Playback control not bound") → return

2. URL Processing (Key!)
   └── streamUrl.toUri()
         .buildUpon()
         .scheme("https")      // Force protocol to HTTPS
         .build()

3. Create Media Object
   └── val media = Media(libVLC, uri)

4. Set and Play
   ├── mediaPlayer?.media = media
   ├── media.release()          // Immediately release Media object (VLC has internal reference)
   └── mediaPlayer?.play()

⚠️ Force HTTPS: Regardless of the incoming URL protocol, it will be forcibly changed to https://. This is for unified secure transmission but may cause RTSP/RTMP streams to fail (need to confirm server support).


5.4 Rendering Control (Foreground/Background Switching)

pauseRender() — Pause Rendering (Switch to Background/Lock Screen)

fun pauseRender()

Execution Steps:

1. if (isPlaying) → pause()          // Pause decoding
2. detachViews()                      // Unbind Surface, release BufferQueue
3. stopProgressUpdate()              // Stop progress update task

Purpose: Called when the Activity goes into the background or the screen is locked, releasing Surface resources to avoid system errors.


resumeRender() — Resume Rendering (Switch to Foreground/Unlock)

fun resumeRender()

Execution Steps:

1. Check surface validity
   └── surface == null → onError("Resume rendering failed: Playback control not bound") → return

2. Check Surface dimensions
   ├── width > 0 && height > 0
   │     ├── attachViews(currentSurface, null, false, false)  // Rebuild BufferQueue
   │     └── if (!isPlaying) → play()
   │
   └── width == 0 || height == 0
         └── postDelayed(300ms) → resumeRender()  // Recursive delayed wait

Purpose: Rebuild the rendering pipeline when the Activity returns to the foreground to restore video display.


5.5 Playback Control

Method Function Internal Logic
pause() Pause playback mediaPlayer?.pause() + stopProgressUpdate()
resume() Resume playback Check initialization/Surface → detachViews()attachViews()play()
stop() Stop playback mediaPlayer?.stop() + stopProgressUpdate()
seekTo(position) Seek progress mediaPlayer?.time = position
getTotalDuration() Get total duration mediaPlayer?.length ?: 0L
detachSurface() Unbind Surface mediaPlayer?.detachViews()

5.6 Resource Release

fun release()

Release Order (Strict!):

1. stopProgressUpdate()                    // Stop progress task
2. progressHandler.removeCallbacksAndMessages(null)  // Clear Handler message queue
3. mediaPlayer?.stop()                     // Stop playback
4. mediaPlayer?.release()                  // Release player
5. libVLC?.release()                       // Release VLC engine
6. libVLC = null                           // Nullify reference
7. mediaPlayer = null
8. surface = null
9. listener = null
10. isInitialized = false

⚠️ Important: Must be called in Activity onDestroy(), otherwise it will cause memory leaks and native layer crashes.


6. VLC Parameter Configuration Details

Parameter Value Function
--no-video-title-show - Hide video title bar to avoid blocking the picture
--network-caching=800 800ms Network stream buffer time, reduce latency
--demux=hevc hevc Force use of H.265 demuxer
--codec=hevc hevc Force use of H.265 decoder
--http-reconnect - Auto-reconnect after HTTP disconnect
--vout=android_display android_display Use Android native display output
--live-caching=800 800ms Live stream specific cache
-vvv - Highest level logs (for debugging, remove when publishing)
--hevc-fps=20 20fps Limit HEVC decoding frame rate, reduce CPU usage
--avcodec-skiploopfilter=all all Skip loop filter, reduce decoding computation
--android-display-chroma=RV32 RV32 Specify RGB32 color format
--avcodec-hw=none none Disable hardware decoding, use software decoding (compatibility priority)
--video-on-top - Video window on top

7. Lifecycle Management

7.1 Activity Lifecycle Mapping

Activity Callback Method to Call Description
onCreate() EasyVlcPlayer(context)init() Initialize player
onResume() resumeRender() Resume rendering
onPause() pauseRender() Pause rendering, release Surface
onDestroy() release() Completely release resources

7.2 State Transition Diagram

[Uninitialized]
    │
    ▼ init()
[Initialized]
    │
    ▼ setSurface()
[Surface Bound]
    │
    ▼ play(url)
[Playing]
    │
    ├──► pauseRender() ──► [Rendering Paused]
    │       │
    │       ▼ resumeRender()
    │       [Rendering Resumed]
    │
    ├──► pause() ──► [Paused]
    │       │
    │       ▼ resume()
    │       [Playing]
    │
    └──► stop() / release()
            │
            ▼
        [Stopped/Released]

8. Notes and Potential Issues

8.1 ⚠️ Force HTTPS Issue

val uri = streamUrl.toUri()
    .buildUpon()
    .scheme("https")  // Force HTTPS
    .build()

Risk:

8.2 ⚠️ Disabling Hardware Decoding

"--avcodec-hw=none"

Impact:

8.3 ⚠️ Surface Not Ready Handling

setSurface() and resumeRender() both handle the width/height == 0 case:

vlcVideoLayout.post { this.surface = layout }       // setSurface delayed binding
currentSurface.postDelayed({ resumeRender() }, 300) // resumeRender recursive wait

Suggestion: Ensure play() is called only after the Surface is fully created to avoid unnecessary delays.

8.4 ⚠️ Progress Update Not Implemented

The code defines progressRunnable and progressHandler, but does not actually start the progress update task. If a progress bar function is needed, add:

// Start after play() succeeds
progressRunnable = object : Runnable {
    override fun run() {
        val current = mediaPlayer?.time ?: 0L
        val total = mediaPlayer?.length ?: 0L
        listener?.onProgress(current, total)
        progressHandler.postDelayed(this, 1000)
    }
}
progressHandler.post(progressRunnable!!)

8.5 ⚠️ Event Listener Commented Out

/* it.setEventListener { event ->
    when (event.type) {
        MediaPlayer.Event.Playing -> ...
        MediaPlayer.Event.EncounteredError -> ...
        MediaPlayer.Event.EndReached -> ...
    }
} */

Impact: Playback completion, errors, and other events cannot be notified to the upper layer through VLC internal event callbacks, and can only be perceived through external calls.


8.6 Video Playback Stuttering and Black Screen Issues

9. Usage Example

package com.x.x.iot.ui.activity

import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.view.WindowManager
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import com.google.gson.Gson
import com.x.x.iot.R
import com.x.x.iot.databinding.ActivityVlcPlaerBinding
import com.x.x.iot.event.StreamMessageEvent
import com.x.x.iot.http.ApiException
import com.x.x.iot.http.ApiResult
import com.x.x.iot.http.EasyHttpUtil
import com.x.x.iot.http.weakHashMapOf
import com.x.x.iot.utils.IotConstants
import com.x.x.iot.utils.FlowBus
import com.x.x.iot.utils.LogUtils
import com.x.x.iot.webrtc.EasyVlcPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

/**
 * @author: smile
 * @time: 2025/11/18 18:31
 * @description: Use vlc to play video stream
 **/
class VlcVideoPlayerActivity : AppCompatActivity(), EasyVlcPlayer.OnPlayListener {
    private val TAG = "VlcVideoPlayerActivity"
    private lateinit var binding: ActivityVlcPlaerBinding
    private lateinit var vlcPlayer: EasyVlcPlayer
    private val mainHandler = Handler(Looper.getMainLooper())
    private var videoUrl: String = ""
    private var isPlaying = false
    private var isPaused = false
    private val liveScope = CoroutineScope(Dispatchers.IO)
    private var liveJob: Job? = null
    private var requestSuccess: Boolean = false
    private var code: Int = 0
    private var errorMsg = ""
    private var deviceId = ""

    override fun onCreate(savedInstanceState: Bundle?) {
        enableEdgeToEdge()
        super.onCreate(savedInstanceState)
        binding = ActivityVlcPlaerBinding.inflate(layoutInflater)
        setContentView(binding.root)
        initVlcPlayer()
        initData()
        initViews()
        setupClickListeners()
        setUpEdgeToEdge()
    }

    private fun initVlcPlayer() {
        //Keep screen on
        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
        vlcPlayer = EasyVlcPlayer(this)
        vlcPlayer.setOnPlayListener(this)
        vlcPlayer.init()
        binding.vlcVideoLayout.post {
            if (::vlcPlayer.isInitialized) {
                vlcPlayer.setSurface(binding.vlcVideoLayout)
                Log.d(TAG, "VLCVideoLayout dimensions: ${binding.vlcVideoLayout.width}x${binding.vlcVideoLayout.height}")
            }
        }

    }

    private fun initData() {
        intent?.let {
            deviceId = it.getStringExtra("deviceId").toString()
        }
        getStartLiveLoop(deviceId)
        FlowBus.with<StreamMessageEvent>("start_live")
            .register(this@VlcVideoPlayerActivity) { it ->
                LogUtils.d(TAG, "===Received message is===" + Gson().toJson(it))
                val originalLink = it.link?.trim().toString()
                LogUtils.d(TAG, "===Received message url is===$originalLink")
                if (originalLink.isBlank() || !originalLink.startsWith("https://")) {
                    Log.e(TAG, "Invalid address: $originalLink")
                    return@register
                }
                videoUrl = originalLink
                playH265Video()
            }
    }

    private fun setUpEdgeToEdge() {
        val mainView = findViewById<View>(R.id.main)
        ViewCompat.setOnApplyWindowInsetsListener(mainView) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
    }

    private fun initViews() {
        updateButtonStates(false, false)
        Log.d(TAG, "Ready")
    }

    private fun setupClickListeners() {
        binding.btnPlay.setOnClickListener { playH265Video() }
        binding.btnStop.setOnClickListener { stopH265Video() }
        binding.btnPause.setOnClickListener { pauseH265Video() }
        binding.btnResume.setOnClickListener { resumeH265Video() }
    }

    /**
     * Send command to start device playback
     */
    private fun getStartLiveLoop(deviceId: String) {
        stopLiveLoop()
        liveJob = liveScope.launch {
            while (isActive) {
                // Execute every 1 second
                delay(1000)
                val requestBody = weakHashMapOf<String, Any>(
                    "device_id" to deviceId,
                    "directive" to IotConstants.USER_START_LIVE)
                val requestHeaders = weakHashMapOf(
                    "x-app-pkgname" to IotConstants.APP_PACKAGE_PET_GUGU,
                    "accept-language" to "en-us",
                    "x-app-id" to "10003"
                )
                try {
                    EasyHttpUtil.post(
                        urlStr = IotConstants.BASE_IOT_URL + IotConstants.USER_START_LIVE,
                        bodyParams = requestBody,
                        headers = requestHeaders,
                        clazz = Int::class.java)
                        .onStart {
                            LogUtils.d(TAG, "Start playback command sending started:")
                        }.catch { exception ->
                            errorMsg = exception.message.toString()
                            LogUtils.e(TAG, "Start playback command sending failed: $errorMsg")
                        }.first().let { result ->
                            when (result) {
                                is ApiResult.Success -> {
                                    requestSuccess = result.data == 200
                                    LogUtils.d(
                                        TAG,
                                        "Start playback command result: ${if (requestSuccess) "Success" else "Failed"}"
                                    )
                                }
                                is ApiResult.Error -> {
                                    throw ApiException(result.code, result.message)
                                }
                            }
                        }
                }catch (e: Exception){
                    LogUtils.e(TAG,"===Request error==="+e.message)
                    throw e
                }
            }
        }
    }

    private fun initStopVideo() {
        val requestBody = weakHashMapOf<String, Any>("device_id" to deviceId, "directive" to IotConstants.USER_STOP_LIVE)
        val requestHeaders = weakHashMapOf(
            "x-app-pkgname" to IotConstants.APP_PACKAGE_PET_GUGU,
            "accept-language" to "en-us",
            "x-app-id" to "10003"
        )
        lifecycleScope.launch {
            EasyHttpUtil.post(
                urlStr = IotConstants.BASE_IOT_URL + IotConstants.USER_STOP_LIVE,
                bodyParams = requestBody,
                headers = requestHeaders,
                clazz = Int::class.java
            ).onStart {
                LogUtils.d(TAG, "Stop playback command sending started:")
            }.catch { exception ->
                val errorMsg = exception.message.toString()
                LogUtils.d(TAG, "Stop playback command sending error: $errorMsg")
            }.first().let { result ->
                when (result) {
                    is ApiResult.Success -> {
                        requestSuccess = result.data == 200
                        LogUtils.d(
                            TAG,
                            "Stop playback command: ${if (requestSuccess) "Success" else "Failed"}"
                        )
                    }
                    is ApiResult.Error -> {
                        throw ApiException(result.code, result.message)
                    }
                }
            }
        }
    }

    private fun stopLiveLoop() {
        liveJob?.cancel()
    }

    // Play video
    private fun playH265Video() {
        if (!::vlcPlayer.isInitialized || !vlcPlayer.isInitialized()) {
            Log.d(TAG, "Player not initialized")
            return
        }
        if (binding.vlcVideoLayout.width == 0 || binding.vlcVideoLayout.height == 0) {
            Log.e(TAG, "Playback failed: Playback control not ready")
            Log.d(TAG, "Error: Playback control not ready")
            return
        }
        try {
            Log.d(TAG, "Start playing address: $videoUrl")
            vlcPlayer.play(videoUrl)
            isPlaying = true
            isPaused = false
            updateButtonStates(isPlaying, isPaused)
        } catch (e: Exception) {
            Log.e(TAG, "Playback failed", e)
            Log.d(TAG, "Playback failed: ${e.message}")
        }
    }

    // Stop playback
    private fun stopH265Video() {
        Log.d(TAG, "Stop playback")
        if (!isPlaying) {
            Log.d(TAG, "Not playing")
            return
        }
        vlcPlayer.stop()
        isPlaying = false
        isPaused = false
        updateButtonStates(isPlaying, isPaused)
    }

    // Pause playback
    private fun pauseH265Video() {
        Log.d(TAG, "Pause playback")
        if (!isPlaying || isPaused) {
            Log.d(TAG, "Already paused or not playing")
            return
        }
        if (vlcPlayer.isInitialized()) {
            vlcPlayer.pauseRender()
        }
        isPaused = true
        updateButtonStates(isPlaying, isPaused)
    }

    // Resume playback
    private fun resumeH265Video() {
        Log.d(TAG, "Resume playback")
        if (!isPlaying || !isPaused) {
            Log.d(TAG, "Not paused or not playing")
            return
        }
        if (vlcPlayer.isInitialized()) {
            vlcPlayer.resumeRender()
        }
        isPaused = false
        updateButtonStates(isPlaying, isPaused)
    }

    private fun updateButtonStates(isPlaying: Boolean, isPaused: Boolean) {
        mainHandler.post {
            binding.btnPlay.isEnabled = !isPlaying
            binding.btnStop.isEnabled = isPlaying
            binding.btnPause.isEnabled = isPlaying && !isPaused
            binding.btnResume.isEnabled = isPlaying && isPaused
        }
    }

    override fun onStart() {
        super.onStart()
        Log.d("VlcActivity", "onStart: Resume rendering")
        if (vlcPlayer.isInitialized()) {
            vlcPlayer.resumeRender()
        }
    }


    override fun onStop() {
        super.onStop()
        Log.d(TAG, "onPause")
        if (vlcPlayer.isInitialized()) {
            vlcPlayer.pauseRender()
        }
    }

/*    override fun onResume() {
        super.onResume()
        Log.d(TAG, "onResume")
        if (isPaused) {
            resumeH265Video()
        }
    }*/

    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, "onDestroy")
        vlcPlayer.detachSurface()
        vlcPlayer.release()
        mainHandler.removeCallbacksAndMessages(null)
    }

    override fun onPrepared() {
        LogUtils.d(TAG,"Preparation complete, playing...")
    }

    override fun onError(message: String) {
        LogUtils.d(TAG,"Error: $message")
        mainHandler.post {
            isPlaying = false
            isPaused = false
            updateButtonStates(isPlaying, isPaused)
        }
    }

    override fun onStopped() {
        LogUtils.d(TAG,"Playback stopped")
        mainHandler.post {
            isPlaying = false
            isPaused = false
            updateButtonStates(isPlaying, isPaused)
        }
    }

    override fun onProgress(currentPosition: Long, totalDuration: Long) {
    }
}

10. Implementation Effect:

image.png


11. Comparison of Two Approaches:

The device pushes a complete H.264/H.265 video stream, and the App side has two processing strategies:

Approach One: Frame-by-Frame Playback

Dimension Processing Method
Data Parsing Manually parse NAL unit boundaries (0x00 00 00 01 or 0x00 00 01), split I/P/B frames by nal_unit_type, maintain decoding order (DTS) and display order (PTS)
Buffering Strategy Very small buffer queue (1-3 frames), "decode upon receipt, render upon decoding"
Clock Control Need to maintain frame rate clock (20fps → render one frame every 50ms), precisely controlled with Choreographer or custom timer
Latency Very low (50-100ms)
Jitter Resistance Poor, network fluctuations directly manifest as picture stuttering/frame skipping
CPU Usage High, requires frequent NALU splitting, decoding thread scheduling
Applicable Scenarios Remote real-time control, low-latency intercom

Key Difficulty: H.265 NALUs are usually larger than H.264, making frame-by-frame parsing more expensive; must also handle out-of-order, packet loss, and frame reassembly after retransmission.

Approach Two: Stream-based Playback (Current VLC Approach)

Dimension Processing Method
Data Parsing VLC internal Demuxer handles automatically, automatically identifies AnnexB / AVCC format, no manual frame splitting needed
Buffering Strategy Depends on network-caching + live-caching (current code set to 800ms), internally maintains ES stream buffer queue
Clock Control VLC internally uses PCR/PTS for audio/video synchronization, automatically handles frame rate adaptation, frame dropping/repeating
Latency Medium (300ms ~ 2s, depending on cache size)
Jitter Resistance Strong, buffer queue can absorb network fluctuations
CPU Usage Controllable, VLC internally optimizes the decoding pipeline
Applicable Scenarios Surveillance live streaming, IoT video return (current code scenario)

Frame-by-frame playback: Manual frame splitting → Small buffer → Precise clock → Low latency but prone to stuttering Stream-based playback: VLC automatic processing → Large buffer → Internal synchronization → Slightly higher latency but more stable

image.png

12. Root Cause Analysis of Slow Start, Stuttering, and Black Screen

Slow Start (3-5 seconds or longer)

Root Cause Description
Excessive Cache Current --network-caching=800 / --live-caching=800, VLC accumulates 800ms of data before starting playback
Waiting for Keyframe H.265 encoding is in GOP units, the player must wait for an IDR frame to start decoding and rendering
Protocol Handshake RTSP requires DESCRIBE → SETUP → PLAY, HTTP requires TLS handshake + request/response
Slow Software Decoding Initialization --avcodec-hw=none forces software decoding, H.265 decoder initialization time is significantly longer than H.264
Surface Delayed Binding In setSurface(), when width/height is 0, it uses post { } delayed binding, increasing start time

Stuttering / Black Screen

Root Cause Description
Cache Too Small If cache is set too small, buffer depletes during network jitter, directly causing stream interruption and stuttering
Insufficient Software Decoding Performance H.265 software decoding is computationally heavy, low-end devices cannot decode fast enough for the bitrate, causing frame drops
Delayed Frame Accumulation --drop-late-frames is not currently configured, expired frames accumulate causing increasing delay
Hardware Decoding Compatibility Some devices have black/flashing screen issues with H.265 hardware decoding (--avcodec-hw=none in the code is to avoid this)
No Event Listener setEventListener is commented out in the code, unable to perceive error states like EncounteredError

13. Summary:

13.1 Expected Optimization Effects:

Metric Before Optimization After Optimization (Strong Network) After Optimization (Weak Network)
Start Time 3-5s 0.5-1s 1-2s
End-to-End Latency 1.5-3s 200-400ms 800ms-1.5s
Stutter Rate Medium Low (requires stable network) Low (buffer protection)
Black Screen Rate High (no error awareness) Low (event listener + auto degradation) Low (forced software decoding)

13.2 Conclusion:

I am currently using H.265 format decoding. Initially using H.264, I found the effect was not very good, and after confirming with the device side that the protocol used is also H.265, I switched to H.265. The frame rate set is adjusted according to the protocol transmitted by the device. The network buffer time and live stream specific buffer time can both be dynamically adjusted. Of course, there are other players and solutions, and parameters are adjusted as needed. The above implementation is a summary of my own project. Everyone is welcome to actively discuss. In the AI era, cherish technology and take it step by step.