Bloga dön
Transport16 min read

DLNA Yayın: Sıfırdan Bir UPnP Gönderici ve Alıcı Kurmak

PlainApp, DLNA/UPnP AV yayınını her iki yönde nasıl uygular: bir gönderici olarak TV'leri SOAP üzerinden tarayıp kontrol etmek ve bir alıcı olarak telefonun kendisini bir UPnP MediaRenderer'a dönüştürmek — SSDP keşfi, DIDL-Lite meta verileri, Range-istekli medya sunumu, GENA olay geri çağrıları ve gönderici-IP izin/red güven modeli, tamamen saf Kotlin Multiplatform ile.

DLNA (UPnP AV üzerine inşa edilmiştir), akıllı TV'lerdeki "TV'ye Yayınla" düğmelerinin arkasındaki protokoldür. Tamamen yerel ağ üzerinde çalışır, bulut hesabı veya eşleştirme adımı gerektirmez. PlainApp bu protokolün her iki yönünü de uygular: yerel bir video, şarkı veya fotoğrafı DLNA uyumlu herhangi bir akıllı TV'ye gönderebilir ve telefonun kendisini bir MediaRenderer'a dönüştürerek bir TV uzaktan kumanda uygulamasının, VLC'nin veya başka bir PlainApp'in ona yayın yapmasını sağlayabilir.

Bu makale, kablolu protokolü (SSDP + SOAP + DIDL-Lite), medyayı TV'lerin gerçekten ihtiyaç duyduğu başlıklarla yayınlayan yerel HTTP sunucusunu, oynatma durumu için GENA olay aboneliğini ve kimlik doğrulaması olmayan 1990'lar dönemi bir protokolü modern bir telefonda güvenli bir şekilde çalıştıran gönderici-IP güven modelini kapsar.

İçindekiler

Üst Düzey Mimari

Diagram 1
1

DLNA/UPnP AV'nin merkezi bir sunucusu veya bulut bileşeni yoktur — her şey yerel ağ üzerinden UDP çoklu yayın (keşif) ve HTTP (kontrol + medya) ile gerçekleşir. PlainApp, aynı commonMain protokol kodunu paylaşan iki bağımsız rolü uygular:

RolNe yaparAnahtar sınıflar
Gönderici ("TV'ye Yayınla")Oluşturucuları tarar, birine URL'yi getirmesini söyler, oynatmayı kontrol ederDlnaDeviceScanner, DlnaTransportController, CastPlayer
Alıcı ("Kablosuz Yayın")Kendini bir MediaRenderer olarak tanıtır, herhangi bir UPnP denetleyicisinden kontrol kabul ederDlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel

Her iki rol de features/dlna/common/ altındaki DlnaSoap (SOAP zarf oluşturucuları) ve DlnaDevice (UPnP cihaz modeli) bileşenlerini yeniden kullanır. DLNA belirtiminde hiçbir kimlik doğrulama türü yoktur — LAN üzerinde bir oluşturucunun kontrol URL'sini bilen herkes ona komut gönderebilir. Bu durum, aşağıda açıklanan hemen hemen tüm tasarım kararlarını, özellikle de alıcının güven modelini şekillendirir.

SSDP Keşfi: Sunucu Olmadan Cihaz Bulma

Keşif, 239.255.255.250:1900 adresine UDP çoklu yayın üzerinden ince bir katman olan SSDP'yi (Basit Hizmet Keşif Protokolü) kullanır. Bir dizin sunucusu yoktur — cihazlar kendilerini doğrudan duyurur ve arama sorgularını doğrudan yanıtlar.

Diagram 2
2

Gönderici olarak, DlnaDeviceScanner, urn:schemas-upnp-org:service:AVTransport:1 hedefini alan bir M-SEARCH veri birimi yayınlar ve her biri oluşturucunun description.xml dosyasını gösteren bir LOCATION başlığı taşıyan tek noktaya yayın 200 OK yanıtlarını toplar. Tarayıcı, hostAddress'e göre yinelenenleri kaldırır — cihaz XML'ini kendisi ayrıştırmaz; bu iş, LOCATION'ı getiren, device.update(xml) çağıran ve yalnızca device.isAVTransport() true döndüren cihazları yüzeye çıkaran CastViewModel.searchAsync()'ye bırakılır:

