使用 Cling 实现 DLNA 投屏:从局域网发现到媒体播放控制

1 阅读4分钟

前言

在移动端视频应用中,“投屏到电视”已经成为用户非常熟悉的能力:打开手机上的视频,搜索同一局域网的电视盒子,点击设备即可在大屏播放。这个过程看起来简单,但背后实际上涉及设备发现、协议协商、媒体资源可访问性、播放状态同步以及连接异常处理等多个环节。

什么是DLNA

DLNA(Digital Living Network Alliance)是局域网投屏场景中常见的一套协议体系。它基于UPnP协议构建,能够在手机、电视、电视盒子、音响等设备在同一网络中自动发现彼此,并完成媒体内容的浏览、推送与播放控制。

Cling又是什么

在Android开发中,Cling是一个较为成熟的UPnP/DLNA实现库。借助Cling,我们不需要从零实现SSDP设备搜索、SOAP控制请求或设备描述解析,而是可以更专注于业务层能力,例如:

  • 搜索局域网内支持投屏的电视盒子与播放器;
  • 建立与MediaRenderer设备的连接;
  • 将在线视频地址或本地媒体地址推送到电视端播放;
  • 控制播放、暂停、停止、静音、音量与进度;
  • 监听设备连接状态和播放状态;
  • 处理设备断开、网络切换、转码失败等异常场景。

不过,真正接入时,最容易踩坑的往往不是“如何搜索设备”,而是“为什么电视无法播放这个URL。例如,手机本地文件通常不能直接被电视访问;部分在线视频地址需要鉴权;某些电视仅支持特定编码格式;还有一些设备虽然能被发现,却并不支持AVTransport播放控制服务。

使用场景

本文将以Android项目为例,系统介绍如何基于Cling实现一套可落地的DLNA投屏能力。从初始化UPnP服务、发现设备、筛选可播放设备,到推送媒体地址、控制播放状态,以及处理常见兼容性问题,逐步搭建完整的投屏链路。

Screenshot_20260622_110348_com.studyplan.english.jpg

通常在播放器界面中接入投屏功能。

配置权限

首先需要在AndroidManifest.xml清单文件中配置权限。

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
<!-- Android13适配 -->
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"/>

创建服务

package com.android.cast.dlna.dmc

import android.content.Intent
import com.android.cast.dlna.core.Logger
import org.fourthline.cling.UpnpServiceConfiguration
import org.fourthline.cling.android.AndroidUpnpServiceConfiguration
import org.fourthline.cling.android.AndroidUpnpServiceImpl
import org.fourthline.cling.model.types.ServiceType

class DLNACastService : AndroidUpnpServiceImpl() {
    private val logger = Logger.create("CastService")
    override fun onCreate() {
        logger.i("DLNACastService onCreate")
//        LoggingUtil.resetRootHandler(FixedAndroidLogHandler())
        super.onCreate()
    }

    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
        logger.i("DLNACastService onStartCommand: $flags, $startId, $intent")
        return super.onStartCommand(intent, flags, startId)
    }

    override fun onDestroy() {
        logger.w("DLNACastService onDestroy")
        super.onDestroy()
    }

    override fun createConfiguration(): UpnpServiceConfiguration = object : AndroidUpnpServiceConfiguration() {
        override fun getExclusiveServiceTypes(): Array<ServiceType> = arrayOf(
            DLNACastManager.SERVICE_TYPE_AV_TRANSPORT,
            DLNACastManager.SERVICE_TYPE_RENDERING_CONTROL,
            DLNACastManager.SERVICE_TYPE_CONTENT_DIRECTORY,
            DLNACastManager.SERVICE_CONNECTION_MANAGER
        )
    }
}

并在清单文件指定<service android:name="com.android.cast.dlna.dmc.DLNACastService" />

服务连接管理器

package com.android.cast.dlna.dmc

import android.app.Activity
import android.app.Application
import android.app.Service
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import com.android.cast.dlna.core.Logger
import com.android.cast.dlna.core.http.LocalServer
import com.android.cast.dlna.dmc.control.CastControlImpl
import com.android.cast.dlna.dmc.control.DeviceControl
import com.android.cast.dlna.dmc.control.EmptyDeviceControl
import com.android.cast.dlna.dmc.control.OnDeviceControlListener
import org.fourthline.cling.android.AndroidUpnpService
import org.fourthline.cling.model.message.header.STAllHeader
import org.fourthline.cling.model.message.header.UDADeviceTypeHeader
import org.fourthline.cling.model.meta.Device
import org.fourthline.cling.model.types.DeviceType
import org.fourthline.cling.model.types.ServiceType
import org.fourthline.cling.model.types.UDADeviceType
import org.fourthline.cling.model.types.UDAServiceType

