鸿蒙 DLNA 投屏

0 阅读6分钟

鸿蒙 DLNA 投屏

参考文档

为什么不用 AVCastController

鸿蒙系统提供了 AVCastController API 作为官方投屏方案,但在实际使用中存在以下限制:

  1. 生命周期受限AVCastController 由系统获取并返回,在设备连接成功后获取,在设备断开后不能继续使用,否则会抛出异常。这意味着每次断连后必须重新走完整的设备发现和连接流程,无法复用控制器实例。

  2. DRM 绑定过深AVCastController 支持在线 DRM 视频资源投播能力,需注册 DRM 许可证请求回调函数,获取许可证后调用处理许可证响应函数。这套机制与系统播放器深度耦合,对于自定义播放器或纯本地/局域网投屏场景来说,引入的复杂度远大于收益。

  3. 灵活性不足:系统级 API 对投屏行为的控制粒度有限,无法自定义 SOAP 交互细节(如手动构造 SetAVTransportURISeek 等),也难以在投屏过程中灵活切换媒体源或实现自定义的进度轮询策略。

因此,本项目选择基于 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设备唯一标识
controlURLAVTransport 服务的 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.videoItemprotocolInfo = http-get:*:video/mp4:*
  • 音频:upnp:class = object.item.audioItem.musicTrackprotocolInfo = 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 '&lt;DIDL-Lite xmlns=&quot;urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/&quot; ' +
      'xmlns:dc=&quot;http://purl.org/dc/elements/1.1/&quot; ' +
      'xmlns:upnp=&quot;urn:schemas-upnp-org:metadata-1-0/upnp/&quot;&gt;' +
      '&lt;item id=&quot;0&quot; parentID=&quot;-1&quot; restricted=&quot;1&quot;&gt;' +
      '&lt;upnp:class&gt;' + upnpClass + '&lt;/upnp:class&gt;' +
      '&lt;dc:title&gt;' + this.escapeXml(title) + '&lt;/dc:title&gt;' +
      '&lt;res protocolInfo=&quot;' + protocolInfo + '&quot;&gt;' + this.escapeXml(url) + '&lt;/res&gt;' +
      '&lt;/item&gt;' +
      '&lt;/DIDL-Lite&gt;'
  }

  // ==================== 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;').replace(/'/g, '&apos;')
  }

  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
  }
}