fun search(): Flow<DlnaDevice> = searchDlnaDevicesRaw().transform { ssdp ->
    if (devices.none { it.hostAddress == ssdp.hostAddress }) {
        val device = DlnaDevice(ssdp.hostAddress, ssdp.header)
        devices.add(device)
        emit(device)
    }
}

Alıcı olarak, DlnaReceiverEngine.runSsdpLoop() başlangıçta üç NOTIFY ssdp:alive veri birimi gönderir (kök cihaz, MediaRenderer:1 cihaz türü, AVTransport:1 hizmet türü), her 30 saniyede bir yeniden duyuru yapar (CACHE-CONTROL: max-age=1800) ve gelen M-SEARCH isteklerini tek noktaya yayın yanıtlarıyla yanıtlar. stop() çağrıldığında, 30 dakikalık önbelleğin süresinin dolmasını beklemek yerine hemen ssdp:byebye gönderir — böylece bir TV uzaktan kumanda uygulaması, "Kablosuz Yayın" kapatıldığı anda PlainApp'i listelemeyi durdurur.

Port yedekleme

Alıcının HTTP sunucusu önce 7878 portunu, ardından 7879 ve 7880 portlarını dener ve en son hangi port çalıştıysa onu tercih eder:

private val CANDIDATE_PORTS = listOf(7878, 7879, 7880)
private fun openServerSocket(): DlnaServerSocket? {
    val candidates = lastPort
        ?.let { listOf(it) + CANDIDATE_PORTS.filter { p -> p != it } }
        ?: CANDIDATE_PORTS
    for (port in candidates) {
        val ss = createDlnaServerSocket(port)
        if (ss != null) return ss
    }
    return null
}

Eğer üç port da doluysa (nadir olsa da, başka DLNA uygulamaları çalışıyorken mümkündür), startError ayarlanır ve sessizce başarısız olmak yerine arayüzde gösterilir.

AVTransport Kontrolü: SOAP Protokolü

Bir oluşturucu bulunduğunda, oynatma UPnP AVTransport üzerinden kontrol edilir — bu, HTTP üzerinden bir SOAP hizmetidir. Her eylem, oluşturucunun kontrol URL'sine bir SOAPAction başlığı ve bir SOAP zarfına sarılmış XML gövdesi ile yapılan bir POST isteğidir.

Diagram 3
3

DlnaTransportController, her isteği paylaşılan bir yardımcı işlevle oluşturur:

private suspend fun executeAVTransportCommand(
    device: DlnaDevice,
    action: String,
    parameters: String = "<InstanceID>0</InstanceID>",
): String {
    val st = device.getAVTransportService()?.serviceType ?: return ""
    return executeSOAPRequest(device, action, "<u:$action xmlns:u=\"$st\">$parameters</u:$action>")
}

executeSOAPRequest, SOAPAction: "<serviceType>#<action>" başlığını ayarlar ve DlnaSoap.requestEnvelope(soapBody)'yi POST'lar — alıcı tarafın yanıt oluşturmak için kullandığı aynı zarf sabitleri, böylece kablolu formatın yalnızca commonMain içinde bir kez tanımlanması gerekir.

Alıcının DlnaHttpRouter.handleSoap()'ı diğer uçta bunu yansıtır: soapaction başlığını okur, # işaretinden sonraki eylem adını çıkarır ve buna göre dağıtır — SetAVTransportURI, Play, Pause, Stop, Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo, GetDeviceCapabilities. RenderingControl (ses seviyesi) sabit 100 ile saplama olarak bırakılmıştır — PlainApp, UPnP üzerinden cihaz ses seviyesini açığa çıkarmaz.

DIDL-Lite Meta Verileri ve Çift Kaçış Detayı