object DLNACastManager : OnDeviceRegistryListener {

    val DEVICE_TYPE_MEDIA_RENDERER = UDADeviceType("MediaRenderer")
    val DEVICE_TYPE_MEDIA_SERVER = UDADeviceType("MediaServer")

    val SERVICE_TYPE_AV_TRANSPORT: ServiceType = UDAServiceType("AVTransport")
    val SERVICE_TYPE_RENDERING_CONTROL: ServiceType = UDAServiceType("RenderingControl")
    val SERVICE_TYPE_CONTENT_DIRECTORY: ServiceType = UDAServiceType("ContentDirectory")
    val SERVICE_CONNECTION_MANAGER: ServiceType = UDAServiceType("ConnectionManager")

    private val logger = Logger.create("CastManager")
    private val deviceRegistryImpl = DeviceRegistryImpl(this)
    private var searchDeviceType: DeviceType? = null
    private var upnpService: AndroidUpnpService? = null
    private var applicationContext: Context? = null

    fun bindCastService(context: Context) {
        applicationContext = context.applicationContext
        if (context is Application || context is Activity) {
            context.bindService(Intent(context, DLNACastService::class.java), serviceConnection, Service.BIND_AUTO_CREATE)
        } else {
            logger.e("bindCastService only support Application or Activity implementation.")
        }
    }

    fun unbindCastService(context: Context) {
        if (context is Application || context is Activity) {
            context.unbindService(serviceConnection)
        } else {
            logger.e("bindCastService only support Application or Activity implementation.")
        }
    }

    private val serviceConnection: ServiceConnection = object : ServiceConnection {
        override fun onServiceConnected(componentName: ComponentName, iBinder: IBinder) {
            val upnpServiceBinder = iBinder as AndroidUpnpService
            if (upnpService !== upnpServiceBinder) {
                upnpService = upnpServiceBinder
                logger.i(String.format("onServiceConnected: [%s]", componentName.shortClassName))
                val registry = upnpServiceBinder.registry
                // add registry listener
                val collection = registry.listeners
                if (collection == null || !collection.contains(deviceRegistryImpl)) {
                    registry.addListener(deviceRegistryImpl)
                }
                search()
            }
        }

        override fun onServiceDisconnected(componentName: ComponentName) {
            logger.w(String.format("[%s] onServiceDisconnected", componentName.shortClassName))
            removeRegistryListener()
        }

        override fun onBindingDied(componentName: ComponentName) {
            logger.w(String.format("[%s] onBindingDied", componentName.shortClassName))
            removeRegistryListener()
        }

        private fun removeRegistryListener() {
            upnpService?.registry?.removeListener(deviceRegistryImpl)
            upnpService = null
        }
    }

    // -----------------------------------------------------------------------------------------
    // ---- register or unregister device listener
    // -----------------------------------------------------------------------------------------
    private val registerDeviceListeners: MutableList<OnDeviceRegistryListener> = ArrayList()

    fun registerDeviceListener(listener: OnDeviceRegistryListener?) {
        if (listener == null) return
        upnpService?.also { service ->
            service.registry.devices?.forEach { device ->
                // if some devices has been found, notify first.
                listener.onDeviceAdded(device)
            }
        }
        if (!registerDeviceListeners.contains(listener)) registerDeviceListeners.add(listener)
    }

    fun unregisterListener(listener: OnDeviceRegistryListener) {
        registerDeviceListeners.remove(listener)
    }

    override fun onDeviceAdded(device: Device<*, *, *>) {
        if (checkDeviceType(device)) {
            registerDeviceListeners.forEach { listener -> listener.onDeviceAdded(device) }
        }
    }

    override fun onDeviceRemoved(device: Device<*, *, *>) {
        // TODO:if this device is casting, disconnect first!
        // disconnectDevice(device)
        if (checkDeviceType(device)) {
            registerDeviceListeners.forEach { listener -> listener.onDeviceRemoved(device) }
        }
    }

