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

前言

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

什么是DLNA

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

Cling又是什么

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

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

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

使用场景

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

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

配置权限

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

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"/>

创建服务

kt 复制代码
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" />

服务连接管理器

kt 复制代码
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
    }
}

启动服务

kt 复制代码
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()方法进行设备的搜索。

kt 复制代码
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

kt 复制代码
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 指令。

javascript 复制代码
deviceControl.setAVTransportURI(videoUrl, "测试视频") {
    deviceControl.play("1")
}

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

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

暂停播放

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

kt 复制代码
playPauseButton.setOnClickListener {
    deviceControl.pause()
}

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

恢复播放

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

kt 复制代码
deviceControl.play("1")

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

css 复制代码
播放
 ↓
Pause
 ↓
暂停
 ↓
Play
 ↓
继续播放

停止播放

停止播放对应 Stop 指令。

kt 复制代码
deviceControl.stop()

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

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

kt 复制代码
override fun onDestroy() {
    deviceControl.stop()
    super.onDestroy()
}

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

快进与拖动进度

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

例如拖动到第5分钟:

kt 复制代码
deviceControl.seek(5 * 60 * 1000L)

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

makefile 复制代码
00:05:00

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

kt 复制代码
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
  • 网络直播

这些资源通常无法快进。

获取播放进度

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

可以定时调用:

kt 复制代码
deviceControl.getPositionInfo { position ->
    val current = position.relTimeSeconds
    val duration = position.trackDurationSeconds

    progressBar.max = duration
    progressBar.progress = current
}

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

kt 复制代码
handler.postDelayed(object : Runnable {
    override fun run() {
        deviceControl.getPositionInfo(...)
        handler.postDelayed(this, 1000)
    }
}, 1000)

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

获取播放信息

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

kt 复制代码
deviceControl.getMediaInfo { info ->
    Log.d("Cast", "URI=${info.currentURI}")
}

通常用于:

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

获取播放状态

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

kt 复制代码
deviceControl.getTransportInfo {
    Log.d("Cast", it.currentTransportState.name)
}

常见状态包括:

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

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

调节音量

音量控制属于 RenderingControl 服务。

kt 复制代码
deviceControl.setVolume(30)

获取当前音量:

kt 复制代码
deviceControl.getVolume {
    volumeSeekBar.progress = it
}

DLNA标准音量范围一般是:

复制代码
0 ~ 100

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


静音控制

静音同样属于RenderingControl服务。

kt 复制代码
deviceControl.setMute(true)

取消静音:

kt 复制代码
deviceControl.setMute(false)

查询静音状态:

kt 复制代码
deviceControl.getMute {
    Log.d("Cast", "mute=$it")
}

播放状态监听

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

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

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

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

        else -> {}
    }
}

音量变化同样可以监听:

kt 复制代码
override fun onRendererVolumeChanged(volume: Int) {
    volumeSeekBar.progress = volume
}

静音状态:

kotlin 复制代码
override fun onRendererVolumeMuteChanged(mute: Boolean) {
    muteButton.isSelected = mute
}

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

总结

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

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

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

相关推荐
apihz2 小时前
全球域名 WHOIS 信息实时查询免费 API 接口教程,支持1000+后缀
android·网络·网络协议·tcp/ip·apache·域名·whois
非典型Android程序员4 小时前
Binder机制学习总结
android
AI2中文网4 小时前
App Inventor 2 音频播放器 vs 音效 vs 视频播放器:三个媒体组件,选错了App直接卡死
android·音视频·媒体
李剑一4 小时前
uni-app x 实现本地 JSON 文件的导入导出(APP端可用)
android·前端·uni-app
喜欢打篮球的普通人5 小时前
LLVM后端指令选择
android·java·javascript
hunterandroid5 小时前
Room 数据库迁移:让本地数据升级更稳
android·前端
阿巴斯甜5 小时前
Compose NavHost的使用:
android
雁鸣零落5 小时前
浏览器扩展 CaptionGo,在网页视频上显示双语字幕,支持 PC 和手机端使用
android·chrome·edge·firefox·安卓
2501_915106325 小时前
iOS 软件测试工具性能监控、日志分析 KeyMob、Instruments等
android·ios·小程序·https·uni-app·iphone·webview