鸿蒙 DLNA 投屏
为什么不用 AVCastController
鸿蒙系统提供了 AVCastController API 作为官方投屏方案,但在实际使用中存在以下限制:
-
生命周期受限:
AVCastController由系统获取并返回,在设备连接成功后获取,在设备断开后不能继续使用,否则会抛出异常。这意味着每次断连后必须重新走完整的设备发现和连接流程,无法复用控制器实例。 -
DRM 绑定过深:
AVCastController支持在线 DRM 视频资源投播能力,需注册 DRM 许可证请求回调函数,获取许可证后调用处理许可证响应函数。这套机制与系统播放器深度耦合,对于自定义播放器或纯本地/局域网投屏场景来说,引入的复杂度远大于收益。 -
灵活性不足:系统级 API 对投屏行为的控制粒度有限,无法自定义 SOAP 交互细节(如手动构造
SetAVTransportURI、Seek等),也难以在投屏过程中灵活切换媒体源或实现自定义的进度轮询策略。
因此,本项目选择基于 SSDP + SOAP 的自定义 DLNA 实现,直接走 UPnP 协议栈,完全掌控设备发现、媒体推送和播放控制的每个环节。
整体架构
┌──────────────────────────────────────────────────┐
│ CCDlnaManager │
│ │
│ ┌─────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ SSDP 发现 │ │ SOAP 控制 │ │ 进度轮询 │ │
│ │ M-SEARCH │→│ SetAVTrans │→│ GetPosition │ │
│ │ UDP 239.x │ │ Play/Pause │ │ 2s 间隔 │ │
│ │ 8s 超时 │ │ Stop/Seek │ │ │ │
│ └─────────────┘ └────────────┘ └────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ CCDlnaDevice 设备模型 │ │
│ │ deviceId / friendlyName / controlURL / ... │ │
│ └─────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
核心流程
1. 设备发现(SSDP)
使用 UDP 组播向 239.255.255.250:1900 发送 M-SEARCH 请求,搜索 AVTransport 服务类型的设备:
let msearch =
'M-SEARCH * HTTP/1.1\r\n' +
'HOST: 239.255.255.250:1900\r\n' +
'MAN: "ssdp:discover"\r\n' +
'MX: 3\r\n' +
'ST: urn:schemas-upnp-org:service:AVTransport:1\r\n' +
'\r\n'
关键步骤:
- 通过
socket.constructUDPSocketInstance()创建 UDP 实例,绑定到0.0.0.0随机端口 - 从绑定的 socket 获取本机局域网 IP
- 发送两次 M-SEARCH 提高发现率
- 8 秒超时后停止发现
- 收到 SSDP 响应后,解析
LOCATION头,去重后请求设备描述 XML
2. 设备描述解析
拿到 LOCATION URL 后,通过 HTTP GET 获取设备描述 XML,解析以下关键信息:
| 字段 | 说明 |
|---|---|
friendlyName | 设备显示名称 |
manufacturer | 制造商 |
modelName | 型号名 |
UDN | 设备唯一标识 |
controlURL | AVTransport 服务的 SOAP 控制地址 |
iconURL | 设备图标(取 iconList 中第一个) |
注意 controlURL 可能是相对路径,需要结合 LOCATION 的 host 拼接为完整 URL。
3. 投屏控制(SOAP)
所有播放控制通过 SOAP 请求发送到设备的 controlURL,统一封装在 sendSOAP 方法中:
// SOAP 信封结构
'<?xml version="1.0" encoding="utf-8"?>' +
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ' +
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
'<s:Body>' + body + '</s:Body>' +
'</s:Envelope>'
请求头:
Content-Type: text/xml; charset="utf-8"
SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#<Action>"
支持的控制动作:
| 动作 | 用途 | 调用时机 |
|---|---|---|
SetAVTransportURI | 设置投屏媒体源 | 投屏开始时,携带 URL + DIDL-Lite 元数据 |
Play | 开始播放 | SetAVTransportURI 成功后 500ms 触发 |
Pause | 暂停播放 | 用户点击暂停 |
Stop | 停止播放 | 用户停止投屏 |
Seek | 跳转进度 | 用户拖动进度条,格式 HH:MM:SS |
GetPositionInfo | 获取播放进度 | 每 2 秒轮询 |
4. DIDL-Lite 元数据
SetAVTransportURI 需要携带 CurrentURIMetaData,格式为 HTML 实体编码的 DIDL-Lite XML:
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
<item id="0" parentID="-1" restricted="1">
<upnp:class>object.item.videoItem</upnp:class>
<dc:title>视频标题</dc:title>
<res protocolInfo="http-get:*:video/mp4:*">http://...</res>
</item>
</DIDL-Lite>
根据 mediaType 区分视频/音频:
- 视频:
upnp:class=object.item.videoItem,protocolInfo=http-get:*:video/mp4:* - 音频:
upnp:class=object.item.audioItem.musicTrack,protocolInfo=http-get:*:audio/mpeg:*
5. 进度轮询
投屏开始后启动 2 秒间隔的定时器,通过 GetPositionInfo SOAP 动作获取:
RelTime:当前播放位置(HH:MM:SS格式)TrackDuration:总时长AbsTime:绝对时间(通常不使用)
获取到进度后通过 AppStorage 更新 UI:
AppStorage.setOrCreate('uiAppStorageDurationTime', duration * 1000)
AppStorage.setOrCreate('uiAppStorageProgress', (seconds / duration) * 100)
AppStorage.setOrCreate('uiAppStorageRightTime', this.formatTime(duration))
AppStorage.setOrCreate('uiAppStorageLeftTime', this.formatTime(seconds))
如果设备不返回 TrackDuration(返回 NOT_IMPLEMENTED),则使用 fallbackDurationSeconds 作为兜底时长。
状态管理
devices: CCDlnaDevice[] // 已发现设备列表
isCasting: boolean // 是否正在投屏
currentDevice: CCDlnaDevice // 当前投屏设备
isPaused: boolean // 是否暂停
fallbackDurationSeconds: number // 兜底时长(秒)
localIP: string // 本机局域网 IP
状态变更通过 emitter 事件通知 UI 层:
eventIdDlnaDeviceFound:发现新设备eventIdDlnaCastState:投屏状态变更(isCasting / deviceName)
单例模式
CCDlnaManager 采用单例模式,全局唯一实例:
CCDlnaManager.shared // 获取单例
调用 destroy() 可销毁单例并释放所有资源(停止投屏、停止发现、清空引用)。
完整代码
import { http, socket } from '@kit.NetworkKit'
import { emitter } from '@kit.BasicServicesKit'
// 事件 ID 常量
const eventIdDlnaDeviceFound = 'eventIdDlnaDeviceFound'
const eventIdDlnaCastState = 'eventIdDlnaCastState'
const TAG = 'CCDlnaManager'
// ==================== 数据模型 ====================
export class CCDlnaDevice {
deviceId: string = ''
friendlyName: string = ''
manufacturer: string = ''
modelName: string = ''
location: string = ''
serviceHost: string = ''
controlURL: string = ''
iconURL: string = ''
get displayName(): string {
return this.friendlyName || this.modelName || '未知设备'
}
}
export interface DlnaPositionInfo {
relTime: string
absTime: string
trackDuration: string
}
// ==================== 核心管理器 ====================
export class CCDlnaManager {
private static _shared: CCDlnaManager | null = null
static get shared(): CCDlnaManager {
if (!CCDlnaManager._shared) {
CCDlnaManager._shared = new CCDlnaManager()
}
return CCDlnaManager._shared
}
devices: CCDlnaDevice[] = []
isCasting: boolean = false
currentDevice: CCDlnaDevice | null = null
isPaused: boolean = false
fallbackDurationSeconds: number = 0
localIP: string = ''
private discoveryTimer: number = 0
private positionTimer: number = 0
private knownDeviceIds: string[] = []
private udpSocket: socket.UDPSocket | null = null
private pendingLocations: string[] = []
// ==================== SSDP Discovery ====================
startDiscovery(): void {
this.stopDiscovery()
this.devices = []
this.knownDeviceIds = []
this.pendingLocations = []
console.log(`[${TAG}] Discovery started (UDP SSDP)`)
try {
this.udpSocket = socket.constructUDPSocketInstance()
} catch (e) {
console.error(`[${TAG}] constructUDPSocketInstance failed: ${JSON.stringify(e)}`)
return
}
let bindAddress: socket.NetAddress = {
address: '0.0.0.0',
port: 0,
family: 1,
}
this.udpSocket.bind(bindAddress).then(() => {
if (!this.udpSocket) {
return
}
// 从绑定的 UDP socket 获取本机局域网 IP
this.udpSocket.getLocalAddress().then((addr: socket.NetAddress) => {
if (addr.address && addr.address !== '0.0.0.0') {
this.localIP = addr.address
console.log(`[${TAG}] Local IP captured: ${this.localIP}`)
}
}).catch(() => {})
this.udpSocket.on('message', (value: socket.SocketMessageInfo) => {
this.handleSsdpResponse(value)
})
let msearch =
'M-SEARCH * HTTP/1.1\r\n' +
'HOST: 239.255.255.250:1900\r\n' +
'MAN: "ssdp:discover"\r\n' +
'MX: 3\r\n' +
'ST: urn:schemas-upnp-org:service:AVTransport:1\r\n' +
'\r\n'
let sendOptions: socket.UDPSendOptions = {
address: {
address: '239.255.255.250',
port: 1900,
family: 1,
},
data: msearch
}
this.udpSocket.send(sendOptions).then(() => {
console.log(`[${TAG}] M-SEARCH sent`)
}).catch((err: Error) => {
console.error(`[${TAG}] M-SEARCH send failed: ${err.message}`)
})
this.udpSocket.send(sendOptions).catch((err: Error) => {
console.error(`[${TAG}] M-SEARCH retry failed: ${err.message}`)
})
}).catch((err: Error) => {
console.error(`[${TAG}] UDP bind failed: ${err.message}`)
})
this.discoveryTimer = setTimeout(() => {
this.stopDiscovery()
console.log(`[${TAG}] Discovery timeout, found ${this.devices.length} devices`)
}, 8000)
}
stopDiscovery(): void {
if (this.discoveryTimer > 0) {
clearTimeout(this.discoveryTimer)
this.discoveryTimer = 0
}
if (this.udpSocket) {
try {
this.udpSocket.off('message')
this.udpSocket.close()
} catch (e) {
// ignore
}
this.udpSocket = null
}
this.pendingLocations = []
}
private handleSsdpResponse(value: socket.SocketMessageInfo): void {
try {
let text = ''
if (value.message instanceof ArrayBuffer) {
let view = new Uint8Array(value.message)
let chars: string[] = []
for (let i = 0; i < view.length; i++) {
chars.push(String.fromCharCode(view[i]))
}
text = chars.join('')
} else {
text = String(value.message)
}
let location = this.parseHeader(text, 'LOCATION')
if (!location) {
return
}
if (this.pendingLocations.indexOf(location) >= 0) {
return
}
this.pendingLocations.push(location)
console.log(`[${TAG}] SSDP response LOCATION: ${location}`)
this.fetchDeviceDescription(location)
} catch (e) {
console.error(`[${TAG}] handleSsdpResponse error: ${JSON.stringify(e)}`)
}
}
private parseHeader(text: string, name: string): string {
let lower = name.toLowerCase()
let lines = text.split('\n')
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim()
let colonIdx = line.indexOf(':')
if (colonIdx < 0) {
continue
}
let key = line.substring(0, colonIdx).trim().toLowerCase()
if (key === lower) {
return line.substring(colonIdx + 1).trim()
}
}
return ''
}
// ==================== Device Description Fetch ====================
private fetchDeviceDescription(location: string): void {
let httpRequest = http.createHttp()
httpRequest.request(location, {
method: http.RequestMethod.GET,
connectTimeout: 5000,
readTimeout: 8000
}, (err: Error, data: http.HttpResponse) => {
httpRequest.destroy()
if (err) {
console.error(`[${TAG}] Fetch description failed: ${JSON.stringify(err)}`)
return
}
if (data.responseCode !== 200) {
console.error(`[${TAG}] Description status: ${data.responseCode}`)
return
}
let xml = data.result as string
this.parseAndAddDevice(location, xml)
})
}
private parseAndAddDevice(location: string, xml: string): void {
let friendlyName = this.parseXmlTag(xml, 'friendlyName')
let manufacturer = this.parseXmlTag(xml, 'manufacturer')
let modelName = this.parseXmlTag(xml, 'modelName')
let udn = this.parseXmlTag(xml, 'UDN') || location
let controlURL = this.parseAVTransportControlURL(xml)
let iconURL = this.parseFirstIconURL(xml)
if (!controlURL) {
console.warn(`[${TAG}] No AVTransport controlURL for ${friendlyName || location}`)
return
}
let serviceHost = this.parseServiceHostFromLocation(location)
let fullControlURL = controlURL
if (controlURL.startsWith('/')) {
fullControlURL = serviceHost + controlURL
} else if (controlURL.indexOf('http') < 0) {
fullControlURL = serviceHost + '/' + controlURL
}
let device = new CCDlnaDevice()
device.deviceId = udn
device.friendlyName = friendlyName
device.manufacturer = manufacturer
device.modelName = modelName
device.location = location
device.serviceHost = serviceHost
device.controlURL = fullControlURL
device.iconURL = iconURL
? (iconURL.startsWith('http') ? iconURL : serviceHost + (iconURL.startsWith('/') ? iconURL : '/' + iconURL))
: ''
if (this.knownDeviceIds.indexOf(device.deviceId) < 0) {
this.knownDeviceIds.push(device.deviceId)
this.devices.push(device)
console.log(`[${TAG}] Added device: ${device.displayName} (${device.serviceHost})`)
emitter.emit(eventIdDlnaDeviceFound)
}
}
private parseServiceHostFromLocation(location: string): string {
try {
let noScheme = location.substring(location.indexOf('//') + 2)
let slashIdx = noScheme.indexOf('/')
let hostPort = slashIdx > 0 ? noScheme.substring(0, slashIdx) : noScheme
return 'http://' + hostPort
} catch (e) {
// ignore
}
return location
}
private parseAVTransportControlURL(xml: string): string {
let serviceType = 'urn:schemas-upnp-org:service:AVTransport:1'
let idx = xml.indexOf(serviceType)
if (idx < 0) {
serviceType = 'urn:schemas-upnp-org:service:AVTransport'
idx = xml.indexOf(serviceType)
}
if (idx < 0) {
return ''
}
let endService = xml.indexOf('</service>', idx)
if (endService < 0) {
endService = xml.length
}
let block = xml.substring(idx, endService)
return this.parseXmlTag(block, 'controlURL')
}
private parseFirstIconURL(xml: string): string {
let iconIdx = xml.indexOf('<iconList>')
if (iconIdx < 0) {
return ''
}
let iconEnd = xml.indexOf('</iconList>', iconIdx)
if (iconEnd < 0) {
return ''
}
let block = xml.substring(iconIdx, iconEnd)
return this.parseXmlTag(block, 'url')
}
// 手动添加设备(用于测试)
addDevice(device: CCDlnaDevice): void {
if (this.knownDeviceIds.indexOf(device.location) < 0) {
this.knownDeviceIds.push(device.location)
this.devices.push(device)
emitter.emit(eventIdDlnaDeviceFound)
}
}
// ==================== SOAP Control ====================
cast(device: CCDlnaDevice, url: string, title: string, mediaType: string = 'video'): void {
console.log(`[${TAG}] cast: device=${device.displayName}, url=${url}, title=${title}, mediaType=${mediaType}`)
this.currentDevice = device
this.isCasting = true
this.isPaused = false
this.emitCastState()
this.setAVTransportURI(device, url, title, mediaType, (success: boolean) => {
console.log(`[${TAG}] setAVTransportURI result: ${success}`)
if (success) {
setTimeout(() => {
this.play(device)
this.startPositionPolling()
}, 500)
} else {
console.error(`[${TAG}] setAVTransportURI failed, aborting cast`)
}
})
}
pauseCast(): void {
if (this.currentDevice && !this.isPaused) {
this.pause(this.currentDevice)
this.isPaused = true
}
}
resumeCast(): void {
if (this.currentDevice && this.isPaused) {
this.play(this.currentDevice)
this.isPaused = false
}
}
stopCast(): void {
if (this.currentDevice) {
this.stop(this.currentDevice)
}
this.stopPositionPolling()
this.isCasting = false
this.isPaused = false
this.currentDevice = null
this.fallbackDurationSeconds = 0
this.emitCastState()
}
getPosition(callback: (info: DlnaPositionInfo) => void): void {
if (!this.currentDevice) {
return
}
let device = this.currentDevice
let body = '<u:GetPositionInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">' +
'<InstanceID>0</InstanceID>' +
'</u:GetPositionInfo>'
this.sendSOAP(device, 'GetPositionInfo', body, (result: string) => {
if (result) {
let info: DlnaPositionInfo = {
relTime: this.parseSoapValue(result, 'RelTime'),
absTime: this.parseSoapValue(result, 'AbsTime'),
trackDuration: this.parseSoapValue(result, 'TrackDuration')
}
callback(info)
}
})
}
seekCast(positionSeconds: number): void {
if (!this.currentDevice) {
return
}
let h = Math.floor(positionSeconds / 3600)
let m = Math.floor((positionSeconds % 3600) / 60)
let s = Math.floor(positionSeconds % 60)
let timeStr = this.padTime(h) + ':' + this.padTime(m) + ':' + this.padTime(s)
let body = '<u:Seek xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">' +
'<InstanceID>0</InstanceID>' +
'<Unit>REL_TIME</Unit>' +
'<Target>' + timeStr + '</Target>' +
'</u:Seek>'
this.sendSOAP(this.currentDevice, 'Seek', body)
}
private setAVTransportURI(
device: CCDlnaDevice, url: string, title: string, mediaType: string,
callback: (success: boolean) => void
): void {
let body = '<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">' +
'<InstanceID>0</InstanceID>' +
'<CurrentURI>' + this.escapeXml(url) + '</CurrentURI>' +
'<CurrentURIMetaData>' + this.buildDidlLite(url, title, mediaType) + '</CurrentURIMetaData>' +
'</u:SetAVTransportURI>'
this.sendSOAP(device, 'SetAVTransportURI', body, (result: string) => {
callback(result.length > 0)
})
}
private play(device: CCDlnaDevice, callback?: (success: boolean) => void): void {
let body = '<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">' +
'<InstanceID>0</InstanceID>' +
'<Speed>1</Speed>' +
'</u:Play>'
this.sendSOAP(device, 'Play', body, (result: string) => {
if (callback) {
callback(result.length > 0)
}
})
}
private pause(device: CCDlnaDevice): void {
let body = '<u:Pause xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">' +
'<InstanceID>0</InstanceID>' +
'</u:Pause>'
this.sendSOAP(device, 'Pause', body)
}
private stop(device: CCDlnaDevice): void {
let body = '<u:Stop xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">' +
'<InstanceID>0</InstanceID>' +
'</u:Stop>'
this.sendSOAP(device, 'Stop', body)
}
private buildDidlLite(url: string, title: string, mediaType: string): string {
let upnpClass = mediaType === 'audio'
? 'object.item.audioItem.musicTrack'
: 'object.item.videoItem'
let protocolInfo = mediaType === 'audio'
? 'http-get:*:audio/mpeg:*'
: 'http-get:*:video/mp4:*'
return '<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ' +
'xmlns:dc="http://purl.org/dc/elements/1.1/" ' +
'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">' +
'<item id="0" parentID="-1" restricted="1">' +
'<upnp:class>' + upnpClass + '</upnp:class>' +
'<dc:title>' + this.escapeXml(title) + '</dc:title>' +
'<res protocolInfo="' + protocolInfo + '">' + this.escapeXml(url) + '</res>' +
'</item>' +
'</DIDL-Lite>'
}
// ==================== SOAP Transport ====================
private sendSOAP(
device: CCDlnaDevice, action: string, body: string,
callback?: (result: string) => void
): void {
let controlURL = device.controlURL
if (controlURL.indexOf('http') < 0) {
controlURL = device.serviceHost +
(controlURL.startsWith('/') ? controlURL : '/' + controlURL)
}
console.log(`[${TAG}] SOAP ${action} -> ${controlURL}`)
console.log(`[${TAG}] SOAP body: ${body}`)
let soapBody =
'<?xml version="1.0" encoding="utf-8"?>' +
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ' +
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
'<s:Body>' + body + '</s:Body>' +
'</s:Envelope>'
let httpRequest = http.createHttp()
let extraHeaders: Record<string, string> = {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPAction': '"urn:schemas-upnp-org:service:AVTransport:1#' + action + '"'
}
httpRequest.request(controlURL, {
method: http.RequestMethod.POST,
header: extraHeaders,
extraData: soapBody,
connectTimeout: 5000,
readTimeout: 10000
}, (err: Error, data: http.HttpResponse) => {
httpRequest.destroy()
if (err) {
console.error(`[${TAG}] SOAP ${action} error: ${JSON.stringify(err)}`)
if (callback) {
callback('')
}
} else if (data.responseCode === 200) {
console.log(`[${TAG}] SOAP ${action} OK, response length: ${(data.result as string).length}`)
if (callback) {
callback(data.result as string)
}
} else {
console.error(`[${TAG}] SOAP ${action} status: ${data.responseCode}, body: ${data.result as string}`)
if (callback) {
callback('')
}
}
})
}
// ==================== Position Polling ====================
private startPositionPolling(): void {
this.stopPositionPolling()
this.positionTimer = setInterval(() => {
this.getPosition((info: DlnaPositionInfo) => {
if (info.relTime && info.relTime !== 'NOT_IMPLEMENTED') {
let seconds = this.timeToSeconds(info.relTime)
let duration = 0
if (info.trackDuration && info.trackDuration !== 'NOT_IMPLEMENTED') {
duration = this.timeToSeconds(info.trackDuration)
}
if (duration <= 0 && this.fallbackDurationSeconds > 0) {
duration = this.fallbackDurationSeconds
}
if (duration > 0) {
AppStorage.setOrCreate('uiAppStorageDurationTime', duration * 1000)
AppStorage.setOrCreate('uiAppStorageProgress', (seconds / duration) * 100)
AppStorage.setOrCreate('uiAppStorageRightTime', this.formatTime(duration))
}
AppStorage.setOrCreate('uiAppStorageLeftTime', this.formatTime(seconds))
}
})
}, 2000)
}
private stopPositionPolling(): void {
if (this.positionTimer > 0) {
clearInterval(this.positionTimer)
this.positionTimer = 0
}
}
// ==================== Utility ====================
isNetworkURL(filename: string): boolean {
return filename.startsWith('http://') || filename.startsWith('https://')
}
private emitCastState(): void {
emitter.emit(eventIdDlnaCastState, {
isCasting: this.isCasting,
deviceName: this.currentDevice ? this.currentDevice.displayName : ''
} as emitter.EventData)
}
private parseXmlTag(xml: string, tag: string): string {
let regex = new RegExp('<' + tag + '>([\\s\\S]*?)</' + tag + '>', 'i')
let match = xml.match(regex)
return match ? match[1].trim() : ''
}
private parseSoapValue(xml: string, tag: string): string {
let regex = new RegExp('<(?:\\w+:)?' + tag + '>([\\s\\S]*?)</(?:\\w+:)?' + tag + '>', 'i')
let match = xml.match(regex)
return match ? match[1].trim() : ''
}
private escapeXml(str: string): string {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''')
}
private timeToSeconds(timeStr: string): number {
let parts = timeStr.split(':')
if (parts.length === 3) {
let h = parseInt(parts[0])
let m = parseInt(parts[1])
let s = parseInt(parts[2])
return h * 3600 + m * 60 + s
}
return 0
}
private padTime(n: number): string {
return n < 10 ? '0' + n : '' + n
}
private formatTime(totalSeconds: number): string {
let h = Math.floor(totalSeconds / 3600)
let m = Math.floor((totalSeconds % 3600) / 60)
let s = Math.floor(totalSeconds % 60)
return this.padTime(h) + ':' + this.padTime(m) + ':' + this.padTime(s)
}
destroy(): void {
this.stopCast()
this.stopDiscovery()
CCDlnaManager._shared = null
}
}