    private fun checkDeviceType(device: Device<*, *, *>): Boolean = searchDeviceType == null || searchDeviceType == device.type

    // -----------------------------------------------------------------------------------------
    // ---- LocalServer
    // -----------------------------------------------------------------------------------------
    var localServer: LocalServer? = null
        private set

    fun startLocalHttpServer(port: Int = 8192, jetty: Boolean = true) {
        if (localServer == null) {
            applicationContext?.run {
                localServer = LocalServer(this, port, jetty)
            }
        }
        localServer?.startServer()
    }

    fun stopLocalHttpServer() {
        localServer?.stopServer()
    }

    // -----------------------------------------------------------------------------------------
    // ---- Action
    // -----------------------------------------------------------------------------------------
    fun search(type: DeviceType? = null) {
        upnpService?.get()?.also { service ->
            searchDeviceType = type
            service.registry.devices?.filter { searchDeviceType == null || searchDeviceType != it.type }?.onEach {
                // notify device removed without type check.
                registerDeviceListeners.forEach { listener -> listener.onDeviceRemoved(it) }
                service.registry.removeDevice(it.identity.udn)
            }
            // when search device, clear all founded first.
            // service.registry.removeAllRemoteDevices()

            // search the special type device
            service.controlPoint.search(type?.let { UDADeviceTypeHeader(it) } ?: STAllHeader())
        }
    }

    private val deviceControlMap = mutableMapOf<Device<*, *, *>, DeviceControl?>()
    
    fun connectDevice(device: Device<*, *, *>, listener: OnDeviceControlListener): DeviceControl {
        val service = upnpService?.get() ?: return EmptyDeviceControl
        var control = deviceControlMap[device]
        if (control == null) {
            val newController = CastControlImpl(service.controlPoint, device, listener)
            deviceControlMap[device] = newController
            control = newController
        }
        return control
    }

    fun disconnectDevice(device: Device<*, *, *>) {
        (deviceControlMap[device] as? CastControlImpl)?.released = true
        deviceControlMap[device] = null
    }
}

启动服务

class App : Application() {

    override fun onCreate() {
        super.onCreate()
        DLNACastManager.bindCastService(this)
        // 如果要投本地视频,启动本地HTTP服务
        DLNACastManager.startLocalHttpServer()
    }

    override fun onTerminate() {
        super.onTerminate()
        DLNACastManager.unbindCastService(this)
    }
}

在Application的onCreate中bindCastService,连接上可以自动搜索。

搜索设备

也可以手动调用DLNACastManagersearch()方法进行设备的搜索。

override fun onResume() {
    super.onResume()
    DLNACastManager.registerDeviceListener(deviceListener)
    DLNACastManager.search(
        DLNACastManager.DEVICE_TYPE_MEDIA_RENDERER
    )
}

override fun onDestroy() {
    super.onDestroy()
    DLNACastManager.unregisterListener(deviceListener)
}

搜索到设备后显示名称使用device.details.friendlyName

连接设备

连接设备得到DeviceControl

package com.android.cast.dlna.dmc.control

import org.fourthline.cling.model.meta.Device
import org.fourthline.cling.support.avtransport.lastchange.AVTransportVariable.TransportState
import org.fourthline.cling.support.lastchange.EventedValue
import org.fourthline.cling.support.model.BrowseFlag
import org.fourthline.cling.support.model.DIDLContent
import org.fourthline.cling.support.model.MediaInfo
import org.fourthline.cling.support.model.PositionInfo
import org.fourthline.cling.support.model.TransportInfo
import org.fourthline.cling.support.renderingcontrol.lastchange.EventedValueChannelMute
import org.fourthline.cling.support.renderingcontrol.lastchange.EventedValueChannelVolume

interface DeviceControl : AvTransportServiceAction, RendererServiceAction, ContentServiceAction