SetAVTransportURI iki parametre taşır: CurrentURI (medya URL'si) ve CurrentURIMetaData — başlığı, medya sınıfını ve albüm kapağını tanımlayan, dış SOAP gövdesinin içine XML-kaçışlı bir dize olarak gömülü bir DIDL-Lite XML parçası:

private fun buildDidlLiteMetadata(mediaUrl: String, title: String, albumArtUri: String): String {
    val upnpClass = when {
        ext in setOf("mp3", "m4a", "flac", ...) -> "object.item.audioItem.musicTrack"
        ext in setOf("jpg", "jpeg", "png", ...) -> "object.item.imageItem"
        else -> "object.item.videoItem"
    }
    val didl = """<DIDL-Lite xmlns="..."><item id="0" parentID="-1" restricted="0">
        <dc:title>$escapedTitle</dc:title><upnp:class>$upnpClass</upnp:class>$albumArtTag</item></DIDL-Lite>"""
    return didl.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

DIDL-Lite XML iki kez kaçışa uğrar: bir kez başlık metninin kendisi için (böylece Fire & Ice adlı bir şarkı DIDL-Lite etiketlerini bozmaz) ve bir kez de tüm DIDL-Lite belgesi için (böylece kendi </> karakterleri, metin olarak gömülü olduğu dış SOAP zarfını bozmaz). Bu, iyi bilinen bir UPnP tuhaflığıdır, bir hata değil — CurrentURIMetaData, iç içe XML öğeleri olarak değil, dize içeriği olarak tanımlanmıştır.

Alıcı tarafında, DlnaSoapHandler bunu tersine çevirir: parseSoapAction, SOAP gövdesini bir kez kaçıştan çıkararak DIDL-Lite metnini elde eder, ardından extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/extractAlbumArtUriFromDidlMeta'nın her biri, bu iç dizede ikinci bir varlık kaçış çözme ve etiket çıkarma işlemi yapar:

fun extractMediaTypeFromDidlMeta(meta: String, fallbackUri: String = ""): DlnaMediaType {
    val cls = meta.substring(classStart + 12, classEnd).lowercase()
    return when {
        "audioitem" in cls || "musictrack" in cls -> DlnaMediaType.AUDIO
        "imageitem" in cls || "photo" in cls -> DlnaMediaType.IMAGE
        "videoitem" in cls -> DlnaMediaType.VIDEO
        else -> DlnaMediaType.UNKNOWN
    }
}

Eğer <upnp:class> eksikse (bazı göndericiler bunu atlar), cleanMediaTitle() URI'nin kendi dosya uzantısına geri döner — medya türü tespiti asla sert bir şekilde başarısız olmaz, sadece UNKNOWN'a düşer ve bu da güvenli bir varsayılan olarak video oynatıcıya yönlendirilir.

TV'ye Medya Sunma: Range İstekleri ve DLNA Başlıkları

Bir SetAVTransportURI çağrısı, oluşturucuya yalnızca medyayı nereden alacağını söyler — gerçek baytlar, PlainApp'in kendi yerel HTTP sunucusu tarafından /media/{id} adresinde sunulur.

Diagram 4
4

UrlHelper.getMediaHttpUrl(path), gerçek yolu (bir content:// URI'si, uzak bir URL veya düz bir dosya yolu olabilir) kısa bir kimlik altında kaydeder ve http://<cihaz-ip>:<port>/media/<id>.<uzantı> döndürür. Rota daha sonra kaynağın gerçekte ne tür olduğuna göre dallanır:

when {
    path.isUrl() -> call.proxyUrl(path)                 // uzak URL: kaynak yanıtı akışla ilet
    isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
    path.isImageFast() -> call.respondFile(path)         // resimler: düz statik sunum
    else -> call.respondDlnaFile(path)                   // ses/video: DLNA bilinçli sunum
}

respondDlnaFile ilginç durumdur — birçok akıllı TV ve DLNA oluşturucu, yanıt uygun bir DLNA medya sunucusu yanıtı gibi görünmediği sürece bir akışı oynatmayı reddeder:

override suspend fun respondDlnaFile(path: String): Boolean {
    val file = java.io.File(path)
    if (!file.exists()) return false
    applicationCall.response.run {
        header("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*")
        header("contentFeatures.dlna.org", "")
        header("transferMode.dlna.org", "Streaming")
        header("Connection", "keep-alive")
        header("Server", "DLNADOC/1.50 UPnP/1.0 Plain/1.0 Android/${android.os.Build.VERSION.RELEASE}")
        status(HttpStatusCode.PartialContent) // bazı TV işletim sistemleri yalnızca 206 kabul eder
    }
    applicationCall.respond(LocalFileContent(file))
    return true
}

Durumun her zaman 206 Partial Content olduğuna, 200 OK olmadığına dikkat edin — bazı TV üretici yazılımları, tam dosya GET'i için bile olsa, düz bir 200 yanıtını "arama yapılamaz" olarak değerlendirir ve oynatmayı reddeder. Bu tek durum kodu seçimi, birkaç gerçek cihazda "sorunsuz oynatma" ile "TV'nin sonsuza kadar dönen bir yükleyici göstermesi" arasındaki farktır.

Aynı rota, yayınlanan ses öğeleri için albüm kapağı sunmak amacıyla da kullanılır: UrlHelper.getAlbumArtHttpUrl(), bir content://media/.../albumart/<id> URI'sini aynı /media/{id} yoluna eşler, böylece "medya" ister şarkının kendisi ister kapak resmi olsun, content:// akışı ve DLNA dosya sunumu tek bir kod yolunu paylaşır.

GENA Olayları: Oynatma Durumu Geri Çağrıları

Oynatma başladıktan sonra, gönderici oluşturucunun AVTransport olay hizmetine (GENA — Genel Olay Bildirim Mimarisi) abone olur, böylece durum değişikliklerini yoklama yapmadan öğrenir:

suspend fun subscribeEvent(device: DlnaDevice, callbackUrl: String): String {
    val service = device.getAVTransportService() ?: return ""
    val response = createHttpClient().subscribe(baseUrl + eventSubURL) {
        headers { set("NT", "upnp:event"); set("TIMEOUT", "Second-3600"); set("CALLBACK", "<$callbackUrl>") }
    }
    return response.headers["SID"].orEmpty()
}

SUBSCRIBE/RENEW/UNSUBSCRIBE, standart fiil kümesinde olmayan özel HTTP yöntemleridir ve Ktor'un genel HttpMethod("SUBSCRIBE") istek oluşturucusu aracılığıyla işlenir. Oluşturucu daha sonra, taşıma durumu, konum veya süre değiştiğinde callbackUrl'ye — PlainApp'in kendi /callback/cast rotasına — NOTIFY gönderir:

if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
    // oynatma listesinde sonraki öğeye geç
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
    CastPlayer.isPlaying.value = true
}

Çift geri çağrı koruması

Bazı oluşturucular, aynı STOPPED geçişi için hızlı bir şekilde iki NOTIFY geri çağrısı gönderir — ikincisi, ilkinde olmayan AVTransportURIMetaData'yı taşır. Oynatma listesini her ikisinde de ilerletmek, oynatma doğal olarak her durduğunda bir parçayı atlamaya neden olur. !xml.contains("AVTransportURIMetaData") kontrolü, kasıtlı, dar bir filtredir: yalnızca ilk (meta verisiz) STOPPED bildirimi otomatik ilerlemeyi tetikler. Bir startPositionUpdater() işi, yedek olarak her saniye GetPositionInfo'yu yoklar, çünkü PlainApp'in kendi SUBSCRIBE onayı, olayları diğer denetleyicilere geri göndermez — yalnızca gönderici tarafı, TV'lerden GENA geri çağrılarını tüketir.

Alıcı Modu: Bir UPnP MediaRenderer Olmak

Yönü tersine çevirin: herhangi bir DLNA denetleyicisi (bir TV uzaktan kumanda uygulaması, VLC, başka bir PlainApp) telefona medya gönderebilir. DlnaReceiverEngine, yukarıda açıklananla aynı tür HTTP + SSDP sunucusunu açar, ancak bu kez denetleyici değil, kontrol edilen MediaRenderer olarak.

DlnaHttpRouter.route(), description.xml'i sunar (DlnaXmlTemplates.deviceDescription() tarafından oluşturulur, bir AVTransport ve bir saplama RenderingControl hizmetini listeler) ve SOAP eylemlerini DlnaSoapHandler'a dağıtır. Bir SetAVTransportURI çağrısı hemen bir şey oynatmaz — bir PendingCastRequest saklar ve bekler:

if (uri.isNotEmpty()) {
    DlnaRendererState.rawPendingCastRequest.value =
        PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
    DlnaRendererState.pendingPlayQueued.value = false
}

Bekleyen istek çözülmeden önce gelen bir Play, oynatmayı başlatmaz — bunun yerine pendingPlayQueued = true olarak ayarlar, böylece yayın isteği kabul edildiğinde komut otomatik olarak yeniden oynatılır, sessizce kaybolmak yerine:

val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
    DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
    DlnaRendererState.pendingPlayQueued.value = true
} else {
    DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}

Kabul edilen komutlar, DlnaReceiverViewModel tarafından tüketilen tek bir Channel<DlnaCommand> üzerinden akar ve ham soket işleme eşyordamını UI/oynatıcı durumundan ayırır — HTTP işleyicisi asla doğrudan ExoPlayer'a dokunmaz. Oynatma daha sonra DlnaMediaType'a göre üç tam ekran composable'dan birine yönlendirilir: DlnaReceiverAudioPlayerContent (gradyan arka plan, albüm kapağı, arama çubuğu), bir resim görüntüleyici veya DlnaReceiverVideoPlayerContent (ExoPlayer).

Güvenlik: İzin/Red Listeleri ile Gönderici Güveni

DLNA, tasarım gereği hiçbir kimlik doğrulamaya sahip değildir — LAN üzerindeki herhangi bir cihaz, bir oluşturucuya SetAVTransportURI gönderebilir. Kişisel bir telefonu kimlik doğrulamasız bir MediaRenderer'a dönüştürmek, aynı Wi-Fi üzerindeki herkesin (paylaşılan bir ofis ağı, bir arkadaşın evi, düşmanca bir misafir ağı) ona rastgele medya URL'leri göndermesine izin verir. PlainApp bu açığı, gönderici başına IP güven listesiyle kapatır ve her gelen yayın isteğini bu listeden geçirir:

Diagram 5
5

DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
    val allowed = DlnaAllowedSendersPreference.getAsync()
    val denied = DlnaDeniedSendersPreference.getAsync()
    when {
        DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
            // Otomatik kabul: iletişim kutusu göstermeden doğrudan komutları gönder
        }
        DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
            // Otomatik red: sessizce at
        }
        else -> {
            // Bilinmeyen gönderici: kullanıcının karar vermesi için UI görünür durumuna yükselt
            DlnaRendererState.pendingCastRequest.value = pending
        }
    }
}

