DLNA (UPnP AV-এর উপর ভিত্তি করে) স্মার্ট টিভিতে "Cast to TV" বাটনের পিছনের প্রোটোকল। এটি সম্পূর্ণরূপে লোকাল নেটওয়ার্কে চলে, কোনো ক্লাউড অ্যাকাউন্ট বা পেয়ারিং ধাপের প্রয়োজন হয় না। PlainApp এটির উভয় দিক বাস্তবায়ন করে: এটি যেকোনো DLNA-সামঞ্জস্যপূর্ণ স্মার্ট টিভিতে একটি লোকাল ভিডিও, গান বা ছবি পুশ করতে পারে, এবং ফোনটিকেই একটি MediaRenderer-এ রূপান্তর করতে পারে যাতে একটি টিভি রিমোট অ্যাপ, VLC, বা অন্য PlainApp এতে কাস্ট করতে পারে।
এই নিবন্ধটি ওয়্যার প্রোটোকল (SSDP + SOAP + DIDL-Lite), লোকাল HTTP সার্ভার যা টিভির আসলেই প্রয়োজনীয় হেডার সহ মিডিয়া স্ট্রিম করে, প্লেব্যাক স্টেটের জন্য GENA ইভেন্ট সাবস্ক্রিপশন, এবং প্রেরক-আইপি ট্রাস্ট মডেল কভার করে — যা একটি অপ্রমাণীকৃত ১৯৯০-এর দশকের প্রোটোকলকে আধুনিক ফোনে নিরাপদে চালানোর সুযোগ দেয়।
সূচিপত্র
- সূচিপত্র
- উচ্চ-স্তরের আর্কিটেকচার
- SSDP ডিসকভারি: সার্ভার ছাড়াই ডিভাইস খোঁজা
- AVTransport কন্ট্রোল: SOAP প্রোটোকল
- DIDL-Lite মেটাডেটা এবং ডাবল-এস্কেপিং কুয়ার্ক
- টিভিতে মিডিয়া পরিবেশন: Range রিকোয়েস্ট ও DLNA হেডার
- GENA ইভেন্ট: প্লেব্যাক স্টেট কলব্যাক
- গ্রাহক মোড: UPnP MediaRenderer হওয়া
- নিরাপত্তা: অনুমতি/অস্বীকৃতি তালিকার মাধ্যমে প্রেরক ট্রাস্ট
- প্ল্যাটফর্ম বিভাজন: commonMain অর্কেস্ট্রেশন, androidMain সকেট
- বিশুদ্ধ Kotlin ইঞ্জিনিয়ারিং নোট
- ডিজাইন প্যাটার্ন পুনরালোচনা
- আরও পড়ুন
উচ্চ-স্তরের আর্কিটেকচার
DLNA/UPnP AV-এর কোনো কেন্দ্রীয় সার্ভার বা ক্লাউড উপাদান নেই — সবকিছু লোকাল-নেটওয়ার্ক UDP মাল্টিকাস্ট (ডিসকভারি) এবং HTTP (কন্ট্রোল + মিডিয়া) এর মাধ্যমে ঘটে। PlainApp দুটি স্বাধীন ভূমিকা বাস্তবায়ন করে যা একই commonMain প্রোটোকল কোড ভাগ করে:
| ভূমিকা | কী করে | মূল ক্লাস |
|---|---|---|
| প্রেরক ("Cast to TV") | রেন্ডারার স্ক্যান করে, একটি নির্দিষ্ট URL আনতে বলে, প্লেব্যাক নিয়ন্ত্রণ করে | DlnaDeviceScanner, DlnaTransportController, CastPlayer |
| গ্রাহক ("Wireless Cast") | নিজেকে একটি MediaRenderer হিসেবে ঘোষণা করে, যেকোনো UPnP কন্ট্রোলার থেকে নিয়ন্ত্রণ গ্রহণ করে | DlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel |
উভয় ভূমিকাই features/dlna/common/ থেকে DlnaSoap (SOAP খাম নির্মাতা) এবং DlnaDevice (UPnP ডিভাইস মডেল) পুনরায় ব্যবহার করে। DLNA স্পেসিফিকেশনে কোনো প্রকার অথেনটিকেশন নেই — LAN-এর যেকোনো ব্যক্তি যে একটি রেন্ডারারের কন্ট্রোল URL জানে, সে কমান্ড পাঠাতে পারে। এটি নিচে বর্ণিত প্রায় প্রতিটি ডিজাইন সিদ্ধান্তকে আকার দেয়, বিশেষ করে গ্রাহকের ট্রাস্ট মডেল।
SSDP ডিসকভারি: সার্ভার ছাড়াই ডিভাইস খোঁজা
ডিসকভারি SSDP (Simple Service Discovery Protocol) ব্যবহার করে, যা UDP মাল্টিকাস্টের উপর একটি পাতলা স্তর 239.255.255.250:1900-এ। কোনো ডিরেক্টরি সার্ভার নেই — ডিভাইসগুলি নিজেদের ঘোষণা করে এবং সার্চ কোয়েরির সরাসরি উত্তর দেয়।
প্রেরক হিসেবে, DlnaDeviceScanner একটি M-SEARCH ডেটাগ্রাম সম্প্রচার করে যা urn:schemas-upnp-org:service:AVTransport:1 টার্গেট করে এবং ইউনিকাস্ট 200 OK রিপ্লাই সংগ্রহ করে, প্রতিটি রিপ্লাই রেন্ডারারের description.xml-এর দিকে নির্দেশ করা একটি LOCATION হেডার বহন করে। স্ক্যানারটি hostAddress দ্বারা ডি-ডুপ্লিকেশন করে — এটি নিজে ডিভাইস XML পার্স করে না; সেটি CastViewModel.searchAsync()-এর জন্য ছেড়ে দেওয়া হয়, যা LOCATION ফেচ করে, device.update(xml) কল করে, এবং শুধুমাত্র সেই ডিভাইসগুলিকে উপস্থাপন করে যেখানে device.isAVTransport() true রিটার্ন করে:
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)
}
}
গ্রাহক হিসেবে, DlnaReceiverEngine.runSsdpLoop() শুরুতে তিনটি NOTIFY ssdp:alive ডেটাগ্রাম পাঠায় (রুট ডিভাইস, MediaRenderer:1 ডিভাইস টাইপ, AVTransport:1 সার্ভিস টাইপ), প্রতি ৩০ সেকেন্ডে পুনরায় ঘোষণা করে (CACHE-CONTROL: max-age=1800), এবং আগত M-SEARCH রিকোয়েস্টের ইউনিকাস্ট উত্তর দেয়। stop()-এ, এটি ৩০ মিনিটের ক্যাশে শেষ হওয়ার জন্য অপেক্ষা না করে অবিলম্বে ssdp:byebye পাঠায় — যাতে "Wireless Cast" বন্ধ করার সাথে সাথেই একটি টিভি রিমোট অ্যাপ PlainApp-কে তালিকাভুক্ত করা বন্ধ করে দেয়।
পোর্ট ফলব্যাক
গ্রাহকের HTTP সার্ভার প্রথমে পোর্ট 7878 চেষ্টা করে, তারপর 7879, তারপর 7880, এবং শেষবার কোন পোর্ট কাজ করেছিল তা পছন্দ করে:
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
}
যদি তিনটি পোর্টই দখল করা থাকে (বিরল, তবে অন্যান্য DLNA অ্যাপ চালু থাকলে সম্ভব), startError সেট করা হয় এবং UI-তে দেখানো হয়, নীরবে ব্যর্থ হওয়ার পরিবর্তে।
AVTransport কন্ট্রোল: SOAP প্রোটোকল
একবার রেন্ডারার পাওয়া গেলে, প্লেব্যাক UPnP AVTransport-এর মাধ্যমে নিয়ন্ত্রিত হয় — এটি একটি SOAP-ওভার-HTTP সার্ভিস। প্রতিটি অ্যাকশন হল রেন্ডারারের কন্ট্রোল URL-এ একটি POST যার সাথে একটি SOAPAction হেডার এবং একটি SOAP খামে মোড়ানো XML বডি থাকে।
DlnaTransportController একটি শেয়ার্ড হেল্পার দিয়ে প্রতিটি রিকোয়েস্ট তৈরি করে:
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>" সেট করে এবং DlnaSoap.requestEnvelope(soapBody) POST করে — গ্রাহক পক্ষ রেসপন্স তৈরিতেও একই খাম কনস্ট্যান্ট ব্যবহার করে, তাই ওয়্যার ফর্ম্যাটটি শুধুমাত্র একবার commonMain-এ সংজ্ঞায়িত করতে হয়।
গ্রাহকের DlnaHttpRouter.handleSoap() অপর প্রান্তে এটিকে মিরর করে: এটি soapaction হেডার পড়ে, #-এর পরে অ্যাকশন নাম বের করে এবং তার উপর ভিত্তি করে ডিসপ্যাচ করে — SetAVTransportURI, Play, Pause, Stop, Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo, GetDeviceCapabilities। RenderingControl (ভলিউম) একটি স্ট্যাটিক 100 দিয়ে স্টাব করা হয়েছে — PlainApp UPnP-এর মাধ্যমে ডিভাইস ভলিউম প্রকাশ করে না।
DIDL-Lite মেটাডেটা এবং ডাবল-এস্কেপিং কুয়ার্ক
SetAVTransportURI দুটি প্যারামিটার বহন করে: CurrentURI (মিডিয়া URL) এবং CurrentURIMetaData — একটি DIDL-Lite XML খণ্ড যা শিরোনাম, মিডিয়া ক্লাস এবং অ্যালবাম আর্ট বর্ণনা করে, বাইরের SOAP বডির ভিতরে একটি XML-এস্কেপড স্ট্রিং হিসেবে এম্বেড করা:
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("&", "&").replace("<", "<").replace(">", ">")
}
DIDL-Lite XML দুবার এস্কেপ করা হয়: একবার শিরোনাম টেক্সটের জন্য (যাতে Fire & Ice নামক একটি গান DIDL-Lite ট্যাগ ভেঙে না দেয়), এবং একবার সম্পূর্ণ DIDL-Lite ডকুমেন্টের জন্য (যাতে এর নিজস্ব </> টেক্সট হিসেবে এম্বেড করা বাইরের SOAP খাম ভেঙে না দেয়)। এটি একটি সুপরিচিত UPnP কুয়ার্ক, বাগ নয় — CurrentURIMetaData স্ট্রিং কন্টেন্ট হিসেবে সংজ্ঞায়িত, নেস্টেড XML এলিমেন্ট হিসেবে নয়।
গ্রাহক পক্ষে, DlnaSoapHandler এটি উল্টো করে: parseSoapAction DIDL-Lite টেক্সট পেতে SOAP বডিকে একবার আন-এস্কেপ করে, তারপর extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/extractAlbumArtUriFromDidlMeta প্রতিটি সেই ভিতরের স্ট্রিংয়ে দ্বিতীয়বার এন্টিটি আন-এস্কেপিং এবং ট্যাগ এক্সট্র্যাকশন করে:
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
}
}
যদি <upnp:class> অনুপস্থিত থাকে (কিছু প্রেরক এটি বাদ দেয়), cleanMediaTitle() URI-এর নিজস্ব ফাইল এক্সটেনশনে ফিরে যায় — মিডিয়া টাইপ সনাক্তকরণ কখনই হার্ড-ফেল করে না, এটি কেবল UNKNOWN-এ ডিগ্রেড হয় যা নিরাপদ ডিফল্ট হিসেবে ভিডিও প্লেয়ারে রুট করে।
টিভিতে মিডিয়া পরিবেশন: Range রিকোয়েস্ট ও DLNA হেডার
একটি SetAVTransportURI কল শুধুমাত্র রেন্ডারারকে বলে কোথায় মিডিয়া আনতে হবে — প্রকৃত বাইটগুলি PlainApp-এর নিজস্ব লোকাল HTTP সার্ভার দ্বারা /media/{id}-এ পরিবেশন করা হয়।
UrlHelper.getMediaHttpUrl(path) প্রকৃত পাথ (যা একটি content:// URI, একটি দূরবর্তী URL, বা একটি সাধারণ ফাইল পাথ হতে পারে) একটি ছোট id-এর অধীনে নিবন্ধন করে এবং http://<device-ip>:<port>/media/<id>.<ext> রিটার্ন করে। রুটটি তখন এটি আসলে কী ধরণের উৎস তার উপর ভিত্তি করে শাখা তৈরি করে:
when {
path.isUrl() -> call.proxyUrl(path) // দূরবর্তী URL: আপস্ট্রিম রেসপন্স স্ট্রিম করুন
isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
path.isImageFast() -> call.respondFile(path) // ছবি: সরল স্ট্যাটিক সার্ভিং
else -> call.respondDlnaFile(path) // অডিও/ভিডিও: DLNA-সচেতন সার্ভিং
}
respondDlnaFile হল আকর্ষণীয় কেস — অনেক স্মার্ট টিভি এবং DLNA রেন্ডারার একটি স্ট্রিম প্লে করতে অস্বীকার করে যদি না এটি একটি সঠিক DLNA মিডিয়া সার্ভার রেসপন্সের মতো দেখায়:
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) // কিছু টিভি OS শুধুমাত্র 206 গ্রহণ করে
}
applicationCall.respond(LocalFileContent(file))
return true
}
লক্ষ্য করুন স্ট্যাটাস সর্বদা 206 Partial Content, 200 OK নয় — কিছু টিভি ফার্মওয়্যার একটি সাধারণ 200 রেসপন্সকে "সিকেবল নয়" হিসেবে বিবেচনা করে এবং এটি প্লে করতে অস্বীকার করে, এমনকি একটি সম্পূর্ণ-ফাইল GET-এর জন্যও। এই একটি স্ট্যাটাস-কোড পছন্দটি বেশ কয়েকটি বাস্তব ডিভাইসে "ঠিকঠাক চলে" এবং "টিভি চিরতরে স্পিনার দেখায়" -এর মধ্যে পার্থক্য।
একই রুট কাস্ট করা অডিও আইটেমের জন্য অ্যালবাম আর্ট পরিবেশন করে: UrlHelper.getAlbumArtHttpUrl() একটি content://media/.../albumart/<id> URI-কে একই /media/{id} পাথে ম্যাপ করে, তাই content:// স্ট্রিমিং এবং DLNA ফাইল সার্ভিং একটি কোড পাথ ভাগ করে, প্রশ্নবিদ্ধ "মিডিয়া" গানটি হোক বা এর কভার ইমেজ।
GENA ইভেন্ট: প্লেব্যাক স্টেট কলব্যাক
প্লেব্যাক শুরু করার পর, প্রেরক রেন্ডারারের AVTransport ইভেন্টিং সার্ভিসে (GENA — General Event Notification Architecture) সাবস্ক্রাইব করে যাতে পোলিং ছাড়াই স্টেট পরিবর্তন সম্পর্কে জানতে পারে:
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 হল কাস্টম HTTP মেথড (স্ট্যান্ডার্ড ভার্ব সেটে নেই), Ktor-এর জেনেরিক HttpMethod("SUBSCRIBE") রিকোয়েস্ট বিল্ডারের মাধ্যমে হ্যান্ডেল করা হয়। এরপর রেন্ডারার callbackUrl-এ NOTIFY পাঠায় — PlainApp-এর নিজস্ব /callback/cast রুট — যখনই ট্রান্সপোর্ট স্টেট, পজিশন বা ডিউরেশন পরিবর্তিত হয়:
if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
// প্লেলিস্টের পরবর্তী আইটেমে অগ্রসর হন
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
CastPlayer.isPlaying.value = true
}
ডুপ্লিকেট-কলব্যাক গার্ড
কিছু রেন্ডারার একই STOPPED ট্রানজিশনের জন্য দ্রুত পরপর দুটি NOTIFY কলব্যাক পাঠায় — দ্বিতীয়টি ঘটনাক্রমে AVTransportURIMetaData বহন করে যখন প্রথমটি তা করে না। উভয়েই প্লেলিস্ট অগ্রসর করলে, প্লেব্যাক স্বাভাবিকভাবে থামার সময় প্রতিবার একটি ট্র্যাক স্কিপ হবে। !xml.contains("AVTransportURIMetaData") চেকটি একটি ইচ্ছাকৃত, সংকীর্ণ ফিল্টার: শুধুমাত্র প্রথম STOPPED নোটিফিকেশন (মেটাডেটা ছাড়া) অটো-অ্যাডভান্স ট্রিগার করে। একটি startPositionUpdater() জব প্রতি সেকেন্ডে GetPositionInfo-ও পোল করে ফলব্যাক হিসেবে, যেহেতু PlainApp-এর নিজস্ব SUBSCRIBE অ্যাকনলেজমেন্ট অন্যান্য কন্ট্রোলারদের কাছে ইভেন্ট পুশ করে না — শুধুমাত্র প্রেরক পক্ষ টিভি থেকে GENA কলব্যাক গ্রহণ করে।
গ্রাহক মোড: UPnP MediaRenderer হওয়া
দিক উল্টিয়ে দেখি: যেকোনো DLNA কন্ট্রোলার (একটি টিভি রিমোট অ্যাপ, VLC, অন্য PlainApp) ফোনে নিজেই মিডিয়া পুশ করতে পারে। DlnaReceiverEngine উপরে বর্ণিত একই ধরণের HTTP + SSDP সার্ভার খোলে, কিন্তু এবার নিয়ন্ত্রিত MediaRenderer হিসেবে, কন্ট্রোলার হিসেবে নয়।
DlnaHttpRouter.route() description.xml (যা DlnaXmlTemplates.deviceDescription() দ্বারা নির্মিত, একটি AVTransport এবং একটি স্টাব RenderingControl সার্ভিস তালিকাভুক্ত করে) পরিবেশন করে এবং SOAP অ্যাকশনগুলি DlnaSoapHandler-এ ডিসপ্যাচ করে। একটি SetAVTransportURI কল অবিলম্বে কিছু প্লে করে না — এটি একটি PendingCastRequest সংরক্ষণ করে এবং অপেক্ষা করে:
if (uri.isNotEmpty()) {
DlnaRendererState.rawPendingCastRequest.value =
PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
DlnaRendererState.pendingPlayQueued.value = false
}
একটি Play যা পেন্ডিং রিকোয়েস্ট সমাধানের আগে আসে, সেটিও প্লেব্যাক শুরু করে না — এটি pendingPlayQueued = true সেট করে যাতে কাস্ট রিকোয়েস্ট গ্রহণ করা হলে কমান্ডটি স্বয়ংক্রিয়ভাবে পুনরায় প্লে হয়, নীরবে হারিয়ে যাওয়ার পরিবর্তে:
val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
DlnaRendererState.pendingPlayQueued.value = true
} else {
DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}
গৃহীত কমান্ডগুলি একটি একক Channel<DlnaCommand>-এর মাধ্যমে প্রবাহিত হয় যা DlnaReceiverViewModel ড্রেইন করে, র সকেট-হ্যান্ডলিং করুটিনকে UI/প্লেয়ার স্টেট থেকে ডিকাপল করে — HTTP হ্যান্ডলার কখনই সরাসরি ExoPlayer স্পর্শ করে না। প্লেব্যাক তারপর DlnaMediaType অনুযায়ী তিনটি ফুল-স্ক্রিন কম্পোজেবলের একটিতে রুট করে: DlnaReceiverAudioPlayerContent (গ্রেডিয়েন্ট ব্যাকগ্রাউন্ড, অ্যালবাম আর্ট, সিক বার), একটি ইমেজ ভিউয়ার, বা DlnaReceiverVideoPlayerContent (ExoPlayer)।
নিরাপত্তা: অনুমতি/অস্বীকৃতি তালিকার মাধ্যমে প্রেরক ট্রাস্ট
DLNA-তে ডিজাইন অনুযায়ী কোনো অথেনটিকেশন নেই — LAN-এর যেকোনো ডিভাইস একটি রেন্ডারারকে SetAVTransportURI পাঠাতে পারে। একটি ব্যক্তিগত ফোনকে অপ্রমাণীকৃত MediaRenderer-এ রূপান্তর করলে একই Wi-Fi-এর যে কেউ (একটি শেয়ার্ড অফিস নেটওয়ার্ক, বন্ধুর বাড়ি, একটি প্রতিকূল গেস্ট নেটওয়ার্ক) এতে ইচ্ছামতো মিডিয়া URL পুশ করতে পারে। PlainApp প্রতিটি আগত কাস্ট রিকোয়েস্টে একটি প্রতি-প্রেরক-আইপি ট্রাস্ট তালিকা দিয়ে এই ফাঁক বন্ধ করে:
DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
val allowed = DlnaAllowedSendersPreference.getAsync()
val denied = DlnaDeniedSendersPreference.getAsync()
when {
DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
// স্বয়ংক্রিয়ভাবে গ্রহণ: ডায়ালগ না দেখিয়ে সরাসরি কমান্ড পাঠান
}
DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
// স্বয়ংক্রিয়ভাবে প্রত্যাখ্যান: নীরবে বাতিল করুন
}
else -> {
// অজানা প্রেরক: UI-দৃশ্যমান স্টেটে উন্নীত করুন যাতে ব্যবহারকারী সিদ্ধান্ত নিতে পারেন
DlnaRendererState.pendingCastRequest.value = pending
}
}
}
একটি অজানা প্রেরকের কাস্ট রিকোয়েস্ট একটি নিশ্চিতকরণ ডায়ালগ দেখায় যার সাথে একটি ঐচ্ছিক "এই পছন্দটি মনে রাখুন" ফ্ল্যাগ থাকে; মনে রাখতে নির্বাচন করলে প্রেরকের IP অনুমতি বা অস্বীকৃতি প্রেফারেন্সে লেখা হয় যাতে একই ঠিকানা থেকে ভবিষ্যতের রিকোয়েস্টগুলি ডায়ালগ এড়িয়ে যায়। এটি সম্পূর্ণরূপে DlnaReceiverViewModel-এ প্রয়োগ করা হয়, ওয়্যার প্রোটোকলের উপরে — SOAP হ্যান্ডলার নিজেই ট্রাস্ট সিদ্ধান্ত নির্বিশেষে সর্বদা 200 OK রিটার্ন করে (UPnP স্পেক অনুযায়ী, ট্রান্সপোর্ট কল সফল হয়েছে; মিডিয়া আসলে প্লে হয় কিনা তা একটি পৃথক, লোকাল সিদ্ধান্ত)।
প্ল্যাটফর্ম বিভাজন: commonMain অর্কেস্ট্রেশন, androidMain সকেট
PlainApp-এর অন্যান্য নেটওয়ার্ক ফিচারের মতো একই প্যাটার্ন অনুসরণ করে, শুধুমাত্র র বাইট-লেভেল সকেট I/O প্ল্যাটফর্ম-নির্দিষ্ট। DlnaServerSocket এবং DlnaSsdpSocket হল expect ইন্টারফেস; Android-এর actual ইমপ্লিমেন্টেশনগুলি সরাসরি java.net.ServerSocket এবং java.net.MulticastSocket র্যাপ করে। বাকি সবকিছু — পোর্ট সিলেকশন, SSDP alive/byebye ক্যাডেন্স, HTTP রাউটিং, SOAP পার্সিং, DIDL-Lite মেটাডেটা, এবং চারটি DlnaCommand স্টেট ট্রানজিশন — commonMain-এ থাকে এবং একটি Android ডিভাইস ছাড়াই JVM-এ ইউনিট টেস্টযোগ্য।
iOS প্রেরক-পক্ষের SOAP কন্ট্রোল পায় (এটি একটি সাধারণ HTTP ক্লায়েন্ট, বাস্তবায়নের জন্য কোনো সকেট নেই), কিন্তু গ্রাহকটি ইচ্ছাকৃতভাবে নো-অপ:
actual fun startDlnaRenderer() {}
iOS একটি পর্যাপ্ত নির্ভরযোগ্য ব্যাকগ্রাউন্ড UDP মাল্টিকাস্ট লিসেনার চালানোর কোনো উপায় প্রকাশ করে না যা একটি ভাল MediaRenderer সিটিজেন হওয়ার জন্য প্রয়োজন, তাই "Wireless Cast" (গ্রহণ) শুধুমাত্র Android-এ; "Cast to TV" (প্রেরণ) উভয় প্ল্যাটফর্মেই কাজ করে।
বিশুদ্ধ Kotlin ইঞ্জিনিয়ারিং নোট
কয়েকটি বাস্তবায়ন বিবরণ বিশেষভাবে প্ল্যাটফর্ম নির্ভরতা এড়াতে বিদ্যমান যা Kotlin Multiplatform শেয়ারিং ভেঙে দেবে:
- UUID v4 জেনারেশন।
DlnaReceiverEngine.randomUuid()java.util.UUID.randomUUID()-এর পরিবর্তেRandom.nextBytes(16)থেকে একটি RFC 4122 v4 UUID হ্যান্ড-রোল করে, যাতে ডিভাইস আইডেন্টিটি জেনারেটর প্রতিটি প্ল্যাটফর্মে অভিন্ন হয়। - পার্সেন্ট-ডিকোডিং।
DlnaSoapHandler-এর প্রাইভেটpercentDecode()একটি মিডিয়া URI-তে URL-এনকোডেড অবস্থায় আসা শিরোনামের জন্যjava.net.URLDecoder.decode()-কে প্রতিস্থাপন করে। - বাইট-লেভেল HTTP বডি রিড।
Content-Lengthএকটি বাইট গণনা, একটি অক্ষর গণনা নয়।AndroidDlnaClientConnection.readHttpRequest()রBufferedInputStream-এর বিরুদ্ধেreadBodyBytes(bis, contentLength)-এর মাধ্যমে বডি পড়ে, কখনইBufferedReader/CharArrayব্যবহার করে না — একটি মাল্টি-বাইট UTF-8 (যেমন চীনা অক্ষর, প্রতিটি ৩ বাইট) সম্বলিত শিরোনাম অন্যথায় বডি কম পড়বে এবং ইতিমধ্যে আগত বাইটগুলির জন্য অপেক্ষা করতে গিয়ে আটকে যাবে, নীরবে নন-ASCII শিরোনামের জন্যSetAVTransportURIভেঙে দেবে। java.net.URLছাড়া বেস URL পার্সিং।DlnaDevice.getBaseUrl()একটিjava.net.URLনির্মাণ না করে সরল স্ট্রিং স্লাইসিং (substringAfter("://"),substringBefore('/')) দিয়েLOCATIONহেডার থেকে স্কিম/হোস্ট/পোর্ট বের করে।
ডিজাইন প্যাটার্ন পুনরালোচনা
| প্যাটার্ন | অবস্থান | কারণ |
|---|---|---|
| শেয়ার্ড প্রোটোকল, স্প্লিট I/O | DlnaSoap/DlnaXmlTemplates commonMain-এ, সকেট androidMain-এ | ওয়্যার ফর্ম্যাট একবার সংজ্ঞায়িত, প্ল্যাটফর্ম কেবল বাইট ইন/আউট সরবরাহ করে |
| পেন্ডিং → নিয়ম পরীক্ষা → উন্নীতকরণ | rawPendingCastRequest → pendingCastRequest | "একটি রিকোয়েস্ট এসেছে" থেকে "একটি রিকোয়েস্টের মানব সিদ্ধান্ত প্রয়োজন" আলাদা করে |
| কমান্ড কিউ ডিকাপলিং | Channel<DlnaCommand> | HTTP হ্যান্ডলার করুটিন কখনই ExoPlayer/UI স্টেট সরাসরি স্পর্শ করে না |
| কিউ-কৃত কমান্ড পুনরায় চালানো | pendingPlayQueued | একটি Play যা SetAVTransportURI-এর অনুমোদনের আগে চলে আসে তা বাতিল হয় না |
| ইচ্ছাকৃত স্ট্যাটাস কোড | respondDlnaFile সর্বদা 206 | বাস্তব টিভি ফার্মওয়্যার যা expects, শুধু স্পেক-মিনিমাম 200 নয় |
| সংকীর্ণ ডুপ্লিকেট ফিল্টার | !xml.contains("AVTransportURIMetaData") | কিছু রেন্ডারার যে দুটি STOPPED কলব্যাক পাঠায় তা আলাদা করে, একটি জেনেরিক ডিডাপ মেকানিজম ছাড়াই |
| তাৎক্ষণিক Byebye | stop() বাতিলের আগে ssdp:byebye পাঠায় | ব্যবহারকারী ফিচার বন্ধ করার পরে ৩০ মিনিটের stale SSDP ক্যাশে এন্ট্রি এড়ায় |
| অজানায় ফেল-ওপেন, ডিফল্টে ফেল-ক্লোজড | অনুমতি/অস্বীকৃতি প্রেফারেন্স + ডায়ালগ | প্রথমবারের প্রেরককে neither অটো-ট্রাস্ট nor অটো-ব্লক করে — একজন মানুষ একবার সিদ্ধান্ত নেয় |
আরও পড়ুন
- UPnP Device Architecture — অন্তর্নিহিত SSDP/GENA/SOAP স্পেসিফিকেশন।
- WebSocket-ভিত্তিক লো-লেটেন্সি স্ক্রিন মিররিং ফিচার সম্পর্কে (DLNA-সম্পর্কিত নয় একটি ভিন্ন কাস্টিং পাথ), দেখুন Screen Mirror।