object EmptyDeviceControl : DeviceControl {
    override fun setAVTransportURI(uri: String, title: String, callback: ServiceActionCallback<Unit>?) {}
    override fun setNextAVTransportURI(uri: String, title: String, callback: ServiceActionCallback<Unit>?) {}
    override fun play(speed: String, callback: ServiceActionCallback<Unit>?) {}
    override fun pause(callback: ServiceActionCallback<Unit>?) {}
    override fun stop(callback: ServiceActionCallback<Unit>?) {}
    override fun seek(millSeconds: Long, callback: ServiceActionCallback<Unit>?) {}
    override fun next(callback: ServiceActionCallback<Unit>?) {}
    override fun previous(callback: ServiceActionCallback<Unit>?) {}
    override fun getPositionInfo(callback: ServiceActionCallback<PositionInfo>?) {}
    override fun getMediaInfo(callback: ServiceActionCallback<MediaInfo>?) {}
    override fun getTransportInfo(callback: ServiceActionCallback<TransportInfo>?) {}
    override fun setVolume(volume: Int, callback: ServiceActionCallback<Unit>?) {}
    override fun getVolume(callback: ServiceActionCallback<Int>?) {}
    override fun setMute(mute: Boolean, callback: ServiceActionCallback<Unit>?) {}
    override fun getMute(callback: ServiceActionCallback<Boolean>?) {}
    override fun browse(objectId: String, flag: BrowseFlag, filter: String, firstResult: Int, maxResults: Int, callback: ServiceActionCallback<DIDLContent>?) {}
    override fun search(containerId: String, searchCriteria: String, filter: String, firstResult: Int, maxResults: Int, callback: ServiceActionCallback<DIDLContent>?) {}
}

interface OnDeviceControlListener {
    fun onConnected(device: Device<*, *, *>) {}
    fun onDisconnected(device: Device<*, *, *>) {}
    fun onEventChanged(event: EventedValue<*>) {
        when (event) {
            is TransportState -> onAvTransportStateChanged(event.value)
            is EventedValueChannelVolume -> onRendererVolumeChanged(event.value.volume)
            is EventedValueChannelMute -> onRendererVolumeMuteChanged(event.value.mute)
        }
    }

    fun onAvTransportStateChanged(state: org.fourthline.cling.support.model.TransportState) {}
    fun onRendererVolumeChanged(volume: Int) {}
    fun onRendererVolumeMuteChanged(mute: Boolean) {}
}

internal interface SubscriptionListener {
    fun failed(subscriptionId: String?) {}
    fun established(subscriptionId: String?) {}
    fun ended(subscriptionId: String?) {}
    fun onReceived(subscriptionId: String?, event: EventedValue<*>) {}
}

投屏

调用setAVTransportURI进行投屏。可以投屏远端URL,也可以启动本地服务器创建localserver投屏本地URL。

遥控器控制

完成媒体推送之后,DLNA并不会自动开始播放,后续的播放、暂停、停止、快进、音量等操作都需要通过设备提供的 AVTransportRenderingControl 服务完成。因此,一个完整的投屏功能通常都会实现一套遥控器能力,与电视遥控器的操作保持一致。

Cling已经对这些标准服务进行了封装,我们只需要调用对应接口即可完成控制。

开始播放

在调用 setAVTransportURI() 设置播放地址之后,大多数设备还需要主动发送一次 Play 指令。

deviceControl.setAVTransportURI(videoUrl, "测试视频") {
    deviceControl.play("1")
}

其中 "1" 表示正常播放速度,几乎所有DLNA设备都支持。

播放器界面通常会在收到 onSuccess 回调后自动切换为投屏状态。

暂停播放

暂停播放对应DLNA标准中的 Pause 指令。

playPauseButton.setOnClickListener {
    deviceControl.pause()
}

需要注意的是,并不是所有设备都支持暂停。例如某些电视直播播放器只能播放和停止,因此调用失败属于正常情况,应做好异常处理。

恢复播放

恢复播放依然使用 Play 接口。

deviceControl.play("1")

因此播放器一般只需要维护一个播放状态即可:

播放
 ↓
Pause
 ↓
暂停
 ↓
Play
 ↓
继续播放

停止播放

停止播放对应 Stop 指令。

deviceControl.stop()

停止之后,大部分设备会释放播放器资源。

如果用户退出投屏页面,也建议主动调用一次:

override fun onDestroy() {
    deviceControl.stop()
    super.onDestroy()
}

这样能够避免电视端仍然停留在播放界面。

快进与拖动进度

DLNA通过 Seek 指令控制播放位置。

例如拖动到第5分钟:

deviceControl.seek(5 * 60 * 1000L)

