@@ -11,6 +11,9 @@ import java.util.Locale
1111import com.yinnho.upnpcast.CastOptions
1212import com.yinnho.upnpcast.internal.discovery.RemoteDevice
1313import com.yinnho.upnpcast.internal.discovery.DeviceDescriptionParser
14+ import com.yinnho.upnpcast.internal.util.MetadataBuilder
15+ import com.yinnho.upnpcast.internal.util.SoapXml
16+ import com.yinnho.upnpcast.internal.util.UpnpTime
1417
1518/* *
1619 * DLNA media controller with SOAP-based control implementation
@@ -93,7 +96,7 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
9396 if (! checkAvailable()) return @withContext false
9497
9598 try {
96- val setUriSuccess = setMediaUri(mediaUrl, createMetadata (title, episodeLabel, mediaUrl, options))
99+ val setUriSuccess = setMediaUri(mediaUrl, MetadataBuilder .build (title, episodeLabel, mediaUrl, options))
97100 if (! setUriSuccess) return @withContext false
98101
99102 val playSuccess = control(" play" )
@@ -216,7 +219,7 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
216219 is String -> value.toLongOrNull() ? : return false
217220 else -> return false
218221 }
219- val timeString = formatTime (positionMs)
222+ val timeString = UpnpTime .format (positionMs)
220223 executeAVTransportAction(" Seek" , " \n <Unit>REL_TIME</Unit>\n <Target>${escapeXmlContent(timeString)} </Target>" )
221224 }
222225
@@ -263,7 +266,7 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
263266 </u:GetPositionInfo>
264267 """ .trimIndent(),
265268 needResponse = true ,
266- parser = ::parsePositionInfo
269+ parser = SoapXml ::parsePositionInfo
267270 )
268271
269272 /* *
@@ -281,54 +284,9 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
281284 </u:GetTransportInfo>
282285 """ .trimIndent(),
283286 needResponse = true ,
284- parser = { response -> parseXmlValue (response, " CurrentTransportState" ) { it .trim() } }
287+ parser = { response -> SoapXml .extractValue (response, " CurrentTransportState" )? .trim() }
285288 )
286289
287- /* *
288- * Parse playback position information
289- */
290- private fun parsePositionInfo (response : String ): Pair <Long , Long >? {
291- try {
292- // Simple XML parsing to get RelTime and TrackDuration
293- val relTimePattern = " <RelTime>(.*?)</RelTime>" .toRegex()
294- val durationPattern = " <TrackDuration>(.*?)</TrackDuration>" .toRegex()
295-
296- val relTimeMatch = relTimePattern.find(response)
297- val durationMatch = durationPattern.find(response)
298-
299- val currentTime = relTimeMatch?.groupValues?.get(1 )?.let { parseTimeToMs(it) } ? : 0L
300- val totalTime = durationMatch?.groupValues?.get(1 )?.let { parseTimeToMs(it) } ? : 0L
301-
302- return Pair (currentTime, totalTime)
303- } catch (e: Exception ) {
304- Log .e(tag, " Failed to parse position info: ${e.message} " )
305- return null
306- }
307- }
308-
309- /* *
310- * Parse time string to milliseconds
311- */
312- private fun parseTimeToMs (timeString : String ): Long {
313- try {
314- if (timeString == " NOT_IMPLEMENTED" || timeString.isEmpty()) {
315- return 0L
316- }
317-
318- val parts = timeString.split(" :" )
319- if (parts.size == 3 ) {
320- val hours = parts[0 ].toLongOrNull() ? : 0L
321- val minutes = parts[1 ].toLongOrNull() ? : 0L
322- val seconds = parts[2 ].toDoubleOrNull() ? : 0.0
323-
324- return (hours * 3600 + minutes * 60 + seconds).toLong() * 1000
325- }
326- return 0L
327- } catch (e: Exception ) {
328- return 0L
329- }
330- }
331-
332290 /* *
333291 * Generic SOAP request - handles both Boolean and String responses
334292 */
@@ -387,42 +345,6 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
387345 }
388346 }
389347
390- /* *
391- * Generic XML value parser
392- */
393- private fun <T > parseXmlValue (
394- response : String ,
395- tagName : String ,
396- converter : (String ) -> T ?
397- ): T ? {
398- try {
399- val pattern = " <$tagName >(.*?)</$tagName >" .toRegex()
400- val match = pattern.find(response)
401- return match?.groupValues?.get(1 )?.let { converter(it) }
402- } catch (e: Exception ) {
403- Log .e(tag, " Failed to parse $tagName from response: ${e.message} " )
404- return null
405- }
406- }
407-
408- /* *
409- * Parse volume from response
410- */
411- private fun parseVolumeFromResponse (response : String ): Int? =
412- parseXmlValue(response, " CurrentVolume" ) { it.toIntOrNull() }
413-
414- /* *
415- * Parse mute state from response
416- */
417- private fun parseMuteFromResponse (response : String ): Boolean? =
418- parseXmlValue(response, " CurrentMute" ) { value ->
419- when (value) {
420- " 1" , " true" , " True" -> true
421- " 0" , " false" , " False" -> false
422- else -> null
423- }
424- }
425-
426348 /* *
427349 * Basic XML escape (common characters)
428350 */
@@ -446,70 +368,7 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
446368 * XML escape for URLs (basic only)
447369 */
448370 private fun escapeXmlUrl (url : String ): String = escapeXmlBasic(url)
449-
450- /* *
451- * Create DIDL-Lite metadata
452- *
453- * [CastOptions.metadata] is sent verbatim when provided. Otherwise the
454- * metadata is generated, honoring the mimeType/upnpClass overrides and
455- * attaching the subtitle resource when [CastOptions.subtitleUri] is set
456- * (the Samsung `sec:SubtitleUri` extension is included as well).
457- */
458- private fun createMetadata (
459- title : String ,
460- episodeLabel : String ,
461- mediaUrl : String = "",
462- options : CastOptions = CastOptions ()
463- ): String {
464- options.metadata?.let { return it }
465-
466- val displayTitle = if (episodeLabel.isNotEmpty()) " $title - $episodeLabel " else title
467- val safeDisplayTitle = escapeXmlContent(displayTitle)
468- val safeMediaUrl = escapeXmlUrl(mediaUrl)
469-
470- val mediaType = options.mimeType ? : when {
471- mediaUrl.contains(" .mp4" , ignoreCase = true ) -> " video/mp4"
472- mediaUrl.contains(" .mkv" , ignoreCase = true ) -> " video/x-matroska"
473- mediaUrl.contains(" .m3u8" , ignoreCase = true ) -> " application/vnd.apple.mpegurl"
474- mediaUrl.contains(" .mp3" , ignoreCase = true ) -> " audio/mpeg"
475- else -> " video/mp4"
476- }
477-
478- val upnpClass = options.upnpClass ? : if (mediaType.startsWith(" video" ) || mediaType.contains(" mpegurl" )) {
479- " object.item.videoItem"
480- } else {
481- " object.item.audioItem.musicTrack"
482- }
483-
484- val safeSubtitleUri = options.subtitleUri?.let { escapeXmlUrl(it) }
485- val subtitleRes = safeSubtitleUri
486- ?.let { " \n <res protocolInfo=\" http-get:*:${options.subtitleMimeType} :*\" >$it </res>" }
487- ? : " "
488- val subtitleElement = safeSubtitleUri
489- ?.let { " \n <sec:SubtitleUri>$it </sec:SubtitleUri>" }
490- ? : " "
491- val secNamespace = if (safeSubtitleUri != null ) " xmlns:sec=\" http://www.samsung.com/sec/\" " else " "
492371
493- 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/"$secNamespace >
494- <item id="1" parentID="0" restricted="1">
495- <dc:title>$safeDisplayTitle </dc:title>
496- <upnp:class>$upnpClass </upnp:class>$subtitleElement
497- <res protocolInfo="http-get:*:$mediaType :*">$safeMediaUrl </res>$subtitleRes
498- </item>
499- </DIDL-Lite>"""
500- }
501-
502- /* *
503- * Time format
504- */
505- private fun formatTime (positionMs : Long ): String {
506- val totalSeconds = positionMs / 1000
507- val hours = totalSeconds / 3600
508- val minutes = (totalSeconds % 3600 ) / 60
509- val seconds = totalSeconds % 60
510- return String .format(Locale .ROOT , " %02d:%02d:%02d" , hours, minutes, seconds)
511- }
512-
513372 /* *
514373 * Execute RenderingControl action - unified method for both set and get operations
515374 */
@@ -538,12 +397,12 @@ internal class DlnaMediaController(private val device: RemoteDevice) {
538397 /* *
539398 * Get current volume
540399 */
541- suspend fun getVolumeAsync (): Int? = executeRenderingControl(" GetVolume" , needResponse = true , parser = ::parseVolumeFromResponse )
400+ suspend fun getVolumeAsync (): Int? = executeRenderingControl(" GetVolume" , needResponse = true , parser = SoapXml ::parseVolume )
542401
543402 /* *
544403 * Get current mute state
545404 */
546- suspend fun getMuteAsync (): Boolean? = executeRenderingControl(" GetMute" , needResponse = true , parser = ::parseMuteFromResponse )
405+ suspend fun getMuteAsync (): Boolean? = executeRenderingControl(" GetMute" , needResponse = true , parser = SoapXml ::parseMute )
547406
548407
549408
0 commit comments