[{"data":1,"prerenderedAt":2884},["ShallowReactive",2],{"i-ph:moon-bold":3,"i-ph:globe-simple":8,"i-ph:caret-down":10,"i-ph:list":12,"i-ph:heart-fill":14,"i-ph:discord-logo-bold":16,"i-ph:github-logo-bold":18,"i-ph:cookie-bold":20,"blog-article-en-dlna-cast":22,"i-ph:arrow-left":2882},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7},0,256,false,"\u003Cpath fill=\"currentColor\" d=\"M236.37 139.4a12 12 0 0 0-12-3A84.07 84.07 0 0 1 119.6 31.59a12 12 0 0 0-15-15a108.86 108.86 0 0 0-54.91 38.48A108 108 0 0 0 136 228a107.1 107.1 0 0 0 64.93-21.69a108.86 108.86 0 0 0 38.44-54.94a12 12 0 0 0-3-11.97m-49.88 47.74A84 84 0 0 1 68.86 69.51a84.9 84.9 0 0 1 23.41-21.22Q92 52.13 92 56a108.12 108.12 0 0 0 108 108q3.87 0 7.71-.27a84.8 84.8 0 0 1-21.22 23.41\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":9},"\u003Cpath fill=\"currentColor\" d=\"M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m87.62 96h-39.83c-1.79-36.51-15.85-62.33-27.38-77.6a88.19 88.19 0 0 1 67.22 77.6ZM96.23 136h63.54c-2.31 41.61-22.23 67.11-31.77 77c-9.55-9.9-29.46-35.4-31.77-77m0-16c2.31-41.61 22.23-67.11 31.77-77c9.55 9.93 29.46 35.43 31.77 77Zm11.36-77.6C96.06 57.67 82 83.49 80.21 120H40.37a88.19 88.19 0 0 1 67.22-77.6M40.37 136h39.84c1.82 36.51 15.85 62.33 27.38 77.6A88.19 88.19 0 0 1 40.37 136m108 77.6c11.53-15.27 25.56-41.09 27.38-77.6h39.84a88.19 88.19 0 0 1-67.18 77.6Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":11},"\u003Cpath fill=\"currentColor\" d=\"m213.66 101.66l-80 80a8 8 0 0 1-11.32 0l-80-80a8 8 0 0 1 11.32-11.32L128 164.69l74.34-74.35a8 8 0 0 1 11.32 11.32\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":13},"\u003Cpath fill=\"currentColor\" d=\"M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8M40 72h176a8 8 0 0 0 0-16H40a8 8 0 0 0 0 16m176 112H40a8 8 0 0 0 0 16h176a8 8 0 0 0 0-16\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":15},"\u003Cpath fill=\"currentColor\" d=\"M240 102c0 70-103.79 126.66-108.21 129a8 8 0 0 1-7.58 0C119.79 228.66 16 172 16 102a62.07 62.07 0 0 1 62-62c20.65 0 38.73 8.88 50 23.89C139.27 48.88 157.35 40 178 40a62.07 62.07 0 0 1 62 62\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":17},"\u003Cpath fill=\"currentColor\" d=\"M108 136a16 16 0 1 1-16-16a16 16 0 0 1 16 16m56-16a16 16 0 1 0 16 16a16 16 0 0 0-16-16m76.07 76.56l-67 29.71A20.15 20.15 0 0 1 146 214.9l-8.54-23.13c-3.13.14-6.27.24-9.45.24s-6.32-.1-9.45-.24L110 214.9a20.19 20.19 0 0 1-27.08 11.37l-67-29.71a19.93 19.93 0 0 1-11.3-23.15L34.15 57a20 20 0 0 1 16.22-14.81l36.06-5.93a20.26 20.26 0 0 1 22.79 14.84l4.41 17.41c4.74-.33 9.52-.51 14.37-.51s9.63.18 14.37.51l4.41-17.41a20.25 20.25 0 0 1 22.79-14.84l36.06 5.93A20 20 0 0 1 221.85 57l29.53 116.38a19.93 19.93 0 0 1-11.31 23.18M227.28 176L199.23 65.46l-30.07-4.94l-2.84 11.17c2.9.58 5.78 1.2 8.61 1.92a12 12 0 1 1-5.86 23.27A168.4 168.4 0 0 0 128 92a168.4 168.4 0 0 0-41.07 4.88a12 12 0 0 1-5.86-23.27c2.83-.72 5.71-1.34 8.61-1.92l-2.83-11.17l-30.08 4.94L28.72 176l60.22 26.7l5-13.57c-4.37-.76-8.67-1.65-12.88-2.71a12 12 0 0 1 5.86-23.28A168.4 168.4 0 0 0 128 168a168.4 168.4 0 0 0 41.07-4.88a12 12 0 0 1 5.86 23.28c-4.21 1.06-8.51 1.95-12.88 2.71l5 13.57Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":19},"\u003Cpath fill=\"currentColor\" d=\"M212.62 75.17A63.7 63.7 0 0 0 206.39 26A12 12 0 0 0 196 20a63.71 63.71 0 0 0-50 24h-20a63.71 63.71 0 0 0-50-24a12 12 0 0 0-10.39 6a63.7 63.7 0 0 0-6.23 49.17A61.5 61.5 0 0 0 52 104v8a60.1 60.1 0 0 0 45.76 58.28A43.66 43.66 0 0 0 92 192v4H76a20 20 0 0 1-20-20a44.05 44.05 0 0 0-44-44a12 12 0 0 0 0 24a20 20 0 0 1 20 20a44.05 44.05 0 0 0 44 44h16v12a12 12 0 0 0 24 0v-40a20 20 0 0 1 40 0v40a12 12 0 0 0 24 0v-40a43.66 43.66 0 0 0-5.76-21.72A60.1 60.1 0 0 0 220 112v-8a61.5 61.5 0 0 0-7.38-28.83M196 112a36 36 0 0 1-36 36h-48a36 36 0 0 1-36-36v-8a37.87 37.87 0 0 1 6.13-20.12a11.65 11.65 0 0 0 1.58-11.49a39.9 39.9 0 0 1-.4-27.72a39.87 39.87 0 0 1 26.41 17.8a12 12 0 0 0 10.1 5.53h32.35a12 12 0 0 0 10.11-5.53a39.84 39.84 0 0 1 26.41-17.8a39.9 39.9 0 0 1-.4 27.72a12 12 0 0 0 1.61 11.53A37.85 37.85 0 0 1 196 104Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":21},"\u003Cpath fill=\"currentColor\" d=\"M167.31 160.69a16 16 0 1 1-22.62 0a16 16 0 0 1 22.62 0m-86.62-8a16 16 0 1 0 22.62 0a16 16 0 0 0-22.62 0m14.62-33.38a16 16 0 1 0-22.62 0a16 16 0 0 0 22.62 0m48-6.62a16 16 0 1 0 0 22.62a16 16 0 0 0 0-22.62M236 128A108 108 0 1 1 128 20a12 12 0 0 1 12 12a36 36 0 0 0 36 36a12 12 0 0 1 12 12a36 36 0 0 0 36 36a12 12 0 0 1 12 12m-24.67 10.65A60.17 60.17 0 0 1 165 91a60.17 60.17 0 0 1-47.66-46.32a84 84 0 1 0 94 94Z\"\u002F>",{"id":23,"title":24,"body":25,"category":2869,"date":2870,"description":2871,"extension":2872,"meta":2873,"navigation":2874,"path":2875,"readingTime":2876,"seo":2877,"seoDescription":2878,"seoTitle":2879,"stem":2880,"__hash__":2881},"blog_en\u002Fblog\u002Fdlna-cast.md","DLNA Cast: Building a UPnP Sender and Receiver From Scratch",{"type":26,"value":27,"toc":2850},"minimark",[28,47,50,55,153,156,163,166,240,255,258,269,275,322,447,483,487,502,688,695,698,713,719,724,855,873,924,927,944,1170,1194,1215,1368,1383,1386,1399,1405,1419,1527,1532,1739,1753,1774,1777,1784,1943,1971,2075,2078,2117,2120,2129,2159,2210,2219,2298,2323,2326,2336,2342,2492,2505,2508,2514,2547,2550,2568,2574,2577,2580,2674,2677,2825,2828,2846],[29,30,31,32,36,37,41,42,46],"p",{},"DLNA (built on UPnP AV) is the protocol behind \"Cast to TV\" buttons on\nsmart TVs. It runs entirely on the local network, with no cloud account\nand no pairing step. PlainApp implements ",[33,34,35],"strong",{},"both directions"," of it: it\ncan push a local video, song, or photo to any DLNA-compatible smart TV,\nand it can turn the phone itself into a ",[38,39,40],"code",{},"MediaRenderer"," so a TV remote\napp, VLC, or another PlainApp can cast ",[43,44,45],"em",{},"to"," it.",[29,48,49],{},"This article covers the wire protocol (SSDP + SOAP + DIDL-Lite), the local\nHTTP server that streams media with the headers TVs actually require, the\nGENA event subscription for playback state, and the sender-IP trust model\nthat keeps an unauthenticated 1990s-era protocol safe to run on a modern\nphone.",[51,52,54],"h2",{"id":53},"table-of-contents","Table of Contents",[56,57,58,65,71,85,91,97,103,117,123,129,135,141,147],"ul",{},[59,60,61],"li",{},[62,63,54],"a",{"href":64},"#table-of-contents",[59,66,67],{},[62,68,70],{"href":69},"#high-level-architecture","High-Level Architecture",[59,72,73,77],{},[62,74,76],{"href":75},"#ssdp-discovery-finding-devices-without-a-server","SSDP Discovery: Finding Devices Without a Server",[56,78,79],{},[59,80,81],{},[62,82,84],{"href":83},"#port-fallback","Port fallback",[59,86,87],{},[62,88,90],{"href":89},"#avtransport-control-the-soap-protocol","AVTransport Control: The SOAP Protocol",[59,92,93],{},[62,94,96],{"href":95},"#didl-lite-metadata-and-the-double-escaping-quirk","DIDL-Lite Metadata and the Double-Escaping Quirk",[59,98,99],{},[62,100,102],{"href":101},"#serving-media-to-the-tv-range-requests--dlna-headers","Serving Media to the TV: Range Requests & DLNA Headers",[59,104,105,109],{},[62,106,108],{"href":107},"#gena-events-playback-state-callbacks","GENA Events: Playback State Callbacks",[56,110,111],{},[59,112,113],{},[62,114,116],{"href":115},"#the-duplicate-callback-guard","The duplicate-callback guard",[59,118,119],{},[62,120,122],{"href":121},"#receiver-mode-becoming-a-upnp-mediarenderer","Receiver Mode: Becoming a UPnP MediaRenderer",[59,124,125],{},[62,126,128],{"href":127},"#security-sender-trust-via-allowdeny-lists","Security: Sender Trust via Allow\u002FDeny Lists",[59,130,131],{},[62,132,134],{"href":133},"#platform-split-commonmain-orchestration-androidmain-sockets","Platform Split: commonMain Orchestration, androidMain Sockets",[59,136,137],{},[62,138,140],{"href":139},"#pure-kotlin-engineering-notes","Pure-Kotlin Engineering Notes",[59,142,143],{},[62,144,146],{"href":145},"#design-patterns-recap","Design Patterns Recap",[59,148,149],{},[62,150,152],{"href":151},"#further-reading","Further Reading",[51,154,70],{"id":155},"high-level-architecture",[29,157,158],{},[159,160],"img",{"alt":161,"src":162},"Diagram 1","\u002Fblog\u002Fdlna-cast\u002Fdiagram-01.svg",[29,164,165],{},"DLNA\u002FUPnP AV has no central server and no cloud component — everything\nhappens over local-network UDP multicast (discovery) and HTTP (control +\nmedia). PlainApp implements two independent roles that happen to share the\nsame commonMain protocol code:",[167,168,169,185],"table",{},[170,171,172],"thead",{},[173,174,175,179,182],"tr",{},[176,177,178],"th",{},"Role",[176,180,181],{},"What it does",[176,183,184],{},"Key classes",[186,187,188,212],"tbody",{},[173,189,190,197,200],{},[191,192,193,196],"td",{},[33,194,195],{},"Sender"," (\"Cast to TV\")",[191,198,199],{},"Scans for renderers, tells one to fetch a URL, controls playback",[191,201,202,205,206,205,209],{},[38,203,204],{},"DlnaDeviceScanner",", ",[38,207,208],{},"DlnaTransportController",[38,210,211],{},"CastPlayer",[173,213,214,220,226],{},[191,215,216,219],{},[33,217,218],{},"Receiver"," (\"Wireless Cast\")",[191,221,222,223,225],{},"Advertises itself as a ",[38,224,40],{},", accepts control from any UPnP controller",[191,227,228,205,231,205,234,205,237],{},[38,229,230],{},"DlnaReceiverEngine",[38,232,233],{},"DlnaHttpRouter",[38,235,236],{},"DlnaSoapHandler",[38,238,239],{},"DlnaReceiverViewModel",[29,241,242,243,246,247,250,251,254],{},"Both roles reuse ",[38,244,245],{},"DlnaSoap"," (SOAP envelope builders) and ",[38,248,249],{},"DlnaDevice"," (the\nUPnP device model) from ",[38,252,253],{},"features\u002Fdlna\u002Fcommon\u002F",". The DLNA specification\nhas no authentication of any kind — anyone on the LAN who knows a\nrenderer's control URL can send it commands. This shapes almost every\ndesign decision described below, especially the receiver's trust model.",[51,256,76],{"id":257},"ssdp-discovery-finding-devices-without-a-server",[29,259,260,261,264,265,268],{},"Discovery uses ",[33,262,263],{},"SSDP"," (Simple Service Discovery Protocol), a thin layer\nover UDP multicast to ",[38,266,267],{},"239.255.255.250:1900",". There is no directory\nserver — devices announce themselves and answer search queries directly.",[29,270,271],{},[159,272],{"alt":273,"src":274},"Diagram 2","\u002Fblog\u002Fdlna-cast\u002Fdiagram-02.svg",[29,276,277,205,280,282,283,286,287,290,291,294,295,298,299,302,303,306,307,310,311,313,314,317,318,321],{},[33,278,279],{},"As a sender",[38,281,204],{}," broadcasts an ",[38,284,285],{},"M-SEARCH"," datagram\ntargeting ",[38,288,289],{},"urn:schemas-upnp-org:service:AVTransport:1"," and collects\nunicast ",[38,292,293],{},"200 OK"," replies, each carrying a ",[38,296,297],{},"LOCATION"," header pointing at\nthe renderer's ",[38,300,301],{},"description.xml",". The scanner de-duplicates by\n",[38,304,305],{},"hostAddress"," — it doesn't parse the device XML itself; that's left to\n",[38,308,309],{},"CastViewModel.searchAsync()",", which fetches ",[38,312,297],{},", calls\n",[38,315,316],{},"device.update(xml)",", and only surfaces devices where\n",[38,319,320],{},"device.isAVTransport()"," returns true:",[323,324,329],"pre",{"className":325,"code":326,"language":327,"meta":328,"style":328},"language-kotlin shiki shiki-themes github-light github-dark","fun search(): Flow\u003CDlnaDevice> = searchDlnaDevicesRaw().transform { ssdp ->\n    if (devices.none { it.hostAddress == ssdp.hostAddress }) {\n        val device = DlnaDevice(ssdp.hostAddress, ssdp.header)\n        devices.add(device)\n        emit(device)\n    }\n}\n","kotlin","",[38,330,331,377,398,415,427,435,441],{"__ignoreMap":328},[332,333,336,340,344,348,351,354,356,359,362,365,368,371,374],"span",{"class":334,"line":335},"line",1,[332,337,339],{"class":338},"szBVR","fun",[332,341,343],{"class":342},"sScJk"," search",[332,345,347],{"class":346},"sVt8B","(): ",[332,349,350],{"class":342},"Flow",[332,352,353],{"class":346},"\u003C",[332,355,249],{"class":342},[332,357,358],{"class":346},"> ",[332,360,361],{"class":338},"=",[332,363,364],{"class":342}," searchDlnaDevicesRaw",[332,366,367],{"class":346},"().",[332,369,370],{"class":342},"transform",[332,372,373],{"class":346}," { ssdp ",[332,375,376],{"class":338},"->\n",[332,378,380,383,386,389,392,395],{"class":334,"line":379},2,[332,381,382],{"class":338},"    if",[332,384,385],{"class":346}," (devices.",[332,387,388],{"class":342},"none",[332,390,391],{"class":346}," { it.hostAddress ",[332,393,394],{"class":338},"==",[332,396,397],{"class":346}," ssdp.hostAddress }) {\n",[332,399,401,404,407,409,412],{"class":334,"line":400},3,[332,402,403],{"class":338},"        val",[332,405,406],{"class":346}," device ",[332,408,361],{"class":338},[332,410,411],{"class":342}," DlnaDevice",[332,413,414],{"class":346},"(ssdp.hostAddress, ssdp.header)\n",[332,416,418,421,424],{"class":334,"line":417},4,[332,419,420],{"class":346},"        devices.",[332,422,423],{"class":342},"add",[332,425,426],{"class":346},"(device)\n",[332,428,430,433],{"class":334,"line":429},5,[332,431,432],{"class":342},"        emit",[332,434,426],{"class":346},[332,436,438],{"class":334,"line":437},6,[332,439,440],{"class":346},"    }\n",[332,442,444],{"class":334,"line":443},7,[332,445,446],{"class":346},"}\n",[29,448,449,205,452,455,456,459,460,463,464,467,468,471,472,474,475,478,479,482],{},[33,450,451],{},"As a receiver",[38,453,454],{},"DlnaReceiverEngine.runSsdpLoop()"," sends three\n",[38,457,458],{},"NOTIFY ssdp:alive"," datagrams on start (root device, ",[38,461,462],{},"MediaRenderer:1","\ndevice type, ",[38,465,466],{},"AVTransport:1"," service type), re-announces every 30 seconds\n(",[38,469,470],{},"CACHE-CONTROL: max-age=1800","), and answers incoming ",[38,473,285],{}," requests\nwith unicast responses. On ",[38,476,477],{},"stop()",", it sends ",[38,480,481],{},"ssdp:byebye"," immediately\nrather than waiting for the 30-minute cache to expire — so a TV remote\napp stops listing PlainApp the moment \"Wireless Cast\" is turned off.",[484,485,84],"h3",{"id":486},"port-fallback",[29,488,489,490,493,494,497,498,501],{},"The receiver's HTTP server tries port ",[38,491,492],{},"7878"," first, then ",[38,495,496],{},"7879",", then\n",[38,499,500],{},"7880",", preferring whichever port worked last time:",[323,503,505],{"className":325,"code":504,"language":327,"meta":328,"style":328},"private val CANDIDATE_PORTS = listOf(7878, 7879, 7880)\nprivate fun openServerSocket(): DlnaServerSocket? {\n    val candidates = lastPort\n        ?.let { listOf(it) + CANDIDATE_PORTS.filter { p -> p != it } }\n        ?: CANDIDATE_PORTS\n    for (port in candidates) {\n        val ss = createDlnaServerSocket(port)\n        if (ss != null) return ss\n    }\n    return null\n}\n",[38,506,507,540,558,571,612,617,631,646,669,674,683],{"__ignoreMap":328},[332,508,509,512,515,518,520,523,526,529,531,533,535,537],{"class":334,"line":335},[332,510,511],{"class":338},"private",[332,513,514],{"class":338}," val",[332,516,517],{"class":346}," CANDIDATE_PORTS ",[332,519,361],{"class":338},[332,521,522],{"class":342}," listOf",[332,524,525],{"class":346},"(",[332,527,492],{"class":528},"sj4cs",[332,530,205],{"class":346},[332,532,496],{"class":528},[332,534,205],{"class":346},[332,536,500],{"class":528},[332,538,539],{"class":346},")\n",[332,541,542,544,547,550,552,555],{"class":334,"line":379},[332,543,511],{"class":338},[332,545,546],{"class":338}," fun",[332,548,549],{"class":342}," openServerSocket",[332,551,347],{"class":346},[332,553,554],{"class":342},"DlnaServerSocket",[332,556,557],{"class":346},"? {\n",[332,559,560,563,566,568],{"class":334,"line":400},[332,561,562],{"class":338},"    val",[332,564,565],{"class":346}," candidates ",[332,567,361],{"class":338},[332,569,570],{"class":346}," lastPort\n",[332,572,573,576,579,582,585,588,591,594,597,600,603,606,609],{"class":334,"line":417},[332,574,575],{"class":346},"        ?.",[332,577,578],{"class":342},"let",[332,580,581],{"class":346}," { ",[332,583,584],{"class":342},"listOf",[332,586,587],{"class":346},"(it) ",[332,589,590],{"class":338},"+",[332,592,593],{"class":346}," CANDIDATE_PORTS.",[332,595,596],{"class":342},"filter",[332,598,599],{"class":346}," { p ",[332,601,602],{"class":338},"->",[332,604,605],{"class":346}," p ",[332,607,608],{"class":338},"!=",[332,610,611],{"class":346}," it } }\n",[332,613,614],{"class":334,"line":429},[332,615,616],{"class":346},"        ?: CANDIDATE_PORTS\n",[332,618,619,622,625,628],{"class":334,"line":437},[332,620,621],{"class":338},"    for",[332,623,624],{"class":346}," (port ",[332,626,627],{"class":338},"in",[332,629,630],{"class":346}," candidates) {\n",[332,632,633,635,638,640,643],{"class":334,"line":443},[332,634,403],{"class":338},[332,636,637],{"class":346}," ss ",[332,639,361],{"class":338},[332,641,642],{"class":342}," createDlnaServerSocket",[332,644,645],{"class":346},"(port)\n",[332,647,649,652,655,657,660,663,666],{"class":334,"line":648},8,[332,650,651],{"class":338},"        if",[332,653,654],{"class":346}," (ss ",[332,656,608],{"class":338},[332,658,659],{"class":528}," null",[332,661,662],{"class":346},") ",[332,664,665],{"class":338},"return",[332,667,668],{"class":346}," ss\n",[332,670,672],{"class":334,"line":671},9,[332,673,440],{"class":346},[332,675,677,680],{"class":334,"line":676},10,[332,678,679],{"class":338},"    return",[332,681,682],{"class":528}," null\n",[332,684,686],{"class":334,"line":685},11,[332,687,446],{"class":346},[29,689,690,691,694],{},"If all three ports are taken (rare, but possible with other DLNA apps\nrunning), ",[38,692,693],{},"startError"," is set and surfaced in the UI rather than failing\nsilently.",[51,696,90],{"id":697},"avtransport-control-the-soap-protocol",[29,699,700,701,704,705,708,709,712],{},"Once a renderer is found, playback is controlled through ",[33,702,703],{},"UPnP\nAVTransport",", a SOAP-over-HTTP service. Every action is a ",[38,706,707],{},"POST"," to the\nrenderer's control URL with a ",[38,710,711],{},"SOAPAction"," header and an XML body wrapped\nin a SOAP envelope.",[29,714,715],{},[159,716],{"alt":717,"src":718},"Diagram 3","\u002Fblog\u002Fdlna-cast\u002Fdiagram-03.svg",[29,720,721,723],{},[38,722,208],{}," builds each request with a shared helper:",[323,725,727],{"className":325,"code":726,"language":327,"meta":328,"style":328},"private suspend fun executeAVTransportCommand(\n    device: DlnaDevice,\n    action: String,\n    parameters: String = \"\u003CInstanceID>0\u003C\u002FInstanceID>\",\n): String {\n    val st = device.getAVTransportService()?.serviceType ?: return \"\"\n    return executeSOAPRequest(device, action, \"\u003Cu:$action xmlns:u=\\\"$st\\\">$parameters\u003C\u002Fu:$action>\")\n}\n",[38,728,729,744,754,764,780,790,813,851],{"__ignoreMap":328},[332,730,731,733,736,738,741],{"class":334,"line":335},[332,732,511],{"class":338},[332,734,735],{"class":338}," suspend",[332,737,546],{"class":338},[332,739,740],{"class":342}," executeAVTransportCommand",[332,742,743],{"class":346},"(\n",[332,745,746,749,751],{"class":334,"line":379},[332,747,748],{"class":346},"    device: ",[332,750,249],{"class":342},[332,752,753],{"class":346},",\n",[332,755,756,759,762],{"class":334,"line":400},[332,757,758],{"class":346},"    action: ",[332,760,761],{"class":342},"String",[332,763,753],{"class":346},[332,765,766,769,771,774,778],{"class":334,"line":417},[332,767,768],{"class":346},"    parameters: ",[332,770,761],{"class":342},[332,772,773],{"class":338}," =",[332,775,777],{"class":776},"sZZnC"," \"\u003CInstanceID>0\u003C\u002FInstanceID>\"",[332,779,753],{"class":346},[332,781,782,785,787],{"class":334,"line":429},[332,783,784],{"class":346},"): ",[332,786,761],{"class":342},[332,788,789],{"class":346}," {\n",[332,791,792,794,797,799,802,805,808,810],{"class":334,"line":437},[332,793,562],{"class":338},[332,795,796],{"class":346}," st ",[332,798,361],{"class":338},[332,800,801],{"class":346}," device.",[332,803,804],{"class":342},"getAVTransportService",[332,806,807],{"class":346},"()?.serviceType ?: ",[332,809,665],{"class":338},[332,811,812],{"class":776}," \"\"\n",[332,814,815,817,820,823,826,829,832,835,838,841,844,846,849],{"class":334,"line":443},[332,816,679],{"class":338},[332,818,819],{"class":342}," executeSOAPRequest",[332,821,822],{"class":346},"(device, action, ",[332,824,825],{"class":776},"\"\u003Cu:",[332,827,828],{"class":528},"$action",[332,830,831],{"class":776}," xmlns:u=",[332,833,834],{"class":528},"\\\"$st\\\"",[332,836,837],{"class":776},">",[332,839,840],{"class":528},"$parameters",[332,842,843],{"class":776},"\u003C\u002Fu:",[332,845,828],{"class":528},[332,847,848],{"class":776},">\"",[332,850,539],{"class":346},[332,852,853],{"class":334,"line":648},[332,854,446],{"class":346},[29,856,857,860,861,864,865,868,869,872],{},[38,858,859],{},"executeSOAPRequest"," sets ",[38,862,863],{},"SOAPAction: \"\u003CserviceType>#\u003Caction>\""," and posts\n",[38,866,867],{},"DlnaSoap.requestEnvelope(soapBody)"," — the same envelope constants used by\nthe receiver side to build responses, so the wire format only needs to be\ndefined once in ",[38,870,871],{},"commonMain",".",[29,874,875,876,879,880,883,884,887,888,205,891,205,894,205,897,753,900,205,903,205,906,205,909,753,912,915,916,919,920,923],{},"The receiver's ",[38,877,878],{},"DlnaHttpRouter.handleSoap()"," mirrors this on the other\nend: it reads the ",[38,881,882],{},"soapaction"," header, extracts the action name after the\n",[38,885,886],{},"#",", and dispatches on it —",[38,889,890],{},"SetAVTransportURI",[38,892,893],{},"Play",[38,895,896],{},"Pause",[38,898,899],{},"Stop",[38,901,902],{},"Seek",[38,904,905],{},"GetTransportInfo",[38,907,908],{},"GetPositionInfo",[38,910,911],{},"GetMediaInfo",[38,913,914],{},"GetDeviceCapabilities",". ",[38,917,918],{},"RenderingControl"," (volume) is stubbed with a\nstatic ",[38,921,922],{},"100"," — PlainApp doesn't expose device volume through UPnP.",[51,925,96],{"id":926},"didl-lite-metadata-and-the-double-escaping-quirk",[29,928,929,931,932,935,936,939,940,943],{},[38,930,890],{}," carries two parameters: ",[38,933,934],{},"CurrentURI"," (the media URL)\nand ",[38,937,938],{},"CurrentURIMetaData"," — a ",[33,941,942],{},"DIDL-Lite"," XML fragment describing the\ntitle, media class, and album art, embedded as an XML-escaped string\ninside the outer SOAP body:",[323,945,947],{"className":325,"code":946,"language":327,"meta":328,"style":328},"private fun buildDidlLiteMetadata(mediaUrl: String, title: String, albumArtUri: String): String {\n    val upnpClass = when {\n        ext in setOf(\"mp3\", \"m4a\", \"flac\", ...) -> \"object.item.audioItem.musicTrack\"\n        ext in setOf(\"jpg\", \"jpeg\", \"png\", ...) -> \"object.item.imageItem\"\n        else -> \"object.item.videoItem\"\n    }\n    val didl = \"\"\"\u003CDIDL-Lite xmlns=\"...\">\u003Citem id=\"0\" parentID=\"-1\" restricted=\"0\">\n        \u003Cdc:title>$escapedTitle\u003C\u002Fdc:title>\u003Cupnp:class>$upnpClass\u003C\u002Fupnp:class>$albumArtTag\u003C\u002Fitem>\u003C\u002FDIDL-Lite>\"\"\"\n    return didl.replace(\"&\", \"&amp;\").replace(\"\u003C\", \"&lt;\").replace(\">\", \"&gt;\")\n}\n",[38,948,949,979,993,1031,1065,1076,1080,1092,1115,1166],{"__ignoreMap":328},[332,950,951,953,955,958,961,963,966,968,971,973,975,977],{"class":334,"line":335},[332,952,511],{"class":338},[332,954,546],{"class":338},[332,956,957],{"class":342}," buildDidlLiteMetadata",[332,959,960],{"class":346},"(mediaUrl: ",[332,962,761],{"class":342},[332,964,965],{"class":346},", title: ",[332,967,761],{"class":342},[332,969,970],{"class":346},", albumArtUri: ",[332,972,761],{"class":342},[332,974,784],{"class":346},[332,976,761],{"class":342},[332,978,789],{"class":346},[332,980,981,983,986,988,991],{"class":334,"line":379},[332,982,562],{"class":338},[332,984,985],{"class":346}," upnpClass ",[332,987,361],{"class":338},[332,989,990],{"class":338}," when",[332,992,789],{"class":346},[332,994,995,998,1000,1003,1005,1008,1010,1013,1015,1018,1020,1023,1026,1028],{"class":334,"line":400},[332,996,997],{"class":346},"        ext ",[332,999,627],{"class":338},[332,1001,1002],{"class":342}," setOf",[332,1004,525],{"class":346},[332,1006,1007],{"class":776},"\"mp3\"",[332,1009,205],{"class":346},[332,1011,1012],{"class":776},"\"m4a\"",[332,1014,205],{"class":346},[332,1016,1017],{"class":776},"\"flac\"",[332,1019,205],{"class":346},[332,1021,1022],{"class":338},"..",[332,1024,1025],{"class":346},".) ",[332,1027,602],{"class":338},[332,1029,1030],{"class":776}," \"object.item.audioItem.musicTrack\"\n",[332,1032,1033,1035,1037,1039,1041,1044,1046,1049,1051,1054,1056,1058,1060,1062],{"class":334,"line":417},[332,1034,997],{"class":346},[332,1036,627],{"class":338},[332,1038,1002],{"class":342},[332,1040,525],{"class":346},[332,1042,1043],{"class":776},"\"jpg\"",[332,1045,205],{"class":346},[332,1047,1048],{"class":776},"\"jpeg\"",[332,1050,205],{"class":346},[332,1052,1053],{"class":776},"\"png\"",[332,1055,205],{"class":346},[332,1057,1022],{"class":338},[332,1059,1025],{"class":346},[332,1061,602],{"class":338},[332,1063,1064],{"class":776}," \"object.item.imageItem\"\n",[332,1066,1067,1070,1073],{"class":334,"line":429},[332,1068,1069],{"class":338},"        else",[332,1071,1072],{"class":338}," ->",[332,1074,1075],{"class":776}," \"object.item.videoItem\"\n",[332,1077,1078],{"class":334,"line":437},[332,1079,440],{"class":346},[332,1081,1082,1084,1087,1089],{"class":334,"line":443},[332,1083,562],{"class":338},[332,1085,1086],{"class":346}," didl ",[332,1088,361],{"class":338},[332,1090,1091],{"class":776}," \"\"\"\u003CDIDL-Lite xmlns=\"...\">\u003Citem id=\"0\" parentID=\"-1\" restricted=\"0\">\n",[332,1093,1094,1097,1100,1103,1106,1109,1112],{"class":334,"line":648},[332,1095,1096],{"class":776},"        \u003Cdc:title>",[332,1098,1099],{"class":528},"$escapedTitle",[332,1101,1102],{"class":776},"\u003C\u002Fdc:title>\u003Cupnp:class>",[332,1104,1105],{"class":528},"$upnpClass",[332,1107,1108],{"class":776},"\u003C\u002Fupnp:class>",[332,1110,1111],{"class":528},"$albumArtTag",[332,1113,1114],{"class":776},"\u003C\u002Fitem>\u003C\u002FDIDL-Lite>\"\"\"\n",[332,1116,1117,1119,1122,1125,1127,1130,1132,1135,1138,1140,1142,1145,1147,1150,1152,1154,1156,1159,1161,1164],{"class":334,"line":671},[332,1118,679],{"class":338},[332,1120,1121],{"class":346}," didl.",[332,1123,1124],{"class":342},"replace",[332,1126,525],{"class":346},[332,1128,1129],{"class":776},"\"&\"",[332,1131,205],{"class":346},[332,1133,1134],{"class":776},"\"&amp;\"",[332,1136,1137],{"class":346},").",[332,1139,1124],{"class":342},[332,1141,525],{"class":346},[332,1143,1144],{"class":776},"\"\u003C\"",[332,1146,205],{"class":346},[332,1148,1149],{"class":776},"\"&lt;\"",[332,1151,1137],{"class":346},[332,1153,1124],{"class":342},[332,1155,525],{"class":346},[332,1157,1158],{"class":776},"\">\"",[332,1160,205],{"class":346},[332,1162,1163],{"class":776},"\"&gt;\"",[332,1165,539],{"class":346},[332,1167,1168],{"class":334,"line":676},[332,1169,446],{"class":346},[29,1171,1172,1173,1176,1177,1180,1181,1183,1184,1186,1187,1190,1191,1193],{},"The DIDL-Lite XML is escaped ",[33,1174,1175],{},"twice",": once for the title text itself\n(so a song called ",[38,1178,1179],{},"Fire & Ice"," doesn't break the DIDL-Lite tags), and once\nfor the entire DIDL-Lite document (so its own ",[38,1182,353],{},"\u002F",[38,1185,837],{}," don't break the\n",[43,1188,1189],{},"outer"," SOAP envelope it's embedded in as text). This is a well-known\nUPnP quirk, not a bug — ",[38,1192,938],{}," is defined as string content,\nnot as nested XML elements.",[29,1195,1196,1197,1199,1200,1203,1204,1183,1207,1210,1211,1214],{},"On the receiver side, ",[38,1198,236],{}," reverses this: ",[38,1201,1202],{},"parseSoapAction","\nun-escapes the SOAP body once to get the DIDL-Lite text, then\n",[38,1205,1206],{},"extractTitleFromDidlMeta",[38,1208,1209],{},"extractMediaTypeFromDidlMeta","\u002F\n",[38,1212,1213],{},"extractAlbumArtUriFromDidlMeta"," each do a second pass of entity\nunescaping and tag extraction on that inner string:",[323,1216,1218],{"className":325,"code":1217,"language":327,"meta":328,"style":328},"fun extractMediaTypeFromDidlMeta(meta: String, fallbackUri: String = \"\"): DlnaMediaType {\n    val cls = meta.substring(classStart + 12, classEnd).lowercase()\n    return when {\n        \"audioitem\" in cls || \"musictrack\" in cls -> DlnaMediaType.AUDIO\n        \"imageitem\" in cls || \"photo\" in cls -> DlnaMediaType.IMAGE\n        \"videoitem\" in cls -> DlnaMediaType.VIDEO\n        else -> DlnaMediaType.UNKNOWN\n    }\n}\n",[38,1219,1220,1249,1281,1289,1314,1337,1351,1360,1364],{"__ignoreMap":328},[332,1221,1222,1224,1227,1230,1232,1235,1237,1239,1242,1244,1247],{"class":334,"line":335},[332,1223,339],{"class":338},[332,1225,1226],{"class":342}," extractMediaTypeFromDidlMeta",[332,1228,1229],{"class":346},"(meta: ",[332,1231,761],{"class":342},[332,1233,1234],{"class":346},", fallbackUri: ",[332,1236,761],{"class":342},[332,1238,773],{"class":338},[332,1240,1241],{"class":776}," \"\"",[332,1243,784],{"class":346},[332,1245,1246],{"class":342},"DlnaMediaType",[332,1248,789],{"class":346},[332,1250,1251,1253,1256,1258,1261,1264,1267,1269,1272,1275,1278],{"class":334,"line":379},[332,1252,562],{"class":338},[332,1254,1255],{"class":346}," cls ",[332,1257,361],{"class":338},[332,1259,1260],{"class":346}," meta.",[332,1262,1263],{"class":342},"substring",[332,1265,1266],{"class":346},"(classStart ",[332,1268,590],{"class":338},[332,1270,1271],{"class":528}," 12",[332,1273,1274],{"class":346},", classEnd).",[332,1276,1277],{"class":342},"lowercase",[332,1279,1280],{"class":346},"()\n",[332,1282,1283,1285,1287],{"class":334,"line":400},[332,1284,679],{"class":338},[332,1286,990],{"class":338},[332,1288,789],{"class":346},[332,1290,1291,1294,1297,1299,1302,1305,1307,1309,1311],{"class":334,"line":417},[332,1292,1293],{"class":776},"        \"audioitem\"",[332,1295,1296],{"class":338}," in",[332,1298,1255],{"class":346},[332,1300,1301],{"class":338},"||",[332,1303,1304],{"class":776}," \"musictrack\"",[332,1306,1296],{"class":338},[332,1308,1255],{"class":346},[332,1310,602],{"class":338},[332,1312,1313],{"class":346}," DlnaMediaType.AUDIO\n",[332,1315,1316,1319,1321,1323,1325,1328,1330,1332,1334],{"class":334,"line":429},[332,1317,1318],{"class":776},"        \"imageitem\"",[332,1320,1296],{"class":338},[332,1322,1255],{"class":346},[332,1324,1301],{"class":338},[332,1326,1327],{"class":776}," \"photo\"",[332,1329,1296],{"class":338},[332,1331,1255],{"class":346},[332,1333,602],{"class":338},[332,1335,1336],{"class":346}," DlnaMediaType.IMAGE\n",[332,1338,1339,1342,1344,1346,1348],{"class":334,"line":437},[332,1340,1341],{"class":776},"        \"videoitem\"",[332,1343,1296],{"class":338},[332,1345,1255],{"class":346},[332,1347,602],{"class":338},[332,1349,1350],{"class":346}," DlnaMediaType.VIDEO\n",[332,1352,1353,1355,1357],{"class":334,"line":443},[332,1354,1069],{"class":338},[332,1356,1072],{"class":338},[332,1358,1359],{"class":346}," DlnaMediaType.UNKNOWN\n",[332,1361,1362],{"class":334,"line":648},[332,1363,440],{"class":346},[332,1365,1366],{"class":334,"line":671},[332,1367,446],{"class":346},[29,1369,1370,1371,1374,1375,1378,1379,1382],{},"If ",[38,1372,1373],{},"\u003Cupnp:class>"," is missing (some senders omit it), ",[38,1376,1377],{},"cleanMediaTitle()","\nfalls back to the file extension of the URI itself — media type\ndetection never hard-fails, it just degrades to ",[38,1380,1381],{},"UNKNOWN"," which routes to\nthe video player as a safe default.",[51,1384,102],{"id":1385},"serving-media-to-the-tv-range-requests-dlna-headers",[29,1387,1388,1389,1391,1392,1395,1396,872],{},"A ",[38,1390,890],{}," call only tells the renderer ",[43,1393,1394],{},"where"," to fetch the\nmedia from — the actual bytes are served by PlainApp's own local HTTP\nserver, at ",[38,1397,1398],{},"\u002Fmedia\u002F{id}",[29,1400,1401],{},[159,1402],{"alt":1403,"src":1404},"Diagram 4","\u002Fblog\u002Fdlna-cast\u002Fdiagram-04.svg",[29,1406,1407,1410,1411,1414,1415,1418],{},[38,1408,1409],{},"UrlHelper.getMediaHttpUrl(path)"," registers the real path (which might be\na ",[38,1412,1413],{},"content:\u002F\u002F"," URI, a remote URL, or a plain file path) under a short id\nand returns ",[38,1416,1417],{},"http:\u002F\u002F\u003Cdevice-ip>:\u003Cport>\u002Fmedia\u002F\u003Cid>.\u003Cext>",". The route then\nbranches on what kind of source it actually is:",[323,1420,1422],{"className":325,"code":1421,"language":327,"meta":328,"style":328},"when {\n    path.isUrl() -> call.proxyUrl(path)                 \u002F\u002F remote URL: stream upstream response\n    isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }\n    path.isImageFast() -> call.respondFile(path)         \u002F\u002F images: plain static serve\n    else -> call.respondDlnaFile(path)                   \u002F\u002F audio\u002Fvideo: DLNA-aware serving\n}\n",[38,1423,1424,1431,1457,1483,1505,1523],{"__ignoreMap":328},[332,1425,1426,1429],{"class":334,"line":335},[332,1427,1428],{"class":338},"when",[332,1430,789],{"class":346},[332,1432,1433,1436,1439,1442,1444,1447,1450,1453],{"class":334,"line":379},[332,1434,1435],{"class":346},"    path.",[332,1437,1438],{"class":342},"isUrl",[332,1440,1441],{"class":346},"() ",[332,1443,602],{"class":338},[332,1445,1446],{"class":346}," call.",[332,1448,1449],{"class":342},"proxyUrl",[332,1451,1452],{"class":346},"(path)                 ",[332,1454,1456],{"class":1455},"sJ8bj","\u002F\u002F remote URL: stream upstream response\n",[332,1458,1459,1462,1465,1467,1469,1472,1475,1477,1480],{"class":334,"line":400},[332,1460,1461],{"class":342},"    isContentUri",[332,1463,1464],{"class":346},"(path) ",[332,1466,602],{"class":338},[332,1468,1446],{"class":346},[332,1470,1471],{"class":342},"respondStream",[332,1473,1474],{"class":346}," { sink ",[332,1476,602],{"class":338},[332,1478,1479],{"class":342}," streamContentUri",[332,1481,1482],{"class":346},"(path, sink) }\n",[332,1484,1485,1487,1490,1492,1494,1496,1499,1502],{"class":334,"line":417},[332,1486,1435],{"class":346},[332,1488,1489],{"class":342},"isImageFast",[332,1491,1441],{"class":346},[332,1493,602],{"class":338},[332,1495,1446],{"class":346},[332,1497,1498],{"class":342},"respondFile",[332,1500,1501],{"class":346},"(path)         ",[332,1503,1504],{"class":1455},"\u002F\u002F images: plain static serve\n",[332,1506,1507,1510,1512,1514,1517,1520],{"class":334,"line":429},[332,1508,1509],{"class":338},"    else",[332,1511,1072],{"class":338},[332,1513,1446],{"class":346},[332,1515,1516],{"class":342},"respondDlnaFile",[332,1518,1519],{"class":346},"(path)                   ",[332,1521,1522],{"class":1455},"\u002F\u002F audio\u002Fvideo: DLNA-aware serving\n",[332,1524,1525],{"class":334,"line":437},[332,1526,446],{"class":346},[29,1528,1529,1531],{},[38,1530,1516],{}," is the interesting case — many smart TVs and DLNA\nrenderers refuse to play a stream unless it looks like a proper DLNA\nmedia server response:",[323,1533,1535],{"className":325,"code":1534,"language":327,"meta":328,"style":328},"override suspend fun respondDlnaFile(path: String): Boolean {\n    val file = java.io.File(path)\n    if (!file.exists()) return false\n    applicationCall.response.run {\n        header(\"realTimeInfo.dlna.org\", \"DLNA.ORG_TLAG=*\")\n        header(\"contentFeatures.dlna.org\", \"\")\n        header(\"transferMode.dlna.org\", \"Streaming\")\n        header(\"Connection\", \"keep-alive\")\n        header(\"Server\", \"DLNADOC\u002F1.50 UPnP\u002F1.0 Plain\u002F1.0 Android\u002F${android.os.Build.VERSION.RELEASE}\")\n        status(HttpStatusCode.PartialContent) \u002F\u002F some TV OS only accept 206\n    }\n    applicationCall.respond(LocalFileContent(file))\n    return true\n}\n",[38,1536,1537,1561,1579,1603,1613,1630,1646,1662,1678,1694,1705,1709,1726,1734],{"__ignoreMap":328},[332,1538,1539,1542,1544,1546,1549,1552,1554,1556,1559],{"class":334,"line":335},[332,1540,1541],{"class":338},"override",[332,1543,735],{"class":338},[332,1545,546],{"class":338},[332,1547,1548],{"class":342}," respondDlnaFile",[332,1550,1551],{"class":346},"(path: ",[332,1553,761],{"class":342},[332,1555,784],{"class":346},[332,1557,1558],{"class":342},"Boolean",[332,1560,789],{"class":346},[332,1562,1563,1565,1568,1570,1573,1576],{"class":334,"line":379},[332,1564,562],{"class":338},[332,1566,1567],{"class":346}," file ",[332,1569,361],{"class":338},[332,1571,1572],{"class":346}," java.io.",[332,1574,1575],{"class":342},"File",[332,1577,1578],{"class":346},"(path)\n",[332,1580,1581,1583,1586,1589,1592,1595,1598,1600],{"class":334,"line":400},[332,1582,382],{"class":338},[332,1584,1585],{"class":346}," (",[332,1587,1588],{"class":338},"!",[332,1590,1591],{"class":346},"file.",[332,1593,1594],{"class":342},"exists",[332,1596,1597],{"class":346},"()) ",[332,1599,665],{"class":338},[332,1601,1602],{"class":528}," false\n",[332,1604,1605,1608,1611],{"class":334,"line":417},[332,1606,1607],{"class":346},"    applicationCall.response.",[332,1609,1610],{"class":342},"run",[332,1612,789],{"class":346},[332,1614,1615,1618,1620,1623,1625,1628],{"class":334,"line":429},[332,1616,1617],{"class":342},"        header",[332,1619,525],{"class":346},[332,1621,1622],{"class":776},"\"realTimeInfo.dlna.org\"",[332,1624,205],{"class":346},[332,1626,1627],{"class":776},"\"DLNA.ORG_TLAG=*\"",[332,1629,539],{"class":346},[332,1631,1632,1634,1636,1639,1641,1644],{"class":334,"line":437},[332,1633,1617],{"class":342},[332,1635,525],{"class":346},[332,1637,1638],{"class":776},"\"contentFeatures.dlna.org\"",[332,1640,205],{"class":346},[332,1642,1643],{"class":776},"\"\"",[332,1645,539],{"class":346},[332,1647,1648,1650,1652,1655,1657,1660],{"class":334,"line":443},[332,1649,1617],{"class":342},[332,1651,525],{"class":346},[332,1653,1654],{"class":776},"\"transferMode.dlna.org\"",[332,1656,205],{"class":346},[332,1658,1659],{"class":776},"\"Streaming\"",[332,1661,539],{"class":346},[332,1663,1664,1666,1668,1671,1673,1676],{"class":334,"line":648},[332,1665,1617],{"class":342},[332,1667,525],{"class":346},[332,1669,1670],{"class":776},"\"Connection\"",[332,1672,205],{"class":346},[332,1674,1675],{"class":776},"\"keep-alive\"",[332,1677,539],{"class":346},[332,1679,1680,1682,1684,1687,1689,1692],{"class":334,"line":671},[332,1681,1617],{"class":342},[332,1683,525],{"class":346},[332,1685,1686],{"class":776},"\"Server\"",[332,1688,205],{"class":346},[332,1690,1691],{"class":776},"\"DLNADOC\u002F1.50 UPnP\u002F1.0 Plain\u002F1.0 Android\u002F${android.os.Build.VERSION.RELEASE}\"",[332,1693,539],{"class":346},[332,1695,1696,1699,1702],{"class":334,"line":676},[332,1697,1698],{"class":342},"        status",[332,1700,1701],{"class":346},"(HttpStatusCode.PartialContent) ",[332,1703,1704],{"class":1455},"\u002F\u002F some TV OS only accept 206\n",[332,1706,1707],{"class":334,"line":685},[332,1708,440],{"class":346},[332,1710,1712,1715,1718,1720,1723],{"class":334,"line":1711},12,[332,1713,1714],{"class":346},"    applicationCall.",[332,1716,1717],{"class":342},"respond",[332,1719,525],{"class":346},[332,1721,1722],{"class":342},"LocalFileContent",[332,1724,1725],{"class":346},"(file))\n",[332,1727,1729,1731],{"class":334,"line":1728},13,[332,1730,679],{"class":338},[332,1732,1733],{"class":528}," true\n",[332,1735,1737],{"class":334,"line":1736},14,[332,1738,446],{"class":346},[29,1740,1741,1742,1745,1746,1748,1749,1752],{},"Note the status is always ",[38,1743,1744],{},"206 Partial Content",", not ",[38,1747,293],{}," — some TV\nfirmware treats a plain ",[38,1750,1751],{},"200"," response as \"not seekable\" and refuses to\nplay it, even for a full-file GET. This one status-code choice is the\ndifference between \"plays fine\" and \"TV shows a spinner forever\" on\nseveral real devices.",[29,1754,1755,1756,1759,1760,1763,1764,1767,1768,1770,1771,1773],{},"The same route serves ",[33,1757,1758],{},"album art"," for cast audio items too:\n",[38,1761,1762],{},"UrlHelper.getAlbumArtHttpUrl()"," maps a ",[38,1765,1766],{},"content:\u002F\u002Fmedia\u002F...\u002Falbumart\u002F\u003Cid>","\nURI into the identical ",[38,1769,1398],{}," path, so ",[38,1772,1413],{}," streaming and\nDLNA file serving share one code path regardless of whether the \"media\"\nin question is the song or its cover image.",[51,1775,108],{"id":1776},"gena-events-playback-state-callbacks",[29,1778,1779,1780,1783],{},"After starting playback, the sender subscribes to the renderer's\n",[33,1781,1782],{},"AVTransport eventing"," service (GENA — General Event Notification\nArchitecture) so it learns about state changes without polling:",[323,1785,1787],{"className":325,"code":1786,"language":327,"meta":328,"style":328},"suspend fun subscribeEvent(device: DlnaDevice, callbackUrl: String): String {\n    val service = device.getAVTransportService() ?: return \"\"\n    val response = createHttpClient().subscribe(baseUrl + eventSubURL) {\n        headers { set(\"NT\", \"upnp:event\"); set(\"TIMEOUT\", \"Second-3600\"); set(\"CALLBACK\", \"\u003C$callbackUrl>\") }\n    }\n    return response.headers[\"SID\"].orEmpty()\n}\n",[38,1788,1789,1815,1835,1860,1917,1921,1939],{"__ignoreMap":328},[332,1790,1791,1794,1796,1799,1802,1804,1807,1809,1811,1813],{"class":334,"line":335},[332,1792,1793],{"class":338},"suspend",[332,1795,546],{"class":338},[332,1797,1798],{"class":342}," subscribeEvent",[332,1800,1801],{"class":346},"(device: ",[332,1803,249],{"class":342},[332,1805,1806],{"class":346},", callbackUrl: ",[332,1808,761],{"class":342},[332,1810,784],{"class":346},[332,1812,761],{"class":342},[332,1814,789],{"class":346},[332,1816,1817,1819,1822,1824,1826,1828,1831,1833],{"class":334,"line":379},[332,1818,562],{"class":338},[332,1820,1821],{"class":346}," service ",[332,1823,361],{"class":338},[332,1825,801],{"class":346},[332,1827,804],{"class":342},[332,1829,1830],{"class":346},"() ?: ",[332,1832,665],{"class":338},[332,1834,812],{"class":776},[332,1836,1837,1839,1842,1844,1847,1849,1852,1855,1857],{"class":334,"line":400},[332,1838,562],{"class":338},[332,1840,1841],{"class":346}," response ",[332,1843,361],{"class":338},[332,1845,1846],{"class":342}," createHttpClient",[332,1848,367],{"class":346},[332,1850,1851],{"class":342},"subscribe",[332,1853,1854],{"class":346},"(baseUrl ",[332,1856,590],{"class":338},[332,1858,1859],{"class":346}," eventSubURL) {\n",[332,1861,1862,1865,1867,1870,1872,1875,1877,1880,1883,1885,1887,1890,1892,1895,1897,1899,1901,1904,1906,1909,1912,1914],{"class":334,"line":417},[332,1863,1864],{"class":342},"        headers",[332,1866,581],{"class":346},[332,1868,1869],{"class":338},"set",[332,1871,525],{"class":346},[332,1873,1874],{"class":776},"\"NT\"",[332,1876,205],{"class":346},[332,1878,1879],{"class":776},"\"upnp:event\"",[332,1881,1882],{"class":346},"); ",[332,1884,1869],{"class":338},[332,1886,525],{"class":346},[332,1888,1889],{"class":776},"\"TIMEOUT\"",[332,1891,205],{"class":346},[332,1893,1894],{"class":776},"\"Second-3600\"",[332,1896,1882],{"class":346},[332,1898,1869],{"class":338},[332,1900,525],{"class":346},[332,1902,1903],{"class":776},"\"CALLBACK\"",[332,1905,205],{"class":346},[332,1907,1908],{"class":776},"\"\u003C",[332,1910,1911],{"class":528},"$callbackUrl",[332,1913,848],{"class":776},[332,1915,1916],{"class":346},") }\n",[332,1918,1919],{"class":334,"line":429},[332,1920,440],{"class":346},[332,1922,1923,1925,1928,1931,1934,1937],{"class":334,"line":437},[332,1924,679],{"class":338},[332,1926,1927],{"class":346}," response.headers[",[332,1929,1930],{"class":776},"\"SID\"",[332,1932,1933],{"class":346},"].",[332,1935,1936],{"class":342},"orEmpty",[332,1938,1280],{"class":346},[332,1940,1941],{"class":334,"line":443},[332,1942,446],{"class":346},[29,1944,1945,1183,1948,1183,1951,1954,1955,1958,1959,1962,1963,1966,1967,1970],{},[38,1946,1947],{},"SUBSCRIBE",[38,1949,1950],{},"RENEW",[38,1952,1953],{},"UNSUBSCRIBE"," are custom HTTP methods (not in the\nstandard verb set), handled via Ktor's generic ",[38,1956,1957],{},"HttpMethod(\"SUBSCRIBE\")","\nrequest builder. The renderer then ",[38,1960,1961],{},"NOTIFY","s\n",[38,1964,1965],{},"callbackUrl"," — PlainApp's own ",[38,1968,1969],{},"\u002Fcallback\u002Fcast"," route — whenever\ntransport state, position, or duration changes:",[323,1972,1974],{"className":325,"code":1973,"language":327,"meta":328,"style":328},"if (xml.contains(\"TransportState val=\\\"STOPPED\\\"\") && !xml.contains(\"AVTransportURIMetaData\")) {\n    \u002F\u002F advance to next playlist item\n} else if (xml.contains(\"TransportState val=\\\"PLAYING\\\"\")) {\n    CastPlayer.isPlaying.value = true\n}\n",[38,1975,1976,2024,2029,2059,2071],{"__ignoreMap":328},[332,1977,1978,1981,1984,1987,1989,1992,1995,1998,2000,2003,2005,2008,2011,2014,2016,2018,2021],{"class":334,"line":335},[332,1979,1980],{"class":338},"if",[332,1982,1983],{"class":346}," (xml.",[332,1985,1986],{"class":342},"contains",[332,1988,525],{"class":346},[332,1990,1991],{"class":776},"\"TransportState val=",[332,1993,1994],{"class":528},"\\\"",[332,1996,1997],{"class":776},"STOPPED",[332,1999,1994],{"class":528},[332,2001,2002],{"class":776},"\"",[332,2004,662],{"class":346},[332,2006,2007],{"class":338},"&&",[332,2009,2010],{"class":338}," !",[332,2012,2013],{"class":346},"xml.",[332,2015,1986],{"class":342},[332,2017,525],{"class":346},[332,2019,2020],{"class":776},"\"AVTransportURIMetaData\"",[332,2022,2023],{"class":346},")) {\n",[332,2025,2026],{"class":334,"line":379},[332,2027,2028],{"class":1455},"    \u002F\u002F advance to next playlist item\n",[332,2030,2031,2034,2037,2040,2042,2044,2046,2048,2050,2053,2055,2057],{"class":334,"line":400},[332,2032,2033],{"class":346},"} ",[332,2035,2036],{"class":338},"else",[332,2038,2039],{"class":338}," if",[332,2041,1983],{"class":346},[332,2043,1986],{"class":342},[332,2045,525],{"class":346},[332,2047,1991],{"class":776},[332,2049,1994],{"class":528},[332,2051,2052],{"class":776},"PLAYING",[332,2054,1994],{"class":528},[332,2056,2002],{"class":776},[332,2058,2023],{"class":346},[332,2060,2061,2064,2067,2069],{"class":334,"line":417},[332,2062,2063],{"class":346},"    CastPlayer.isPlaying.",[332,2065,2066],{"class":338},"value",[332,2068,773],{"class":338},[332,2070,1733],{"class":528},[332,2072,2073],{"class":334,"line":429},[332,2074,446],{"class":346},[484,2076,116],{"id":2077},"the-duplicate-callback-guard",[29,2079,2080,2081,2084,2085,2087,2088,2090,2091,2094,2095,2098,2099,2102,2103,2106,2107,2109,2110,2112,2113,2116],{},"Some renderers send ",[33,2082,2083],{},"two"," ",[38,2086,1961],{}," callbacks in quick succession for the\nsame ",[38,2089,1997],{}," transition — the second one happens to carry\n",[38,2092,2093],{},"AVTransportURIMetaData"," while the first doesn't. Advancing the playlist\non both would skip a track every time playback naturally stops. The\n",[38,2096,2097],{},"!xml.contains(\"AVTransportURIMetaData\")"," check is a deliberate,\nnarrow filter: only the ",[43,2100,2101],{},"first"," STOPPED notification (without metadata)\ntriggers auto-advance. A ",[38,2104,2105],{},"startPositionUpdater()"," job also polls\n",[38,2108,908],{}," every second as a fallback, since PlainApp's own\n",[38,2111,1947],{}," acknowledgment doesn't push events back to ",[43,2114,2115],{},"other","\ncontrollers — only the sender side consumes GENA callbacks from TVs.",[51,2118,122],{"id":2119},"receiver-mode-becoming-a-upnp-mediarenderer",[29,2121,2122,2123,2125,2126,2128],{},"Flip the direction: any DLNA controller (a TV remote app, VLC, another\nPlainApp) can push media to the phone itself. ",[38,2124,230],{}," opens\nthe same kind of HTTP + SSDP server described above, but as the\n",[38,2127,40],{}," being controlled rather than the controller.",[29,2130,2131,2134,2135,2137,2138,2141,2142,2145,2146,2148,2149,2151,2152,2154,2155,2158],{},[38,2132,2133],{},"DlnaHttpRouter.route()"," serves ",[38,2136,301],{}," (built by\n",[38,2139,2140],{},"DlnaXmlTemplates.deviceDescription()",", listing an ",[38,2143,2144],{},"AVTransport"," and a\nstub ",[38,2147,918],{}," service) and dispatches SOAP actions to\n",[38,2150,236],{},". A ",[38,2153,890],{}," call doesn't play anything\nimmediately — it stores a ",[38,2156,2157],{},"PendingCastRequest"," and waits:",[323,2160,2162],{"className":325,"code":2161,"language":327,"meta":328,"style":328},"if (uri.isNotEmpty()) {\n    DlnaRendererState.rawPendingCastRequest.value =\n        PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)\n    DlnaRendererState.pendingPlayQueued.value = false\n}\n",[38,2163,2164,2177,2187,2195,2206],{"__ignoreMap":328},[332,2165,2166,2168,2171,2174],{"class":334,"line":335},[332,2167,1980],{"class":338},[332,2169,2170],{"class":346}," (uri.",[332,2172,2173],{"class":342},"isNotEmpty",[332,2175,2176],{"class":346},"()) {\n",[332,2178,2179,2182,2184],{"class":334,"line":379},[332,2180,2181],{"class":346},"    DlnaRendererState.rawPendingCastRequest.",[332,2183,2066],{"class":338},[332,2185,2186],{"class":338}," =\n",[332,2188,2189,2192],{"class":334,"line":400},[332,2190,2191],{"class":342},"        PendingCastRequest",[332,2193,2194],{"class":346},"(senderIp, senderName, uri, title, mediaType, albumArtUri)\n",[332,2196,2197,2200,2202,2204],{"class":334,"line":417},[332,2198,2199],{"class":346},"    DlnaRendererState.pendingPlayQueued.",[332,2201,2066],{"class":338},[332,2203,773],{"class":338},[332,2205,1602],{"class":528},[332,2207,2208],{"class":334,"line":429},[332,2209,446],{"class":346},[29,2211,1388,2212,2214,2215,2218],{},[38,2213,893],{}," that arrives before the pending request is resolved doesn't\nstart playback either — it sets ",[38,2216,2217],{},"pendingPlayQueued = true"," so the command\nreplays automatically once the cast request is accepted, instead of being\nsilently lost:",[323,2220,2222],{"className":325,"code":2221,"language":327,"meta":328,"style":328},"val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||\n    DlnaRendererState.pendingCastRequest.value != null\nif (hasPending) {\n    DlnaRendererState.pendingPlayQueued.value = true\n} else {\n    DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)\n}\n",[38,2223,2224,2247,2258,2265,2275,2283,2294],{"__ignoreMap":328},[332,2225,2226,2229,2232,2234,2237,2239,2242,2244],{"class":334,"line":335},[332,2227,2228],{"class":338},"val",[332,2230,2231],{"class":346}," hasPending ",[332,2233,361],{"class":338},[332,2235,2236],{"class":346}," DlnaRendererState.rawPendingCastRequest.",[332,2238,2066],{"class":338},[332,2240,2241],{"class":338}," !=",[332,2243,659],{"class":528},[332,2245,2246],{"class":338}," ||\n",[332,2248,2249,2252,2254,2256],{"class":334,"line":379},[332,2250,2251],{"class":346},"    DlnaRendererState.pendingCastRequest.",[332,2253,2066],{"class":338},[332,2255,2241],{"class":338},[332,2257,682],{"class":528},[332,2259,2260,2262],{"class":334,"line":400},[332,2261,1980],{"class":338},[332,2263,2264],{"class":346}," (hasPending) {\n",[332,2266,2267,2269,2271,2273],{"class":334,"line":417},[332,2268,2199],{"class":346},[332,2270,2066],{"class":338},[332,2272,773],{"class":338},[332,2274,1733],{"class":528},[332,2276,2277,2279,2281],{"class":334,"line":429},[332,2278,2033],{"class":346},[332,2280,2036],{"class":338},[332,2282,789],{"class":346},[332,2284,2285,2288,2291],{"class":334,"line":437},[332,2286,2287],{"class":346},"    DlnaRendererState.commandChannel.",[332,2289,2290],{"class":342},"trySend",[332,2292,2293],{"class":346},"(DlnaCommand.Play)\n",[332,2295,2296],{"class":334,"line":443},[332,2297,446],{"class":346},[29,2299,2300,2301,2304,2305,2307,2308,2311,2312,2314,2315,2318,2319,2322],{},"Accepted commands flow through a single ",[38,2302,2303],{},"Channel\u003CDlnaCommand>"," that\n",[38,2306,239],{}," drains, decoupling the raw socket-handling\ncoroutine from UI\u002Fplayer state — the HTTP handler never touches\n",[38,2309,2310],{},"ExoPlayer"," directly. Playback then routes to one of three full-screen\ncomposables by ",[38,2313,1246],{},": ",[38,2316,2317],{},"DlnaReceiverAudioPlayerContent"," (gradient\nbackground, album art, seek bar), an image viewer, or\n",[38,2320,2321],{},"DlnaReceiverVideoPlayerContent"," (ExoPlayer).",[51,2324,128],{"id":2325},"security-sender-trust-via-allowdeny-lists",[29,2327,2328,2329,2332,2333,2335],{},"DLNA has ",[33,2330,2331],{},"no authentication"," by design — any device on the LAN can send\na renderer a ",[38,2334,890],{},". Turning a personal phone into an\nunauthenticated MediaRenderer would let anyone on the same Wi-Fi (a\nshared office network, a friend's house, a hostile guest network) push\narbitrary media URLs to it. PlainApp closes this gap with a\nper-sender-IP trust list, gating every incoming cast request:",[29,2337,2338],{},[159,2339],{"alt":2340,"src":2341},"Diagram 5","\u002Fblog\u002Fdlna-cast\u002Fdiagram-05.svg",[323,2343,2345],{"className":325,"code":2344,"language":327,"meta":328,"style":328},"DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->\n    val allowed = DlnaAllowedSendersPreference.getAsync()\n    val denied = DlnaDeniedSendersPreference.getAsync()\n    when {\n        DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {\n            \u002F\u002F Auto-accept: send commands directly without showing a dialog\n        }\n        DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {\n            \u002F\u002F Auto-reject: silently discard\n        }\n        else -> {\n            \u002F\u002F Unknown sender: promote to UI-visible state for the user to decide\n            DlnaRendererState.pendingCastRequest.value = pending\n        }\n    }\n}\n",[38,2346,2347,2365,2382,2398,2405,2420,2425,2430,2444,2449,2453,2461,2466,2478,2482,2487],{"__ignoreMap":328},[332,2348,2349,2352,2355,2357,2360,2363],{"class":334,"line":335},[332,2350,2351],{"class":346},"DlnaRendererState.rawPendingCastRequest.",[332,2353,2354],{"class":342},"filterNotNull",[332,2356,367],{"class":346},[332,2358,2359],{"class":342},"collect",[332,2361,2362],{"class":346}," { pending ",[332,2364,376],{"class":338},[332,2366,2367,2369,2372,2374,2377,2380],{"class":334,"line":379},[332,2368,562],{"class":338},[332,2370,2371],{"class":346}," allowed ",[332,2373,361],{"class":338},[332,2375,2376],{"class":346}," DlnaAllowedSendersPreference.",[332,2378,2379],{"class":342},"getAsync",[332,2381,1280],{"class":346},[332,2383,2384,2386,2389,2391,2394,2396],{"class":334,"line":400},[332,2385,562],{"class":338},[332,2387,2388],{"class":346}," denied ",[332,2390,361],{"class":338},[332,2392,2393],{"class":346}," DlnaDeniedSendersPreference.",[332,2395,2379],{"class":342},[332,2397,1280],{"class":346},[332,2399,2400,2403],{"class":334,"line":417},[332,2401,2402],{"class":338},"    when",[332,2404,789],{"class":346},[332,2406,2407,2410,2413,2416,2418],{"class":334,"line":429},[332,2408,2409],{"class":346},"        DlnaAllowedSendersPreference.",[332,2411,2412],{"class":342},"containsIp",[332,2414,2415],{"class":346},"(allowed, pending.senderIp) ",[332,2417,602],{"class":338},[332,2419,789],{"class":346},[332,2421,2422],{"class":334,"line":437},[332,2423,2424],{"class":1455},"            \u002F\u002F Auto-accept: send commands directly without showing a dialog\n",[332,2426,2427],{"class":334,"line":443},[332,2428,2429],{"class":346},"        }\n",[332,2431,2432,2435,2437,2440,2442],{"class":334,"line":648},[332,2433,2434],{"class":346},"        DlnaDeniedSendersPreference.",[332,2436,2412],{"class":342},[332,2438,2439],{"class":346},"(denied, pending.senderIp) ",[332,2441,602],{"class":338},[332,2443,789],{"class":346},[332,2445,2446],{"class":334,"line":671},[332,2447,2448],{"class":1455},"            \u002F\u002F Auto-reject: silently discard\n",[332,2450,2451],{"class":334,"line":676},[332,2452,2429],{"class":346},[332,2454,2455,2457,2459],{"class":334,"line":685},[332,2456,1069],{"class":338},[332,2458,1072],{"class":338},[332,2460,789],{"class":346},[332,2462,2463],{"class":334,"line":1711},[332,2464,2465],{"class":1455},"            \u002F\u002F Unknown sender: promote to UI-visible state for the user to decide\n",[332,2467,2468,2471,2473,2475],{"class":334,"line":1728},[332,2469,2470],{"class":346},"            DlnaRendererState.pendingCastRequest.",[332,2472,2066],{"class":338},[332,2474,773],{"class":338},[332,2476,2477],{"class":346}," pending\n",[332,2479,2480],{"class":334,"line":1736},[332,2481,2429],{"class":346},[332,2483,2485],{"class":334,"line":2484},15,[332,2486,440],{"class":346},[332,2488,2490],{"class":334,"line":2489},16,[332,2491,446],{"class":346},[29,2493,2494,2495,2497,2498,2500,2501,2504],{},"An unknown sender's cast request surfaces a confirmation dialog with an\noptional \"remember this choice\" flag; choosing to remember writes the\nsender's IP to the allow or deny preference so future requests from the\nsame address skip the dialog. This is enforced entirely in\n",[38,2496,239],{},", above the wire protocol — the SOAP handler\nitself always returns ",[38,2499,293],{}," regardless of the trust decision (per the\nUPnP spec, the transport call succeeded; whether the ",[43,2502,2503],{},"media"," actually\nplays is a separate, local decision).",[51,2506,134],{"id":2507},"platform-split-commonmain-orchestration-androidmain-sockets",[29,2509,2510],{},[159,2511],{"alt":2512,"src":2513},"Diagram 6","\u002Fblog\u002Fdlna-cast\u002Fdiagram-06.svg",[29,2515,2516,2517,2519,2520,2523,2524,2527,2528,2531,2532,2535,2536,2539,2540,2543,2544,2546],{},"Following the same pattern as PlainApp's other network features, only the\nraw byte-level socket I\u002FO is platform-specific. ",[38,2518,554],{}," and\n",[38,2521,2522],{},"DlnaSsdpSocket"," are ",[38,2525,2526],{},"expect"," interfaces; Android's ",[38,2529,2530],{},"actual","\nimplementations wrap ",[38,2533,2534],{},"java.net.ServerSocket"," and ",[38,2537,2538],{},"java.net.MulticastSocket","\ndirectly. Everything else — port selection, the SSDP alive\u002Fbyebye\ncadence, HTTP routing, SOAP parsing, DIDL-Lite metadata, and all four\n",[38,2541,2542],{},"DlnaCommand"," state transitions — lives in ",[38,2545,871],{}," and is unit\ntestable on the JVM without an Android device.",[29,2548,2549],{},"iOS gets sender-side SOAP control (it's a plain HTTP client, no sockets to\nimplement), but the receiver is a deliberate no-op:",[323,2551,2553],{"className":325,"code":2552,"language":327,"meta":328,"style":328},"actual fun startDlnaRenderer() {}\n",[38,2554,2555],{"__ignoreMap":328},[332,2556,2557,2560,2562,2565],{"class":334,"line":335},[332,2558,2559],{"class":346},"actual ",[332,2561,339],{"class":338},[332,2563,2564],{"class":342}," startDlnaRenderer",[332,2566,2567],{"class":346},"() {}\n",[29,2569,2570,2571,2573],{},"iOS doesn't expose a way to run a background UDP multicast listener\nreliably enough to be a good ",[38,2572,40],{}," citizen, so \"Wireless Cast\"\n(receiving) is Android-only; \"Cast to TV\" (sending) works on both.",[51,2575,140],{"id":2576},"pure-kotlin-engineering-notes",[29,2578,2579],{},"A few implementation details exist specifically to avoid platform\ndependencies that would break Kotlin Multiplatform sharing:",[56,2581,2582,2599,2615,2650],{},[59,2583,2584,2084,2587,2590,2591,2594,2595,2598],{},[33,2585,2586],{},"UUID v4 generation.",[38,2588,2589],{},"DlnaReceiverEngine.randomUuid()"," hand-rolls an\nRFC 4122 v4 UUID from ",[38,2592,2593],{},"Random.nextBytes(16)"," instead of\n",[38,2596,2597],{},"java.util.UUID.randomUUID()",", so the device identity generator is\nidentical on every platform.",[59,2600,2601,2084,2604,2606,2607,2610,2611,2614],{},[33,2602,2603],{},"Percent-decoding.",[38,2605,236],{},"'s private ",[38,2608,2609],{},"percentDecode()","\nreplaces ",[38,2612,2613],{},"java.net.URLDecoder.decode()"," for titles that arrive\nURL-encoded in a media URI.",[59,2616,2617,2084,2620,2623,2624,2627,2628,2631,2632,2635,2636,2639,2640,1183,2643,2646,2647,2649],{},[33,2618,2619],{},"Byte-level HTTP body reads.",[38,2621,2622],{},"Content-Length"," is a ",[43,2625,2626],{},"byte"," count, not\na character count. ",[38,2629,2630],{},"AndroidDlnaClientConnection.readHttpRequest()"," reads\nthe body via ",[38,2633,2634],{},"readBodyBytes(bis, contentLength)"," against the raw\n",[38,2637,2638],{},"BufferedInputStream",", never a ",[38,2641,2642],{},"BufferedReader",[38,2644,2645],{},"CharArray"," — a title\ncontaining multi-byte UTF-8 (e.g. Chinese characters, 3 bytes each)\nwould otherwise under-read the body and stall waiting for bytes that\nalready arrived, silently breaking ",[38,2648,890],{}," for\nnon-ASCII titles.",[59,2651,2652,2084,2658,2661,2662,2664,2665,205,2668,2671,2672,872],{},[33,2653,2654,2655,872],{},"Base URL parsing without ",[38,2656,2657],{},"java.net.URL",[38,2659,2660],{},"DlnaDevice.getBaseUrl()","\nextracts the scheme\u002Fhost\u002Fport from a ",[38,2663,297],{}," header with plain\nstring slicing (",[38,2666,2667],{},"substringAfter(\":\u002F\u002F\")",[38,2669,2670],{},"substringBefore('\u002F')",")\ninstead of constructing a ",[38,2673,2657],{},[51,2675,146],{"id":2676},"design-patterns-recap",[167,2678,2679,2692],{},[170,2680,2681],{},[173,2682,2683,2686,2689],{},[176,2684,2685],{},"Pattern",[176,2687,2688],{},"Where",[176,2690,2691],{},"Why",[186,2693,2694,2712,2731,2745,2765,2780,2794,2812],{},[173,2695,2696,2701,2709],{},[191,2697,2698],{},[33,2699,2700],{},"Shared Protocol, Split I\u002FO",[191,2702,2703,1183,2705,2708],{},[38,2704,245],{},[38,2706,2707],{},"DlnaXmlTemplates"," in commonMain, sockets in androidMain",[191,2710,2711],{},"Wire format defined once, platform only supplies bytes in\u002Fout",[173,2713,2714,2719,2728],{},[191,2715,2716],{},[33,2717,2718],{},"Pending → Rule Check → Promote",[191,2720,2721,2724,2725],{},[38,2722,2723],{},"rawPendingCastRequest"," → ",[38,2726,2727],{},"pendingCastRequest",[191,2729,2730],{},"Separates \"a request arrived\" from \"a request needs a human decision\"",[173,2732,2733,2738,2742],{},[191,2734,2735],{},[33,2736,2737],{},"Command Queue Decoupling",[191,2739,2740],{},[38,2741,2303],{},[191,2743,2744],{},"HTTP handler coroutine never touches ExoPlayer\u002FUI state directly",[173,2746,2747,2752,2757],{},[191,2748,2749],{},[33,2750,2751],{},"Queued-Command Replay",[191,2753,2754],{},[38,2755,2756],{},"pendingPlayQueued",[191,2758,1388,2759,2761,2762,2764],{},[38,2760,893],{}," that races ahead of ",[38,2763,890],{},"'s approval isn't dropped",[173,2766,2767,2772,2777],{},[191,2768,2769],{},[33,2770,2771],{},"Deliberate Status Code",[191,2773,2774,2776],{},[38,2775,1516],{}," → always 206",[191,2778,2779],{},"Matches what real TV firmware expects, not just spec-minimum 200",[173,2781,2782,2787,2791],{},[191,2783,2784],{},[33,2785,2786],{},"Narrow Duplicate Filter",[191,2788,2789],{},[38,2790,2097],{},[191,2792,2793],{},"Distinguishes the two STOPPED callbacks some renderers send, without a generic dedup mechanism",[173,2795,2796,2801,2809],{},[191,2797,2798],{},[33,2799,2800],{},"Immediate Byebye",[191,2802,2803,2805,2806,2808],{},[38,2804,477],{}," sends ",[38,2807,481],{}," before cancel",[191,2810,2811],{},"Avoids a stale 30-minute SSDP cache entry after the user turns the feature off",[173,2813,2814,2819,2822],{},[191,2815,2816],{},[33,2817,2818],{},"Fail-Open on Unknown, Fail-Closed by Default",[191,2820,2821],{},"allow\u002Fdeny preference + dialog",[191,2823,2824],{},"Neither auto-trusts nor auto-blocks a first-time sender — a human decides once",[51,2826,152],{"id":2827},"further-reading",[56,2829,2830,2839],{},[59,2831,2832,2838],{},[62,2833,2837],{"href":2834,"rel":2835},"https:\u002F\u002Fopenconnectivity.org\u002Fdeveloper\u002Fspecifications\u002Fupnp-resources\u002Fupnp\u002F",[2836],"nofollow","UPnP Device Architecture"," — the underlying SSDP\u002FGENA\u002FSOAP specification.",[59,2840,2841,2842,872],{},"For the WebSocket-based low-latency screen mirroring feature (a different, non-DLNA casting path), see ",[62,2843,2845],{"href":2844},"\u002Fblog\u002Fscreen-mirror","Screen Mirror",[2847,2848,2849],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":328,"searchDepth":400,"depth":400,"links":2851},[2852,2853,2854,2857,2858,2859,2860,2863,2864,2865,2866,2867,2868],{"id":53,"depth":379,"text":54},{"id":155,"depth":379,"text":70},{"id":257,"depth":379,"text":76,"children":2855},[2856],{"id":486,"depth":400,"text":84},{"id":697,"depth":379,"text":90},{"id":926,"depth":379,"text":96},{"id":1385,"depth":379,"text":102},{"id":1776,"depth":379,"text":108,"children":2861},[2862],{"id":2077,"depth":400,"text":116},{"id":2119,"depth":379,"text":122},{"id":2325,"depth":379,"text":128},{"id":2507,"depth":379,"text":134},{"id":2576,"depth":379,"text":140},{"id":2676,"depth":379,"text":146},{"id":2827,"depth":379,"text":152},"Transport","2025-07-30","How PlainApp implements DLNA\u002FUPnP AV casting on both sides: scanning and controlling TVs over SOAP as a sender, and turning the phone itself into a UPnP MediaRenderer as a receiver — with SSDP discovery, DIDL-Lite metadata, Range-request media serving, GENA event callbacks, and a sender-IP allow\u002Fdeny trust model, all in pure Kotlin Multiplatform.","md",{},true,"\u002Fblog\u002Fdlna-cast","16 min read",{"title":24,"description":2871},"Want to cast to any DLNA smart TV from your own app? See how PlainApp implements UPnP discovery, SOAP control, and a secure sender-trust model — open source.","DLNA Cast: Build a UPnP Sender & Receiver in Kotlin","blog\u002Fdlna-cast","R560NC4kxWz0IiWYJCa9EMYCVGse_gIUEqMBVnO7vMk",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":2883},"\u003Cpath fill=\"currentColor\" d=\"M224 128a8 8 0 0 1-8 8H59.31l58.35 58.34a8 8 0 0 1-11.32 11.32l-72-72a8 8 0 0 1 0-11.32l72-72a8 8 0 0 1 11.32 11.32L59.31 120H216a8 8 0 0 1 8 8\"\u002F>",1788009009868]