这里使用毫秒作为单位,内部会转换为DLNA要求的 "HH:mm:ss" 格式,例如:

00:05:00

播放器的进度条可以直接绑定:

seekBar.setOnSeekBarChangeListener(object :
    SeekBar.OnSeekBarChangeListener {

    override fun onStopTrackingTouch(seekBar: SeekBar) {
        deviceControl.seek(seekBar.progress.toLong())
    }

    override fun onProgressChanged(
        seekBar: SeekBar,
        progress: Int,
        fromUser: Boolean
    ) {
    }

    override fun onStartTrackingTouch(seekBar: SeekBar) {
    }
})

不过需要注意,并不是所有视频都允许Seek,例如:

  • HLS直播流(m3u8 Live)
  • IPTV
  • 网络直播

这些资源通常无法快进。

获取播放进度

播放器需要不断同步电视端的播放位置。

可以定时调用:

deviceControl.getPositionInfo { position ->
    val current = position.relTimeSeconds
    val duration = position.trackDurationSeconds

    progressBar.max = duration
    progressBar.progress = current
}

一般每隔1秒查询一次即可。

handler.postDelayed(object : Runnable {
    override fun run() {
        deviceControl.getPositionInfo(...)
        handler.postDelayed(this, 1000)
    }
}, 1000)

如果设备支持AVTransport事件订阅,则还可以通过事件通知更新状态,比轮询更加实时。

获取播放信息

除了播放进度,还可以查询当前媒体信息。

deviceControl.getMediaInfo { info ->
    Log.d("Cast", "URI=${info.currentURI}")
}

通常用于:

  • 判断当前是否仍然在播放指定视频;
  • 投屏恢复;
  • 多播放器同步。

获取播放状态

播放器状态可以通过 TransportInfo 获取。

deviceControl.getTransportInfo {
    Log.d("Cast", it.currentTransportState.name)
}

常见状态包括:

状态说明
STOPPED已停止
PLAYING正在播放
PAUSED_PLAYBACK已暂停
TRANSITIONING正在缓冲
NO_MEDIA_PRESENT没有媒体

播放器UI通常就是根据这些状态切换按钮图标。

调节音量

音量控制属于 RenderingControl 服务。

deviceControl.setVolume(30)

获取当前音量:

deviceControl.getVolume {
    volumeSeekBar.progress = it
}

DLNA标准音量范围一般是:

0 ~ 100

不同品牌电视可能存在映射差异,例如电视实际只有20级音量,但会自动转换到100级。


静音控制

静音同样属于RenderingControl服务。

deviceControl.setMute(true)

取消静音:

deviceControl.setMute(false)

查询静音状态:

deviceControl.getMute {
    Log.d("Cast", "mute=$it")
}

播放状态监听

相比轮询,更推荐监听设备事件。

override fun onAvTransportStateChanged(
    state: TransportState
) {
    when (state) {
        TransportState.PLAYING -> {
            // 更新播放按钮
        }

        TransportState.PAUSED_PLAYBACK -> {
            // 更新暂停按钮
        }

        TransportState.STOPPED -> {
            // 投屏结束
        }

        else -> {}
    }
}

音量变化同样可以监听:

override fun onRendererVolumeChanged(volume: Int) {
    volumeSeekBar.progress = volume
}

静音状态:

override fun onRendererVolumeMuteChanged(mute: Boolean) {
    muteButton.isSelected = mute
}

由于这些事件来自电视端,因此即使用户使用电视遥控器调整了音量或暂停播放,手机界面也能够及时同步状态。

总结

遥控器控制实际上对应的是DLNA协议中的两大核心服务:AVTransport 负责媒体播放控制,包括播放、暂停、停止、快进、获取播放状态等;RenderingControl 则负责渲染相关控制,例如音量调节、静音以及其他播放参数。通过Cling对这两个服务的封装,我们无需直接处理底层SOAP请求,就能够快速实现一套完整的投屏遥控能力。

实际项目中,播放器通常会在推送媒体成功后进入投屏模式,并持续监听设备状态变化,使手机端与电视端保持同步。配合播放控制、进度同步和音量管理,基本可以实现与主流视频应用一致的投屏体验。

最后如果你对投屏功能感兴趣的话,可以下载 www.pgyer.com/englishstud… 体验投屏功能。