Bilinmeyen bir göndericinin yayın isteği, isteğe bağlı "bu seçimi hatırla" işaretine sahip bir onay iletişim kutusu gösterir; hatırlamayı seçmek, göndericinin IP'sini izin veya red tercihine yazar, böylece aynı adresten gelen gelecek istekler iletişim kutusunu atlar. Bu tamamen DlnaReceiverViewModel içinde, kablolu protokolün üzerinde uygulanır — SOAP işleyicisinin kendisi, güven kararından bağımsız olarak her zaman 200 OK döndürür (UPnP belirtimine göre, taşıma çağrısı başarılı olmuştur; medyanın gerçekten oynatılıp oynatılmadığı ayrı, yerel bir karardır).

Platform Ayrımı: commonMain Orkestrasyonu, androidMain Soketleri

Diagram 6
6

PlainApp'in diğer ağ özellikleriyle aynı deseni izleyerek, yalnızca ham bayt düzeyindeki soket G/Ç'si platforma özgüdür. DlnaServerSocket ve DlnaSsdpSocket, expect arayüzleridir; Android'in actual uygulamaları doğrudan java.net.ServerSocket ve java.net.MulticastSocket'i sarar. Geriye kalan her şey — port seçimi, SSDP alive/byebye ritmi, HTTP yönlendirme, SOAP ayrıştırma, DIDL-Lite meta verileri ve dört DlnaCommand durum geçişinin tamamı — commonMain içinde bulunur ve bir Android cihazı olmadan JVM üzerinde birim test edilebilir.

iOS, gönderici tarafı SOAP kontrolünü alır (düz bir HTTP istemcisidir, uygulanacak soket yoktur), ancak alıcı kasıtlı olarak boş bir işlemdir:

actual fun startDlnaRenderer() {}

iOS, iyi bir MediaRenderer vatandaşı olacak kadar güvenilir bir şekilde arka planda bir UDP çoklu yayın dinleyicisi çalıştırmanın bir yolunu sunmaz, bu nedenle "Kablosuz Yayın" (alma) yalnızca Android'e özgüdür; "TV'ye Yayınla" (gönderme) her iki platformda da çalışır.

Saf Kotlin Mühendislik Notları

Kotlin Multiplatform paylaşımını bozacak platform bağımlılıklarından kaçınmak için birkaç uygulama detayı mevcuttur:

  • UUID v4 oluşturma. DlnaReceiverEngine.randomUuid(), java.util.UUID.randomUUID() yerine Random.nextBytes(16) ile elle bir RFC 4122 v4 UUID'si oluşturur, böylece cihaz kimlik oluşturucusu her platformda aynıdır.
  • Yüzde çözümleme. DlnaSoapHandler'ın özel percentDecode() işlevi, bir medya URI'sinde URL kodlanmış olarak gelen başlıklar için java.net.URLDecoder.decode() yerine geçer.
  • Bayt düzeyinde HTTP gövde okuma. Content-Length, bir karakter sayısı değil, bir bayt sayısıdır. AndroidDlnaClientConnection.readHttpRequest(), gövdeyi ham BufferedInputStream üzerinde readBodyBytes(bis, contentLength) aracılığıyla okur, asla bir BufferedReader/CharArray kullanmaz — çok baytlı UTF-8 içeren bir başlık (örneğin, her biri 3 bayt olan Çince karakterler) aksi takdirde gövdeyi eksik okur ve zaten ulaşmış baytları bekleyerek takılıp kalır, ASCII olmayan başlıklar için SetAVTransportURI'yi sessizce bozar.
  • java.net.URL olmadan temel URL ayrıştırma. DlnaDevice.getBaseUrl(), bir java.net.URL oluşturmak yerine, düz dize dilimleme (substringAfter("://"), substringBefore('/')) ile bir LOCATION başlığından şema/ana bilgisayar/port'u çıkarır.

Tasarım Desenleri Özeti

DesenNeredeNeden
Paylaşılan Protokol, Bölünmüş G/ÇDlnaSoap/DlnaXmlTemplates commonMain'de, soketler androidMain'deKablolu format bir kez tanımlanır, platform yalnızca bayt giriş/çıkışı sağlar
Beklemede -> Kural Kontrolü -> YükseltrawPendingCastRequest -> pendingCastRequest"Bir istek geldi" ile "bir isteğin insan kararına ihtiyacı var"ı ayırır
Komut Kuyruğu AyırmaChannel<DlnaCommand>HTTP işleyici eşyordamı asla ExoPlayer/UI durumuna doğrudan dokunmaz
Sıralanmış Komut Tekrar OynatmapendingPlayQueuedSetAVTransportURI'nin onayından önce gelen bir Play düşürülmez
Kasıtlı Durum KodurespondDlnaFile -> her zaman 206Yalnızca belirtim minimumu olan 200'ü değil, gerçek TV üretici yazılımının beklediğini eşleştirir
Dar Yinelenen Filtre!xml.contains("AVTransportURIMetaData")Genel bir yineleme kaldırma mekanizması olmadan, bazı oluşturucuların gönderdiği iki STOPPED geri çağrısını ayırt eder
Anında Byebyestop(), iptal etmeden önce ssdp:byebye gönderirKullanıcı özelliği kapattıktan sonra 30 dakikalık eski bir SSDP önbellek girişinin kalmasını önler
Bilinmeyende Açık Başarısız, Varsayılan Olarak Kapalı Başarısızizin/red tercihi + iletişim kutusuİlk kez gönderen birine ne otomatik olarak güvenir ne de otomatik olarak engeller — bir insan bir kez karar verir

Daha Fazla Okuma

  • UPnP Device Architecture — altta yatan SSDP/GENA/SOAP belirtimi.
  • WebSocket tabanlı düşük gecikmeli ekran yansıtma özelliği (DLNA dışı farklı bir yayın yolu) için bkz. Screen Mirror.