diff --git a/app/build.gradle b/app/build.gradle
index da055b53a63be2ac6252c23cebc894ecedc6d2e0..866a47dd451113d89321a082c884cf20a69617a2 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -144,10 +144,18 @@ android {
     buildFeatures {
         buildConfig true
     }
+    sourceSets {
+        main {
+            assets {
+                srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
+            }
+        }
+    }
 }
 
 dependencies {
     implementation 'com.google.dagger:dagger:2.48'
+    implementation 'androidx.test:monitor:1.7.2'
     annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
 
     //Core
@@ -186,7 +194,6 @@ dependencies {
     implementation 'androidx.media:media:1.7.0'
 
     //Other
-    implementation 'org.jmdns:jmdns:3.5.1'
     implementation 'org.jsoup:jsoup:1.15.3'
     implementation 'com.google.android.flexbox:flexbox:3.0.0'
     implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
diff --git a/app/src/main/assets/scripts/source.js b/app/src/main/assets/scripts/source.js
index 0324e0f577b6ba6eb7c3b37264625ee06158710a..134dd4a7a43760f5c43911c2eb166c5cbdbd0028 100644
--- a/app/src/main/assets/scripts/source.js
+++ b/app/src/main/assets/scripts/source.js
@@ -406,6 +406,39 @@ class DashSource {
             this.requestModifier = obj.requestModifier;
     }
 }
+class DashManifestRawSource {
+    constructor(obj) {
+        obj = obj ?? {};
+        this.plugin_type = "DashRawSource";
+        this.name = obj.name ?? "";
+        this.bitrate = obj.bitrate ?? 0;
+        this.container = obj.container ?? "";
+        this.codec = obj.codec ?? "";
+        this.duration = obj.duration ?? 0;
+        this.url = obj.url;
+        this.language = obj.language ?? Language.UNKNOWN;
+        if(obj.requestModifier)
+            this.requestModifier = obj.requestModifier;
+    }
+}
+
+class DashManifestRawAudioSource {
+    constructor(obj) {
+        obj = obj ?? {};
+        this.plugin_type = "DashRawAudioSource";
+        this.name = obj.name ?? "";
+        this.bitrate = obj.bitrate ?? 0;
+        this.container = obj.container ?? "";
+        this.codec = obj.codec ?? "";
+        this.duration = obj.duration ?? 0;
+        this.url = obj.url;
+        this.language = obj.language ?? Language.UNKNOWN;
+        this.manifest = obj.manifest ?? null;
+        if(obj.requestModifier)
+            this.requestModifier = obj.requestModifier;
+    }
+}
+
 
 class RequestModifier {
     constructor(obj) {
diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt
index 71516d7f916a5399a501f279dc2028680c6c0ce1..72903ec806b15cade70e56a0adbb45e235ef0721 100644
--- a/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt
+++ b/app/src/main/java/com/futo/platformplayer/Extensions_Content.kt
@@ -18,7 +18,10 @@ fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
 @UnstableApi
 fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory {
     val requestModifier = getRequestModifier();
-    return if (requestModifier != null) {
+    val requestExecutor = getRequestExecutor();
+    return if (requestExecutor != null) {
+        JSHttpDataSource.Factory().setRequestExecutor(requestExecutor);
+    } else if (requestModifier != null) {
         JSHttpDataSource.Factory().setRequestModifier(requestModifier);
     } else {
         DefaultHttpDataSource.Factory();
diff --git a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt
index 6c316ac5021a08fb6189e7b05ec8f8929d2b803a..0ebaa40f8b4ddd848782c6b5d200667eddb88157 100644
--- a/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt
+++ b/app/src/main/java/com/futo/platformplayer/Extensions_Formatting.kt
@@ -9,6 +9,7 @@ import org.jsoup.nodes.Element
 import org.jsoup.nodes.Node
 import org.jsoup.nodes.TextNode
 import org.jsoup.parser.Tag
+import java.lang.IllegalStateException
 import java.text.DecimalFormat
 import java.time.OffsetDateTime
 import java.time.temporal.ChronoUnit
@@ -352,10 +353,23 @@ fun String.toSafeFileName(): String {
     return this.map { if (it in safeCharacters) it else '_' }.joinToString(separator = "")
 }
 
+private val slds = hashSetOf(".com.ac", ".net.ac", ".gov.ac", ".org.ac", ".mil.ac", ".co.ae", ".net.ae", ".gov.ae", ".ac.ae", ".sch.ae", ".org.ae", ".mil.ae", ".pro.ae", ".name.ae", ".com.af", ".edu.af", ".gov.af", ".net.af", ".org.af", ".com.al", ".edu.al", ".gov.al", ".mil.al", ".net.al", ".org.al", ".ed.ao", ".gv.ao", ".og.ao", ".co.ao", ".pb.ao", ".it.ao", ".com.ar", ".edu.ar", ".gob.ar", ".gov.ar", ".gov.ar", ".int.ar", ".mil.ar", ".net.ar", ".org.ar", ".tur.ar", ".gv.at", ".ac.at", ".co.at", ".or.at", ".com.au", ".net.au", ".org.au", ".edu.au", ".gov.au", ".csiro.au", ".asn.au", ".id.au", ".org.ba", ".net.ba", ".edu.ba", ".gov.ba", ".mil.ba", ".unsa.ba", ".untz.ba", ".unmo.ba", ".unbi.ba", ".unze.ba", ".co.ba", ".com.ba", ".rs.ba", ".co.bb", ".com.bb", ".net.bb", ".org.bb", ".gov.bb", ".edu.bb", ".info.bb", ".store.bb", ".tv.bb", ".biz.bb", ".com.bh", ".info.bh", ".cc.bh", ".edu.bh", ".biz.bh", ".net.bh", ".org.bh", ".gov.bh", ".com.bn", ".edu.bn", ".gov.bn", ".net.bn", ".org.bn", ".com.bo", ".net.bo", ".org.bo", ".tv.bo", ".mil.bo", ".int.bo", ".gob.bo", ".gov.bo", ".edu.bo", ".adm.br", ".adv.br", ".agr.br", ".am.br", ".arq.br", ".art.br", ".ato.br", ".b.br", ".bio.br", ".blog.br", ".bmd.br", ".cim.br", ".cng.br", ".cnt.br", ".com.br", ".coop.br", ".ecn.br", ".edu.br", ".eng.br", ".esp.br", ".etc.br", ".eti.br", ".far.br", ".flog.br", ".fm.br", ".fnd.br", ".fot.br", ".fst.br", ".g12.br", ".ggf.br", ".gov.br", ".imb.br", ".ind.br", ".inf.br", ".jor.br", ".jus.br", ".lel.br", ".mat.br", ".med.br", ".mil.br", ".mus.br", ".net.br", ".nom.br", ".not.br", ".ntr.br", ".odo.br", ".org.br", ".ppg.br", ".pro.br", ".psc.br", ".psi.br", ".qsl.br", ".rec.br", ".slg.br", ".srv.br", ".tmp.br", ".trd.br", ".tur.br", ".tv.br", ".vet.br", ".vlog.br", ".wiki.br", ".zlg.br", ".com.bs", ".net.bs", ".org.bs", ".edu.bs", ".gov.bs", "com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz", ".ab.ca", ".bc.ca", ".mb.ca", ".nb.ca", ".nf.ca", ".nl.ca", ".ns.ca", ".nt.ca", ".nu.ca", ".on.ca", ".pe.ca", ".qc.ca", ".sk.ca", ".yk.ca", ".co.ck", ".org.ck", ".edu.ck", ".gov.ck", ".net.ck", ".gen.ck", ".biz.ck", ".info.ck", ".ac.cn", ".com.cn", ".edu.cn", ".gov.cn", ".mil.cn", ".net.cn", ".org.cn", ".ah.cn", ".bj.cn", ".cq.cn", ".fj.cn", ".gd.cn", ".gs.cn", ".gz.cn", ".gx.cn", ".ha.cn", ".hb.cn", ".he.cn", ".hi.cn", ".hl.cn", ".hn.cn", ".jl.cn", ".js.cn", ".jx.cn", ".ln.cn", ".nm.cn", ".nx.cn", ".qh.cn", ".sc.cn", ".sd.cn", ".sh.cn", ".sn.cn", ".sx.cn", ".tj.cn", ".tw.cn", ".xj.cn", ".xz.cn", ".yn.cn", ".zj.cn", ".com.co", ".org.co", ".edu.co", ".gov.co", ".net.co", ".mil.co", ".nom.co", ".ac.cr", ".co.cr", ".ed.cr", ".fi.cr", ".go.cr", ".or.cr", ".sa.cr", ".cr", ".ac.cy", ".net.cy", ".gov.cy", ".org.cy", ".pro.cy", ".name.cy", ".ekloges.cy", ".tm.cy", ".ltd.cy", ".biz.cy", ".press.cy", ".parliament.cy", ".com.cy", ".edu.do", ".gob.do", ".gov.do", ".com.do", ".sld.do", ".org.do", ".net.do", ".web.do", ".mil.do", ".art.do", ".com.dz", ".org.dz", ".net.dz", ".gov.dz", ".edu.dz", ".asso.dz", ".pol.dz", ".art.dz", ".com.ec", ".info.ec", ".net.ec", ".fin.ec", ".med.ec", ".pro.ec", ".org.ec", ".edu.ec", ".gov.ec", ".mil.ec", ".com.eg", ".edu.eg", ".eun.eg", ".gov.eg", ".mil.eg", ".name.eg", ".net.eg", ".org.eg", ".sci.eg", ".com.er", ".edu.er", ".gov.er", ".mil.er", ".net.er", ".org.er", ".ind.er", ".rochest.er", ".w.er", ".com.es", ".nom.es", ".org.es", ".gob.es", ".edu.es", ".com.et", ".gov.et", ".org.et", ".edu.et", ".net.et", ".biz.et", ".name.et", ".info.et", ".ac.fj", ".biz.fj", ".com.fj", ".info.fj", ".mil.fj", ".name.fj", ".net.fj", ".org.fj", ".pro.fj", ".co.fk", ".org.fk", ".gov.fk", ".ac.fk", ".nom.fk", ".net.fk", ".fr", ".tm.fr", ".asso.fr", ".nom.fr", ".prd.fr", ".presse.fr", ".com.fr", ".gouv.fr", ".co.gg", ".net.gg", ".org.gg", ".com.gh", ".edu.gh", ".gov.gh", ".org.gh", ".mil.gh", ".com.gn", ".ac.gn", ".gov.gn", ".org.gn", ".net.gn", ".com.gr", ".edu.gr", ".net.gr", ".org.gr", ".gov.gr", ".mil.gr", ".com.gt", ".edu.gt", ".net.gt", ".gob.gt", ".org.gt", ".mil.gt", ".ind.gt", ".com.gu", ".net.gu", ".gov.gu", ".org.gu", ".edu.gu", ".com.hk", ".edu.hk", ".gov.hk", ".idv.hk", ".net.hk", ".org.hk", ".ac.id", ".co.id", ".net.id", ".or.id", ".web.id", ".sch.id", ".mil.id", ".go.id", ".war.net.id", ".ac.il", ".co.il", ".org.il", ".net.il", ".k12.il", ".gov.il", ".muni.il", ".idf.il", ".in", ".4fd.in", ".co.in", ".firm.in", ".net.in", ".org.in", ".gen.in", ".ind.in", ".ac.in", ".edu.in", ".res.in", ".ernet.in", ".gov.in", ".mil.in", ".nic.in", ".nic.in", ".iq", ".gov.iq", ".edu.iq", ".com.iq", ".mil.iq", ".org.iq", ".net.iq", ".ir", ".ac.ir", ".co.ir", ".gov.ir", ".id.ir", ".net.ir", ".org.ir", ".sch.ir", ".dnssec.ir", ".gov.it", ".edu.it", ".co.je", ".net.je", ".org.je", ".com.jo", ".net.jo", ".gov.jo", ".edu.jo", ".org.jo", ".mil.jo", ".name.jo", ".sch.jo", ".ac.jp", ".ad.jp", ".co.jp", ".ed.jp", ".go.jp", ".gr.jp", ".lg.jp", ".ne.jp", ".or.jp", ".co.ke", ".or.ke", ".ne.ke", ".go.ke", ".ac.ke", ".sc.ke", ".me.ke", ".mobi.ke", ".info.ke", ".per.kh", ".com.kh", ".edu.kh", ".gov.kh", ".mil.kh", ".net.kh", ".org.kh", ".com.ki", ".biz.ki", ".de.ki", ".net.ki", ".info.ki", ".org.ki", ".gov.ki", ".edu.ki", ".mob.ki", ".tel.ki", ".km", ".com.km", ".coop.km", ".asso.km", ".nom.km", ".presse.km", ".tm.km", ".medecin.km", ".notaires.km", ".pharmaciens.km", ".veterinaire.km", ".edu.km", ".gouv.km", ".mil.km", ".net.kn", ".org.kn", ".edu.kn", ".gov.kn", ".kr", ".co.kr", ".ne.kr", ".or.kr", ".re.kr", ".pe.kr", ".go.kr", ".mil.kr", ".ac.kr", ".hs.kr", ".ms.kr", ".es.kr", ".sc.kr", ".kg.kr", ".seoul.kr", ".busan.kr", ".daegu.kr", ".incheon.kr", ".gwangju.kr", ".daejeon.kr", ".ulsan.kr", ".gyeonggi.kr", ".gangwon.kr", ".chungbuk.kr", ".chungnam.kr", ".jeonbuk.kr", ".jeonnam.kr", ".gyeongbuk.kr", ".gyeongnam.kr", ".jeju.kr", ".edu.kw", ".com.kw", ".net.kw", ".org.kw", ".gov.kw", ".com.ky", ".org.ky", ".net.ky", ".edu.ky", ".gov.ky", ".com.kz", ".edu.kz", ".gov.kz", ".mil.kz", ".net.kz", ".org.kz", ".com.lb", ".edu.lb", ".gov.lb", ".net.lb", ".org.lb", ".gov.lk", ".sch.lk", ".net.lk", ".int.lk", ".com.lk", ".org.lk", ".edu.lk", ".ngo.lk", ".soc.lk", ".web.lk", ".ltd.lk", ".assn.lk", ".grp.lk", ".hotel.lk", ".com.lr", ".edu.lr", ".gov.lr", ".org.lr", ".net.lr", ".com.lv", ".edu.lv", ".gov.lv", ".org.lv", ".mil.lv", ".id.lv", ".net.lv", ".asn.lv", ".conf.lv", ".com.ly", ".net.ly", ".gov.ly", ".plc.ly", ".edu.ly", ".sch.ly", ".med.ly", ".org.ly", ".id.ly", ".ma", ".net.ma", ".ac.ma", ".org.ma", ".gov.ma", ".press.ma", ".co.ma", ".tm.mc", ".asso.mc", ".co.me", ".net.me", ".org.me", ".edu.me", ".ac.me", ".gov.me", ".its.me", ".priv.me", ".org.mg", ".nom.mg", ".gov.mg", ".prd.mg", ".tm.mg", ".edu.mg", ".mil.mg", ".com.mg", ".com.mk", ".org.mk", ".net.mk", ".edu.mk", ".gov.mk", ".inf.mk", ".name.mk", ".pro.mk", ".com.ml", ".net.ml", ".org.ml", ".edu.ml", ".gov.ml", ".presse.ml", ".gov.mn", ".edu.mn", ".org.mn", ".com.mo", ".edu.mo", ".gov.mo", ".net.mo", ".org.mo", ".com.mt", ".org.mt", ".net.mt", ".edu.mt", ".gov.mt", ".aero.mv", ".biz.mv", ".com.mv", ".coop.mv", ".edu.mv", ".gov.mv", ".info.mv", ".int.mv", ".mil.mv", ".museum.mv", ".name.mv", ".net.mv", ".org.mv", ".pro.mv", ".ac.mw", ".co.mw", ".com.mw", ".coop.mw", ".edu.mw", ".gov.mw", ".int.mw", ".museum.mw", ".net.mw", ".org.mw", ".com.mx", ".net.mx", ".org.mx", ".edu.mx", ".gob.mx", ".com.my", ".net.my", ".org.my", ".gov.my", ".edu.my", ".sch.my", ".mil.my", ".name.my", ".com.nf", ".net.nf", ".arts.nf", ".store.nf", ".web.nf", ".firm.nf", ".info.nf", ".other.nf", ".per.nf", ".rec.nf", ".com.ng", ".org.ng", ".gov.ng", ".edu.ng", ".net.ng", ".sch.ng", ".name.ng", ".mobi.ng", ".biz.ng", ".mil.ng", ".gob.ni", ".co.ni", ".com.ni", ".ac.ni", ".edu.ni", ".org.ni", ".nom.ni", ".net.ni", ".mil.ni", ".com.np", ".edu.np", ".gov.np", ".org.np", ".mil.np", ".net.np", ".edu.nr", ".gov.nr", ".biz.nr", ".info.nr", ".net.nr", ".org.nr", ".com.nr", ".com.om", ".co.om", ".edu.om", ".ac.om", ".sch.om", ".gov.om", ".net.om", ".org.om", ".mil.om", ".museum.om", ".biz.om", ".pro.om", ".med.om", ".edu.pe", ".gob.pe", ".nom.pe", ".mil.pe", ".sld.pe", ".org.pe", ".com.pe", ".net.pe", ".com.ph", ".net.ph", ".org.ph", ".mil.ph", ".ngo.ph", ".i.ph", ".gov.ph", ".edu.ph", ".com.pk", ".net.pk", ".edu.pk", ".org.pk", ".fam.pk", ".biz.pk", ".web.pk", ".gov.pk", ".gob.pk", ".gok.pk", ".gon.pk", ".gop.pk", ".gos.pk", ".pwr.pl", ".com.pl", ".biz.pl", ".net.pl", ".art.pl", ".edu.pl", ".org.pl", ".ngo.pl", ".gov.pl", ".info.pl", ".mil.pl", ".waw.pl", ".warszawa.pl", ".wroc.pl", ".wroclaw.pl", ".krakow.pl", ".katowice.pl", ".poznan.pl", ".lodz.pl", ".gda.pl", ".gdansk.pl", ".slupsk.pl", ".radom.pl", ".szczecin.pl", ".lublin.pl", ".bialystok.pl", ".olsztyn.pl", ".torun.pl", ".gorzow.pl", ".zgora.pl", ".biz.pr", ".com.pr", ".edu.pr", ".gov.pr", ".info.pr", ".isla.pr", ".name.pr", ".net.pr", ".org.pr", ".pro.pr", ".est.pr", ".prof.pr", ".ac.pr", ".com.ps", ".net.ps", ".org.ps", ".edu.ps", ".gov.ps", ".plo.ps", ".sec.ps", ".co.pw", ".ne.pw", ".or.pw", ".ed.pw", ".go.pw", ".belau.pw", ".arts.ro", ".com.ro", ".firm.ro", ".info.ro", ".nom.ro", ".nt.ro", ".org.ro", ".rec.ro", ".store.ro", ".tm.ro", ".www.ro", ".co.rs", ".org.rs", ".edu.rs", ".ac.rs", ".gov.rs", ".in.rs", ".com.sb", ".net.sb", ".edu.sb", ".org.sb", ".gov.sb", ".com.sc", ".net.sc", ".edu.sc", ".gov.sc", ".org.sc", ".co.sh", ".com.sh", ".org.sh", ".gov.sh", ".edu.sh", ".net.sh", ".nom.sh", ".com.sl", ".net.sl", ".org.sl", ".edu.sl", ".gov.sl", ".gov.st", ".saotome.st", ".principe.st", ".consulado.st", ".embaixada.st", ".org.st", ".edu.st", ".net.st", ".com.st", ".store.st", ".mil.st", ".co.st", ".edu.sv", ".gob.sv", ".com.sv", ".org.sv", ".red.sv", ".co.sz", ".ac.sz", ".org.sz", ".com.tr", ".gen.tr", ".org.tr", ".biz.tr", ".info.tr", ".av.tr", ".dr.tr", ".pol.tr", ".bel.tr", ".tsk.tr", ".bbs.tr", ".k12.tr", ".edu.tr", ".name.tr", ".net.tr", ".gov.tr", ".web.tr", ".tel.tr", ".tv.tr", ".co.tt", ".com.tt", ".org.tt", ".net.tt", ".biz.tt", ".info.tt", ".pro.tt", ".int.tt", ".coop.tt", ".jobs.tt", ".mobi.tt", ".travel.tt", ".museum.tt", ".aero.tt", ".cat.tt", ".tel.tt", ".name.tt", ".mil.tt", ".edu.tt", ".gov.tt", ".edu.tw", ".gov.tw", ".mil.tw", ".com.tw", ".net.tw", ".org.tw", ".idv.tw", ".game.tw", ".ebiz.tw", ".club.tw", ".com.mu", ".gov.mu", ".net.mu", ".org.mu", ".ac.mu", ".co.mu", ".or.mu", ".ac.mz", ".co.mz", ".edu.mz", ".org.mz", ".gov.mz", ".com.na", ".co.na", ".ac.nz", ".co.nz", ".cri.nz", ".geek.nz", ".gen.nz", ".govt.nz", ".health.nz", ".iwi.nz", ".maori.nz", ".mil.nz", ".net.nz", ".org.nz", ".parliament.nz", ".school.nz", ".abo.pa", ".ac.pa", ".com.pa", ".edu.pa", ".gob.pa", ".ing.pa", ".med.pa", ".net.pa", ".nom.pa", ".org.pa", ".sld.pa", ".com.pt", ".edu.pt", ".gov.pt", ".int.pt", ".net.pt", ".nome.pt", ".org.pt", ".publ.pt", ".com.py", ".edu.py", ".gov.py", ".mil.py", ".net.py", ".org.py", ".com.qa", ".edu.qa", ".gov.qa", ".mil.qa", ".net.qa", ".org.qa", ".asso.re", ".com.re", ".nom.re", ".ac.ru", ".adygeya.ru", ".altai.ru", ".amur.ru", ".arkhangelsk.ru", ".astrakhan.ru", ".bashkiria.ru", ".belgorod.ru", ".bir.ru", ".bryansk.ru", ".buryatia.ru", ".cbg.ru", ".chel.ru", ".chelyabinsk.ru", ".chita.ru", ".chita.ru", ".chukotka.ru", ".chuvashia.ru", ".com.ru", ".dagestan.ru", ".e-burg.ru", ".edu.ru", ".gov.ru", ".grozny.ru", ".int.ru", ".irkutsk.ru", ".ivanovo.ru", ".izhevsk.ru", ".jar.ru", ".joshkar-ola.ru", ".kalmykia.ru", ".kaluga.ru", ".kamchatka.ru", ".karelia.ru", ".kazan.ru", ".kchr.ru", ".kemerovo.ru", ".khabarovsk.ru", ".khakassia.ru", ".khv.ru", ".kirov.ru", ".koenig.ru", ".komi.ru", ".kostroma.ru", ".kranoyarsk.ru", ".kuban.ru", ".kurgan.ru", ".kursk.ru", ".lipetsk.ru", ".magadan.ru", ".mari.ru", ".mari-el.ru", ".marine.ru", ".mil.ru", ".mordovia.ru", ".mosreg.ru", ".msk.ru", ".murmansk.ru", ".nalchik.ru", ".net.ru", ".nnov.ru", ".nov.ru", ".novosibirsk.ru", ".nsk.ru", ".omsk.ru", ".orenburg.ru", ".org.ru", ".oryol.ru", ".penza.ru", ".perm.ru", ".pp.ru", ".pskov.ru", ".ptz.ru", ".rnd.ru", ".ryazan.ru", ".sakhalin.ru", ".samara.ru", ".saratov.ru", ".simbirsk.ru", ".smolensk.ru", ".spb.ru", ".stavropol.ru", ".stv.ru", ".surgut.ru", ".tambov.ru", ".tatarstan.ru", ".tom.ru", ".tomsk.ru", ".tsaritsyn.ru", ".tsk.ru", ".tula.ru", ".tuva.ru", ".tver.ru", ".tyumen.ru", ".udm.ru", ".udmurtia.ru", ".ulan-ude.ru", ".vladikavkaz.ru", ".vladimir.ru", ".vladivostok.ru", ".volgograd.ru", ".vologda.ru", ".voronezh.ru", ".vrn.ru", ".vyatka.ru", ".yakutia.ru", ".yamal.ru", ".yekaterinburg.ru", ".yuzhno-sakhalinsk.ru", ".ac.rw", ".co.rw", ".com.rw", ".edu.rw", ".gouv.rw", ".gov.rw", ".int.rw", ".mil.rw", ".net.rw", ".com.sa", ".edu.sa", ".gov.sa", ".med.sa", ".net.sa", ".org.sa", ".pub.sa", ".sch.sa", ".com.sd", ".edu.sd", ".gov.sd", ".info.sd", ".med.sd", ".net.sd", ".org.sd", ".tv.sd", ".a.se", ".ac.se", ".b.se", ".bd.se", ".c.se", ".d.se", ".e.se", ".f.se", ".g.se", ".h.se", ".i.se", ".k.se", ".l.se", ".m.se", ".n.se", ".o.se", ".org.se", ".p.se", ".parti.se", ".pp.se", ".press.se", ".r.se", ".s.se", ".t.se", ".tm.se", ".u.se", ".w.se", ".x.se", ".y.se", ".z.se", ".com.sg", ".edu.sg", ".gov.sg", ".idn.sg", ".net.sg", ".org.sg", ".per.sg", ".art.sn", ".com.sn", ".edu.sn", ".gouv.sn", ".org.sn", ".perso.sn", ".univ.sn", ".com.sy", ".edu.sy", ".gov.sy", ".mil.sy", ".net.sy", ".news.sy", ".org.sy", ".ac.th", ".co.th", ".go.th", ".in.th", ".mi.th", ".net.th", ".or.th", ".ac.tj", ".biz.tj", ".co.tj", ".com.tj", ".edu.tj", ".go.tj", ".gov.tj", ".info.tj", ".int.tj", ".mil.tj", ".name.tj", ".net.tj", ".nic.tj", ".org.tj", ".test.tj", ".web.tj", ".agrinet.tn", ".com.tn", ".defense.tn", ".edunet.tn", ".ens.tn", ".fin.tn", ".gov.tn", ".ind.tn", ".info.tn", ".intl.tn", ".mincom.tn", ".nat.tn", ".net.tn", ".org.tn", ".perso.tn", ".rnrt.tn", ".rns.tn", ".rnu.tn", ".tourism.tn", ".ac.tz", ".co.tz", ".go.tz", ".ne.tz", ".or.tz", ".biz.ua", ".cherkassy.ua", ".chernigov.ua", ".chernovtsy.ua", ".ck.ua", ".cn.ua", ".co.ua", ".com.ua", ".crimea.ua", ".cv.ua", ".dn.ua", ".dnepropetrovsk.ua", ".donetsk.ua", ".dp.ua", ".edu.ua", ".gov.ua", ".if.ua", ".in.ua", ".ivano-frankivsk.ua", ".kh.ua", ".kharkov.ua", ".kherson.ua", ".khmelnitskiy.ua", ".kiev.ua", ".kirovograd.ua", ".km.ua", ".kr.ua", ".ks.ua", ".kv.ua", ".lg.ua", ".lugansk.ua", ".lutsk.ua", ".lviv.ua", ".me.ua", ".mk.ua", ".net.ua", ".nikolaev.ua", ".od.ua", ".odessa.ua", ".org.ua", ".pl.ua", ".poltava.ua", ".pp.ua", ".rovno.ua", ".rv.ua", ".sebastopol.ua", ".sumy.ua", ".te.ua", ".ternopil.ua", ".uzhgorod.ua", ".vinnica.ua", ".vn.ua", ".zaporizhzhe.ua", ".zhitomir.ua", ".zp.ua", ".zt.ua", ".ac.ug", ".co.ug", ".go.ug", ".ne.ug", ".or.ug", ".org.ug", ".sc.ug", ".ac.uk", ".bl.uk", ".british-library.uk", ".co.uk", ".cym.uk", ".gov.uk", ".govt.uk", ".icnet.uk", ".jet.uk", ".lea.uk", ".ltd.uk", ".me.uk", ".mil.uk", ".mod.uk", ".mod.uk", ".national-library-scotland.uk", ".nel.uk", ".net.uk", ".nhs.uk", ".nhs.uk", ".nic.uk", ".nls.uk", ".org.uk", ".orgn.uk", ".parliament.uk", ".parliament.uk", ".plc.uk", ".police.uk", ".sch.uk", ".scot.uk", ".soc.uk", ".4fd.us", ".dni.us", ".fed.us", ".isa.us", ".kids.us", ".nsn.us", ".com.uy", ".edu.uy", ".gub.uy", ".mil.uy", ".net.uy", ".org.uy", ".co.ve", ".com.ve", ".edu.ve", ".gob.ve", ".info.ve", ".mil.ve", ".net.ve", ".org.ve", ".web.ve", ".co.vi", ".com.vi", ".k12.vi", ".net.vi", ".org.vi", ".ac.vn", ".biz.vn", ".com.vn", ".edu.vn", ".gov.vn", ".health.vn", ".info.vn", ".int.vn", ".name.vn", ".net.vn", ".org.vn", ".pro.vn", ".co.ye", ".com.ye", ".gov.ye", ".ltd.ye", ".me.ye", ".net.ye", ".org.ye", ".plc.ye", ".ac.yu", ".co.yu", ".edu.yu", ".gov.yu", ".org.yu", ".ac.za", ".agric.za", ".alt.za", ".bourse.za", ".city.za", ".co.za", ".cybernet.za", ".db.za", ".ecape.school.za", ".edu.za", ".fs.school.za", ".gov.za", ".gp.school.za", ".grondar.za", ".iaccess.za", ".imt.za", ".inca.za", ".kzn.school.za", ".landesign.za", ".law.za", ".lp.school.za", ".mil.za", ".mpm.school.za", ".ncape.school.za", ".net.za", ".ngo.za", ".nis.za", ".nom.za", ".nw.school.za", ".olivetti.za", ".org.za", ".pix.za", ".school.za", ".tm.za", ".wcape.school.za", ".web.za", ".ac.zm", ".co.zm", ".com.zm", ".edu.zm", ".gov.zm", ".net.zm", ".org.zm", ".sch.zm")
 fun String.matchesDomain(queryDomain: String): Boolean {
-    if(queryDomain.startsWith("."))
-    //TODO: Should be safe, but double verify if can't be exploited
-        return this.endsWith(queryDomain) || this == queryDomain.trimStart('.')
+
+    if(queryDomain.startsWith(".")) {
+
+        val parts = queryDomain.lowercase().split(".");
+        if(parts.size < 3)
+            throw IllegalStateException("Illegal use of wildcards on First-Level-Domain");
+        if(parts.size >= 3){
+            val isSLD = slds.contains("." + parts[parts.size - 2] + "." + parts[parts.size - 1]);
+            if(isSLD && parts.size <= 3)
+                throw IllegalStateException("Illegal use of wildcards on Second-Level-Domain");
+        }
+
+        //TODO: Should be safe, but double verify if can't be exploited
+        return this.endsWith(queryDomain) || this == queryDomain.trimStart('.');
+    }
     else
         return this == queryDomain;
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt
index ff7a203ad304abcfa54c1c6fa40eda8b29978fd6..92e5cdb1e729620c4706a4e49260441842b9e492 100644
--- a/app/src/main/java/com/futo/platformplayer/Settings.kt
+++ b/app/src/main/java/com/futo/platformplayer/Settings.kt
@@ -350,7 +350,7 @@ class Settings : FragmentedStorageFileJson() {
     var playback = PlaybackSettings();
     @Serializable
     class PlaybackSettings {
-        @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
+        @FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -1)
         @DropdownFieldOptionsId(R.array.audio_languages)
         var primaryLanguage: Int = 0;
 
@@ -377,7 +377,7 @@ class Settings : FragmentedStorageFileJson() {
 
         //= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
 
-        @FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
+        @FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 0)
         @DropdownFieldOptionsId(R.array.playback_speeds)
         var defaultPlaybackSpeed: Int = 3;
         fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
@@ -393,22 +393,26 @@ class Settings : FragmentedStorageFileJson() {
             else -> 1.0f;
         };
 
-        @FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 2)
+        @FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 1)
         @DropdownFieldOptionsId(R.array.preferred_quality_array)
         var preferredQuality: Int = 0;
 
-        @FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 3)
+        @FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 2)
         @DropdownFieldOptionsId(R.array.preferred_quality_array)
         var preferredMeteredQuality: Int = 0;
         fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
         fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
         fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
 
-        @FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 4)
+        @FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 3)
         @DropdownFieldOptionsId(R.array.preferred_quality_array)
         var preferredPreviewQuality: Int = 5;
         fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
 
+
+        @FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
+        var simplifySources: Boolean = true;
+
         @FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
         @DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
         var autoRotate: Int = 2;
@@ -466,6 +470,12 @@ class Settings : FragmentedStorageFileJson() {
 
         @FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13)
         var fullscreenPortrait: Boolean = false;
+
+
+        @FormField(R.string.prefer_webm, FieldForm.TOGGLE, R.string.prefer_webm_description, 14)
+        var preferWebmVideo: Boolean = false;
+        @FormField(R.string.prefer_webm_audio, FieldForm.TOGGLE, R.string.prefer_webm_audio_description, 15)
+        var preferWebmAudio: Boolean = false;
     }
 
     @FormField(R.string.comments, "group", R.string.comments_description, 6)
@@ -795,10 +805,10 @@ class Settings : FragmentedStorageFileJson() {
         fun export() {
             val activity = SettingsActivity.getActivity() ?: return;
             UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
-                SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", null, {
+                SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
                     StateBackup.shareExternalBackup();
                 }),
-                SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", null, {
+                SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", tag = null, call = {
                     StateBackup.saveExternalBackup(activity);
                 })
             )
diff --git a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
index f26763a40cb38ff16f5ea1d6c825dfe4658bb9ad..06f88f679d9a044ea813afb35f06dcfca037404a 100644
--- a/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
+++ b/app/src/main/java/com/futo/platformplayer/UISlideOverlays.kt
@@ -40,7 +40,6 @@ import com.futo.platformplayer.states.StatePlatform
 import com.futo.platformplayer.states.StatePlayer
 import com.futo.platformplayer.states.StatePlaylists
 import com.futo.platformplayer.states.StateSubscriptionGroups
-import com.futo.platformplayer.states.StateSubscriptions
 import com.futo.platformplayer.views.AnyAdapterView
 import com.futo.platformplayer.views.AnyAdapterView.Companion.asAny
 import com.futo.platformplayer.views.LoaderView
@@ -92,9 +91,17 @@ class UISlideOverlays {
 
                 withContext(Dispatchers.Main) {
                     items.addAll(listOf(
-                        SlideUpMenuItem(container.context, R.drawable.ic_notifications, "Notifications", "", "notifications", {
-                            subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
-                        }, false),
+                        SlideUpMenuItem(
+                            container.context,
+                            R.drawable.ic_notifications,
+                            "Notifications",
+                            "",
+                            tag = "notifications",
+                            call = {
+                                subscription.doNotifications = menu?.selectOption(null, "notifications", true, true) ?: subscription.doNotifications;
+                            },
+                            invokeParent = false
+                        ),
                         if(StateSubscriptionGroups.instance.getSubscriptionGroups().isNotEmpty())
                             SlideUpMenuGroup(container.context, "Subscription Groups",
                                 "You can select which groups this subscription is part of.",
@@ -129,22 +136,62 @@ class UISlideOverlays {
                         SlideUpMenuGroup(container.context, "Fetch Settings",
                             "Depending on the platform you might not need to enable a type for it to be available.",
                             -1, listOf()),
-                        if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(container.context, R.drawable.ic_live_tv, "Livestreams", "Check for livestreams", "fetchLive", {
-                            subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
-                        }, false) else null,
-                        if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(container.context, R.drawable.ic_play, "Streams", "Check for streams", "fetchStreams", {
-                            subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
-                        }, false) else null,
+                        if(capabilities.hasType(ResultCapabilities.TYPE_LIVE)) SlideUpMenuItem(
+                            container.context,
+                            R.drawable.ic_live_tv,
+                            "Livestreams",
+                            "Check for livestreams",
+                            tag = "fetchLive",
+                            call = {
+                                subscription.doFetchLive = menu?.selectOption(null, "fetchLive", true, true) ?: subscription.doFetchLive;
+                            },
+                            invokeParent = false
+                        ) else null,
+                        if(capabilities.hasType(ResultCapabilities.TYPE_STREAMS)) SlideUpMenuItem(
+                            container.context,
+                            R.drawable.ic_play,
+                            "Streams",
+                            "Check for streams",
+                            tag = "fetchStreams",
+                            call = {
+                                subscription.doFetchStreams = menu?.selectOption(null, "fetchStreams", true, true) ?: subscription.doFetchStreams;
+                            },
+                            invokeParent = false
+                        ) else null,
                         if(capabilities.hasType(ResultCapabilities.TYPE_VIDEOS))
-                            SlideUpMenuItem(container.context, R.drawable.ic_play, "Videos", "Check for videos", "fetchVideos", {
-                            subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
-                        }, false) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
-                            SlideUpMenuItem(container.context, R.drawable.ic_play, "Content", "Check for content", "fetchVideos", {
-                                subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
-                            }, false) else null,
-                        if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(container.context, R.drawable.ic_chat, "Posts", "Check for posts", "fetchPosts", {
-                            subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
-                        }, false) else null/*,,
+                            SlideUpMenuItem(
+                                container.context,
+                                R.drawable.ic_play,
+                                "Videos",
+                                "Check for videos",
+                                tag = "fetchVideos",
+                                call = {
+                                    subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
+                                },
+                                invokeParent = false
+                            ) else if(capabilities.hasType(ResultCapabilities.TYPE_MIXED) || capabilities.types.isEmpty())
+                            SlideUpMenuItem(
+                                container.context,
+                                R.drawable.ic_play,
+                                "Content",
+                                "Check for content",
+                                tag = "fetchVideos",
+                                call = {
+                                    subscription.doFetchVideos = menu?.selectOption(null, "fetchVideos", true, true) ?: subscription.doFetchVideos;
+                                },
+                                invokeParent = false
+                            ) else null,
+                        if(capabilities.hasType(ResultCapabilities.TYPE_POSTS)) SlideUpMenuItem(
+                            container.context,
+                            R.drawable.ic_chat,
+                            "Posts",
+                            "Check for posts",
+                            tag = "fetchPosts",
+                            call = {
+                                subscription.doFetchPosts = menu?.selectOption(null, "fetchPosts", true, true) ?: subscription.doFetchPosts;
+                            },
+                            invokeParent = false
+                        ) else null/*,,
 
                         SlideUpMenuGroup(container.context, "Actions",
                             "Various things you can do with this subscription",
@@ -243,11 +290,23 @@ class UISlideOverlays {
                     masterPlaylist = HLS.parseMasterPlaylist(masterPlaylistContent, sourceUrl)
 
                     masterPlaylist.getAudioSources().forEach { it ->
-                        audioButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "), it, {
-                            selectedAudioVariant = it
-                            slideUpMenuOverlay.selectOption(audioButtons, it)
-                            slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
-                        }, false))
+
+                        val estSize = VideoHelper.estimateSourceSize(it);
+                        val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
+                        audioButtons.add(SlideUpMenuItem(
+                            container.context,
+                            R.drawable.ic_music,
+                            it.name,
+                            listOf(it.language, it.codec).mapNotNull { x -> x.ifEmpty { null } }.joinToString(", "),
+                            (prefix + it.codec).trim(),
+                            tag = it,
+                            call = {
+                                selectedAudioVariant = it
+                                slideUpMenuOverlay.selectOption(audioButtons, it)
+                                slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
+                            },
+                            invokeParent = false
+                        ))
                     }
 
                     /*masterPlaylist.getSubtitleSources().forEach { it ->
@@ -259,11 +318,22 @@ class UISlideOverlays {
                     }*/
 
                     masterPlaylist.getVideoSources().forEach {
-                        videoButtons.add(SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
-                            selectedVideoVariant = it
-                            slideUpMenuOverlay.selectOption(videoButtons, it)
-                            slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
-                        }, false))
+                        val estSize = VideoHelper.estimateSourceSize(it);
+                        val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
+                        videoButtons.add(SlideUpMenuItem(
+                            container.context,
+                            R.drawable.ic_movie,
+                            it.name,
+                            "${it.width}x${it.height}",
+                            (prefix + it.codec).trim(),
+                            tag = it,
+                            call = {
+                                selectedVideoVariant = it
+                                slideUpMenuOverlay.selectOption(videoButtons, it)
+                                slideUpMenuOverlay.setOk(container.context.getString(R.string.download))
+                            },
+                            invokeParent = false
+                        ))
                     }
 
                     val newItems = arrayListOf<View>()
@@ -342,29 +412,56 @@ class UISlideOverlays {
             }
 
             items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.video), videoSources,
-                listOf(listOf(SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.none), container.context.getString(R.string.audio_only), "none", {
-                    selectedVideo = null;
-                    menu?.selectOption(videoSources, "none");
-                    if(selectedAudio != null || !requiresAudio)
-                        menu?.setOk(container.context.getString(R.string.download));
-                }, false)) +
+                listOf(listOf(SlideUpMenuItem(
+                    container.context,
+                    R.drawable.ic_movie,
+                    container.context.getString(R.string.none),
+                    container.context.getString(R.string.audio_only),
+                    tag = "none",
+                    call = {
+                        selectedVideo = null;
+                        menu?.selectOption(videoSources, "none");
+                        if(selectedAudio != null || !requiresAudio)
+                            menu?.setOk(container.context.getString(R.string.download));
+                    },
+                    invokeParent = false
+                )) +
                 videoSources
                 .filter { it.isDownloadable() }
                 .map {
                     when (it) {
                         is IVideoUrlSource -> {
-                            SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it, {
-                                selectedVideo = it
-                                menu?.selectOption(videoSources, it);
-                                if(selectedAudio != null || !requiresAudio)
-                                    menu?.setOk(container.context.getString(R.string.download));
-                            }, false)
+                            val estSize = VideoHelper.estimateSourceSize(it);
+                            val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
+                            SlideUpMenuItem(
+                                container.context,
+                                R.drawable.ic_movie,
+                                it.name,
+                                "${it.width}x${it.height}",
+                                (prefix + it.codec).trim(),
+                                tag = it,
+                                call = {
+                                    selectedVideo = it
+                                    menu?.selectOption(videoSources, it);
+                                    if(selectedAudio != null || !requiresAudio)
+                                        menu?.setOk(container.context.getString(R.string.download));
+                                },
+                                invokeParent = false
+                            )
                         }
 
                         is IHLSManifestSource -> {
-                            SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS", it, {
-                                showHlsPicker(video, it, it.url, container)
-                            }, false)
+                            SlideUpMenuItem(
+                                container.context,
+                                R.drawable.ic_movie,
+                                it.name,
+                                "HLS",
+                                tag = it,
+                                call = {
+                                    showHlsPicker(video, it, it.url, container)
+                                },
+                                invokeParent = false
+                            )
                         }
 
                         else -> {
@@ -389,17 +486,36 @@ class UISlideOverlays {
                     .map {
                         when (it) {
                             is IAudioUrlSource -> {
-                                SlideUpMenuItem(container.context, R.drawable.ic_music, it.name, "${it.bitrate}", it, {
-                                    selectedAudio = it
-                                    menu?.selectOption(audioSources, it);
-                                    menu?.setOk(container.context.getString(R.string.download));
-                                }, false);
+                                val estSize = VideoHelper.estimateSourceSize(it);
+                                val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
+                                SlideUpMenuItem(
+                                    container.context,
+                                    R.drawable.ic_music,
+                                    it.name,
+                                    "${it.bitrate}",
+                                    (prefix + it.codec).trim(),
+                                    tag = it,
+                                    call = {
+                                        selectedAudio = it
+                                        menu?.selectOption(audioSources, it);
+                                        menu?.setOk(container.context.getString(R.string.download));
+                                    },
+                                    invokeParent = false
+                                );
                             }
 
                             is IHLSManifestAudioSource -> {
-                                SlideUpMenuItem(container.context, R.drawable.ic_movie, it.name, "HLS Audio", it, {
-                                    showHlsPicker(video, it, it.url, container)
-                                }, false)
+                                SlideUpMenuItem(
+                                    container.context,
+                                    R.drawable.ic_movie,
+                                    it.name,
+                                    "HLS Audio",
+                                    tag = it,
+                                    call = {
+                                        showHlsPicker(video, it, it.url, container)
+                                    },
+                                    invokeParent = false
+                                )
                             }
 
                             else -> {
@@ -417,15 +533,23 @@ class UISlideOverlays {
 
             if(contentResolver != null && subtitleSources.isNotEmpty()) {
                 items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.subtitles), subtitleSources, subtitleSources.map {
-                        SlideUpMenuItem(container.context, R.drawable.ic_edit, it.name, "", it, {
-                            if (selectedSubtitle == it) {
-                                selectedSubtitle = null;
-                                menu?.selectOption(subtitleSources, null);
-                            } else {
-                                selectedSubtitle = it;
-                                menu?.selectOption(subtitleSources, it);
-                            }
-                        }, false);
+                        SlideUpMenuItem(
+                            container.context,
+                            R.drawable.ic_edit,
+                            it.name,
+                            "",
+                            tag = it,
+                            call = {
+                                if (selectedSubtitle == it) {
+                                    selectedSubtitle = null;
+                                    menu?.selectOption(subtitleSources, null);
+                                } else {
+                                    selectedSubtitle = it;
+                                    menu?.selectOption(subtitleSources, it);
+                                }
+                            },
+                            invokeParent = false
+                        );
                     })
                 );
             }
@@ -537,23 +661,47 @@ class UISlideOverlays {
             );
 
             items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_resolution), "Video", resolutions.map {
-                SlideUpMenuItem(container.context, R.drawable.ic_movie, it.first, it.second, it.third, {
-                    targetPxSize = it.third;
-                    menu?.selectOption("Video", it.third);
-                }, false)
+                SlideUpMenuItem(
+                    container.context,
+                    R.drawable.ic_movie,
+                    it.first,
+                    it.second,
+                    tag = it.third,
+                    call = {
+                        targetPxSize = it.third;
+                        menu?.selectOption("Video", it.third);
+                    },
+                    invokeParent = false
+                )
             }));
 
             items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.target_bitrate), "Bitrate", listOf(
-                SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.low_bitrate), "", 1, {
-                    targetBitrate = 1;
-                    menu?.selectOption("Bitrate", 1);
-                    menu?.setOk(container.context.getString(R.string.download));
-                }, false),
-                SlideUpMenuItem(container.context, R.drawable.ic_movie, container.context.getString(R.string.high_bitrate), "", 9999999, {
-                    targetBitrate = 9999999;
-                    menu?.selectOption("Bitrate", 9999999);
-                    menu?.setOk(container.context.getString(R.string.download));
-                }, false)
+                SlideUpMenuItem(
+                    container.context,
+                    R.drawable.ic_movie,
+                    container.context.getString(R.string.low_bitrate),
+                    "",
+                    tag = 1,
+                    call = {
+                        targetBitrate = 1;
+                        menu?.selectOption("Bitrate", 1);
+                        menu?.setOk(container.context.getString(R.string.download));
+                    },
+                    invokeParent = false
+                ),
+                SlideUpMenuItem(
+                    container.context,
+                    R.drawable.ic_movie,
+                    container.context.getString(R.string.high_bitrate),
+                    "",
+                    tag = 9999999,
+                    call = {
+                        targetBitrate = 9999999;
+                        menu?.selectOption("Bitrate", 9999999);
+                        menu?.setOk(container.context.getString(R.string.download));
+                    },
+                    invokeParent = false
+                )
             )));
 
 
@@ -676,8 +824,12 @@ class UISlideOverlays {
             if (lastUpdated != null) {
                 items.add(
                     SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
-                        SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "",
-                            {
+                        SlideUpMenuItem(container.context,
+                            R.drawable.ic_playlist_add,
+                            lastUpdated.name,
+                            "${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
+                            tag = "",
+                            call = {
                                 StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
                                 StateDownloads.instance.checkForOutdatedPlaylists();
                             }))
@@ -689,44 +841,90 @@ class UISlideOverlays {
             val watchLater = StatePlaylists.instance.getWatchLater();
             items.add(SlideUpMenuGroup(container.context, container.context.getString(R.string.actions), "actions",
                 (listOf(
-                    SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), "download", {
-                        showDownloadVideoOverlay(video, container, true);
-                    }, false),
-                    SlideUpMenuItem(container.context, R.drawable.ic_share, container.context.getString(R.string.share), "Share the video", "share", {
-                        val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
-                        container.context.startActivity(Intent.createChooser(Intent().apply {
-                            action = Intent.ACTION_SEND;
-                            putExtra(Intent.EXTRA_TEXT, url);
-                            type = "text/plain";
-                        }, null));
-                    }, false),
-                    SlideUpMenuItem(container.context, R.drawable.ic_visibility_off, container.context.getString(R.string.hide_creator_from_home), "", "hide_creator", {
-                        StateMeta.instance.addHiddenCreator(video.author.url);
-                        UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
-                    }))
+                    SlideUpMenuItem(
+                        container.context,
+                        R.drawable.ic_download,
+                        container.context.getString(R.string.download),
+                        container.context.getString(R.string.download_the_video),
+                        tag = "download",
+                        call = {
+                            showDownloadVideoOverlay(video, container, true);
+                        },
+                        invokeParent = false
+                    ),
+                    SlideUpMenuItem(
+                        container.context,
+                        R.drawable.ic_share,
+                        container.context.getString(R.string.share),
+                        "Share the video",
+                        tag = "share",
+                        call = {
+                            val url = if(video.shareUrl.isNotEmpty()) video.shareUrl else video.url;
+                            container.context.startActivity(Intent.createChooser(Intent().apply {
+                                action = Intent.ACTION_SEND;
+                                putExtra(Intent.EXTRA_TEXT, url);
+                                type = "text/plain";
+                            }, null));
+                        },
+                        invokeParent = false
+                    ),
+                    SlideUpMenuItem(
+                        container.context,
+                        R.drawable.ic_visibility_off,
+                        container.context.getString(R.string.hide_creator_from_home),
+                        "",
+                        tag = "hide_creator",
+                        call = {
+                            StateMeta.instance.addHiddenCreator(video.author.url);
+                            UIDialogs.toast(container.context, "[${video.author.name}] hidden, you may need to reload home");
+                        }))
                         + actions)
             ));
             items.add(
                 SlideUpMenuGroup(container.context, container.context.getString(R.string.add_to), "addto",
-                    SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.add_to_queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
-                        { StatePlayer.instance.addToQueue(video); }),
-                    SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "", "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
-                        { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
-                    SlideUpMenuItem(container.context, R.drawable.ic_history, container.context.getString(R.string.add_to_history), "Mark as watched", "history",
-                        { StateHistory.instance.markAsWatched(video); }),
+                    SlideUpMenuItem(container.context,
+                        R.drawable.ic_queue_add,
+                        container.context.getString(R.string.add_to_queue),
+                        "${queue.size} " + container.context.getString(R.string.videos),
+                        tag = "queue",
+                        call = { StatePlayer.instance.addToQueue(video); }),
+                    SlideUpMenuItem(container.context,
+                        R.drawable.ic_watchlist_add,
+                        "${container.context.getString(R.string.add_to)} " + StatePlayer.TYPE_WATCHLATER + "",
+                        "${watchLater.size} " + container.context.getString(R.string.videos),
+                        tag = "watch later",
+                        call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
+                    SlideUpMenuItem(container.context,
+                        R.drawable.ic_history,
+                        container.context.getString(R.string.add_to_history),
+                        "Mark as watched",
+                        tag = "history",
+                        call = { StateHistory.instance.markAsWatched(video); }),
             ));
 
             val playlistItems = arrayListOf<SlideUpMenuItem>();
-            playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
-                showCreatePlaylistOverlay(container) {
-                    val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
-                    StatePlaylists.instance.createOrUpdatePlaylist(playlist);
-                };
-            }, false))
+            playlistItems.add(SlideUpMenuItem(
+                container.context,
+                R.drawable.ic_playlist_add,
+                container.context.getString(R.string.new_playlist),
+                container.context.getString(R.string.add_to_new_playlist),
+                tag = "add_to_new_playlist",
+                call = {
+                    showCreatePlaylistOverlay(container) {
+                        val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
+                        StatePlaylists.instance.createOrUpdatePlaylist(playlist);
+                    };
+                },
+                invokeParent = false
+            ))
 
             for (playlist in allPlaylists) {
-                playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, "${container.context.getString(R.string.add_to)} " + playlist.name + "", "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
-                    {
+                playlistItems.add(SlideUpMenuItem(container.context,
+                    R.drawable.ic_playlist_add,
+                    "${container.context.getString(R.string.add_to)} " + playlist.name + "",
+                    "${playlist.videos.size} " + container.context.getString(R.string.videos),
+                    tag = "",
+                    call = {
                         StatePlaylists.instance.addToPlaylist(playlist.id, video);
                         StateDownloads.instance.checkForOutdatedPlaylists();
                     }));
@@ -748,8 +946,12 @@ class UISlideOverlays {
             if (lastUpdated != null) {
                 items.add(
                     SlideUpMenuGroup(container.context, container.context.getString(R.string.recently_used_playlist), "recentlyusedplaylist",
-                        SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, lastUpdated.name, "${lastUpdated.videos.size} " + container.context.getString(R.string.videos), "",
-                            {
+                        SlideUpMenuItem(container.context,
+                            R.drawable.ic_playlist_add,
+                            lastUpdated.name,
+                            "${lastUpdated.videos.size} " + container.context.getString(R.string.videos),
+                            tag = "",
+                            call = {
                                 StatePlaylists.instance.addToPlaylist(lastUpdated.id, video);
                                 StateDownloads.instance.checkForOutdatedPlaylists();
                             }))
@@ -761,25 +963,52 @@ class UISlideOverlays {
             val watchLater = StatePlaylists.instance.getWatchLater();
             items.add(
                 SlideUpMenuGroup(container.context, container.context.getString(R.string.other), "other",
-                    SlideUpMenuItem(container.context, R.drawable.ic_queue_add, container.context.getString(R.string.queue), "${queue.size} " + container.context.getString(R.string.videos), "queue",
-                        { StatePlayer.instance.addToQueue(video); }),
-                    SlideUpMenuItem(container.context, R.drawable.ic_watchlist_add, StatePlayer.TYPE_WATCHLATER, "${watchLater.size} " + container.context.getString(R.string.videos), "watch later",
-                        { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
-                    SlideUpMenuItem(container.context, R.drawable.ic_download, container.context.getString(R.string.download), container.context.getString(R.string.download_the_video), container.context.getString(R.string.download),
-                        { showDownloadVideoOverlay(video, container, true); }, false))
+                    SlideUpMenuItem(container.context,
+                        R.drawable.ic_queue_add,
+                        container.context.getString(R.string.queue),
+                        "${queue.size} " + container.context.getString(R.string.videos),
+                        tag = "queue",
+                        call = { StatePlayer.instance.addToQueue(video); }),
+                    SlideUpMenuItem(container.context,
+                        R.drawable.ic_watchlist_add,
+                        StatePlayer.TYPE_WATCHLATER,
+                        "${watchLater.size} " + container.context.getString(R.string.videos),
+                        tag = "watch later",
+                        call = { StatePlaylists.instance.addToWatchLater(SerializedPlatformVideo.fromVideo(video)); }),
+                    SlideUpMenuItem(
+                        container.context,
+                        R.drawable.ic_download,
+                        container.context.getString(R.string.download),
+                        container.context.getString(R.string.download_the_video),
+                        tag = container.context.getString(R.string.download),
+                        call = { showDownloadVideoOverlay(video, container, true); },
+                        invokeParent = false
+                    ))
             );
 
             val playlistItems = arrayListOf<SlideUpMenuItem>();
-            playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, container.context.getString(R.string.new_playlist), container.context.getString(R.string.add_to_new_playlist), "add_to_new_playlist", {
-                slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) {
-                    val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
-                    StatePlaylists.instance.createOrUpdatePlaylist(playlist);
-                });
-            }, false))
+            playlistItems.add(SlideUpMenuItem(
+                container.context,
+                R.drawable.ic_playlist_add,
+                container.context.getString(R.string.new_playlist),
+                container.context.getString(R.string.add_to_new_playlist),
+                tag = "add_to_new_playlist",
+                call = {
+                    slideUpMenuOverlayUpdated(showCreatePlaylistOverlay(container) {
+                        val playlist = Playlist(it, arrayListOf(SerializedPlatformVideo.fromVideo(video)));
+                        StatePlaylists.instance.createOrUpdatePlaylist(playlist);
+                    });
+                },
+                invokeParent = false
+            ))
 
             for (playlist in allPlaylists) {
-                playlistItems.add(SlideUpMenuItem(container.context, R.drawable.ic_playlist_add, playlist.name, "${playlist.videos.size} " + container.context.getString(R.string.videos), "",
-                    {
+                playlistItems.add(SlideUpMenuItem(container.context,
+                    R.drawable.ic_playlist_add,
+                    playlist.name,
+                    "${playlist.videos.size} " + container.context.getString(R.string.videos),
+                    tag = "",
+                    call = {
                         StatePlaylists.instance.addToPlaylist(playlist.id, video);
                         StateDownloads.instance.checkForOutdatedPlaylists();
                     }));
@@ -804,20 +1033,36 @@ class UISlideOverlays {
 
             val views = arrayOf(
                 hidden
-                    .map { btn -> SlideUpMenuItem(container.context, btn.iconResource, btn.text.text.toString(), "", "", {
-                        btn.handler?.invoke(btn);
-                    }, invokeParents) as View  }.toTypedArray(),
-                arrayOf(SlideUpMenuItem(container.context, R.drawable.ic_pin, container.context.getString(R.string.change_pins), container.context.getString(R.string.decide_which_buttons_should_be_pinned), "", {
-                    showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order),  (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
-                        val selected = it
-                            .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
-                            .filter { it != null }
-                            .map { it!! }
-                            .toList();
-
-                        onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
-                    }
-                }, false))
+                    .map { btn -> SlideUpMenuItem(
+                        container.context,
+                        btn.iconResource,
+                        btn.text.text.toString(),
+                        "",
+                        tag = "",
+                        call = {
+                            btn.handler?.invoke(btn);
+                        },
+                        invokeParent = invokeParents
+                    ) as View  }.toTypedArray(),
+                arrayOf(SlideUpMenuItem(
+                    container.context,
+                    R.drawable.ic_pin,
+                    container.context.getString(R.string.change_pins),
+                    container.context.getString(R.string.decide_which_buttons_should_be_pinned),
+                    tag = "",
+                    call = {
+                        showOrderOverlay(container, container.context.getString(R.string.select_your_pins_in_order),  (visible + hidden).map { Pair(it.text.text.toString(), it.tagRef!!) }) {
+                            val selected = it
+                                .map { x -> visible.find { it.tagRef == x } ?: hidden.find { it.tagRef == x } }
+                                .filter { it != null }
+                                .map { it!! }
+                                .toList();
+
+                            onPinnedbuttons?.invoke(selected + (visible + hidden).filter { !selected.contains(it) });
+                        }
+                    },
+                    invokeParent = false
+                ))
             ).flatten().toTypedArray();
 
             return SlideUpMenuOverlay(container.context, container, container.context.getString(R.string.more_options), null, true, *views).apply { show() };
@@ -829,14 +1074,21 @@ class UISlideOverlays {
             var overlay: SlideUpMenuOverlay? = null;
 
             overlay = SlideUpMenuOverlay(container.context, container, title, container.context.getString(R.string.save), true,
-                options.map { SlideUpMenuItem(container.context, R.drawable.ic_move_up, it.first, "", it.second, {
+                options.map { SlideUpMenuItem(
+                    container.context,
+                    R.drawable.ic_move_up,
+                    it.first,
+                    "",
+                    tag = it.second,
+                    call = {
                         if(overlay!!.selectOption(null, it.second, true, true)) {
                             if(!selection.contains(it.second))
                                 selection.add(it.second);
-                        }
-                        else
+                        } else
                             selection.remove(it.second);
-                    }, false)
+                    },
+                    invokeParent = false
+                )
                 });
             overlay.onOK.subscribe {
                 onOrdered.invoke(selection);
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/chapters/IChapter.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/chapters/IChapter.kt
index 5d7a7c7fbaafa305a4cbe1106ad6b74e3db73e9a..4ed5add4ea463a0e5616e573bbe59a344be9687d 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/models/chapters/IChapter.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/models/chapters/IChapter.kt
@@ -23,7 +23,7 @@ enum class ChapterType(val value: Int) {
     companion object {
         fun fromInt(value: Int): ChapterType
         {
-            val result = ChapterType.values().firstOrNull { it.value == value };
+            val result = ChapterType.entries.firstOrNull { it.value == value };
             if(result == null)
                 throw UnknownPlatformException(value.toString());
             return result;
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt
index 27d51abec0bcaee13d0f526a1a3024d459b75b56..a310e0899bd5b017aa6f789bf6903b717d44183a 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/ContentType.kt
@@ -21,7 +21,7 @@ enum class ContentType(val value: Int) {
     companion object {
         fun fromInt(value: Int): ContentType
         {
-            val result = ContentType.values().firstOrNull { it.value == value };
+            val result = ContentType.entries.firstOrNull { it.value == value };
             if(result == null)
                 throw UnknownPlatformException(value.toString());
             return result;
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt
index ddec2e0ac2afbb3fccdae1a527ef36c21e8b417e..c2f9bc4ad34228631178978ad75a8790ca229215 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/models/live/LiveEventType.kt
@@ -10,7 +10,7 @@ enum class LiveEventType(val value : Int) {
 
     companion object{
         fun fromInt(value : Int) : LiveEventType{
-            return LiveEventType.values().first { it.value == value };
+            return LiveEventType.entries.first { it.value == value };
         }
     }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt
index 8a2c20d404cbd8c7aadc6c72e2dd0d741feb482e..c1de57d1c7453254c01834fe1aa5b935bf917a50 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/models/post/TextType.kt
@@ -10,7 +10,7 @@ enum class TextType(val value: Int) {
     companion object {
         fun fromInt(value: Int): TextType
         {
-            val result = TextType.values().firstOrNull { it.value == value };
+            val result = TextType.entries.firstOrNull { it.value == value };
             if(result == null)
                 throw IllegalArgumentException("Unknown Texttype: $value");
             return result;
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt
index eba214301baf1e3cf71806dbea281921645ab7eb..956b9d31a9fcb4faf7be76e3c6c4e93e3f581bf1 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/models/ratings/RatingType.kt
@@ -8,7 +8,7 @@ enum class RatingType(val value : Int) {
 
     companion object{
         fun fromInt(value : Int) : RatingType{
-            return RatingType.values().first { it.value == value };
+            return RatingType.entries.first { it.value == value };
         }
     }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClientConstants.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClientConstants.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e5c950472cff9278d116102c6e5f7d17d03e57d3
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClientConstants.kt
@@ -0,0 +1,7 @@
+package com.futo.platformplayer.api.media.platforms.js
+
+class JSClientConstants {
+    companion object {
+        val PLUGIN_SPEC_VERSION = 2;
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt
index 23a7a7618f4739ccff17a607b64fa2ce518b2c52..3d57cb41e0946aea9b168284e11f48d6eb1d024e 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt
@@ -4,6 +4,7 @@ import android.net.Uri
 import com.futo.platformplayer.SignatureProvider
 import com.futo.platformplayer.api.media.Serializer
 import com.futo.platformplayer.engine.IV8PluginConfig
+import com.futo.platformplayer.matchesDomain
 import com.futo.platformplayer.states.StatePlugins
 import kotlinx.serialization.Contextual
 import java.net.URL
@@ -79,7 +80,7 @@ class SourcePluginConfig(
     private val _allowUrlsLower: List<String> get() {
         if(_allowUrlsLowerVal == null)
             _allowUrlsLowerVal = allowUrls.map { it.lowercase() }
-                .filter { it.length > 0 && (it[0] != '*' || (_allowRegex.matches(it))) };
+                .filter { it.length > 0 };
         return _allowUrlsLowerVal!!;
     };
 
@@ -172,12 +173,10 @@ class SourcePluginConfig(
             return true;
         val uri = Uri.parse(url);
         val host = uri.host?.lowercase() ?: "";
-        return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '*' && host.endsWith(it.substring(1))) };
+        return _allowUrlsLower.any { it == host || (it.length > 0 && it[0] == '.' && host.matchesDomain(it)) };
     }
 
     companion object {
-        private val _allowRegex = Regex("\\*\\.[a-z0-9]+\\.[a-z]+");
-
         fun fromJson(json: String, sourceUrl: String? = null): SourcePluginConfig {
             val obj = Serializer.json.decodeFromString<SourcePluginConfig>(json);
             if(obj.sourceUrl == null)
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt
index 6127601c7af7d207f29353aeecd7bbb51feb690b..b3dd9dd52aaa257144c1a882443be92740270b0f 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSContent.kt
@@ -54,4 +54,8 @@ open class JSContent : IPlatformContent, IPluginSourced {
 
         _hasGetDetails = _content.has("getDetails");
     }
+
+    fun getUnderlyingObject(): V8ValueObject? {
+        return _content;
+    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a9ab2c1a7555852df3de4a3a4bcdffde8437a012
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/JSRequestExecutor.kt
@@ -0,0 +1,134 @@
+package com.futo.platformplayer.api.media.platforms.js.models
+
+import com.caoccao.javet.values.V8Value
+import com.caoccao.javet.values.primitive.V8ValueString
+import com.caoccao.javet.values.primitive.V8ValueUndefined
+import com.caoccao.javet.values.reference.V8ValueObject
+import com.caoccao.javet.values.reference.V8ValueTypedArray
+import com.futo.platformplayer.api.media.platforms.js.DevJSClient
+import com.futo.platformplayer.api.media.platforms.js.JSClient
+import com.futo.platformplayer.engine.IV8PluginConfig
+import com.futo.platformplayer.engine.V8Plugin
+import com.futo.platformplayer.engine.exceptions.PluginException
+import com.futo.platformplayer.engine.exceptions.ScriptException
+import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
+import com.futo.platformplayer.getOrDefault
+import com.futo.platformplayer.getOrThrow
+import com.futo.platformplayer.logging.Logger
+import com.futo.platformplayer.states.StateDeveloper
+import kotlinx.serialization.Serializable
+import java.util.Base64
+
+class JSRequestExecutor {
+    private val _plugin: JSClient;
+    private val _config: IV8PluginConfig;
+    private var _executor: V8ValueObject;
+    val urlPrefix: String?;
+
+    private val hasCleanup: Boolean;
+
+    constructor(plugin: JSClient, executor: V8ValueObject) {
+        this._plugin = plugin;
+        this._executor = executor;
+        this._config = plugin.config;
+        val config = plugin.config;
+
+        urlPrefix = executor.getOrDefault(config, "urlPrefix", "RequestExecutor", null);
+
+        if(!executor.has("executeRequest"))
+            throw ScriptImplementationException(config, "RequestExecutor is missing executeRequest", null);
+        hasCleanup = executor.has("cleanup");
+    }
+
+    //TODO: Executor properties?
+    @Throws(ScriptException::class)
+    open fun executeRequest(url: String, headers: Map<String, String>): ByteArray {
+        if (_executor.isClosed)
+            throw IllegalStateException("Executor object is closed");
+
+        val result = if(_plugin is DevJSClient)
+            StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
+                V8Plugin.catchScriptErrors<Any>(
+                    _config,
+                    "[${_config.name}] JSRequestExecutor",
+                    "builder.modifyRequest()"
+                ) {
+                    _executor.invoke("executeRequest", url, headers);
+                } as V8Value;
+            }
+            else V8Plugin.catchScriptErrors<Any>(
+                _config,
+                "[${_config.name}] JSRequestExecutor",
+                "builder.modifyRequest()"
+            ) {
+                _executor.invoke("executeRequest", url, headers);
+            } as V8Value;
+
+        try {
+            if(result is V8ValueString) {
+                val base64Result = Base64.getDecoder().decode(result.value);
+                return base64Result;
+            }
+            if(result is V8ValueTypedArray) {
+                val buffer = result.buffer;
+                val byteBuffer = buffer.byteBuffer;
+                val bytesResult = ByteArray(result.byteLength);
+                byteBuffer.get(bytesResult, 0, result.byteLength);
+                buffer.close();
+                return bytesResult;
+            }
+            if(result is V8ValueObject && result.has("type")) {
+                val type = result.getOrThrow<Int>(_config, "type", "JSRequestModifier");
+                when(type) {
+                    //TODO: Buffer type?
+                }
+            }
+            if(result is V8ValueUndefined) {
+                if(_plugin is DevJSClient)
+                    StateDeveloper.instance.logDevException(_plugin.devID, "JSRequestExecutor.executeRequest returned illegal undefined");
+                throw ScriptImplementationException(_config, "JSRequestExecutor.executeRequest returned illegal undefined", null);
+            }
+            throw NotImplementedError("Executor result type not implemented? " + result.javaClass.name);
+        }
+        finally {
+            result.close();
+        }
+    }
+
+
+    open fun cleanup() {
+        if (!hasCleanup || _executor.isClosed)
+            return;
+
+       if(_plugin is DevJSClient)
+            StateDeveloper.instance.handleDevCall(_plugin.devID, "requestExecutor.executeRequest()") {
+                V8Plugin.catchScriptErrors<Any>(
+                    _config,
+                    "[${_config.name}] JSRequestExecutor",
+                    "builder.modifyRequest()"
+                ) {
+                    _executor.invokeVoid("cleanup", null);
+                };
+            }
+        else V8Plugin.catchScriptErrors<Any>(
+            _config,
+            "[${_config.name}] JSRequestExecutor",
+            "builder.modifyRequest()"
+        ) {
+           _executor.invokeVoid("cleanup", null);
+        };
+    }
+
+    protected fun finalize() {
+        cleanup();
+    }
+}
+
+//TODO: are these available..?
+@Serializable
+class ExecutorParameters {
+    var rangeStart: Int = -1;
+    var rangeEnd: Int = -1;
+
+    var segment: Int = -1;
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e95e74367e459d7f3e390f7d14e734a6fe19887a
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawAudioSource.kt
@@ -0,0 +1,64 @@
+package com.futo.platformplayer.api.media.platforms.js.models.sources
+
+import com.caoccao.javet.values.reference.V8ValueObject
+import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
+import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
+import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
+import com.futo.platformplayer.api.media.platforms.js.DevJSClient
+import com.futo.platformplayer.api.media.platforms.js.JSClient
+import com.futo.platformplayer.engine.IV8PluginConfig
+import com.futo.platformplayer.engine.V8Plugin
+import com.futo.platformplayer.getOrDefault
+import com.futo.platformplayer.getOrNull
+import com.futo.platformplayer.getOrThrow
+import com.futo.platformplayer.others.Language
+import com.futo.platformplayer.states.StateDeveloper
+
+class JSDashManifestRawAudioSource : JSSource, IAudioSource {
+    override val container : String = "application/dash+xml";
+    override val name : String;
+    override val codec: String;
+    override val bitrate: Int;
+    override val duration: Long;
+    override val priority: Boolean;
+
+    override val language: String;
+
+    val url: String;
+    var manifest: String?;
+
+    val hasGenerate: Boolean;
+
+    constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
+        val contextName = "DashRawSource";
+        val config = plugin.config;
+        name = _obj.getOrThrow(config, "name", contextName);
+        url = _obj.getOrThrow(config, "url", contextName);
+        manifest = _obj.getOrThrow(config, "manifest", contextName);
+        codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
+        bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
+        duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
+        priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
+        language = _obj.getOrDefault(config, "language", contextName, Language.UNKNOWN) ?: Language.UNKNOWN;
+        hasGenerate = _obj.has("generate");
+    }
+
+    fun generate(): String? {
+        if(!hasGenerate)
+            return manifest;
+        if(_obj.isClosed)
+            throw IllegalStateException("Source object already closed");
+
+        val plugin = _plugin.getUnderlyingPlugin();
+        if(_plugin is DevJSClient)
+            return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRaw", false) {
+                _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
+                    _obj.invokeString("generate");
+                }
+            }
+        else
+            return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw", "dashManifestRaw.generate()") {
+                _obj.invokeString("generate");
+            }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt
new file mode 100644
index 0000000000000000000000000000000000000000..006df8cdd47e24f10bad099b0ed21c13c1546f74
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSDashManifestRawSource.kt
@@ -0,0 +1,109 @@
+package com.futo.platformplayer.api.media.platforms.js.models.sources
+
+import com.caoccao.javet.values.V8Value
+import com.caoccao.javet.values.primitive.V8ValueString
+import com.caoccao.javet.values.reference.V8ValueObject
+import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
+import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
+import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
+import com.futo.platformplayer.api.media.platforms.js.DevJSClient
+import com.futo.platformplayer.api.media.platforms.js.JSClient
+import com.futo.platformplayer.engine.IV8PluginConfig
+import com.futo.platformplayer.engine.V8Plugin
+import com.futo.platformplayer.getOrDefault
+import com.futo.platformplayer.getOrNull
+import com.futo.platformplayer.getOrThrow
+import com.futo.platformplayer.states.StateDeveloper
+
+open class JSDashManifestRawSource: JSSource, IVideoSource {
+    override val container : String = "application/dash+xml";
+    override val name : String;
+    override val width: Int;
+    override val height: Int;
+    override val codec: String;
+    override val bitrate: Int?;
+    override val duration: Long;
+    override val priority: Boolean;
+
+    var url: String?;
+    var manifest: String?;
+
+    val hasGenerate: Boolean;
+    val canMerge: Boolean;
+
+    constructor(plugin: JSClient, obj: V8ValueObject) : super(TYPE_DASH_RAW, plugin, obj) {
+        val contextName = "DashRawSource";
+        val config = plugin.config;
+        name = _obj.getOrThrow(config, "name", contextName);
+        url = _obj.getOrThrow(config, "url", contextName);
+        manifest = _obj.getOrDefault<String>(config, "manifest", contextName, null);
+        width = _obj.getOrDefault(config, "width", contextName, 0) ?: 0;
+        height = _obj.getOrDefault(config, "height", contextName, 0) ?: 0;
+        codec = _obj.getOrDefault(config, "codec", contextName, "") ?: "";
+        bitrate = _obj.getOrDefault(config, "bitrate", contextName, 0) ?: 0;
+        duration = _obj.getOrDefault(config, "duration", contextName, 0) ?: 0;
+        priority = _obj.getOrDefault(config, "priority", contextName, false) ?: false;
+        canMerge = _obj.getOrDefault(config, "canMerge", contextName, false) ?: false;
+        hasGenerate = _obj.has("generate");
+    }
+
+    open fun generate(): String? {
+        if(!hasGenerate)
+            return manifest;
+        if(_obj.isClosed)
+            throw IllegalStateException("Source object already closed");
+        if(_plugin is DevJSClient) {
+            return StateDeveloper.instance.handleDevCall(_plugin.devID, "DashManifestRawSource.generate()") {
+                _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
+                    _obj.invokeString("generate");
+                });
+            }
+        }
+        else
+            return _plugin.getUnderlyingPlugin().catchScriptErrors("DashManifestRaw.generate", "generate()", {
+                _obj.invokeString("generate");
+            });
+    }
+}
+
+class JSDashManifestMergingRawSource(
+    val video: JSDashManifestRawSource,
+    val audio: JSDashManifestRawAudioSource): JSDashManifestRawSource(video.getUnderlyingPlugin()!!, video.getUnderlyingObject()!!), IVideoSource {
+
+    override val name: String
+        get() = video.name;
+    override val bitrate: Int
+        get() = (video.bitrate ?: 0) + audio.bitrate;
+    override val codec: String
+        get() = video.codec
+    override val container: String
+        get() = video.container
+    override val duration: Long
+        get() = video.duration;
+    override val height: Int
+        get() = video.height;
+    override val width: Int
+        get() = video.width;
+    override val priority: Boolean
+        get() = video.priority;
+
+    override fun generate(): String? {
+        val videoDash = video.generate();
+        val audioDash = audio.generate();
+        if(videoDash != null && audioDash == null) return videoDash;
+        if(audioDash != null && videoDash == null) return audioDash;
+        if(videoDash == null) return null;
+
+        //TODO: Temporary simple solution..make more reliable version
+        val audioAdaptationSet = adaptationSetRegex.find(audioDash!!);
+        if(audioAdaptationSet != null) {
+            return videoDash.replace("</AdaptationSet>", "</AdaptationSet>\n" + audioAdaptationSet.value)
+        }
+        else
+            return videoDash;
+    }
+
+    companion object {
+        private val adaptationSetRegex = Regex("<AdaptationSet.*?>.*?<\\/AdaptationSet>", RegexOption.DOT_MATCHES_ALL);
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt
index 862a53a5352e84ab8375c241180ac9b3aa2a6bfa..a658f5cbd7ea4552665054b472dec38bd6cba267 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSSource.kt
@@ -10,10 +10,12 @@ import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
 import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
 import com.futo.platformplayer.api.media.platforms.js.JSClient
 import com.futo.platformplayer.api.media.platforms.js.models.JSRequest
+import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor
 import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier
 import com.futo.platformplayer.engine.IV8PluginConfig
 import com.futo.platformplayer.engine.V8Plugin
 import com.futo.platformplayer.getOrDefault
+import com.futo.platformplayer.logging.Logger
 import com.futo.platformplayer.orNull
 import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
 
@@ -21,9 +23,17 @@ abstract class JSSource {
     protected val _plugin: JSClient;
     protected val _config: IV8PluginConfig;
     protected val _obj: V8ValueObject;
+
     val hasRequestModifier: Boolean;
     private val _requestModifier: JSRequest?;
 
+    val hasRequestExecutor: Boolean;
+    private val _requestExecutor: JSRequest?;
+
+    val requiresCustomDatasource: Boolean get() {
+        return hasRequestModifier || hasRequestExecutor;
+    }
+
     val type : String;
 
     constructor(type: String, plugin: JSClient, obj: V8ValueObject) {
@@ -36,6 +46,11 @@ abstract class JSSource {
             JSRequest(plugin, it, null, null, true);
         }
         hasRequestModifier = _requestModifier != null || obj.has("getRequestModifier");
+
+        _requestExecutor = obj.getOrDefault<V8ValueObject>(_config, "requestExecutor", "JSSource.requestExecutor", null)?.let {
+            JSRequest(plugin, it, null, null, true);
+        }
+        hasRequestExecutor = _requestModifier != null || obj.has("getRequestExecutor");
     }
 
     fun getRequestModifier(): IRequestModifier? {
@@ -44,20 +59,38 @@ abstract class JSSource {
                   return@AdhocRequestModifier _requestModifier.modify(_plugin, url, headers);
             };
 
-        if (!hasRequestModifier || _obj.isClosed) {
+        if (!hasRequestModifier || _obj.isClosed)
             return null;
-        }
 
         val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSVideoUrlSource", "obj.getRequestModifier()") {
             _obj.invoke("getRequestModifier", arrayOf<Any>());
         };
 
-        if (result !is V8ValueObject) {
+        if (result !is V8ValueObject)
             return null;
-        }
 
         return JSRequestModifier(_plugin, result)
     }
+    open fun getRequestExecutor(): JSRequestExecutor? {
+        if (!hasRequestExecutor || _obj.isClosed)
+            return null;
+
+        val result = V8Plugin.catchScriptErrors<Any>(_config, "[${_config.name}] JSSource", "obj.getRequestExecutor()") {
+            _obj.invoke("getRequestExecutor", arrayOf<Any>());
+        };
+
+        if (result !is V8ValueObject)
+            return null;
+
+        return JSRequestExecutor(_plugin, result)
+    }
+
+    fun getUnderlyingPlugin(): JSClient? {
+        return _plugin;
+    }
+    fun getUnderlyingObject(): V8ValueObject? {
+        return _obj;
+    }
 
     companion object {
         const val TYPE_AUDIOURL = "AudioUrlSource";
@@ -65,33 +98,45 @@ abstract class JSSource {
         const val TYPE_AUDIO_WITH_METADATA = "AudioUrlRangeSource";
         const val TYPE_VIDEO_WITH_METADATA = "VideoUrlRangeSource";
         const val TYPE_DASH = "DashSource";
+        const val TYPE_DASH_RAW = "DashRawSource";
+        const val TYPE_DASH_RAW_AUDIO = "DashRawAudioSource";
         const val TYPE_HLS = "HLSSource";
         const val TYPE_AUDIOURL_WIDEVINE = "AudioUrlWidevineSource"
 
         fun fromV8VideoNullable(plugin: JSClient, obj: V8Value?) : IVideoSource? = obj.orNull { fromV8Video(plugin, it as V8ValueObject) };
-        fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource {
+        fun fromV8Video(plugin: JSClient, obj: V8ValueObject) : IVideoSource? {
             val type = obj.getString("plugin_type");
             return when(type) {
                 TYPE_VIDEOURL -> JSVideoUrlSource(plugin, obj);
                 TYPE_VIDEO_WITH_METADATA -> JSVideoUrlRangeSource(plugin, obj);
                 TYPE_HLS -> fromV8HLS(plugin, obj);
                 TYPE_DASH -> fromV8Dash(plugin, obj);
-                else -> throw NotImplementedError("Unknown type ${type}");
+                TYPE_DASH_RAW -> fromV8DashRaw(plugin, obj);
+                else -> {
+                    Logger.w("JSSource", "Unknown video type ${type}");
+                    null;
+                };
             }
         }
         fun fromV8DashNullable(plugin: JSClient, obj: V8Value?) : JSDashManifestSource? = obj.orNull { fromV8Dash(plugin, it as V8ValueObject) };
         fun fromV8Dash(plugin: JSClient, obj: V8ValueObject) : JSDashManifestSource = JSDashManifestSource(plugin, obj);
+        fun fromV8DashRaw(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawSource = JSDashManifestRawSource(plugin, obj);
+        fun fromV8DashRawAudio(plugin: JSClient, obj: V8ValueObject) : JSDashManifestRawAudioSource = JSDashManifestRawAudioSource(plugin, obj);
         fun fromV8HLSNullable(plugin: JSClient, obj: V8Value?) : JSHLSManifestSource? = obj.orNull { fromV8HLS(plugin, it as V8ValueObject) };
         fun fromV8HLS(plugin: JSClient, obj: V8ValueObject) : JSHLSManifestSource = JSHLSManifestSource(plugin, obj);
 
-        fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource {
+        fun fromV8Audio(plugin: JSClient, obj: V8ValueObject) : IAudioSource? {
             val type = obj.getString("plugin_type");
             return when(type) {
                 TYPE_HLS -> JSHLSManifestAudioSource.fromV8HLS(plugin, obj);
                 TYPE_AUDIOURL -> JSAudioUrlSource(plugin, obj);
+                TYPE_DASH_RAW_AUDIO -> fromV8DashRawAudio(plugin, obj);
                 TYPE_AUDIOURL_WIDEVINE -> JSAudioUrlWidevineSource(plugin, obj);
                 TYPE_AUDIO_WITH_METADATA -> JSAudioUrlRangeSource(plugin, obj);
-                else -> throw NotImplementedError("Unknown type ${type}");
+                else -> {
+                    Logger.w("JSSource", "Unknown audio type ${type}");
+                    null;
+                };
             }
         }
     }
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt
index 035f5fb64d13699aaf25335088981a3bf36964f0..548d27617e55f48ade8995df2180726c6493bd1b 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSUnMuxVideoSourceDescriptor.kt
@@ -23,9 +23,11 @@ class JSUnMuxVideoSourceDescriptor: VideoUnMuxedSourceDescriptor {
         this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
         this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
             .map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
+            .filterNotNull()
             .toTypedArray();
         this.audioSources = obj.getOrThrow<V8ValueArray>(config, "audioSources", contextName).toArray()
             .map { JSSource.fromV8Audio(plugin, it as V8ValueObject) }
+            .filterNotNull()
             .toTypedArray();
     }
 }
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt
index 4b983ef71783837f329054a6380eef884d3e3dc1..e68f0ae04a7f5cbc9ec9d011e9073b19e6ade1bc 100644
--- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt
+++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/models/sources/JSVideoSourceDescriptor.kt
@@ -21,6 +21,7 @@ class JSVideoSourceDescriptor : VideoMuxedSourceDescriptor {
         this.isUnMuxed = obj.getOrThrow(config, "isUnMuxed", contextName);
         this.videoSources = obj.getOrThrow<V8ValueArray>(config, "videoSources", contextName).toArray()
             .map { JSSource.fromV8Video(plugin, it as V8ValueObject) }
+            .filterNotNull()
             .toTypedArray();
     }
 
diff --git a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt
index 5e99462e173456efeeb823f3cc53aebfa9fa3d4a..c0301d40c4134a705653b9e72ae1c90e7c37dd22 100644
--- a/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt
+++ b/app/src/main/java/com/futo/platformplayer/casting/StateCasting.kt
@@ -31,6 +31,8 @@ import com.futo.platformplayer.constructs.Event1
 import com.futo.platformplayer.constructs.Event2
 import com.futo.platformplayer.exceptions.UnsupportedCastException
 import com.futo.platformplayer.logging.Logger
+import com.futo.platformplayer.mdns.DnsService
+import com.futo.platformplayer.mdns.ServiceDiscoverer
 import com.futo.platformplayer.models.CastingDeviceInfo
 import com.futo.platformplayer.parsers.HLS
 import com.futo.platformplayer.states.StateApp
@@ -45,15 +47,10 @@ import kotlinx.serialization.Serializable
 import kotlinx.serialization.json.Json
 import java.net.InetAddress
 import java.util.UUID
-import javax.jmdns.JmDNS
-import javax.jmdns.ServiceEvent
-import javax.jmdns.ServiceListener
-import javax.jmdns.ServiceTypeListener
 
 class StateCasting {
     private val _scopeIO = CoroutineScope(Dispatchers.IO);
     private val _scopeMain = CoroutineScope(Dispatchers.Main);
-    private var _jmDNS: JmDNS? = null;
     private val _storage: CastingDeviceInfoStorage = FragmentedStorage.get();
 
     private val _castServer = ManagedHttpServer(9999);
@@ -72,102 +69,46 @@ class StateCasting {
     var activeDevice: CastingDevice? = null;
     private val _client = ManagedHttpClient();
     var _resumeCastingDevice: CastingDeviceInfo? = null;
+    val _serviceDiscoverer = ServiceDiscoverer(arrayOf(
+        "_googlecast._tcp.local",
+        "_airplay._tcp.local",
+        "_fastcast._tcp.local",
+        "_fcast._tcp.local"
+    )) { handleServiceUpdated(it) }
 
     val isCasting: Boolean get() = activeDevice != null;
 
-    private val _chromecastServiceListener = object : ServiceListener {
-        override fun serviceAdded(event: ServiceEvent) {
-            Logger.i(TAG, "ChromeCast service added: " + event.info);
-            addOrUpdateDevice(event);
-        }
-
-        override fun serviceRemoved(event: ServiceEvent) {
-            Logger.i(TAG, "ChromeCast service removed: " + event.info);
-            synchronized(devices) {
-                val device = devices[event.info.name];
-                if (device != null) {
-                    onDeviceRemoved.emit(device);
+    private fun handleServiceUpdated(services: List<DnsService>) {
+        for (s in services) {
+            //TODO: Addresses IPv4 only?
+            val addresses = s.addresses.toTypedArray()
+            val port = s.port.toInt()
+            var name = s.texts.firstOrNull { it.startsWith("md=") }?.substring("md=".length)
+            if (s.name.endsWith("._googlecast._tcp.local")) {
+                if (name == null) {
+                    name = s.name.substring(0, s.name.length - "._googlecast._tcp.local".length)
                 }
-            }
-        }
 
-        override fun serviceResolved(event: ServiceEvent) {
-            Logger.v(TAG, "ChromeCast service resolved: " + event.info);
-            addOrUpdateDevice(event);
-        }
-
-        fun addOrUpdateDevice(event: ServiceEvent) {
-            addOrUpdateChromeCastDevice(event.info.name, event.info.inetAddresses, event.info.port);
-        }
-    }
-
-    private val _airPlayServiceListener = object : ServiceListener {
-        override fun serviceAdded(event: ServiceEvent) {
-            Logger.i(TAG, "AirPlay service added: " + event.info);
-            addOrUpdateDevice(event);
-        }
-
-        override fun serviceRemoved(event: ServiceEvent) {
-            Logger.i(TAG, "AirPlay service removed: " + event.info);
-            synchronized(devices) {
-                val device = devices[event.info.name];
-                if (device != null) {
-                    onDeviceRemoved.emit(device);
+                addOrUpdateChromeCastDevice(name, addresses, port)
+            } else if (s.name.endsWith("._airplay._tcp.local")) {
+                if (name == null) {
+                    name = s.name.substring(0, s.name.length - "._airplay._tcp.local".length)
                 }
-            }
-        }
-
-        override fun serviceResolved(event: ServiceEvent) {
-            Logger.i(TAG, "AirPlay service resolved: " + event.info);
-            addOrUpdateDevice(event);
-        }
 
-        fun addOrUpdateDevice(event: ServiceEvent) {
-            addOrUpdateAirPlayDevice(event.info.name, event.info.inetAddresses, event.info.port);
-        }
-    }
-
-    private val _fastCastServiceListener = object : ServiceListener {
-        override fun serviceAdded(event: ServiceEvent) {
-            Logger.i(TAG, "FastCast service added: " + event.info);
-            addOrUpdateDevice(event);
-        }
-
-        override fun serviceRemoved(event: ServiceEvent) {
-            Logger.i(TAG, "FastCast service removed: " + event.info);
-            synchronized(devices) {
-                val device = devices[event.info.name];
-                if (device != null) {
-                    onDeviceRemoved.emit(device);
+                addOrUpdateAirPlayDevice(name, addresses, port)
+            } else if (s.name.endsWith("._fastcast._tcp.local")) {
+                if (name == null) {
+                    name = s.name.substring(0, s.name.length - "._fastcast._tcp.local".length)
                 }
-            }
-        }
-
-        override fun serviceResolved(event: ServiceEvent) {
-            Logger.i(TAG, "FastCast service resolved: " + event.info);
-            addOrUpdateDevice(event);
-        }
-
-        fun addOrUpdateDevice(event: ServiceEvent) {
-            addOrUpdateFastCastDevice(event.info.name, event.info.inetAddresses, event.info.port);
-        }
-    }
-
-    private val _serviceTypeListener = object : ServiceTypeListener {
-        override fun serviceTypeAdded(event: ServiceEvent?) {
-            if (event == null) {
-                return;
-            }
 
-            Logger.i(TAG, "Service type added (name: ${event.name}, type: ${event.type})");
-        }
+                addOrUpdateFastCastDevice(name, addresses, port)
+            } else if (s.name.endsWith("._fcast._tcp.local")) {
+                if (name == null) {
+                    name = s.name.substring(0, s.name.length - "._fcast._tcp.local".length)
+                }
 
-        override fun subTypeForServiceTypeAdded(event: ServiceEvent?) {
-            if (event == null) {
-                return;
+                addOrUpdateFastCastDevice(name, addresses, port)
             }
-
-            Logger.i(TAG, "Sub type for service type added (name: ${event.name}, type: ${event.type})");
         }
     }
 
@@ -237,29 +178,30 @@ class StateCasting {
         rememberedDevices.clear();
         rememberedDevices.addAll(_storage.deviceInfos.map { deviceFromCastingDeviceInfo(it) });
 
-        _scopeIO.launch {
-            try {
-                val jmDNS = JmDNS.create(InetAddress.getLocalHost());
-                jmDNS.addServiceListener("_googlecast._tcp.local.", _chromecastServiceListener);
-                jmDNS.addServiceListener("_airplay._tcp.local.", _airPlayServiceListener);
-                jmDNS.addServiceListener("_fastcast._tcp.local.", _fastCastServiceListener);
-                jmDNS.addServiceListener("_fcast._tcp.local.", _fastCastServiceListener);
-
-                if (BuildConfig.DEBUG) {
-                    jmDNS.addServiceTypeListener(_serviceTypeListener);
-                }
-
-                _jmDNS = jmDNS;
-            } catch (e: Throwable) {
-                Logger.e(TAG, "Failed to start casting service.", e);
-            }
-        }
         _castServer.start();
         enableDeveloper(true);
 
         Logger.i(TAG, "CastingService started.");
     }
 
+    @Synchronized
+    fun startDiscovering() {
+        try {
+            _serviceDiscoverer.start()
+        } catch (e: Throwable) {
+            Logger.i(TAG, "Failed to start ServiceDiscoverer", e)
+        }
+    }
+
+    @Synchronized
+    fun stopDiscovering() {
+        try {
+            _serviceDiscoverer.stop()
+        } catch (e: Throwable) {
+            Logger.i(TAG, "Failed to stop ServiceDiscoverer", e)
+        }
+    }
+
     @Synchronized
     fun stop() {
         if (!_started)
@@ -269,25 +211,7 @@ class StateCasting {
 
         Logger.i(TAG, "CastingService stopping.")
 
-        val jmDNS = _jmDNS;
-        if (jmDNS != null) {
-            _scopeIO.launch {
-                try {
-                    jmDNS.removeServiceListener("_googlecast._tcp.local.", _chromecastServiceListener);
-                    jmDNS.removeServiceListener("_airplay._tcp", _airPlayServiceListener);
-                    jmDNS.removeServiceListener("_fastcast._tcp.local.", _fastCastServiceListener);
-
-                    if (BuildConfig.DEBUG) {
-                        jmDNS.removeServiceTypeListener(_serviceTypeListener);
-                    }
-
-                    jmDNS.close();
-                } catch (e: Throwable) {
-                    Logger.e(TAG, "Failed to stop mDNS.", e);
-                }
-            }
-        }
-
+        stopDiscovering()
         _scopeIO.cancel();
         _scopeMain.cancel();
 
@@ -1245,7 +1169,7 @@ class StateCasting {
                 }
             } else {
                 val newDevice = deviceFactory();
-                devices[name] = newDevice;
+                this.devices[name] = newDevice;
 
                 invokeEvents = {
                     onDeviceAdded.emit(newDevice);
diff --git a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt
index e386681c244d8103c9cf29321ca338b5b363cca5..acb57b789e975ec59e5426ebff8b8b48d74e04f7 100644
--- a/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt
+++ b/app/src/main/java/com/futo/platformplayer/developer/DeveloperEndpoints.kt
@@ -25,10 +25,8 @@ import com.futo.platformplayer.states.StateDeveloper
 import com.futo.platformplayer.states.StatePlatform
 import com.google.gson.ExclusionStrategy
 import com.google.gson.FieldAttributes
-import com.google.gson.Gson
 import com.google.gson.GsonBuilder
 import com.google.gson.JsonArray
-import com.google.gson.JsonElement
 import com.google.gson.JsonParser
 import kotlinx.serialization.encodeToString
 import kotlinx.serialization.json.Json
@@ -573,7 +571,7 @@ class DeveloperEndpoints(private val context: Context) {
             val resp = _client.get(body.url!!, body.headers);
 
             context.respondCode(200,
-                Json.encodeToString(PackageHttp.BridgeHttpResponse(resp.url, resp.code, resp.body?.string())),
+                Json.encodeToString(PackageHttp.BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string())),
                 context.query.getOrDefault("CT", "text/plain"));
         }
         catch(ex: Exception) {
diff --git a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt
index e9e826fec627d7d9ccedfc8772c06b3bdf26529c..bd5da2eabb7b881aa991a4c66485934aa2aefacd 100644
--- a/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt
+++ b/app/src/main/java/com/futo/platformplayer/dialogs/ConnectCastingDialog.kt
@@ -104,6 +104,8 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
         super.show();
         Logger.i(TAG, "Dialog shown.");
 
+        StateCasting.instance.startDiscovering()
+
         (_imageLoader.drawable as Animatable?)?.start();
 
         _devices.clear();
@@ -169,6 +171,7 @@ class ConnectCastingDialog(context: Context?) : AlertDialog(context) {
 
         (_imageLoader.drawable as Animatable?)?.stop();
 
+        StateCasting.instance.stopDiscovering()
         StateCasting.instance.onDeviceAdded.remove(this);
         StateCasting.instance.onDeviceChanged.remove(this);
         StateCasting.instance.onDeviceRemoved.remove(this);
diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt
index 432bb11413006bb25e8ebeb00a6299baa378ab5b..9c12eb932f471bb5a2f94d902f8541047348b850 100644
--- a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt
+++ b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt
@@ -6,6 +6,8 @@ import com.caoccao.javet.exceptions.JavetException
 import com.caoccao.javet.exceptions.JavetExecutionException
 import com.caoccao.javet.interop.V8Host
 import com.caoccao.javet.interop.V8Runtime
+import com.caoccao.javet.interop.options.V8Flags
+import com.caoccao.javet.interop.options.V8RuntimeOptions
 import com.caoccao.javet.values.V8Value
 import com.caoccao.javet.values.primitive.V8ValueBoolean
 import com.caoccao.javet.values.primitive.V8ValueInteger
@@ -133,9 +135,10 @@ class V8Plugin {
         synchronized(_runtimeLock) {
             if (_runtime != null)
                 return;
-
+            //V8RuntimeOptions.V8_FLAGS.setUseStrict(true);
             val host = V8Host.getV8Instance();
             val options = host.jsRuntimeType.getRuntimeOptions();
+
             _runtime = host.createV8Runtime(options);
             if (!host.isIsolateCreated)
                 throw IllegalStateException("Isolate not created");
diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt
index c26885d9019faa390dc255ae415efda1287730ae..027fd4b8aecacfae20f62a99ac165f8ec5a6aab9 100644
--- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt
+++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt
@@ -2,12 +2,14 @@ package com.futo.platformplayer.engine.packages
 
 import com.caoccao.javet.annotations.V8Function
 import com.caoccao.javet.annotations.V8Property
+import com.caoccao.javet.values.V8Value
 import com.futo.platformplayer.BuildConfig
 import com.futo.platformplayer.logging.Logger
 import com.futo.platformplayer.states.StateDeveloper
 import com.futo.platformplayer.UIDialogs
 import com.futo.platformplayer.api.http.ManagedHttpClient
 import com.futo.platformplayer.api.media.platforms.js.JSClient
+import com.futo.platformplayer.api.media.platforms.js.JSClientConstants
 import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
 import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
 import com.futo.platformplayer.engine.IV8PluginConfig
@@ -49,6 +51,16 @@ class PackageBridge : V8Package {
     fun buildFlavor(): String {
         return BuildConfig.FLAVOR;
     }
+    @V8Property
+    fun buildSpecVersion(): Int {
+        return JSClientConstants.PLUGIN_SPEC_VERSION;
+    }
+
+    @V8Function
+    fun dispose(value: V8Value) {
+        Logger.e(TAG, "Manual dispose: " + value.javaClass.name);
+        value.close();
+    }
 
     @V8Function
     fun toast(str: String) {
diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt
index 7822beb5309d1a00131fd28339ead9d074a04f45..4eab03f01f300845ed8ec53ca9c2ce77895adc83 100644
--- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt
+++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageHttp.kt
@@ -7,7 +7,11 @@ import com.caoccao.javet.enums.V8ConversionMode
 import com.caoccao.javet.enums.V8ProxyMode
 import com.caoccao.javet.interop.V8Runtime
 import com.caoccao.javet.values.V8Value
+import com.caoccao.javet.values.primitive.V8ValueString
+import com.caoccao.javet.values.reference.V8ValueArrayBuffer
 import com.caoccao.javet.values.reference.V8ValueObject
+import com.caoccao.javet.values.reference.V8ValueSharedArrayBuffer
+import com.caoccao.javet.values.reference.V8ValueTypedArray
 import com.futo.platformplayer.api.http.ManagedHttpClient
 import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
 import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient
@@ -16,6 +20,9 @@ import com.futo.platformplayer.engine.V8Plugin
 import com.futo.platformplayer.engine.internal.IV8Convertable
 import com.futo.platformplayer.engine.internal.V8BindObject
 import com.futo.platformplayer.logging.Logger
+import com.futo.platformplayer.states.StateApp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
 import java.net.SocketTimeoutException
 import kotlin.streams.asSequence
 
@@ -64,33 +71,42 @@ class PackageHttp: V8Package {
     }
 
     @V8Function
-    fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
+    fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
         return if(useAuth)
-            _packageClientAuth.request(method, url, headers)
+            _packageClientAuth.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
         else
-            _packageClient.request(method, url, headers);
+            _packageClient.request(method, url, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
     }
 
     @V8Function
-    fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
+    fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, bytesResult: Boolean = false) : IBridgeHttpResponse {
         return if(useAuth)
-            _packageClientAuth.requestWithBody(method, url, body, headers)
+            _packageClientAuth.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING)
         else
-            _packageClient.requestWithBody(method, url, body, headers);
+            _packageClient.requestWithBody(method, url, body, headers, if(bytesResult) ReturnType.BYTES else ReturnType.STRING);
     }
     @V8Function
-    fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
+    fun GET(url: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
         return if(useAuth)
-            _packageClientAuth.GET(url, headers)
+            _packageClientAuth.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING)
         else
-            _packageClient.GET(url, headers);
+            _packageClient.GET(url, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
     }
     @V8Function
-    fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BridgeHttpResponse {
-        return if(useAuth)
-            _packageClientAuth.POST(url, body, headers)
+    fun POST(url: String, body: Any, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false, useByteResponse: Boolean = false) : IBridgeHttpResponse {
+
+        val client = if(useAuth) _packageClientAuth else _packageClient;
+
+        if(body is V8ValueString)
+            return client.POST(url, body.value, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
+        else if(body is V8ValueTypedArray)
+            return client.POST(url, body.toBytes(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
+        else if(body is ByteArray)
+            return client.POST(url, body, headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
+        else if(body is ArrayList<*>) //Avoid this case, used purely for testing
+            return client.POST(url, body.map { (it as Double).toInt().toByte() }.toByteArray(), headers, if(useByteResponse) ReturnType.BYTES else ReturnType.STRING);
         else
-            _packageClient.POST(url, body, headers);
+            throw NotImplementedError("Body type " + body?.javaClass?.name?.toString() + " not implemented for POST");
     }
 
     @V8Function
@@ -111,8 +127,19 @@ class PackageHttp: V8Package {
         }
     }
 
+    interface IBridgeHttpResponse {
+        val url: String;
+        val code: Int;
+        val headers: Map<String, List<String>>?;
+    }
+
     @kotlinx.serialization.Serializable
-    class BridgeHttpResponse(val url: String, val code: Int, val body: String?, val headers: Map<String, List<String>>? = null) : IV8Convertable {
+    class BridgeHttpStringResponse(
+        override val url: String,
+        override val code: Int, val
+        body: String?,
+        override val headers: Map<String, List<String>>? = null) : IV8Convertable, IBridgeHttpResponse {
+
         val isOk = code >= 200 && code < 300;
 
         override fun toV8(runtime: V8Runtime): V8Value? {
@@ -125,6 +152,37 @@ class PackageHttp: V8Package {
             return obj;
         }
     }
+    @kotlinx.serialization.Serializable
+    class BridgeHttpBytesResponse: IV8Convertable, IBridgeHttpResponse {
+        override val url: String;
+        override val code: Int;
+        val body: ByteArray?;
+        override val headers: Map<String, List<String>>?;
+
+        val isOk: Boolean;
+
+        constructor(url: String, code: Int, body: ByteArray? = null, headers: Map<String, List<String>>? = null) {
+            this.url = url;
+            this.code = code;
+            this.body = body;
+            this.headers = headers;
+            this.isOk = code >= 200 && code < 300;
+        }
+
+        override fun toV8(runtime: V8Runtime): V8Value? {
+            val obj = runtime.createV8ValueObject();
+            obj.set("url", url);
+            obj.set("code", code);
+            if(body != null) {
+                val buffer = runtime.createV8ValueArrayBuffer(body.size);
+                buffer.fromBytes(body);
+                obj.set("body", body);
+            }
+            obj.set("headers", headers);
+            obj.set("isOk", isOk);
+            return obj;
+        }
+    }
 
     //TODO: This object is currently re-wrapped each modification, this is due to an issue passing the same object back and forth, should be fixed in future.
     @V8Convert(mode = V8ConversionMode.AllowOnly, proxyMode = V8ProxyMode.Class)
@@ -147,6 +205,12 @@ class PackageHttp: V8Package {
         fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), useAuth: Boolean = false) : BatchBuilder
             = clientPOST(_package.getDefaultClient(useAuth), url, body, headers);
 
+        @V8Function
+        fun DUMMY(): BatchBuilder {
+            _reqs.add(Pair(_package.getDefaultClient(false), RequestDescriptor("DUMMY", "", mutableMapOf())));
+            return BatchBuilder(_package, _reqs);
+        }
+
         //Client-specific
 
         @V8Function
@@ -169,12 +233,14 @@ class PackageHttp: V8Package {
 
         //Finalizer
         @V8Function
-        fun execute(): List<BridgeHttpResponse> {
+        fun execute(): List<IBridgeHttpResponse?> {
             return _reqs.parallelStream().map {
+                if(it.second.method == "DUMMY")
+                    return@map null;
                 if(it.second.body != null)
-                    return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers);
+                    return@map it.first.requestWithBody(it.second.method, it.second.url, it.second.body!!, it.second.headers, it.second.respType);
                 else
-                    return@map it.first.request(it.second.method, it.second.url, it.second.headers);
+                    return@map it.first.request(it.second.method, it.second.url, it.second.headers, it.second.respType);
             }
                 .asSequence()
                 .toList();
@@ -232,63 +298,108 @@ class PackageHttp: V8Package {
         }
 
         @V8Function
-        fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse {
+        fun request(method: String, url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
             applyDefaultHeaders(headers);
             return logExceptions {
                 return@logExceptions catchHttp {
                     val client = _client;
                     //logRequest(method, url, headers, null);
                     val resp = client.requestMethod(method, url, headers);
-                    val responseBody = resp.body?.string();
                     //logResponse(method, url, resp.code, resp.headers, responseBody);
-                    return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
-                        _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                    return@catchHttp when(returnType) {
+                        ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers,
+                            _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                        ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers,
+                            _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                        else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented");
+                    }
                 }
             };
         }
         @V8Function
-        fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse {
+        fun requestWithBody(method: String, url: String, body:String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType) : IBridgeHttpResponse {
             applyDefaultHeaders(headers);
             return logExceptions {
                 catchHttp {
                     val client = _client;
                     //logRequest(method, url, headers, body);
                     val resp = client.requestMethod(method, url, body, headers);
-                    val responseBody = resp.body?.string();
                     //logResponse(method, url, resp.code, resp.headers, responseBody);
-                    return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
-                        _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+
+                    return@catchHttp when(returnType) {
+                        ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers,
+                            _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                        ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers,
+                            _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                        else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented");
+                    }
                 }
             };
         }
 
         @V8Function
-        fun GET(url: String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse {
+        fun GET(url: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
             applyDefaultHeaders(headers);
             return logExceptions {
                 catchHttp {
                     val client = _client;
                     //logRequest("GET", url, headers, null);
                     val resp = client.get(url, headers);
-                    val responseBody = resp.body?.string();
+                    //val responseBody = resp.body?.string();
                     //logResponse("GET", url, resp.code, resp.headers, responseBody);
-                    return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
-                        _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+
+
+                    return@catchHttp when(returnType) {
+                        ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers,
+                            _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                        ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers,
+                            _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                        else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented");
+                    }
+                }
+            };
+        }
+        @V8Function
+        fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
+            applyDefaultHeaders(headers);
+            return logExceptions {
+                catchHttp {
+                    val client = _client;
+                    //logRequest("POST", url, headers, body);
+                    val resp = client.post(url, body, headers);
+                    //val responseBody = resp.body?.string();
+                    //logResponse("POST", url, resp.code, resp.headers, responseBody);
+
+
+                    return@catchHttp when(returnType) {
+                        ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers,
+                            _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                        ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers,
+                            _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                        else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented");
+                    }
                 }
             };
         }
         @V8Function
-        fun POST(url: String, body: String, headers: MutableMap<String, String> = HashMap()) : BridgeHttpResponse {
+        fun POST(url: String, body: ByteArray, headers: MutableMap<String, String> = HashMap(), returnType: ReturnType = ReturnType.STRING) : IBridgeHttpResponse {
             applyDefaultHeaders(headers);
             return logExceptions {
                 catchHttp {
                     val client = _client;
                     //logRequest("POST", url, headers, body);
                     val resp = client.post(url, body, headers);
-                    val responseBody = resp.body?.string();
+                    //val responseBody = resp.body?.string();
                     //logResponse("POST", url, resp.code, resp.headers, responseBody);
-                    return@catchHttp BridgeHttpResponse(resp.url, resp.code, responseBody, sanitizeResponseHeaders(resp.headers,
-                        _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+
+
+                    return@catchHttp when(returnType) {
+                        ReturnType.STRING -> BridgeHttpStringResponse(resp.url, resp.code, resp.body?.string(), sanitizeResponseHeaders(resp.headers,
+                            _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                        ReturnType.BYTES -> BridgeHttpBytesResponse(resp.url, resp.code, resp.body?.bytes(), sanitizeResponseHeaders(resp.headers,
+                            _client !is JSHttpClient || _client.isLoggedIn || _package._config !is SourcePluginConfig || !_package._config.allowAllHttpHeaderAccess));
+                        else -> throw NotImplementedError("Return type " + returnType.toString() + " not implemented");
+                    }
                 }
             };
         }
@@ -388,13 +499,13 @@ class PackageHttp: V8Package {
             }
         }
 
-        private fun catchHttp(handle: ()->BridgeHttpResponse): BridgeHttpResponse {
+        private fun catchHttp(handle: ()->IBridgeHttpResponse): IBridgeHttpResponse {
             try{
                 return handle();
             }
             //Forward timeouts
             catch(ex: SocketTimeoutException) {
-                return BridgeHttpResponse("", 408, null);
+                return BridgeHttpStringResponse("", 408, null);
             }
         }
     }
@@ -514,20 +625,25 @@ class PackageHttp: V8Package {
         val url: String,
         val headers: MutableMap<String, String>,
         val body: String? = null,
-        val contentType: String? = null
+        val contentType: String? = null,
+        val respType: ReturnType = ReturnType.STRING
     )
 
-    private fun catchHttp(handle: ()->BridgeHttpResponse): BridgeHttpResponse {
+    private fun catchHttp(handle: ()->BridgeHttpStringResponse): BridgeHttpStringResponse {
         try{
             return handle();
         }
         //Forward timeouts
         catch(ex: SocketTimeoutException) {
-            return BridgeHttpResponse("", 408, null);
+            return BridgeHttpStringResponse("", 408, null);
         }
     }
 
 
+    enum class ReturnType(val value: Int) {
+        STRING(0),
+        BYTES(1);
+    }
 
     companion object {
         private const val TAG = "PackageHttp";
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt
index fcabcb5b01a196be2d8eaf54ecb3358707da75e0..1a4537f6145f785f854ae92fd01abd063a224e10 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/ContentFeedView.kt
@@ -118,8 +118,13 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
 
     private fun showVideoOptionsOverlay(content: IPlatformVideo) {
         _overlayContainer.let {
-            _videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(context, R.drawable.ic_visibility_off, context.getString(R.string.hide), context.getString(R.string.hide_from_home), "hide",
-                { StateMeta.instance.addHiddenVideo(content.url);
+            _videoOptionsOverlay = UISlideOverlays.showVideoOptionsOverlay(content, it, SlideUpMenuItem(
+                context,
+                R.drawable.ic_visibility_off,
+                context.getString(R.string.hide),
+                context.getString(R.string.hide_from_home),
+                tag = "hide",
+                call = { StateMeta.instance.addHiddenVideo(content.url);
                     if (fragment is HomeFragment) {
                         val removeIndex = recyclerData.results.indexOf(content);
                         if (removeIndex >= 0) {
@@ -128,8 +133,12 @@ abstract class ContentFeedView<TFragment> : FeedView<TFragment, IPlatformContent
                         }
                     }
                 }),
-                SlideUpMenuItem(context, R.drawable.ic_playlist, context.getString(R.string.play_feed_as_queue), context.getString(R.string.play_entire_feed), "playFeed",
-                    {
+                SlideUpMenuItem(context,
+                    R.drawable.ic_playlist,
+                    context.getString(R.string.play_feed_as_queue),
+                    context.getString(R.string.play_entire_feed),
+                    tag = "playFeed",
+                    call = {
                         val newQueue = listOf(content) + recyclerData.results
                             .filterIsInstance<IPlatformVideo>()
                             .filter { it != content };
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt
index af128741b1a96a402c573615bbb99845e87c8879..1c706c286b595b7052082704ed0c2c595ef2a69a 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/PlaylistFragment.kt
@@ -109,19 +109,31 @@ class PlaylistFragment : MainFragment() {
                 val reconstruction = StatePlaylists.instance.playlistStore.getReconstructionString(playlist);
 
                 UISlideOverlays.showOverlay(overlayContainer, context.getString(R.string.playlist) + " [${playlist.name}]", null, {},
-                    SlideUpMenuItem(context, R.drawable.ic_list, context.getString(R.string.share_as_text), context.getString(R.string.share_as_a_list_of_video_urls), 1, {
-                        _fragment.startActivity(ShareCompat.IntentBuilder(context)
-                            .setType("text/plain")
-                            .setText(reconstruction)
-                            .intent);
-                    }),
-                    SlideUpMenuItem(context, R.drawable.ic_move_up, context.getString(R.string.share_as_import), context.getString(R.string.share_as_a_import_file_for_grayjay), 2, {
-                        val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist);
-                        _fragment.startActivity(ShareCompat.IntentBuilder(context)
-                            .setType("application/json")
-                            .setStream(shareUri)
-                            .intent);
-                    })
+                    SlideUpMenuItem(
+                        context,
+                        R.drawable.ic_list,
+                        context.getString(R.string.share_as_text),
+                        context.getString(R.string.share_as_a_list_of_video_urls),
+                        tag = 1,
+                        call = {
+                            _fragment.startActivity(ShareCompat.IntentBuilder(context)
+                                .setType("text/plain")
+                                .setText(reconstruction)
+                                .intent);
+                        }),
+                    SlideUpMenuItem(
+                        context,
+                        R.drawable.ic_move_up,
+                        context.getString(R.string.share_as_import),
+                        context.getString(R.string.share_as_a_import_file_for_grayjay),
+                        tag = 2,
+                        call = {
+                            val shareUri = StatePlaylists.instance.createPlaylistShareJsonUri(context, playlist);
+                            _fragment.startActivity(ShareCompat.IntentBuilder(context)
+                                .setType("application/json")
+                                .setStream(shareUri)
+                                .intent);
+                        })
                 );
             };
 
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt
index e1f311fbb656e17f40f34882f50734b9bf35c677..cbefca3b38915ca0102b8595875e94f5a5986e8c 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailFragment.kt
@@ -157,7 +157,7 @@ class VideoDetailFragment : MainFragment {
         _viewDetail?.preventPictureInPicture = true;
     }
 
-    fun minimizeVideoDetail(){
+    fun minimizeVideoDetail() {
         _viewDetail?.setFullscreen(false);
         if(_view != null)
             _view!!.transitionToStart();
diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt
index 0befae690f37e71b8335397f918e058a3d61d615..c7dd930c6b3c6cb5310a902c97ae9a41b2fc018e 100644
--- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt
+++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/VideoDetailView.kt
@@ -23,7 +23,6 @@ import android.view.View
 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
 import android.view.WindowManager
-import android.webkit.WebView
 import android.widget.FrameLayout
 import android.widget.ImageButton
 import android.widget.ImageView
@@ -59,8 +58,6 @@ import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
 import com.futo.platformplayer.api.media.models.ratings.RatingLikeDislikes
 import com.futo.platformplayer.api.media.models.ratings.RatingLikes
 import com.futo.platformplayer.api.media.models.streams.VideoUnMuxedSourceDescriptor
-import com.futo.platformplayer.api.media.models.streams.sources.DashManifestSource
-import com.futo.platformplayer.api.media.models.streams.sources.HLSManifestSource
 import com.futo.platformplayer.api.media.models.streams.sources.IAudioSource
 import com.futo.platformplayer.api.media.models.streams.sources.IDashManifestSource
 import com.futo.platformplayer.api.media.models.streams.sources.IHLSManifestAudioSource
@@ -75,6 +72,7 @@ import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
 import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
 import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
 import com.futo.platformplayer.api.media.platforms.js.models.JSVideoDetails
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
 import com.futo.platformplayer.api.media.structures.IPager
 import com.futo.platformplayer.casting.CastConnectionState
 import com.futo.platformplayer.casting.StateCasting
@@ -115,6 +113,7 @@ import com.futo.platformplayer.stores.FragmentedStorage
 import com.futo.platformplayer.stores.StringArrayStorage
 import com.futo.platformplayer.stores.db.types.DBHistory
 import com.futo.platformplayer.toHumanBitrate
+import com.futo.platformplayer.toHumanBytesSize
 import com.futo.platformplayer.toHumanNowDiffString
 import com.futo.platformplayer.toHumanNumber
 import com.futo.platformplayer.toHumanTime
@@ -695,6 +694,7 @@ class VideoDetailView : ConstraintLayout {
             _lastAudioSource = null;
             _lastSubtitleSource = null;
             video = null;
+            _player.clear();
             cleanupPlaybackTracker();
             Logger.i(TAG, "Keep screen on unset onClose")
             fragment.activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
@@ -1674,7 +1674,7 @@ class VideoDetailView : ConstraintLayout {
                 _didTriggerDatasourceErrroCount++;
 
                 UIDialogs.toast("Block detected, attempting bypass");
-
+                //return;
                 fragment.lifecycleScope.launch(Dispatchers.IO) {
                     val newDetails = StatePlatform.instance.getContentDetails(currentVideo.url, true).await();
                     val previousVideoSource = _lastVideoSource;
@@ -1808,7 +1808,7 @@ class VideoDetailView : ConstraintLayout {
             }
         }
 
-        val doDedup = false;
+        val doDedup = Settings.instance.playback.simplifySources;
 
         val bestVideoSources = if(doDedup) (videoSources?.map { it.height * it.width }
             ?.distinct()
@@ -1851,40 +1851,56 @@ class VideoDetailView : ConstraintLayout {
                 SlideUpMenuGroup(this.context, context.getString(R.string.offline_video), "video",
                     *localVideoSources
                         .map {
-                            SlideUpMenuItem(this.context, R.drawable.ic_movie, it.name, "${it.width}x${it.height}", it,
-                                { handleSelectVideoTrack(it) });
+                            SlideUpMenuItem(this.context,
+                                R.drawable.ic_movie,
+                                it.name,
+                                "${it.width}x${it.height}",
+                                tag = it,
+                                call = { handleSelectVideoTrack(it) });
                         }.toList().toTypedArray())
             else null,
             if(localAudioSource?.isNotEmpty() == true)
                 SlideUpMenuGroup(this.context, context.getString(R.string.offline_audio), "audio",
                     *localAudioSource
                         .map {
-                            SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
-                                { handleSelectAudioTrack(it) });
+                            SlideUpMenuItem(this.context,
+                                R.drawable.ic_music,
+                                it.name,
+                                it.bitrate.toHumanBitrate(),
+                                tag = it,
+                                call = { handleSelectAudioTrack(it) });
                         }.toList().toTypedArray())
             else null,
             if(localSubtitleSources?.isNotEmpty() == true)
                 SlideUpMenuGroup(this.context, context.getString(R.string.offline_subtitles), "subtitles",
                     *localSubtitleSources
                         .map {
-                            SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
-                                { handleSelectSubtitleTrack(it) })
+                            SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it,
+                                call = { handleSelectSubtitleTrack(it) })
                         }.toList().toTypedArray())
             else null,
             if(liveStreamVideoFormats?.isEmpty() == false)
                 SlideUpMenuGroup(this.context, context.getString(R.string.stream_video), "video",
                     *liveStreamVideoFormats
                         .map {
-                            SlideUpMenuItem(this.context, R.drawable.ic_movie, it.label ?: it.containerMimeType ?: it.bitrate.toString(), "${it.width}x${it.height}", it,
-                                { _player.selectVideoTrack(it.height) });
+                            SlideUpMenuItem(this.context,
+                                R.drawable.ic_movie,
+                                it.label ?: it.containerMimeType ?: it.bitrate.toString(),
+                                "${it.width}x${it.height}",
+                                tag = it,
+                                call = { _player.selectVideoTrack(it.height) });
                         }.toList().toTypedArray())
             else null,
             if(liveStreamAudioFormats?.isEmpty() == false)
                 SlideUpMenuGroup(this.context, context.getString(R.string.stream_audio), "audio",
                     *liveStreamAudioFormats
                         .map {
-                            SlideUpMenuItem(this.context, R.drawable.ic_music, "${it.label ?: it.containerMimeType} ${it.bitrate}", "", it,
-                                { _player.selectAudioTrack(it.bitrate) });
+                            SlideUpMenuItem(this.context,
+                                R.drawable.ic_music,
+                                "${it.label ?: it.containerMimeType} ${it.bitrate}",
+                                "",
+                                tag = it,
+                                call = { _player.selectAudioTrack(it.bitrate) });
                         }.toList().toTypedArray())
             else null,
 
@@ -1892,24 +1908,38 @@ class VideoDetailView : ConstraintLayout {
                 SlideUpMenuGroup(this.context, context.getString(R.string.video), "video",
                     *bestVideoSources
                         .map {
-                            SlideUpMenuItem(this.context, R.drawable.ic_movie, it!!.name, if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "", it,
-                                { handleSelectVideoTrack(it) });
+                            val estSize = VideoHelper.estimateSourceSize(it);
+                            val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
+                            SlideUpMenuItem(this.context,
+                                R.drawable.ic_movie,
+                                it!!.name,
+                                if (it.width > 0 && it.height > 0) "${it.width}x${it.height}" else "",
+                                (prefix + it.codec.trim()).trim(),
+                                tag = it,
+                                call = { handleSelectVideoTrack(it) });
                         }.toList().toTypedArray())
             else null,
             if(bestAudioSources.isNotEmpty())
                 SlideUpMenuGroup(this.context, context.getString(R.string.audio), "audio",
                     *bestAudioSources
                         .map {
-                            SlideUpMenuItem(this.context, R.drawable.ic_music, it.name, it.bitrate.toHumanBitrate(), it,
-                                { handleSelectAudioTrack(it) });
+                            val estSize = VideoHelper.estimateSourceSize(it);
+                            val prefix = if(estSize > 0) "±" + estSize.toHumanBytesSize() + " " else "";
+                            SlideUpMenuItem(this.context,
+                                R.drawable.ic_music,
+                                it.name,
+                                it.bitrate.toHumanBitrate(),
+                                (prefix + it.codec.trim()).trim(),
+                                tag = it,
+                                call = { handleSelectAudioTrack(it) });
                         }.toList().toTypedArray())
             else null,
             if(video?.subtitles?.isNotEmpty() == true)
                 SlideUpMenuGroup(this.context, context.getString(R.string.subtitles), "subtitles",
                     *video.subtitles
                         .map {
-                            SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", it,
-                                { handleSelectSubtitleTrack(it) })
+                            SlideUpMenuItem(this.context, R.drawable.ic_edit, it.name, "", tag = it,
+                                call = { handleSelectSubtitleTrack(it) })
                         }.toList().toTypedArray())
             else null);
     }
diff --git a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt
index c1b89bd3732e9f5624cca574f2cac4b50f9e466c..cd86e01d746116984d7931c0537bcd0479dcf97c 100644
--- a/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt
+++ b/app/src/main/java/com/futo/platformplayer/helpers/VideoHelper.kt
@@ -20,6 +20,7 @@ import com.futo.platformplayer.api.media.models.streams.sources.IVideoSource
 import com.futo.platformplayer.api.media.models.streams.sources.IVideoUrlSource
 import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
 import com.futo.platformplayer.logging.Logger
 import com.futo.platformplayer.others.Language
@@ -186,5 +187,25 @@ class VideoHelper {
                 return@Resolver dataSpec;
             })).createMediaSource(manifest, MediaItem.Builder().setUri(Uri.parse(audioSource.getAudioUrl())).build())
         }
+
+
+        fun estimateSourceSize(source: IVideoSource?): Int {
+            if(source == null) return 0;
+            if(source is IVideoUrlSource) {
+                if(source.bitrate ?: 0 <= 0 || source.duration.toInt() == 0)
+                    return 0;
+                return (source.duration / 8).toInt() * source.bitrate!!;
+            }
+            else return 0;
+        }
+        fun estimateSourceSize(source: IAudioSource?): Int {
+            if(source == null) return 0;
+            if(source is IAudioUrlSource) {
+                if(source.bitrate <= 0 || source.duration?.toInt() ?: 0 == 0)
+                    return 0;
+                return (source.duration!! / 8).toInt() * source.bitrate;
+            }
+            else return 0;
+        }
     }
 }
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt b/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ac3c61e0376c1369ce69adcfbcf9e35c6e02f30a
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/BroadcastService.kt
@@ -0,0 +1,11 @@
+package com.futo.platformplayer.mdns
+
+data class BroadcastService(
+    val deviceName: String,
+    val serviceName: String,
+    val port: UShort,
+    val ttl: UInt,
+    val weight: UShort,
+    val priority: UShort,
+    val texts: List<String>? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2c27edf8a1f169c7f5cb780dc67ffef183e7a764
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsPacket.kt
@@ -0,0 +1,93 @@
+package com.futo.platformplayer.mdns
+
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+enum class QueryResponse(val value: Byte) {
+    Query(0),
+    Response(1)
+}
+
+enum class DnsOpcode(val value: Byte) {
+    StandardQuery(0),
+    InverseQuery(1),
+    ServerStatusRequest(2)
+}
+
+enum class DnsResponseCode(val value: Byte) {
+    NoError(0),
+    FormatError(1),
+    ServerFailure(2),
+    NameError(3),
+    NotImplemented(4),
+    Refused(5)
+}
+
+data class DnsPacketHeader(
+    val identifier: UShort,
+    val queryResponse: Int,
+    val opcode: Int,
+    val authoritativeAnswer: Boolean,
+    val truncated: Boolean,
+    val recursionDesired: Boolean,
+    val recursionAvailable: Boolean,
+    val answerAuthenticated: Boolean,
+    val nonAuthenticatedData: Boolean,
+    val responseCode: DnsResponseCode
+)
+
+data class DnsPacket(
+    val header: DnsPacketHeader,
+    val questions: List<DnsQuestion>,
+    val answers: List<DnsResourceRecord>,
+    val authorities: List<DnsResourceRecord>,
+    val additionals: List<DnsResourceRecord>
+) {
+    companion object {
+        fun parse(data: ByteArray): DnsPacket {
+            val span = data.asUByteArray()
+            val flags = (span[2].toInt() shl 8 or span[3].toInt()).toUShort()
+            val questionCount = (span[4].toInt() shl 8 or span[5].toInt()).toUShort()
+            val answerCount = (span[6].toInt() shl 8 or span[7].toInt()).toUShort()
+            val authorityCount = (span[8].toInt() shl 8 or span[9].toInt()).toUShort()
+            val additionalCount = (span[10].toInt() shl 8 or span[11].toInt()).toUShort()
+
+            var position = 12
+
+            val questions = List(questionCount.toInt()) {
+                DnsQuestion.parse(data, position).also { position = it.second }
+            }.map { it.first }
+
+            val answers = List(answerCount.toInt()) {
+                DnsResourceRecord.parse(data, position).also { position = it.second }
+            }.map { it.first }
+
+            val authorities = List(authorityCount.toInt()) {
+                DnsResourceRecord.parse(data, position).also { position = it.second }
+            }.map { it.first }
+
+            val additionals = List(additionalCount.toInt()) {
+                DnsResourceRecord.parse(data, position).also { position = it.second }
+            }.map { it.first }
+
+            return DnsPacket(
+                header = DnsPacketHeader(
+                    identifier = (span[0].toInt() shl 8 or span[1].toInt()).toUShort(),
+                    queryResponse = ((flags.toUInt() shr 15) and 0b1u).toInt(),
+                    opcode = ((flags.toUInt() shr 11) and 0b1111u).toInt(),
+                    authoritativeAnswer = (flags.toInt() shr 10) and 0b1 != 0,
+                    truncated = (flags.toInt() shr 9) and 0b1 != 0,
+                    recursionDesired = (flags.toInt() shr 8) and 0b1 != 0,
+                    recursionAvailable = (flags.toInt() shr 7) and 0b1 != 0,
+                    answerAuthenticated = (flags.toInt() shr 5) and 0b1 != 0,
+                    nonAuthenticatedData = (flags.toInt() shr 4) and 0b1 != 0,
+                    responseCode = DnsResponseCode.entries[flags.toInt() and 0b1111]
+                ),
+                questions = questions,
+                answers = answers,
+                authorities = authorities,
+                additionals = additionals
+            )
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt
new file mode 100644
index 0000000000000000000000000000000000000000..01a7bd77a90daedf7de9e0abeb720ab18322c1f9
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsQuestion.kt
@@ -0,0 +1,110 @@
+package com.futo.platformplayer.mdns
+
+import com.futo.platformplayer.mdns.Extensions.readDomainName
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+
+enum class QuestionType(val value: UShort) {
+    A(1u),
+    NS(2u),
+    MD(3u),
+    MF(4u),
+    CNAME(5u),
+    SOA(6u),
+    MB(7u),
+    MG(8u),
+    MR(9u),
+    NULL(10u),
+    WKS(11u),
+    PTR(12u),
+    HINFO(13u),
+    MINFO(14u),
+    MX(15u),
+    TXT(16u),
+    RP(17u),
+    AFSDB(18u),
+    SIG(24u),
+    KEY(25u),
+    AAAA(28u),
+    LOC(29u),
+    SRV(33u),
+    NAPTR(35u),
+    KX(36u),
+    CERT(37u),
+    DNAME(39u),
+    APL(42u),
+    DS(43u),
+    SSHFP(44u),
+    IPSECKEY(45u),
+    RRSIG(46u),
+    NSEC(47u),
+    DNSKEY(48u),
+    DHCID(49u),
+    NSEC3(50u),
+    NSEC3PARAM(51u),
+    TSLA(52u),
+    SMIMEA(53u),
+    HIP(55u),
+    CDS(59u),
+    CDNSKEY(60u),
+    OPENPGPKEY(61u),
+    CSYNC(62u),
+    ZONEMD(63u),
+    SVCB(64u),
+    HTTPS(65u),
+    EUI48(108u),
+    EUI64(109u),
+    TKEY(249u),
+    TSIG(250u),
+    URI(256u),
+    CAA(257u),
+    TA(32768u),
+    DLV(32769u),
+    AXFR(252u),
+    IXFR(251u),
+    OPT(41u),
+    MAILB(253u),
+    MALA(254u),
+    All(252u)
+}
+
+enum class QuestionClass(val value: UShort) {
+    IN(1u),
+    CS(2u),
+    CH(3u),
+    HS(4u),
+    All(255u)
+}
+
+data class DnsQuestion(
+    override val name: String,
+    override val type: Int,
+    override val clazz: Int,
+    val queryUnicast: Boolean
+) : DnsResourceRecordBase(name, type, clazz) {
+    companion object {
+        fun parse(data: ByteArray, startPosition: Int): Pair<DnsQuestion, Int> {
+            val span = data.asUByteArray()
+            var position = startPosition
+            val qname = span.readDomainName(position).also { position = it.second }
+            val qtype = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
+            position += 2
+            val qclass = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
+            position += 2
+
+            return DnsQuestion(
+                name = qname.first,
+                type = qtype.toInt(),
+                queryUnicast = ((qclass.toInt() shr 15) and 0b1) != 0,
+                clazz = qclass.toInt() and 0b111111111111111
+            ) to position
+        }
+    }
+}
+
+open class DnsResourceRecordBase(
+    open val name: String,
+    open val type: Int,
+    open val clazz: Int
+)
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt
new file mode 100644
index 0000000000000000000000000000000000000000..83c329ffc650718bcca9663c0a16bb74f21e3730
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsReader.kt
@@ -0,0 +1,514 @@
+package com.futo.platformplayer.mdns
+
+import com.futo.platformplayer.mdns.Extensions.readDomainName
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.charset.StandardCharsets
+import kotlin.math.pow
+import java.net.InetAddress
+
+data class PTRRecord(val domainName: String)
+
+data class ARecord(val address: InetAddress)
+
+data class AAAARecord(val address: InetAddress)
+
+data class MXRecord(val preference: UShort, val exchange: String)
+
+data class CNAMERecord(val cname: String)
+
+data class TXTRecord(val texts: List<String>)
+
+data class SOARecord(
+    val primaryNameServer: String,
+    val responsibleAuthorityMailbox: String,
+    val serialNumber: Int,
+    val refreshInterval: Int,
+    val retryInterval: Int,
+    val expiryLimit: Int,
+    val minimumTTL: Int
+)
+
+data class SRVRecord(val priority: UShort, val weight: UShort, val port: UShort, val target: String)
+
+data class NSRecord(val nameServer: String)
+
+data class CAARecord(val flags: Byte, val tag: String, val value: String)
+
+data class HINFORecord(val cpu: String, val os: String)
+
+data class RPRecord(val mailbox: String, val txtDomainName: String)
+
+
+data class AFSDBRecord(val subtype: UShort, val hostname: String)
+data class LOCRecord(
+    val version: Byte,
+    val size: Double,
+    val horizontalPrecision: Double,
+    val verticalPrecision: Double,
+    val latitude: Double,
+    val longitude: Double,
+    val altitude: Double
+) {
+    companion object {
+        fun decodeSizeOrPrecision(coded: Byte): Double {
+            val baseValue = (coded.toInt() shr 4) and 0x0F
+            val exponent = coded.toInt() and 0x0F
+            return baseValue * 10.0.pow(exponent.toDouble())
+        }
+
+        fun decodeLatitudeOrLongitude(coded: Int): Double {
+            val arcSeconds = coded / 1E3
+            return arcSeconds / 3600.0
+        }
+
+        fun decodeAltitude(coded: Int): Double {
+            return (coded / 100.0) - 100000.0
+        }
+    }
+}
+
+data class NAPTRRecord(
+    val order: UShort,
+    val preference: UShort,
+    val flags: String,
+    val services: String,
+    val regexp: String,
+    val replacement: String
+)
+
+data class RRSIGRecord(
+    val typeCovered: UShort,
+    val algorithm: Byte,
+    val labels: Byte,
+    val originalTTL: UInt,
+    val signatureExpiration: UInt,
+    val signatureInception: UInt,
+    val keyTag: UShort,
+    val signersName: String,
+    val signature: ByteArray
+)
+
+data class KXRecord(val preference: UShort, val exchanger: String)
+
+data class CERTRecord(val type: UShort, val keyTag: UShort, val algorithm: Byte, val certificate: ByteArray)
+
+
+
+data class DNAMERecord(val target: String)
+
+data class DSRecord(val keyTag: UShort, val algorithm: Byte, val digestType: Byte, val digest: ByteArray)
+
+data class SSHFPRecord(val algorithm: Byte, val fingerprintType: Byte, val fingerprint: ByteArray)
+
+data class TLSARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
+
+data class SMIMEARecord(val usage: Byte, val selector: Byte, val matchingType: Byte, val certificateAssociationData: ByteArray)
+
+data class URIRecord(val priority: UShort, val weight: UShort, val target: String)
+
+data class NSECRecord(val ownerName: String, val typeBitMaps: List<Pair<Byte, ByteArray>>)
+data class NSEC3Record(
+    val hashAlgorithm: Byte,
+    val flags: Byte,
+    val iterations: UShort,
+    val salt: ByteArray,
+    val nextHashedOwnerName: ByteArray,
+    val typeBitMaps: List<UShort>
+)
+
+data class NSEC3PARAMRecord(val hashAlgorithm: Byte, val flags: Byte, val iterations: UShort, val salt: ByteArray)
+data class SPFRecord(val texts: List<String>)
+data class TKEYRecord(
+    val algorithm: String,
+    val inception: UInt,
+    val expiration: UInt,
+    val mode: UShort,
+    val error: UShort,
+    val keyData: ByteArray,
+    val otherData: ByteArray
+)
+
+data class TSIGRecord(
+    val algorithmName: String,
+    val timeSigned: UInt,
+    val fudge: UShort,
+    val mac: ByteArray,
+    val originalID: UShort,
+    val error: UShort,
+    val otherData: ByteArray
+)
+
+data class OPTRecordOption(val code: UShort, val data: ByteArray)
+data class OPTRecord(val options: List<OPTRecordOption>)
+
+class DnsReader(private val data: ByteArray, private var position: Int = 0, private val length: Int = data.size) {
+
+    private val endPosition: Int = position + length
+
+    fun readDomainName(): String {
+        return data.asUByteArray().readDomainName(position).also { position = it.second }.first
+    }
+
+    fun readDouble(): Double {
+        checkRemainingBytes(Double.SIZE_BYTES)
+        val result = ByteBuffer.wrap(data, position, Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).double
+        position += Double.SIZE_BYTES
+        return result
+    }
+
+    fun readInt16(): Short {
+        checkRemainingBytes(Short.SIZE_BYTES)
+        val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short
+        position += Short.SIZE_BYTES
+        return result
+    }
+
+    fun readInt32(): Int {
+        checkRemainingBytes(Int.SIZE_BYTES)
+        val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int
+        position += Int.SIZE_BYTES
+        return result
+    }
+
+    fun readInt64(): Long {
+        checkRemainingBytes(Long.SIZE_BYTES)
+        val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long
+        position += Long.SIZE_BYTES
+        return result
+    }
+
+    fun readSingle(): Float {
+        checkRemainingBytes(Float.SIZE_BYTES)
+        val result = ByteBuffer.wrap(data, position, Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).float
+        position += Float.SIZE_BYTES
+        return result
+    }
+
+    fun readByte(): Byte {
+        checkRemainingBytes(Byte.SIZE_BYTES)
+        return data[position++]
+    }
+
+    fun readBytes(length: Int): ByteArray {
+        checkRemainingBytes(length)
+        return ByteArray(length).also { data.copyInto(it, startIndex = position, endIndex = position + length) }
+            .also { position += length }
+    }
+
+    fun readUInt16(): UShort {
+        checkRemainingBytes(Short.SIZE_BYTES)
+        val result = ByteBuffer.wrap(data, position, Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).short.toUShort()
+        position += Short.SIZE_BYTES
+        return result
+    }
+
+    fun readUInt32(): UInt {
+        checkRemainingBytes(Int.SIZE_BYTES)
+        val result = ByteBuffer.wrap(data, position, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int.toUInt()
+        position += Int.SIZE_BYTES
+        return result
+    }
+
+    fun readUInt64(): ULong {
+        checkRemainingBytes(Long.SIZE_BYTES)
+        val result = ByteBuffer.wrap(data, position, Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).long.toULong()
+        position += Long.SIZE_BYTES
+        return result
+    }
+
+    fun readString(): String {
+        val length = data[position++].toInt()
+        checkRemainingBytes(length)
+        return String(data, position, length, StandardCharsets.UTF_8).also { position += length }
+    }
+
+    private fun checkRemainingBytes(requiredBytes: Int) {
+        if (position + requiredBytes > endPosition) throw IndexOutOfBoundsException()
+    }
+
+    fun readRPRecord(): RPRecord {
+        return RPRecord(readDomainName(), readDomainName())
+    }
+
+    fun readKXRecord(): KXRecord {
+        val preference = readUInt16()
+        val exchanger = readDomainName()
+        return KXRecord(preference, exchanger)
+    }
+
+    fun readCERTRecord(): CERTRecord {
+        val type = readUInt16()
+        val keyTag = readUInt16()
+        val algorithm = readByte()
+        val certificateLength = readUInt16().toInt() - 5
+        val certificate = readBytes(certificateLength)
+        return CERTRecord(type, keyTag, algorithm, certificate)
+    }
+
+    fun readPTRRecord(): PTRRecord {
+        return PTRRecord(readDomainName())
+    }
+
+    fun readARecord(): ARecord {
+        val address = readBytes(4)
+        return ARecord(InetAddress.getByAddress(address))
+    }
+
+    fun readAAAARecord(): AAAARecord {
+        val address = readBytes(16)
+        return AAAARecord(InetAddress.getByAddress(address))
+    }
+
+    fun readMXRecord(): MXRecord {
+        val preference = readUInt16()
+        val exchange = readDomainName()
+        return MXRecord(preference, exchange)
+    }
+
+    fun readCNAMERecord(): CNAMERecord {
+        return CNAMERecord(readDomainName())
+    }
+
+    fun readTXTRecord(): TXTRecord {
+        val texts = mutableListOf<String>()
+        while (position < endPosition) {
+            val textLength = data[position++].toInt()
+            checkRemainingBytes(textLength)
+            val text = String(data, position, textLength, StandardCharsets.UTF_8)
+            texts.add(text)
+            position += textLength
+        }
+        return TXTRecord(texts)
+    }
+
+    fun readSOARecord(): SOARecord {
+        val primaryNameServer = readDomainName()
+        val responsibleAuthorityMailbox = readDomainName()
+        val serialNumber = readInt32()
+        val refreshInterval = readInt32()
+        val retryInterval = readInt32()
+        val expiryLimit = readInt32()
+        val minimumTTL = readInt32()
+        return SOARecord(primaryNameServer, responsibleAuthorityMailbox, serialNumber, refreshInterval, retryInterval, expiryLimit, minimumTTL)
+    }
+
+    fun readSRVRecord(): SRVRecord {
+        val priority = readUInt16()
+        val weight = readUInt16()
+        val port = readUInt16()
+        val target = readDomainName()
+        return SRVRecord(priority, weight, port, target)
+    }
+
+    fun readNSRecord(): NSRecord {
+        return NSRecord(readDomainName())
+    }
+
+    fun readCAARecord(): CAARecord {
+        val length = readUInt16().toInt()
+        val flags = readByte()
+        val tagLength = readByte().toInt()
+        val tag = String(data, position, tagLength, StandardCharsets.US_ASCII).also { position += tagLength }
+        val valueLength = length - 1 - 1 - tagLength
+        val value = String(data, position, valueLength, StandardCharsets.US_ASCII).also { position += valueLength }
+        return CAARecord(flags, tag, value)
+    }
+
+    fun readHINFORecord(): HINFORecord {
+        val cpuLength = readByte().toInt()
+        val cpu = String(data, position, cpuLength, StandardCharsets.US_ASCII).also { position += cpuLength }
+        val osLength = readByte().toInt()
+        val os = String(data, position, osLength, StandardCharsets.US_ASCII).also { position += osLength }
+        return HINFORecord(cpu, os)
+    }
+
+    fun readAFSDBRecord(): AFSDBRecord {
+        return AFSDBRecord(readUInt16(), readDomainName())
+    }
+
+    fun readLOCRecord(): LOCRecord {
+        val version = readByte()
+        val size = LOCRecord.decodeSizeOrPrecision(readByte())
+        val horizontalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
+        val verticalPrecision = LOCRecord.decodeSizeOrPrecision(readByte())
+        val latitudeCoded = readInt32()
+        val longitudeCoded = readInt32()
+        val altitudeCoded = readInt32()
+        val latitude = LOCRecord.decodeLatitudeOrLongitude(latitudeCoded)
+        val longitude = LOCRecord.decodeLatitudeOrLongitude(longitudeCoded)
+        val altitude = LOCRecord.decodeAltitude(altitudeCoded)
+        return LOCRecord(version, size, horizontalPrecision, verticalPrecision, latitude, longitude, altitude)
+    }
+
+    fun readNAPTRRecord(): NAPTRRecord {
+        val order = readUInt16()
+        val preference = readUInt16()
+        val flags = readString()
+        val services = readString()
+        val regexp = readString()
+        val replacement = readDomainName()
+        return NAPTRRecord(order, preference, flags, services, regexp, replacement)
+    }
+
+    fun readDNAMERecord(): DNAMERecord {
+        return DNAMERecord(readDomainName())
+    }
+
+    fun readDSRecord(): DSRecord {
+        val keyTag = readUInt16()
+        val algorithm = readByte()
+        val digestType = readByte()
+        val digestLength = readUInt16().toInt() - 4
+        val digest = readBytes(digestLength)
+        return DSRecord(keyTag, algorithm, digestType, digest)
+    }
+
+    fun readSSHFPRecord(): SSHFPRecord {
+        val algorithm = readByte()
+        val fingerprintType = readByte()
+        val fingerprintLength = readUInt16().toInt() - 2
+        val fingerprint = readBytes(fingerprintLength)
+        return SSHFPRecord(algorithm, fingerprintType, fingerprint)
+    }
+
+    fun readTLSARecord(): TLSARecord {
+        val usage = readByte()
+        val selector = readByte()
+        val matchingType = readByte()
+        val dataLength = readUInt16().toInt() - 3
+        val certificateAssociationData = readBytes(dataLength)
+        return TLSARecord(usage, selector, matchingType, certificateAssociationData)
+    }
+
+    fun readSMIMEARecord(): SMIMEARecord {
+        val usage = readByte()
+        val selector = readByte()
+        val matchingType = readByte()
+        val dataLength = readUInt16().toInt() - 3
+        val certificateAssociationData = readBytes(dataLength)
+        return SMIMEARecord(usage, selector, matchingType, certificateAssociationData)
+    }
+
+    fun readURIRecord(): URIRecord {
+        val priority = readUInt16()
+        val weight = readUInt16()
+        val length = readUInt16().toInt()
+        val target = String(data, position, length, StandardCharsets.US_ASCII).also { position += length }
+        return URIRecord(priority, weight, target)
+    }
+
+    fun readRRSIGRecord(): RRSIGRecord {
+        val typeCovered = readUInt16()
+        val algorithm = readByte()
+        val labels = readByte()
+        val originalTTL = readUInt32()
+        val signatureExpiration = readUInt32()
+        val signatureInception = readUInt32()
+        val keyTag = readUInt16()
+        val signersName = readDomainName()
+        val signatureLength = readUInt16().toInt()
+        val signature = readBytes(signatureLength)
+        return RRSIGRecord(
+            typeCovered,
+            algorithm,
+            labels,
+            originalTTL,
+            signatureExpiration,
+            signatureInception,
+            keyTag,
+            signersName,
+            signature
+        )
+    }
+
+    fun readNSECRecord(): NSECRecord {
+        val ownerName = readDomainName()
+        val typeBitMaps = mutableListOf<Pair<Byte, ByteArray>>()
+        while (position < endPosition) {
+            val windowBlock = readByte()
+            val bitmapLength = readByte().toInt()
+            val bitmap = readBytes(bitmapLength)
+            typeBitMaps.add(windowBlock to bitmap)
+        }
+        return NSECRecord(ownerName, typeBitMaps)
+    }
+
+    fun readNSEC3Record(): NSEC3Record {
+        val hashAlgorithm = readByte()
+        val flags = readByte()
+        val iterations = readUInt16()
+        val saltLength = readByte().toInt()
+        val salt = readBytes(saltLength)
+        val hashLength = readByte().toInt()
+        val nextHashedOwnerName = readBytes(hashLength)
+        val bitMapLength = readUInt16().toInt()
+        val typeBitMaps = mutableListOf<UShort>()
+        val endPos = position + bitMapLength
+        while (position < endPos) {
+            typeBitMaps.add(readUInt16())
+        }
+        return NSEC3Record(hashAlgorithm, flags, iterations, salt, nextHashedOwnerName, typeBitMaps)
+    }
+
+    fun readNSEC3PARAMRecord(): NSEC3PARAMRecord {
+        val hashAlgorithm = readByte()
+        val flags = readByte()
+        val iterations = readUInt16()
+        val saltLength = readByte().toInt()
+        val salt = readBytes(saltLength)
+        return NSEC3PARAMRecord(hashAlgorithm, flags, iterations, salt)
+    }
+
+
+    fun readSPFRecord(): SPFRecord {
+        val length = readUInt16().toInt()
+        val texts = mutableListOf<String>()
+        val endPos = position + length
+        while (position < endPos) {
+            val textLength = readByte().toInt()
+            val text = String(data, position, textLength, StandardCharsets.US_ASCII).also { position += textLength }
+            texts.add(text)
+        }
+        return SPFRecord(texts)
+    }
+
+    fun readTKEYRecord(): TKEYRecord {
+        val algorithm = readDomainName()
+        val inception = readUInt32()
+        val expiration = readUInt32()
+        val mode = readUInt16()
+        val error = readUInt16()
+        val keySize = readUInt16().toInt()
+        val keyData = readBytes(keySize)
+        val otherSize = readUInt16().toInt()
+        val otherData = readBytes(otherSize)
+        return TKEYRecord(algorithm, inception, expiration, mode, error, keyData, otherData)
+    }
+
+    fun readTSIGRecord(): TSIGRecord {
+        val algorithmName = readDomainName()
+        val timeSigned = readUInt32()
+        val fudge = readUInt16()
+        val macSize = readUInt16().toInt()
+        val mac = readBytes(macSize)
+        val originalID = readUInt16()
+        val error = readUInt16()
+        val otherSize = readUInt16().toInt()
+        val otherData = readBytes(otherSize)
+        return TSIGRecord(algorithmName, timeSigned, fudge, mac, originalID, error, otherData)
+    }
+
+
+
+    fun readOPTRecord(): OPTRecord {
+        val options = mutableListOf<OPTRecordOption>()
+        while (position < endPosition) {
+            val optionCode = readUInt16()
+            val optionLength = readUInt16().toInt()
+            val optionData = readBytes(optionLength)
+            options.add(OPTRecordOption(optionCode, optionData))
+        }
+        return OPTRecord(options)
+    }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt
new file mode 100644
index 0000000000000000000000000000000000000000..87ec0e5f3c7c40d8dd79b65ddadf18ee539555ab
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsResourceRecord.kt
@@ -0,0 +1,117 @@
+package com.futo.platformplayer.mdns
+
+import com.futo.platformplayer.mdns.Extensions.readDomainName
+
+enum class ResourceRecordType(val value: UShort) {
+    None(0u),
+    A(1u),
+    NS(2u),
+    MD(3u),
+    MF(4u),
+    CNAME(5u),
+    SOA(6u),
+    MB(7u),
+    MG(8u),
+    MR(9u),
+    NULL(10u),
+    WKS(11u),
+    PTR(12u),
+    HINFO(13u),
+    MINFO(14u),
+    MX(15u),
+    TXT(16u),
+    RP(17u),
+    AFSDB(18u),
+    SIG(24u),
+    KEY(25u),
+    AAAA(28u),
+    LOC(29u),
+    SRV(33u),
+    NAPTR(35u),
+    KX(36u),
+    CERT(37u),
+    DNAME(39u),
+    APL(42u),
+    DS(43u),
+    SSHFP(44u),
+    IPSECKEY(45u),
+    RRSIG(46u),
+    NSEC(47u),
+    DNSKEY(48u),
+    DHCID(49u),
+    NSEC3(50u),
+    NSEC3PARAM(51u),
+    TSLA(52u),
+    SMIMEA(53u),
+    HIP(55u),
+    CDS(59u),
+    CDNSKEY(60u),
+    OPENPGPKEY(61u),
+    CSYNC(62u),
+    ZONEMD(63u),
+    SVCB(64u),
+    HTTPS(65u),
+    EUI48(108u),
+    EUI64(109u),
+    TKEY(249u),
+    TSIG(250u),
+    URI(256u),
+    CAA(257u),
+    TA(32768u),
+    DLV(32769u),
+    AXFR(252u),
+    IXFR(251u),
+    OPT(41u)
+}
+
+enum class ResourceRecordClass(val value: UShort) {
+    IN(1u),
+    CS(2u),
+    CH(3u),
+    HS(4u)
+}
+
+data class DnsResourceRecord(
+    override val name: String,
+    override val type: Int,
+    override val clazz: Int,
+    val timeToLive: UInt,
+    val cacheFlush: Boolean,
+    val dataPosition: Int = -1,
+    val dataLength: Int = -1,
+    private val data: ByteArray? = null
+) : DnsResourceRecordBase(name, type, clazz) {
+
+    companion object {
+        fun parse(data: ByteArray, startPosition: Int): Pair<DnsResourceRecord, Int> {
+            val span = data.asUByteArray()
+            var position = startPosition
+            val name = span.readDomainName(position).also { position = it.second }
+            val type = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
+            position += 2
+            val clazz = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
+            position += 2
+            val ttl = (span[position].toInt() shl 24 or (span[position + 1].toInt() shl 16) or
+                    (span[position + 2].toInt() shl 8) or span[position + 3].toInt()).toUInt()
+            position += 4
+            val rdlength = (span[position].toInt() shl 8 or span[position + 1].toInt()).toUShort()
+            val rdposition = position + 2
+            position += 2 + rdlength.toInt()
+
+            return DnsResourceRecord(
+                name = name.first,
+                type = type.toInt(),
+                clazz = clazz.toInt() and 0b1111111_11111111,
+                timeToLive = ttl,
+                cacheFlush = ((clazz.toInt() shr 15) and 0b1) != 0,
+                dataPosition = rdposition,
+                dataLength = rdlength.toInt(),
+                data = data
+            ) to position
+        }
+    }
+
+    fun getDataReader(): DnsReader {
+        return DnsReader(data!!, dataPosition, dataLength)
+    }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt b/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5b2c1f5c0402a8f55d00e4e946f80bd1719abdb2
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/DnsWriter.kt
@@ -0,0 +1,208 @@
+package com.futo.platformplayer.mdns
+
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.charset.StandardCharsets
+
+class DnsWriter {
+    private val data = mutableListOf<Byte>()
+    private val namePositions = mutableMapOf<String, Int>()
+
+    fun toByteArray(): ByteArray = data.toByteArray()
+
+    fun writePacket(
+        header: DnsPacketHeader,
+        questionCount: Int? = null, questionWriter: ((DnsWriter, Int) -> Unit)? = null,
+        answerCount: Int? = null, answerWriter: ((DnsWriter, Int) -> Unit)? = null,
+        authorityCount: Int? = null, authorityWriter: ((DnsWriter, Int) -> Unit)? = null,
+        additionalsCount: Int? = null, additionalWriter: ((DnsWriter, Int) -> Unit)? = null
+    ) {
+        if (questionCount != null && questionWriter == null || questionCount == null && questionWriter != null)
+            throw Exception("When question count is given, question writer should also be given.")
+        if (answerCount != null && answerWriter == null || answerCount == null && answerWriter != null)
+            throw Exception("When answer count is given, answer writer should also be given.")
+        if (authorityCount != null && authorityWriter == null || authorityCount == null && authorityWriter != null)
+            throw Exception("When authority count is given, authority writer should also be given.")
+        if (additionalsCount != null && additionalWriter == null || additionalsCount == null && additionalWriter != null)
+            throw Exception("When additionals count is given, additional writer should also be given.")
+
+        writeHeader(header, questionCount ?: 0, answerCount ?: 0, authorityCount ?: 0, additionalsCount ?: 0)
+
+        repeat(questionCount ?: 0) { questionWriter?.invoke(this, it) }
+        repeat(answerCount ?: 0) { answerWriter?.invoke(this, it) }
+        repeat(authorityCount ?: 0) { authorityWriter?.invoke(this, it) }
+        repeat(additionalsCount ?: 0) { additionalWriter?.invoke(this, it) }
+    }
+
+    fun writeHeader(header: DnsPacketHeader, questionCount: Int, answerCount: Int, authorityCount: Int, additionalsCount: Int) {
+        write(header.identifier)
+
+        var flags: UShort = 0u
+        flags = flags or ((header.queryResponse.toUInt() and 0xFFFFu) shl 15).toUShort()
+        flags = flags or ((header.opcode.toUInt() and 0xFFFFu) shl 11).toUShort()
+        flags = flags or ((if (header.authoritativeAnswer) 1u else 0u) shl 10).toUShort()
+        flags = flags or ((if (header.truncated) 1u else 0u) shl 9).toUShort()
+        flags = flags or ((if (header.recursionDesired) 1u else 0u) shl 8).toUShort()
+        flags = flags or ((if (header.recursionAvailable) 1u else 0u) shl 7).toUShort()
+        flags = flags or ((if (header.answerAuthenticated) 1u else 0u) shl 5).toUShort()
+        flags = flags or ((if (header.nonAuthenticatedData) 1u else 0u) shl 4).toUShort()
+        flags = flags or header.responseCode.value.toUShort()
+        write(flags)
+
+        write(questionCount.toUShort())
+        write(answerCount.toUShort())
+        write(authorityCount.toUShort())
+        write(additionalsCount.toUShort())
+    }
+
+    fun writeDomainName(name: String) {
+        synchronized(namePositions) {
+            val labels = name.split('.')
+            for (label in labels) {
+                val nameAtOffset = name.substring(name.indexOf(label))
+                if (namePositions.containsKey(nameAtOffset)) {
+                    val position = namePositions[nameAtOffset]!!
+                    val pointer = (0b11000000_00000000 or position).toUShort()
+                    write(pointer)
+                    return
+                }
+                if (label.isNotEmpty()) {
+                    val labelBytes = label.toByteArray(StandardCharsets.UTF_8)
+                    val nameStartPos = data.size
+                    write(labelBytes.size.toByte())
+                    write(labelBytes)
+                    namePositions[nameAtOffset] = nameStartPos
+                }
+            }
+            write(0.toByte())  // End of domain name
+        }
+    }
+
+    fun write(value: DnsResourceRecord, dataWriter: (DnsWriter) -> Unit) {
+        writeDomainName(value.name)
+        write(value.type.toUShort())
+        val cls = ((if (value.cacheFlush) 1u else 0u) shl 15).toUShort() or value.clazz.toUShort()
+        write(cls)
+        write(value.timeToLive)
+
+        val lengthOffset = data.size
+        write(0.toUShort())
+        dataWriter(this)
+        val rdLength = data.size - lengthOffset - 2
+        val rdLengthBytes = ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(rdLength.toShort()).array()
+        data[lengthOffset] = rdLengthBytes[0]
+        data[lengthOffset + 1] = rdLengthBytes[1]
+    }
+
+    fun write(value: DnsQuestion) {
+        writeDomainName(value.name)
+        write(value.type.toUShort())
+        write(((if (value.queryUnicast) 1u else 0u shl 15).toUShort() or value.clazz.toUShort()))
+    }
+
+    fun write(value: Double) {
+        val bytes = ByteBuffer.allocate(Double.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putDouble(value).array()
+        write(bytes)
+    }
+
+    fun write(value: Short) {
+        val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value).array()
+        write(bytes)
+    }
+
+    fun write(value: Int) {
+        val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value).array()
+        write(bytes)
+    }
+
+    fun write(value: Long) {
+        val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value).array()
+        write(bytes)
+    }
+
+    fun write(value: Float) {
+        val bytes = ByteBuffer.allocate(Float.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putFloat(value).array()
+        write(bytes)
+    }
+
+    fun write(value: Byte) {
+        data.add(value)
+    }
+
+    fun write(value: ByteArray) {
+        data.addAll(value.asIterable())
+    }
+
+    fun write(value: ByteArray, offset: Int, length: Int) {
+        data.addAll(value.slice(offset until offset + length))
+    }
+
+    fun write(value: UShort) {
+        val bytes = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putShort(value.toShort()).array()
+        write(bytes)
+    }
+
+    fun write(value: UInt) {
+        val bytes = ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putInt(value.toInt()).array()
+        write(bytes)
+    }
+
+    fun write(value: ULong) {
+        val bytes = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value.toLong()).array()
+        write(bytes)
+    }
+
+    fun write(value: String) {
+        val bytes = value.toByteArray(StandardCharsets.UTF_8)
+        write(bytes.size.toByte())
+        write(bytes)
+    }
+
+    fun write(value: PTRRecord) {
+        writeDomainName(value.domainName)
+    }
+
+    fun write(value: ARecord) {
+        val bytes = value.address.address
+        if (bytes.size != 4) throw Exception("Unexpected amount of address bytes.")
+        write(bytes)
+    }
+
+    fun write(value: AAAARecord) {
+        val bytes = value.address.address
+        if (bytes.size != 16) throw Exception("Unexpected amount of address bytes.")
+        write(bytes)
+    }
+
+    fun write(value: TXTRecord) {
+        value.texts.forEach {
+            val bytes = it.toByteArray(StandardCharsets.UTF_8)
+            write(bytes.size.toByte())
+            write(bytes)
+        }
+    }
+
+    fun write(value: SRVRecord) {
+        write(value.priority)
+        write(value.weight)
+        write(value.port)
+        writeDomainName(value.target)
+    }
+
+    fun write(value: NSECRecord) {
+        writeDomainName(value.ownerName)
+        value.typeBitMaps.forEach { (windowBlock, bitmap) ->
+            write(windowBlock)
+            write(bitmap.size.toByte())
+            write(bitmap)
+        }
+    }
+
+    fun write(value: OPTRecord) {
+        value.options.forEach { option ->
+            write(option.code)
+            write(option.data.size.toUShort())
+            write(option.data)
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt b/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..48bb4c6a4b3787818d95b60cdae6b54230d59d68
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/Extensions.kt
@@ -0,0 +1,63 @@
+package com.futo.platformplayer.mdns
+
+import android.util.Log
+
+object Extensions {
+    fun ByteArray.toByteDump(): String {
+        val result = StringBuilder()
+        for (i in indices) {
+            result.append(String.format("%02X ", this[i]))
+
+            if ((i + 1) % 16 == 0 || i == size - 1) {
+                val padding = 3 * (16 - (i % 16 + 1))
+                if (i == size - 1 && (i + 1) % 16 != 0) result.append(" ".repeat(padding))
+
+                result.append("; ")
+                val start = i - (i % 16)
+                val end = minOf(i, size - 1)
+                for (j in start..end) {
+                    val ch = if (this[j] in 32..127) this[j].toChar() else '.'
+                    result.append(ch)
+                }
+                if (i != size - 1) result.appendLine()
+            }
+        }
+        return result.toString()
+    }
+
+    fun UByteArray.readDomainName(startPosition: Int): Pair<String, Int> {
+        var position = startPosition
+        return readDomainName(position, 0)
+    }
+
+    private fun UByteArray.readDomainName(position: Int, depth: Int = 0): Pair<String, Int> {
+        if (depth > 16) throw Exception("Exceeded maximum recursion depth in DNS packet. Possible circular reference.")
+
+        val domainParts = mutableListOf<String>()
+        var newPosition = position
+
+        while (true) {
+            if (newPosition < 0)
+                println()
+
+            val length = this[newPosition].toUByte()
+            if ((length and 0b11000000u).toUInt() == 0b11000000u) {
+                val offset = (((length and 0b00111111u).toUInt()) shl 8) or this[newPosition + 1].toUInt()
+                val (part, _) = this.readDomainName(offset.toInt(), depth + 1)
+                domainParts.add(part)
+                newPosition += 2
+                break
+            } else if (length.toUInt() == 0u) {
+                newPosition++
+                break
+            } else {
+                newPosition++
+                val part = String(this.asByteArray(), newPosition, length.toInt(), Charsets.UTF_8)
+                domainParts.add(part)
+                newPosition += length.toInt()
+            }
+        }
+
+        return domainParts.joinToString(".") to newPosition
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt
new file mode 100644
index 0000000000000000000000000000000000000000..494c4934fe6f6bc173c6a34a17116e901f8280a9
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/MDNSListener.kt
@@ -0,0 +1,482 @@
+package com.futo.platformplayer.mdns
+
+import com.futo.platformplayer.logging.Logger
+import kotlinx.coroutines.*
+import java.net.*
+import java.util.*
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.concurrent.withLock
+
+class MDNSListener {
+    companion object {
+        private val TAG = "MDNSListener"
+        const val MulticastPort = 5353
+        val MulticastAddressIPv4: InetAddress = InetAddress.getByName("224.0.0.251")
+        val MulticastAddressIPv6: InetAddress = InetAddress.getByName("FF02::FB")
+        val MdnsEndpointIPv6: InetSocketAddress = InetSocketAddress(MulticastAddressIPv6, MulticastPort)
+        val MdnsEndpointIPv4: InetSocketAddress = InetSocketAddress(MulticastAddressIPv4, MulticastPort)
+    }
+
+    private val _lockObject = ReentrantLock()
+    private var _receiver4: DatagramSocket? = null
+    private var _receiver6: DatagramSocket? = null
+    private val _senders = mutableListOf<DatagramSocket>()
+    private val _nicMonitor = NICMonitor()
+    private val _serviceRecordAggregator = ServiceRecordAggregator()
+    private var _started = false
+    private var _threadReceiver4: Thread? = null
+    private var _threadReceiver6: Thread? = null
+    private var _scope: CoroutineScope? = null
+
+    var onPacket: ((DnsPacket) -> Unit)? = null
+    var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
+
+    private val _recordLockObject = ReentrantLock()
+    private val _recordsA = mutableListOf<Pair<DnsResourceRecord, ARecord>>()
+    private val _recordsAAAA = mutableListOf<Pair<DnsResourceRecord, AAAARecord>>()
+    private val _recordsPTR = mutableListOf<Pair<DnsResourceRecord, PTRRecord>>()
+    private val _recordsTXT = mutableListOf<Pair<DnsResourceRecord, TXTRecord>>()
+    private val _recordsSRV = mutableListOf<Pair<DnsResourceRecord, SRVRecord>>()
+    private val _services = mutableListOf<BroadcastService>()
+
+    init {
+        _nicMonitor.added = { onNicsAdded(it) }
+        _nicMonitor.removed = { onNicsRemoved(it) }
+        _serviceRecordAggregator.onServicesUpdated = { onServicesUpdated?.invoke(it) }
+    }
+
+    fun start() {
+        if (_started) throw Exception("Already running.")
+        _started = true
+
+        _scope = CoroutineScope(Dispatchers.IO);
+
+        Logger.i(TAG, "Starting")
+        _lockObject.withLock {
+            val receiver4 = DatagramSocket(null).apply {
+                reuseAddress = true
+                bind(InetSocketAddress(InetAddress.getByName("0.0.0.0"), MulticastPort))
+            }
+            _receiver4 = receiver4
+
+            val receiver6 = DatagramSocket(null).apply {
+                reuseAddress = true
+                bind(InetSocketAddress(InetAddress.getByName("::"), MulticastPort))
+            }
+            _receiver6 = receiver6
+
+            _nicMonitor.start()
+            _serviceRecordAggregator.start()
+            onNicsAdded(_nicMonitor.current)
+
+            _threadReceiver4 = Thread {
+                receiveLoop(receiver4)
+            }.apply { start() }
+
+            _threadReceiver6 = Thread {
+                receiveLoop(receiver6)
+            }.apply { start() }
+        }
+    }
+
+    fun queryServices(names: Array<String>) {
+        if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
+
+        val writer = DnsWriter()
+        writer.writePacket(
+            DnsPacketHeader(
+                identifier = 0u,
+                queryResponse = QueryResponse.Query.value.toInt(),
+                opcode = DnsOpcode.StandardQuery.value.toInt(),
+                truncated = false,
+                nonAuthenticatedData = false,
+                recursionDesired = false,
+                answerAuthenticated = false,
+                authoritativeAnswer = false,
+                recursionAvailable = false,
+                responseCode = DnsResponseCode.NoError
+            ),
+            questionCount = names.size,
+            questionWriter = { w, i ->
+                w.write(
+                    DnsQuestion(
+                        name = names[i],
+                        type = QuestionType.PTR.value.toInt(),
+                        clazz = QuestionClass.IN.value.toInt(),
+                        queryUnicast = false
+                    )
+                )
+            }
+        )
+
+        send(writer.toByteArray())
+    }
+
+    private fun send(data: ByteArray) {
+        _lockObject.withLock {
+            for (sender in _senders) {
+                try {
+                    val endPoint = if (sender.localAddress is Inet4Address) MdnsEndpointIPv4 else MdnsEndpointIPv6
+                    sender.send(DatagramPacket(data, data.size, endPoint))
+                } catch (e: Exception) {
+                    Logger.i(TAG, "Failed to send on ${sender.localSocketAddress}: ${e.message}.")
+                }
+            }
+        }
+    }
+
+    fun queryAllQuestions(names: Array<String>) {
+        if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
+
+        val questions = names.flatMap { _serviceRecordAggregator.getAllQuestions(it) }
+        questions.groupBy { it.name }.forEach { (_, questionsForHost) ->
+            val writer = DnsWriter()
+            writer.writePacket(
+                DnsPacketHeader(
+                    identifier = 0u,
+                    queryResponse = QueryResponse.Query.value.toInt(),
+                    opcode = DnsOpcode.StandardQuery.value.toInt(),
+                    truncated = false,
+                    nonAuthenticatedData = false,
+                    recursionDesired = false,
+                    answerAuthenticated = false,
+                    authoritativeAnswer = false,
+                    recursionAvailable = false,
+                    responseCode = DnsResponseCode.NoError
+                ),
+                questionCount = questionsForHost.size,
+                questionWriter = { w, i -> w.write(questionsForHost[i]) }
+            )
+            send(writer.toByteArray())
+        }
+    }
+
+    private fun onNicsAdded(nics: List<NetworkInterface>) {
+        _lockObject.withLock {
+            if (!_started) return
+
+            val addresses = nics.flatMap { nic ->
+                nic.interfaceAddresses.map { it.address }
+                    .filter { it is Inet4Address || (it is Inet6Address && it.isLinkLocalAddress) }
+            }
+
+            addresses.forEach { address ->
+                Logger.i(TAG, "New address discovered $address")
+
+                try {
+                    when (address) {
+                        is Inet4Address -> {
+                            val sender = MulticastSocket(null).apply {
+                                reuseAddress = true
+                                bind(InetSocketAddress(address, MulticastPort))
+                                joinGroup(InetSocketAddress(MulticastAddressIPv4, MulticastPort), NetworkInterface.getByInetAddress(address))
+                            }
+                            _senders.add(sender)
+                        }
+
+                        is Inet6Address -> {
+                            val sender = MulticastSocket(null).apply {
+                                reuseAddress = true
+                                bind(InetSocketAddress(address, MulticastPort))
+                                joinGroup(InetSocketAddress(MulticastAddressIPv6, MulticastPort), NetworkInterface.getByInetAddress(address))
+                            }
+                            _senders.add(sender)
+                        }
+
+                        else -> throw UnsupportedOperationException("Address type ${address.javaClass.name} is not supported.")
+                    }
+                } catch (e: Exception) {
+                    Logger.i(TAG, "Exception occurred when processing added address $address: ${e.message}.")
+                    // Close the socket if there was an error
+                    (_senders.lastOrNull() as? MulticastSocket)?.close()
+                }
+            }
+        }
+
+        if (nics.isNotEmpty()) {
+            try {
+                updateBroadcastRecords()
+                broadcastRecords()
+            } catch (e: Exception) {
+                Logger.i(TAG, "Exception occurred when broadcasting records: ${e.message}.")
+            }
+        }
+    }
+
+    private fun onNicsRemoved(nics: List<NetworkInterface>) {
+        _lockObject.withLock {
+            if (!_started) return
+            //TODO: Cleanup?
+        }
+
+        if (nics.isNotEmpty()) {
+            try {
+                updateBroadcastRecords()
+                broadcastRecords()
+            } catch (e: Exception) {
+                Logger.e(TAG, "Exception occurred when broadcasting records", e)
+            }
+        }
+    }
+
+    private fun receiveLoop(client: DatagramSocket) {
+        Logger.i(TAG, "Started receive loop")
+
+        val buffer = ByteArray(1024)
+        val packet = DatagramPacket(buffer, buffer.size)
+        while (_started) {
+            try {
+                client.receive(packet)
+                handleResult(packet)
+            } catch (e: Exception) {
+                Logger.e(TAG, "An exception occurred while handling UDP result:", e)
+            }
+        }
+
+        Logger.i(TAG, "Stopped receive loop")
+    }
+
+    fun broadcastService(
+        deviceName: String,
+        serviceName: String,
+        port: UShort,
+        ttl: UInt = 120u,
+        weight: UShort = 0u,
+        priority: UShort = 0u,
+        texts: List<String>? = null
+    ) {
+        _recordLockObject.withLock {
+            _services.add(
+                BroadcastService(
+                    deviceName = deviceName,
+                    port = port,
+                    priority = priority,
+                    serviceName = serviceName,
+                    texts = texts,
+                    ttl = ttl,
+                    weight = weight
+                )
+            )
+        }
+
+        updateBroadcastRecords()
+        broadcastRecords()
+    }
+
+    private fun updateBroadcastRecords() {
+        _recordLockObject.withLock {
+            _recordsSRV.clear()
+            _recordsPTR.clear()
+            _recordsA.clear()
+            _recordsAAAA.clear()
+            _recordsTXT.clear()
+
+            _services.forEach { service ->
+                val id = UUID.randomUUID().toString()
+                val deviceDomainName = "${service.deviceName}.${service.serviceName}"
+                val addressName = "$id.local"
+
+                _recordsSRV.add(
+                    DnsResourceRecord(
+                        clazz = ResourceRecordClass.IN.value.toInt(),
+                        type = ResourceRecordType.SRV.value.toInt(),
+                        timeToLive = service.ttl,
+                        name = deviceDomainName,
+                        cacheFlush = false
+                    ) to SRVRecord(
+                        target = addressName,
+                        port = service.port,
+                        priority = service.priority,
+                        weight = service.weight
+                    )
+                )
+
+                _recordsPTR.add(
+                    DnsResourceRecord(
+                        clazz = ResourceRecordClass.IN.value.toInt(),
+                        type = ResourceRecordType.PTR.value.toInt(),
+                        timeToLive = service.ttl,
+                        name = service.serviceName,
+                        cacheFlush = false
+                    ) to PTRRecord(
+                        domainName = deviceDomainName
+                    )
+                )
+
+                val addresses = _nicMonitor.current.flatMap { nic ->
+                    nic.interfaceAddresses.map { it.address }
+                }
+
+                addresses.forEach { address ->
+                    when (address) {
+                        is Inet4Address -> _recordsA.add(
+                            DnsResourceRecord(
+                                clazz = ResourceRecordClass.IN.value.toInt(),
+                                type = ResourceRecordType.A.value.toInt(),
+                                timeToLive = service.ttl,
+                                name = addressName,
+                                cacheFlush = false
+                            ) to ARecord(
+                                address = address
+                            )
+                        )
+
+                        is Inet6Address -> _recordsAAAA.add(
+                            DnsResourceRecord(
+                                clazz = ResourceRecordClass.IN.value.toInt(),
+                                type = ResourceRecordType.AAAA.value.toInt(),
+                                timeToLive = service.ttl,
+                                name = addressName,
+                                cacheFlush = false
+                            ) to AAAARecord(
+                                address = address
+                            )
+                        )
+
+                        else -> Logger.i(TAG, "Invalid address type: $address.")
+                    }
+                }
+
+                if (service.texts != null) {
+                    _recordsTXT.add(
+                        DnsResourceRecord(
+                            clazz = ResourceRecordClass.IN.value.toInt(),
+                            type = ResourceRecordType.TXT.value.toInt(),
+                            timeToLive = service.ttl,
+                            name = deviceDomainName,
+                            cacheFlush = false
+                        ) to TXTRecord(
+                            texts = service.texts
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    private fun broadcastRecords(questions: List<DnsQuestion>? = null) {
+        val writer = DnsWriter()
+        _recordLockObject.withLock {
+            val recordsA: List<Pair<DnsResourceRecord, ARecord>>
+            val recordsAAAA: List<Pair<DnsResourceRecord, AAAARecord>>
+            val recordsPTR: List<Pair<DnsResourceRecord, PTRRecord>>
+            val recordsTXT: List<Pair<DnsResourceRecord, TXTRecord>>
+            val recordsSRV: List<Pair<DnsResourceRecord, SRVRecord>>
+
+            if (questions != null) {
+                recordsA = _recordsA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
+                recordsAAAA = _recordsAAAA.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
+                recordsPTR = _recordsPTR.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
+                recordsSRV = _recordsSRV.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
+                recordsTXT = _recordsTXT.filter { r -> questions.any { q -> q.name == r.first.name && q.clazz == r.first.clazz && q.type == r.first.type } }
+            } else {
+                recordsA = _recordsA
+                recordsAAAA = _recordsAAAA
+                recordsPTR = _recordsPTR
+                recordsSRV = _recordsSRV
+                recordsTXT = _recordsTXT
+            }
+
+            val answerCount = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size + recordsTXT.size
+            if (answerCount < 1) return
+
+            val txtOffset = recordsA.size + recordsAAAA.size + recordsPTR.size + recordsSRV.size
+            val srvOffset = recordsA.size + recordsAAAA.size + recordsPTR.size
+            val ptrOffset = recordsA.size + recordsAAAA.size
+            val aaaaOffset = recordsA.size
+
+            writer.writePacket(
+                DnsPacketHeader(
+                    identifier = 0u,
+                    queryResponse = QueryResponse.Response.value.toInt(),
+                    opcode = DnsOpcode.StandardQuery.value.toInt(),
+                    truncated = false,
+                    nonAuthenticatedData = false,
+                    recursionDesired = false,
+                    answerAuthenticated = false,
+                    authoritativeAnswer = true,
+                    recursionAvailable = false,
+                    responseCode = DnsResponseCode.NoError
+                ),
+                answerCount = answerCount,
+                answerWriter = { w, i ->
+                    when {
+                        i >= txtOffset -> {
+                            val record = recordsTXT[i - txtOffset]
+                            w.write(record.first) { it.write(record.second) }
+                        }
+
+                        i >= srvOffset -> {
+                            val record = recordsSRV[i - srvOffset]
+                            w.write(record.first) { it.write(record.second) }
+                        }
+
+                        i >= ptrOffset -> {
+                            val record = recordsPTR[i - ptrOffset]
+                            w.write(record.first) { it.write(record.second) }
+                        }
+
+                        i >= aaaaOffset -> {
+                            val record = recordsAAAA[i - aaaaOffset]
+                            w.write(record.first) { it.write(record.second) }
+                        }
+
+                        else -> {
+                            val record = recordsA[i]
+                            w.write(record.first) { it.write(record.second) }
+                        }
+                    }
+                }
+            )
+        }
+
+        send(writer.toByteArray())
+    }
+
+    private fun handleResult(result: DatagramPacket) {
+        try {
+            val packet = DnsPacket.parse(result.data)
+            if (packet.questions.isNotEmpty()) {
+                _scope?.launch(Dispatchers.IO) {
+                    try {
+                        broadcastRecords(packet.questions)
+                    } catch (e: Throwable) {
+                        Logger.i(TAG, "Broadcasting records failed", e)
+                    }
+                }
+
+            }
+            _serviceRecordAggregator.add(packet)
+            onPacket?.invoke(packet)
+        } catch (e: Exception) {
+            Logger.v(TAG, "Failed to handle packet: ${Base64.getEncoder().encodeToString(result.data.slice(IntRange(0, result.length - 1)).toByteArray())}", e)
+        }
+    }
+
+    fun stop() {
+        _lockObject.withLock {
+            _started = false
+
+            _scope?.cancel()
+            _scope = null
+
+            _nicMonitor.stop()
+            _serviceRecordAggregator.stop()
+
+            _receiver4?.close()
+            _receiver4 = null
+
+            _receiver6?.close()
+            _receiver6 = null
+
+            _senders.forEach { it.close() }
+            _senders.clear()
+        }
+
+        _threadReceiver4?.join()
+        _threadReceiver4 = null
+
+        _threadReceiver6?.join()
+        _threadReceiver6 = null
+    }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt b/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..884e15143094015137f3d40fc759d7c44fb87ad7
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/NICMonitor.kt
@@ -0,0 +1,66 @@
+package com.futo.platformplayer.mdns
+
+import kotlinx.coroutines.*
+import java.net.NetworkInterface
+
+class NICMonitor {
+    private val lockObject = Any()
+    private val nics = mutableListOf<NetworkInterface>()
+    private var cts: Job? = null
+
+    val current: List<NetworkInterface>
+        get() = synchronized(nics) { nics.toList() }
+
+    var added: ((List<NetworkInterface>) -> Unit)? = null
+    var removed: ((List<NetworkInterface>) -> Unit)? = null
+
+    fun start() {
+        synchronized(lockObject) {
+            if (cts != null) throw Exception("Already started.")
+
+            cts = CoroutineScope(Dispatchers.Default).launch {
+                loopAsync()
+            }
+        }
+
+        nics.clear()
+        nics.addAll(getCurrentInterfaces().toList())
+    }
+
+    fun stop() {
+        synchronized(lockObject) {
+            cts?.cancel()
+            cts = null
+        }
+
+        synchronized(nics) {
+            nics.clear()
+        }
+    }
+
+    private suspend fun loopAsync() {
+        while (cts?.isActive == true) {
+            try {
+                val currentNics = getCurrentInterfaces().toList()
+                removed?.invoke(nics.filter { k -> currentNics.none { n -> k.name == n.name } })
+                added?.invoke(currentNics.filter { nic -> nics.none { k -> k.name == nic.name } })
+
+                synchronized(nics) {
+                    nics.clear()
+                    nics.addAll(currentNics)
+                }
+            } catch (ex: Exception) {
+                // Ignored
+            }
+            delay(5000)
+        }
+    }
+
+    private fun getCurrentInterfaces(): List<NetworkInterface> {
+        val nics = NetworkInterface.getNetworkInterfaces().toList()
+            .filter { it.isUp && !it.isLoopback }
+
+        return if (nics.isNotEmpty()) nics else NetworkInterface.getNetworkInterfaces().toList()
+            .filter { it.isUp }
+    }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..79d29736ec83132c94ffb6ec1aa16f5b4a0fe058
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/ServiceDiscoverer.kt
@@ -0,0 +1,68 @@
+package com.futo.platformplayer.mdns
+
+import com.futo.platformplayer.logging.Logger
+import java.lang.Thread.sleep
+
+class ServiceDiscoverer(names: Array<String>, private val _onServicesUpdated: (List<DnsService>) -> Unit) {
+    private val _names: Array<String>
+    private var _listener: MDNSListener? = null
+    private var _started = false
+    private var _thread: Thread? = null
+
+    init {
+        if (names.isEmpty()) throw IllegalArgumentException("At least one name must be specified.")
+        _names = names
+    }
+
+    fun broadcastService(
+        deviceName: String,
+        serviceName: String,
+        port: UShort,
+        ttl: UInt = 120u,
+        weight: UShort = 0u,
+        priority: UShort = 0u,
+        texts: List<String>? = null
+    ) {
+        _listener?.let {
+            it.broadcastService(deviceName, serviceName, port, ttl, weight, priority, texts)
+        }
+    }
+
+    fun stop() {
+        _started = false
+        _listener?.stop()
+        _listener = null
+        _thread?.join()
+        _thread = null
+    }
+
+    fun start() {
+        if (_started) throw Exception("Already running.")
+        _started = true
+
+        val listener = MDNSListener()
+        _listener = listener
+        listener.onServicesUpdated = { _onServicesUpdated?.invoke(it) }
+        listener.start()
+
+        _thread = Thread {
+            try {
+                sleep(2000)
+
+                while (_started) {
+                    listener.queryServices(_names)
+                    sleep(2000)
+                    listener.queryAllQuestions(_names)
+                    sleep(2000)
+                }
+            } catch (e: Throwable) {
+                Logger.i(TAG, "Exception in loop thread", e)
+                stop()
+            }
+        }.apply { start() }
+    }
+
+    companion object {
+        private val TAG = "ServiceDiscoverer"
+    }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt b/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5ff21a2c2908126b8f391b806e31cb9520e46329
--- /dev/null
+++ b/app/src/main/java/com/futo/platformplayer/mdns/ServiceRecordAggregator.kt
@@ -0,0 +1,219 @@
+package com.futo.platformplayer.mdns
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.net.InetAddress
+import java.util.Date
+
+data class DnsService(
+    var name: String,
+    var target: String,
+    var port: UShort,
+    val addresses: MutableList<InetAddress> = mutableListOf(),
+    val pointers: MutableList<String> = mutableListOf(),
+    val texts: MutableList<String> = mutableListOf()
+)
+
+data class CachedDnsAddressRecord(
+    val expirationTime: Date,
+    val address: InetAddress
+)
+
+data class CachedDnsTxtRecord(
+    val expirationTime: Date,
+    val texts: List<String>
+)
+
+data class CachedDnsPtrRecord(
+    val expirationTime: Date,
+    val target: String
+)
+
+data class CachedDnsSrvRecord(
+    val expirationTime: Date,
+    val service: SRVRecord
+)
+
+class ServiceRecordAggregator {
+    private val _lockObject = Any()
+    private val _cachedAddressRecords = mutableMapOf<String, MutableList<CachedDnsAddressRecord>>()
+    private val _cachedTxtRecords = mutableMapOf<String, CachedDnsTxtRecord>()
+    private val _cachedPtrRecords = mutableMapOf<String, MutableList<CachedDnsPtrRecord>>()
+    private val _cachedSrvRecords = mutableMapOf<String, CachedDnsSrvRecord>()
+    private val _currentServices = mutableListOf<DnsService>()
+    private var _cts: Job? = null
+
+    var onServicesUpdated: ((List<DnsService>) -> Unit)? = null
+
+    fun start() {
+        synchronized(_lockObject) {
+            if (_cts != null) throw Exception("Already started.")
+
+            _cts = CoroutineScope(Dispatchers.Default).launch {
+                while (isActive) {
+                    val now = Date()
+                    synchronized(_currentServices) {
+                        _cachedAddressRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
+                        _cachedTxtRecords.entries.removeIf { now.after(it.value.expirationTime) }
+                        _cachedSrvRecords.entries.removeIf { now.after(it.value.expirationTime) }
+                        _cachedPtrRecords.forEach { it.value.removeAll { record -> now.after(record.expirationTime) } }
+
+                        val newServices = getCurrentServices()
+                        _currentServices.clear()
+                        _currentServices.addAll(newServices)
+                    }
+
+                    onServicesUpdated?.invoke(_currentServices)
+                    delay(5000)
+                }
+            }
+        }
+    }
+
+    fun stop() {
+        synchronized(_lockObject) {
+            _cts?.cancel()
+            _cts = null
+        }
+    }
+
+    fun add(packet: DnsPacket) {
+        val dnsResourceRecords = packet.answers + packet.additionals + packet.authorities
+        val txtRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.TXT.value.toInt() }.map { it to it.getDataReader().readTXTRecord() }
+        val aRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.A.value.toInt() }.map { it to it.getDataReader().readARecord() }
+        val aaaaRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.AAAA.value.toInt() }.map { it to it.getDataReader().readAAAARecord() }
+        val srvRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.SRV.value.toInt() }.map { it to it.getDataReader().readSRVRecord() }
+        val ptrRecords = dnsResourceRecords.filter { it.type == ResourceRecordType.PTR.value.toInt() }.map { it to it.getDataReader().readPTRRecord() }
+
+        /*val builder = StringBuilder()
+        builder.appendLine("Received records:")
+        srvRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: (Port: ${it.second.port}, Target: ${it.second.target}, Priority: ${it.second.priority}, Weight: ${it.second.weight})") }
+        ptrRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.domainName}") }
+        txtRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.texts.joinToString(", ")}") }
+        aRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
+        aaaaRecords.forEach { builder.appendLine(" ${it.first.name} ${it.first.type} ${it.first.clazz} TTL ${it.first.timeToLive}: ${it.second.address}") }
+        synchronized(lockObject) {
+            // Save to file if necessary
+        }*/
+
+        val currentServices: MutableList<DnsService>
+        synchronized(this._currentServices) {
+            ptrRecords.forEach { record ->
+                val cachedPtrRecord = _cachedPtrRecords.getOrPut(record.first.name) { mutableListOf() }
+                val newPtrRecord = CachedDnsPtrRecord(Date(System.currentTimeMillis() + record.first.timeToLive.toLong() * 1000L), record.second.domainName)
+                cachedPtrRecord.replaceOrAdd(newPtrRecord) { it.target == record.second.domainName }
+
+                aRecords.forEach { aRecord ->
+                    val cachedARecord = _cachedAddressRecords.getOrPut(aRecord.first.name) { mutableListOf() }
+                    val newARecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aRecord.first.timeToLive.toLong() * 1000L), aRecord.second.address)
+                    cachedARecord.replaceOrAdd(newARecord) { it.address == newARecord.address }
+                }
+
+                aaaaRecords.forEach { aaaaRecord ->
+                    val cachedAaaaRecord = _cachedAddressRecords.getOrPut(aaaaRecord.first.name) { mutableListOf() }
+                    val newAaaaRecord = CachedDnsAddressRecord(Date(System.currentTimeMillis() + aaaaRecord.first.timeToLive.toLong() * 1000L), aaaaRecord.second.address)
+                    cachedAaaaRecord.replaceOrAdd(newAaaaRecord) { it.address == newAaaaRecord.address }
+                }
+            }
+
+            txtRecords.forEach { txtRecord ->
+                _cachedTxtRecords[txtRecord.first.name] = CachedDnsTxtRecord(Date(System.currentTimeMillis() + txtRecord.first.timeToLive.toLong() * 1000L), txtRecord.second.texts)
+            }
+
+            srvRecords.forEach { srvRecord ->
+                _cachedSrvRecords[srvRecord.first.name] = CachedDnsSrvRecord(Date(System.currentTimeMillis() + srvRecord.first.timeToLive.toLong() * 1000L), srvRecord.second)
+            }
+
+            currentServices = getCurrentServices()
+            this._currentServices.clear()
+            this._currentServices.addAll(currentServices)
+        }
+
+        onServicesUpdated?.invoke(currentServices)
+    }
+
+    fun getAllQuestions(serviceName: String): List<DnsQuestion> {
+        val questions = mutableListOf<DnsQuestion>()
+        synchronized(_currentServices) {
+            val servicePtrRecords = _cachedPtrRecords[serviceName] ?: return emptyList()
+
+            val ptrWithoutSrvRecord = servicePtrRecords.filterNot { _cachedSrvRecords.containsKey(it.target) }.map { it.target }
+            questions.addAll(ptrWithoutSrvRecord.flatMap { s ->
+                listOf(
+                    DnsQuestion(
+                        name = s,
+                        type = QuestionType.SRV.value.toInt(),
+                        clazz = QuestionClass.IN.value.toInt(),
+                        queryUnicast = false
+                    )
+                )
+            })
+
+            val incompleteCurrentServices = _currentServices.filter { it.addresses.isEmpty() && it.name.endsWith(serviceName) }
+            questions.addAll(incompleteCurrentServices.flatMap { s ->
+                listOf(
+                    DnsQuestion(
+                        name = s.name,
+                        type = QuestionType.TXT.value.toInt(),
+                        clazz = QuestionClass.IN.value.toInt(),
+                        queryUnicast = false
+                    ),
+                    DnsQuestion(
+                        name = s.target,
+                        type = QuestionType.A.value.toInt(),
+                        clazz = QuestionClass.IN.value.toInt(),
+                        queryUnicast = false
+                    ),
+                    DnsQuestion(
+                        name = s.target,
+                        type = QuestionType.AAAA.value.toInt(),
+                        clazz = QuestionClass.IN.value.toInt(),
+                        queryUnicast = false
+                    )
+                )
+            })
+        }
+        return questions
+    }
+
+    private fun getCurrentServices(): MutableList<DnsService> {
+        val currentServices = _cachedSrvRecords.map { (key, value) ->
+            DnsService(
+                name = key,
+                target = value.service.target,
+                port = value.service.port
+            )
+        }.toMutableList()
+
+        currentServices.forEach { service ->
+            _cachedAddressRecords[service.target]?.let {
+                service.addresses.addAll(it.map { record -> record.address })
+            }
+        }
+
+        currentServices.forEach { service ->
+            service.pointers.addAll(_cachedPtrRecords.filter { it.value.any { ptr -> ptr.target == service.name } }.map { it.key })
+        }
+
+        currentServices.forEach { service ->
+            _cachedTxtRecords[service.name]?.let {
+                service.texts.addAll(it.texts)
+            }
+        }
+
+        return currentServices
+    }
+
+    private inline fun <T> MutableList<T>.replaceOrAdd(newElement: T, predicate: (T) -> Boolean) {
+        val index = indexOfFirst(predicate)
+        if (index >= 0) {
+            this[index] = newElement
+        } else {
+            add(newElement)
+        }
+    }
+}
diff --git a/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt b/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt
index c261b7552afbccd351fc04e9841cd8a08525e751..9750f3827930864dfb4462c47c9e233010a84cb0 100644
--- a/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/FeedStyle.kt
@@ -16,7 +16,7 @@ enum class FeedStyle(val value: Int) {
 
         fun fromInt(value: Int): FeedStyle
         {
-            val result = FeedStyle.values().firstOrNull { it.value == value };
+            val result = FeedStyle.entries.firstOrNull { it.value == value };
             if(result == null)
                 throw UnknownPlatformException(value.toString());
             return result;
diff --git a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt
index 373132c403440bebc0cf9a5c5f6a691b13cb198e..8e5da9903e67a204e01638491e12e36424246557 100644
--- a/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/overlays/slideup/SlideUpMenuItem.kt
@@ -6,14 +6,17 @@ import android.view.LayoutInflater
 import android.widget.ImageView
 import android.widget.RelativeLayout
 import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.core.view.isVisible
 import com.futo.platformplayer.R
 
-class SlideUpMenuItem : RelativeLayout {
+class SlideUpMenuItem : ConstraintLayout {
 
-    private lateinit var _root: RelativeLayout;
+    private lateinit var _root: ConstraintLayout;
     private lateinit var _image: ImageView;
     private lateinit var _text: TextView;
     private lateinit var _subtext: TextView;
+    private lateinit var _description: TextView;
 
     var selectedOption: Boolean = false;
 
@@ -25,11 +28,27 @@ class SlideUpMenuItem : RelativeLayout {
         init();
     }
 
-    constructor(context: Context, imageRes: Int = 0, mainText: String, subText: String = "", tag: Any?, call: (()->Unit)? = null, invokeParent: Boolean = true): super(context){
+    constructor(
+        context: Context,
+        imageRes: Int = 0,
+        mainText: String,
+        subText: String = "",
+        description: String? = "",
+        tag: Any?,
+        call: (() -> Unit)? = null,
+        invokeParent: Boolean = true
+    ): super(context){
         init();
         _image.setImageResource(imageRes);
         _text.text = mainText;
         _subtext.text = subText;
+
+        if(description.isNullOrEmpty())
+            _description.isVisible = false;
+        else {
+            _description.text = description;
+            _description.isVisible = true;
+        }
         this.itemTag = tag;
 
         if (call != null) {
@@ -48,6 +67,7 @@ class SlideUpMenuItem : RelativeLayout {
         _image = findViewById(R.id.slide_up_menu_item_image);
         _text = findViewById(R.id.slide_up_menu_item_text);
         _subtext = findViewById(R.id.slide_up_menu_item_subtext);
+        _description = findViewById(R.id.slide_up_menu_item_description);
 
         setOptionSelected(false);
     }
diff --git a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt
index 21aca2a76c55eb07a31980ff624b100db2aa4bf0..82ad79440fc2b025eee255027bdaa517b34bc4e2 100644
--- a/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt
+++ b/app/src/main/java/com/futo/platformplayer/views/video/FutoVideoPlayerBase.kt
@@ -3,9 +3,14 @@ package com.futo.platformplayer.views.video
 import android.content.Context
 import android.net.Uri
 import android.util.AttributeSet
+import android.util.Xml
 import android.widget.RelativeLayout
 import androidx.annotation.OptIn
+import androidx.fragment.app.findFragment
+import androidx.lifecycle.coroutineScope
+import androidx.lifecycle.findViewTreeLifecycleOwner
 import androidx.media3.common.C
+import androidx.media3.common.C.Encoding
 import androidx.media3.common.MediaItem
 import androidx.media3.common.PlaybackException
 import androidx.media3.common.Player
@@ -17,6 +22,8 @@ import androidx.media3.datasource.DefaultHttpDataSource
 import androidx.media3.datasource.HttpDataSource
 import androidx.media3.exoplayer.ExoPlayer
 import androidx.media3.exoplayer.dash.DashMediaSource
+import androidx.media3.exoplayer.dash.manifest.DashManifest
+import androidx.media3.exoplayer.dash.manifest.DashManifestParser
 import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider
 import androidx.media3.exoplayer.hls.HlsMediaSource
 import androidx.media3.exoplayer.source.MediaSource
@@ -42,6 +49,9 @@ import com.futo.platformplayer.api.media.models.streams.sources.VideoUrlSource
 import com.futo.platformplayer.api.media.models.subtitles.ISubtitleSource
 import com.futo.platformplayer.api.media.models.video.IPlatformVideoDetails
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSAudioUrlRangeSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestMergingRawSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawAudioSource
+import com.futo.platformplayer.api.media.platforms.js.models.sources.JSDashManifestRawSource
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSHLSManifestAudioSource
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSSource
 import com.futo.platformplayer.api.media.platforms.js.models.sources.JSVideoUrlRangeSource
@@ -52,12 +62,14 @@ import com.futo.platformplayer.helpers.VideoHelper
 import com.futo.platformplayer.logging.Logger
 import com.futo.platformplayer.states.StateApp
 import com.futo.platformplayer.video.PlayerManager
+import com.futo.platformplayer.views.video.datasources.JSHttpDataSource
 import com.google.gson.Gson
 import getHttpDataSourceFactory
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
+import java.io.ByteArrayInputStream
 import java.io.File
 import kotlin.math.abs
 
@@ -319,18 +331,37 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
         swapSources(videoSource, audioSource,false, play, keepSubtitles);
     }
     fun swapSources(videoSource: IVideoSource?, audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true, keepSubtitles: Boolean = false): Boolean {
-        swapSourceInternal(videoSource);
-        swapSourceInternal(audioSource);
+        var videoSourceUsed = videoSource;
+        var audioSourceUsed = audioSource;
+        if(videoSource is JSDashManifestRawSource && audioSource is JSDashManifestRawAudioSource){
+            videoSourceUsed = JSDashManifestMergingRawSource(videoSource, audioSource);
+            audioSourceUsed = null;
+        }
+
+        val didSetVideo = swapSourceInternal(videoSourceUsed, play, resume);
+        val didSetAudio = swapSourceInternal(audioSourceUsed, play, resume);
         if(!keepSubtitles)
             _lastSubtitleMediaSource = null;
-        return loadSelectedSources(play, resume);
+        if(didSetVideo && didSetAudio)
+            return loadSelectedSources(play, resume);
+        else
+            return true;
     }
     fun swapSource(videoSource: IVideoSource?, resume: Boolean = true, play: Boolean = true): Boolean {
-        swapSourceInternal(videoSource);
-        return loadSelectedSources(play, resume);
+        var videoSourceUsed = videoSource;
+        if(videoSource is JSDashManifestRawSource && lastVideoSource is JSDashManifestMergingRawSource)
+            videoSourceUsed = JSDashManifestMergingRawSource(videoSource, (lastVideoSource as JSDashManifestMergingRawSource).audio);
+        val didSet = swapSourceInternal(videoSourceUsed, play, resume);
+        if(didSet)
+            return loadSelectedSources(play, resume);
+        else
+            return true;
     }
     fun swapSource(audioSource: IAudioSource?, resume: Boolean = true, play: Boolean = true): Boolean {
-        swapSourceInternal(audioSource);
+        if(audioSource is JSDashManifestRawAudioSource && lastVideoSource is JSDashManifestMergingRawSource)
+            swapSourceInternal(JSDashManifestMergingRawSource((lastVideoSource as JSDashManifestMergingRawSource).video, audioSource), play, resume);
+        else
+            swapSourceInternal(audioSource, play, resume);
         return loadSelectedSources(play, resume);
     }
 
@@ -381,30 +412,34 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
     }
 
 
-    private fun swapSourceInternal(videoSource: IVideoSource?) {
+    private fun swapSourceInternal(videoSource: IVideoSource?, play: Boolean, resume: Boolean): Boolean {
         _lastGeneratedDash = null;
-        when(videoSource) {
-            is LocalVideoSource -> swapVideoSourceLocal(videoSource);
-            is JSVideoUrlRangeSource -> swapVideoSourceUrlRange(videoSource);
-            is IDashManifestSource -> swapVideoSourceDash(videoSource);
-            is IHLSManifestSource -> swapVideoSourceHLS(videoSource);
-            is IVideoUrlSource -> swapVideoSourceUrl(videoSource);
-            null -> _lastVideoMediaSource = null;
+        val didSet = when(videoSource) {
+            is LocalVideoSource -> { swapVideoSourceLocal(videoSource); true; }
+            is JSVideoUrlRangeSource -> { swapVideoSourceUrlRange(videoSource); true; }
+            is IDashManifestSource -> { swapVideoSourceDash(videoSource); true;}
+            is JSDashManifestRawSource -> swapVideoSourceDashRaw(videoSource, play, resume);
+            is IHLSManifestSource -> { swapVideoSourceHLS(videoSource); true; }
+            is IVideoUrlSource -> { swapVideoSourceUrl(videoSource); true; }
+            null -> { _lastVideoMediaSource = null; true;}
             else -> throw IllegalArgumentException("Unsupported video source [${videoSource.javaClass.simpleName}]");
         }
         lastVideoSource = videoSource;
-    }
-    private fun swapSourceInternal(audioSource: IAudioSource?) {
-        when(audioSource) {
-            is LocalAudioSource -> swapAudioSourceLocal(audioSource);
-            is JSAudioUrlRangeSource -> swapAudioSourceUrlRange(audioSource);
-            is JSHLSManifestAudioSource -> swapAudioSourceHLS(audioSource);
-            is IAudioUrlWidevineSource -> swapAudioSourceUrlWidevine(audioSource)
-            is IAudioUrlSource -> swapAudioSourceUrl(audioSource);
-            null -> _lastAudioMediaSource = null;
+        return didSet;
+    }
+    private fun swapSourceInternal(audioSource: IAudioSource?, play: Boolean, resume: Boolean): Boolean {
+        val didSet = when(audioSource) {
+            is LocalAudioSource -> {swapAudioSourceLocal(audioSource); true; }
+            is JSAudioUrlRangeSource -> { swapAudioSourceUrlRange(audioSource); true; }
+            is JSHLSManifestAudioSource -> { swapAudioSourceHLS(audioSource); true; }
+            is JSDashManifestRawAudioSource -> swapAudioSourceDashRaw(audioSource, play, resume);
+            is IAudioUrlWidevineSource -> { swapAudioSourceUrlWidevine(audioSource); true; }
+            is IAudioUrlSource -> { swapAudioSourceUrl(audioSource); true; }
+            null -> { _lastAudioMediaSource = null; true; }
             else -> throw IllegalArgumentException("Unsupported video source [${audioSource.javaClass.simpleName}]");
         }
         lastAudioSource = audioSource;
+        return didSet;
     }
 
     //Video loads
@@ -441,7 +476,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
     @OptIn(UnstableApi::class)
     private fun swapVideoSourceUrl(videoSource: IVideoUrlSource) {
         Logger.i(TAG, "Loading VideoSource [Url]");
-        val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
+        val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource)
             videoSource.getHttpDataSourceFactory()
         else
             DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
@@ -451,7 +486,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
     @OptIn(UnstableApi::class)
     private fun swapVideoSourceDash(videoSource: IDashManifestSource) {
         Logger.i(TAG, "Loading VideoSource [Dash]");
-        val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
+        val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
             videoSource.getHttpDataSourceFactory()
         else
             DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
@@ -459,9 +494,60 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
             .createMediaSource(MediaItem.fromUri(videoSource.url))
     }
     @OptIn(UnstableApi::class)
+    private fun swapVideoSourceDashRaw(videoSource: JSDashManifestRawSource, play: Boolean, resume: Boolean): Boolean {
+        Logger.i(TAG, "Loading VideoSource [Dash]");
+
+        if(videoSource.hasGenerate) {
+            findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) {
+                try {
+                    val generated = videoSource.generate();
+                    if (generated != null) {
+                        withContext(Dispatchers.Main) {
+                            val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
+                                videoSource.getHttpDataSourceFactory()
+                            else
+                                DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
+
+                            if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource)
+                                dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor());
+                            _lastVideoMediaSource = DashMediaSource.Factory(dataSource)
+                                .createMediaSource(
+                                    DashManifestParser().parse(
+                                        Uri.parse(videoSource.url),
+                                        ByteArrayInputStream(
+                                            generated?.toByteArray() ?: ByteArray(0)
+                                        )
+                                    )
+                                );
+                            if(lastVideoSource == videoSource || (videoSource is JSDashManifestMergingRawSource && videoSource.video == lastVideoSource));
+                                loadSelectedSources(play, resume);
+                        }
+                    }
+                }
+                catch(ex: Throwable) {
+                    Logger.e(TAG, "DashRaw generator failed", ex);
+                }
+            }
+            return false;
+        }
+        else {
+            val dataSource = if(videoSource is JSSource && (videoSource.requiresCustomDatasource))
+                videoSource.getHttpDataSourceFactory()
+            else
+                DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
+
+            if(dataSource is JSHttpDataSource.Factory && videoSource is JSDashManifestMergingRawSource)
+                dataSource.setRequestExecutor2(videoSource.audio.getRequestExecutor());
+            _lastVideoMediaSource = DashMediaSource.Factory(dataSource)
+                .createMediaSource(DashManifestParser().parse(Uri.parse(videoSource.url),
+                    ByteArrayInputStream(videoSource.manifest?.toByteArray() ?: ByteArray(0))));
+            return true;
+        }
+    }
+    @OptIn(UnstableApi::class)
     private fun swapVideoSourceHLS(videoSource: IHLSManifestSource) {
         Logger.i(TAG, "Loading VideoSource [HLS]");
-        val dataSource = if(videoSource is JSSource && videoSource.hasRequestModifier)
+        val dataSource = if(videoSource is JSSource && videoSource.requiresCustomDatasource)
             videoSource.getHttpDataSourceFactory()
         else
             DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
@@ -503,7 +589,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
     @OptIn(UnstableApi::class)
     private fun swapAudioSourceUrl(audioSource: IAudioUrlSource) {
         Logger.i(TAG, "Loading AudioSource [Url]");
-        val dataSource = if(audioSource is JSSource && audioSource.hasRequestModifier)
+        val dataSource = if(audioSource is JSSource && audioSource.requiresCustomDatasource)
             audioSource.getHttpDataSourceFactory()
         else
             DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
@@ -513,7 +599,7 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
     @OptIn(UnstableApi::class)
     private fun swapAudioSourceHLS(audioSource: IHLSManifestAudioSource) {
         Logger.i(TAG, "Loading AudioSource [HLS]");
-        val dataSource = if(audioSource is JSSource && audioSource.hasRequestModifier)
+        val dataSource = if(audioSource is JSSource && audioSource.requiresCustomDatasource)
             audioSource.getHttpDataSourceFactory()
         else
             DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
@@ -521,10 +607,42 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
             .createMediaSource(MediaItem.fromUri(audioSource.url));
     }
 
+    @OptIn(UnstableApi::class)
+    private fun swapAudioSourceDashRaw(audioSource: JSDashManifestRawAudioSource, play: Boolean, resume: Boolean): Boolean {
+        Logger.i(TAG, "Loading AudioSource [DashRaw]");
+        val dataSource = if(audioSource is JSSource && (audioSource.requiresCustomDatasource))
+            audioSource.getHttpDataSourceFactory()
+        else
+            DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT);
+        if(audioSource.hasGenerate) {
+            findViewTreeLifecycleOwner()?.lifecycle?.coroutineScope?.launch(Dispatchers.IO) {
+                val generated = audioSource.generate();
+                if(generated != null) {
+                    withContext(Dispatchers.Main) {
+                        _lastVideoMediaSource = DashMediaSource.Factory(dataSource)
+                            .createMediaSource(DashManifestParser().parse(Uri.parse(audioSource.url),
+                                ByteArrayInputStream(generated?.toByteArray() ?: ByteArray(0))));
+                        loadSelectedSources(play, resume);
+                    }
+                }
+            }
+            return false;
+        }
+        else {
+            _lastVideoMediaSource = DashMediaSource.Factory(dataSource)
+                .createMediaSource(
+                    DashManifestParser().parse(
+                        Uri.parse(audioSource.url),
+                        ByteArrayInputStream(audioSource.manifest?.toByteArray() ?: ByteArray(0))
+                    )
+                );
+            return true;
+        }
+    }
     @OptIn(UnstableApi::class)
     private fun swapAudioSourceUrlWidevine(audioSource: IAudioUrlWidevineSource) {
         Logger.i(TAG, "Loading AudioSource [UrlWidevine]")
-        val dataSource = if (audioSource is JSSource && audioSource.hasRequestModifier)
+        val dataSource = if (audioSource is JSSource && audioSource.requiresCustomDatasource)
             audioSource.getHttpDataSourceFactory()
         else
             DefaultHttpDataSource.Factory().setUserAgent(DEFAULT_USER_AGENT)
@@ -574,28 +692,37 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
         val sourceAudio = _lastAudioMediaSource;
         val sourceSubs = _lastSubtitleMediaSource;
 
-        val sources = listOf(sourceVideo, sourceAudio, sourceSubs).filter { it != null }.map { it!! }.toTypedArray()
 
         beforeSourceChanged();
 
-        _mediaSource = if(sources.size == 1) {
+        val source = mergeMediaSources(sourceVideo, sourceAudio, sourceSubs);
+        if(source == null)
+            return false;
+        _mediaSource = source;
+
+        reloadMediaSource(play, resume);
+        return true;
+    }
+
+    @OptIn(UnstableApi::class)
+    fun mergeMediaSources(sourceVideo: MediaSource?, sourceAudio: MediaSource?, sourceSubs: MediaSource?): MediaSource? {
+        val sources = listOf(sourceVideo, sourceAudio, sourceSubs).filter { it != null }.map { it!! }.toTypedArray()
+        if(sources.size == 1) {
             Logger.i(TAG, "Using single source mode")
-            (sourceVideo ?: sourceAudio);
+            return (sourceVideo ?: sourceAudio);
         }
         else if(sources.size >  1) {
             Logger.i(TAG, "Using multi source mode ${sources.size}")
-            MergingMediaSource(true, *sources);
+            return MergingMediaSource(true, *sources);
         }
         else {
             Logger.i(TAG, "Using no sources loaded");
             stop();
-            return false;
+            return null;
         }
-
-        reloadMediaSource(play, resume);
-        return true;
     }
 
+
     @OptIn(UnstableApi::class)
     private fun reloadMediaSource(play: Boolean = false, resume: Boolean = true) {
         val player = exoPlayer ?: return
@@ -619,6 +746,10 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
     fun clear() {
         exoPlayer?.player?.stop();
         exoPlayer?.player?.clearMediaItems();
+        _lastVideoMediaSource = null;
+        _lastAudioMediaSource = null;
+        _lastSubtitleMediaSource = null;
+        _mediaSource = null;
     }
 
     fun stop(){
@@ -697,8 +828,15 @@ abstract class FutoVideoPlayerBase : RelativeLayout {
     companion object {
         val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
 
-        val PREFERED_VIDEO_CONTAINERS = arrayOf("video/mp4", "video/webm", "video/3gpp");
-        val PREFERED_AUDIO_CONTAINERS = arrayOf("audio/mp3", "audio/mp4", "audio/webm", "audio/opus");
+        val PREFERED_VIDEO_CONTAINERS_MP4Pref = arrayOf("video/mp4", "video/webm", "video/3gpp");
+        val PREFERED_VIDEO_CONTAINERS_WEBMPref = arrayOf("video/webm", "video/mp4", "video/3gpp");
+        val PREFERED_VIDEO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmVideo)
+            PREFERED_VIDEO_CONTAINERS_WEBMPref else PREFERED_VIDEO_CONTAINERS_MP4Pref }
+
+        val PREFERED_AUDIO_CONTAINERS_MP4Pref = arrayOf("audio/mp3", "audio/mp4", "audio/webm", "audio/opus");
+        val PREFERED_AUDIO_CONTAINERS_WEBMPref = arrayOf("audio/webm", "audio/opus", "audio/mp3", "audio/mp4");
+        val PREFERED_AUDIO_CONTAINERS: Array<String> get() { return if(Settings.instance.playback.preferWebmAudio)
+            PREFERED_AUDIO_CONTAINERS_WEBMPref else PREFERED_AUDIO_CONTAINERS_MP4Pref }
 
         val SUPPORTED_SUBTITLES = hashSetOf("text/vtt", "application/x-subrip");
     }
diff --git a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java
index 94b0fc9982dbde4b3c9cca861de2924bc862e697..40e1a50386f647425e5cd78cee3b46f0b51fbb6e 100644
--- a/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java
+++ b/app/src/main/java/com/futo/platformplayer/views/video/datasources/JSHttpDataSource.java
@@ -13,6 +13,8 @@ import androidx.annotation.VisibleForTesting;
 
 import com.futo.platformplayer.api.media.models.modifier.IRequest;
 import com.futo.platformplayer.api.media.models.modifier.IRequestModifier;
+import com.futo.platformplayer.api.media.platforms.js.models.JSRequest;
+import com.futo.platformplayer.api.media.platforms.js.models.JSRequestExecutor;
 import com.futo.platformplayer.api.media.platforms.js.models.JSRequestModifier;
 import androidx.media3.common.C;
 import androidx.media3.common.PlaybackException;
@@ -26,12 +28,16 @@ import androidx.media3.datasource.HttpUtil;
 import androidx.media3.datasource.TransferListener;
 
 import com.futo.platformplayer.engine.dev.V8RemoteObject;
+import com.futo.platformplayer.engine.exceptions.PluginException;
+import com.futo.platformplayer.engine.exceptions.ScriptException;
 import com.futo.platformplayer.logging.Logger;
 import com.google.common.base.Predicate;
 import com.google.common.collect.ForwardingMap;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Sets;
 import com.google.common.net.HttpHeaders;
+
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InterruptedIOException;
@@ -67,6 +73,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
         private boolean allowCrossProtocolRedirects;
         private boolean keepPostFor302Redirects;
         @Nullable private IRequestModifier requestModifier = null;
+        @Nullable public JSRequestExecutor requestExecutor = null;
+        @Nullable public JSRequestExecutor requestExecutor2 = null;
+
 
         /** Creates an instance. */
         public Factory() {
@@ -93,6 +102,30 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
             this.requestModifier = requestModifier;
             return this;
         }
+        /**
+         * Sets the request executor that will be used.
+         *
+         * <p>The default is {@code null}, which results in no request modification
+         *
+         * @param requestExecutor The request modifier that will be used, or {@code null} to use no request modifier
+         * @return This factory.
+         */
+        public Factory setRequestExecutor(@Nullable JSRequestExecutor requestExecutor) {
+            this.requestExecutor = requestExecutor;
+            return this;
+        }
+        /**
+         * Sets the secondary request executor that will be used.
+         *
+         * <p>The default is {@code null}, which results in no request modification
+         *
+         * @param requestExecutor The request modifier that will be used, or {@code null} to use no request modifier
+         * @return This factory.
+         */
+        public Factory setRequestExecutor2(@Nullable JSRequestExecutor requestExecutor) {
+            this.requestExecutor2 = requestExecutor;
+            return this;
+        }
 
         /**
          * Sets the user agent that will be used.
@@ -199,7 +232,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
                             defaultRequestProperties,
                             contentTypePredicate,
                             keepPostFor302Redirects,
-                            requestModifier);
+                            requestModifier,
+                            requestExecutor,
+                            requestExecutor2);
             if (transferListener != null) {
                 dataSource.addTransferListener(transferListener);
             }
@@ -235,6 +270,10 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
     private long bytesToRead;
     private long bytesRead;
     @Nullable private IRequestModifier requestModifier;
+    @Nullable public JSRequestExecutor requestExecutor;
+    @Nullable public JSRequestExecutor requestExecutor2; //Not ideal, but required for now to have 2 executors under 1 datasource
+
+    private Uri fallbackUri = null;
 
     private JSHttpDataSource(
             @Nullable String userAgent,
@@ -244,7 +283,9 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
             @Nullable RequestProperties defaultRequestProperties,
             @Nullable Predicate<String> contentTypePredicate,
             boolean keepPostFor302Redirects,
-            @Nullable IRequestModifier requestModifier) {
+            @Nullable IRequestModifier requestModifier,
+            @Nullable JSRequestExecutor requestExecutor,
+            @Nullable JSRequestExecutor requestExecutor2) {
         super(/* isNetwork= */ true);
         this.userAgent = userAgent;
         this.connectTimeoutMillis = connectTimeoutMillis;
@@ -255,12 +296,14 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
         this.requestProperties = new RequestProperties();
         this.keepPostFor302Redirects = keepPostFor302Redirects;
         this.requestModifier = requestModifier;
+        this.requestExecutor = requestExecutor;
+        this.requestExecutor2 = requestExecutor2;
     }
 
     @Override
     @Nullable
     public Uri getUri() {
-        return connection == null ? null : Uri.parse(connection.getURL().toString());
+        return connection == null ? fallbackUri : Uri.parse(connection.getURL().toString());
     }
 
     @Override
@@ -310,119 +353,147 @@ public class JSHttpDataSource extends BaseDataSource implements HttpDataSource {
         bytesToRead = 0;
         transferInitializing(dataSpec);
 
-        String responseMessage;
-        HttpURLConnection connection;
-        try {
-            this.connection = makeConnection(dataSpec);
-            connection = this.connection;
-            responseCode = connection.getResponseCode();
-            responseMessage = connection.getResponseMessage();
-        } catch (IOException e) {
-            closeConnectionQuietly();
-            throw HttpDataSourceException.createForIOException(
-                    e, dataSpec, HttpDataSourceException.TYPE_OPEN);
-        }
-
-        // Check for a valid response code.
-        if (responseCode < 200 || responseCode > 299) {
-            Map<String, List<String>> headers = connection.getHeaderFields();
-            if (responseCode == 416) {
-                long documentSize = HttpUtil.getDocumentSize(connection.getHeaderField(HttpHeaders.CONTENT_RANGE));
-                if (dataSpec.position == documentSize) {
-                    opened = true;
-                    transferStarted(dataSpec);
-                    return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0;
-                }
-            }
+        //Use executor 2 if it matches the urlPrefix
+        JSRequestExecutor executor = (requestExecutor2 != null && requestExecutor2.getUrlPrefix() != null && dataSpec.uri.toString().startsWith(requestExecutor2.getUrlPrefix())) ?
+                requestExecutor2 : requestExecutor;
+
+        if(executor != null) {
+            try {
+                Logger.Companion.i(TAG, "Executor for " + dataSpec.uri.toString(), null);
+                byte[] data = executor.executeRequest(dataSpec.uri.toString(), dataSpec.httpRequestHeaders);
+                Logger.Companion.i(TAG, "Executor result for " + dataSpec.uri.toString() + " : " + data.length, null);
+                if (data == null)
+                    throw new HttpDataSourceException(
+                            "No response",
+                            dataSpec,
+                            PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
+                            HttpDataSourceException.TYPE_OPEN);
+                inputStream = new ByteArrayInputStream(data);
+                fallbackUri = dataSpec.uri;
+                bytesToRead = data.length;
 
-            @Nullable InputStream errorStream = connection.getErrorStream();
-            byte[] errorResponseBody;
+                transferStarted(dataSpec);
+                return data.length;
+            }
+            catch(PluginException ex) {
+                throw HttpDataSourceException.createForIOException(new IOException("Executor failed: " + ex.getMessage(), ex), dataSpec, HttpDataSourceException.TYPE_OPEN);
+            }
+        }
+        else {
+            String responseMessage;
+            HttpURLConnection connection;
             try {
-                errorResponseBody =
-                        errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY;
+                this.connection = makeConnection(dataSpec);
+                connection = this.connection;
+                responseCode = connection.getResponseCode();
+                responseMessage = connection.getResponseMessage();
             } catch (IOException e) {
-                errorResponseBody = Util.EMPTY_BYTE_ARRAY;
+                closeConnectionQuietly();
+                throw HttpDataSourceException.createForIOException(
+                        e, dataSpec, HttpDataSourceException.TYPE_OPEN);
             }
-            closeConnectionQuietly();
-            @Nullable
-            IOException cause = responseCode == 416
-                ? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE)
-                : null;
 
-            throw new InvalidResponseCodeException(
-                    responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody);
-        }
+            // Check for a valid response code.
+            if (responseCode < 200 || responseCode > 299) {
+                Map<String, List<String>> headers = connection.getHeaderFields();
+                if (responseCode == 416) {
+                    long documentSize = HttpUtil.getDocumentSize(connection.getHeaderField(HttpHeaders.CONTENT_RANGE));
+                    if (dataSpec.position == documentSize) {
+                        opened = true;
+                        transferStarted(dataSpec);
+                        return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : 0;
+                    }
+                }
 
-        // Check for a valid content type.
-        String contentType = connection.getContentType();
-        if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) {
-            closeConnectionQuietly();
-            throw new InvalidContentTypeException(contentType, dataSpec);
-        }
+                @Nullable InputStream errorStream = connection.getErrorStream();
+                byte[] errorResponseBody;
+                try {
+                    errorResponseBody =
+                            errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY;
+                } catch (IOException e) {
+                    errorResponseBody = Util.EMPTY_BYTE_ARRAY;
+                }
+                closeConnectionQuietly();
+                @Nullable
+                IOException cause = responseCode == 416
+                        ? new DataSourceException(PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE)
+                        : null;
+
+                throw new InvalidResponseCodeException(
+                        responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody);
+            }
 
-        // If we requested a range starting from a non-zero position and received a 200 rather than a
-        // 206, then the server does not support partial requests. We'll need to manually skip to the
-        // requested position.
-        long bytesToSkip;
-        if (requestModifier != null && !requestModifier.getAllowByteSkip()) {
-            bytesToSkip = 0;
-        } else {
-            bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
-        }
+            // Check for a valid content type.
+            String contentType = connection.getContentType();
+            if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) {
+                closeConnectionQuietly();
+                throw new InvalidContentTypeException(contentType, dataSpec);
+            }
 
-        // Determine the length of the data to be read, after skipping.
-        boolean isCompressed = isCompressed(connection);
-        if (!isCompressed) {
-            if (dataSpec.length != C.LENGTH_UNSET) {
-                bytesToRead = dataSpec.length;
+            // If we requested a range starting from a non-zero position and received a 200 rather than a
+            // 206, then the server does not support partial requests. We'll need to manually skip to the
+            // requested position.
+            long bytesToSkip;
+            if (requestModifier != null && !requestModifier.getAllowByteSkip()) {
+                bytesToSkip = 0;
             } else {
-                long contentLength = HttpUtil.getContentLength(
-                    connection.getHeaderField(HttpHeaders.CONTENT_LENGTH),
-                    connection.getHeaderField(HttpHeaders.CONTENT_RANGE)
-                );
+                bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0;
+            }
 
-                bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET;
+            // Determine the length of the data to be read, after skipping.
+            boolean isCompressed = isCompressed(connection);
+            if (!isCompressed) {
+                if (dataSpec.length != C.LENGTH_UNSET) {
+                    bytesToRead = dataSpec.length;
+                } else {
+                    long contentLength = HttpUtil.getContentLength(
+                            connection.getHeaderField(HttpHeaders.CONTENT_LENGTH),
+                            connection.getHeaderField(HttpHeaders.CONTENT_RANGE)
+                    );
+
+                    bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET;
+                }
+            } else {
+                // Gzip is enabled. If the server opts to use gzip then the content length in the response
+                // will be that of the compressed data, which isn't what we want. Always use the dataSpec
+                // length in this case.
+                bytesToRead = dataSpec.length;
             }
-        } else {
-            // Gzip is enabled. If the server opts to use gzip then the content length in the response
-            // will be that of the compressed data, which isn't what we want. Always use the dataSpec
-            // length in this case.
-            bytesToRead = dataSpec.length;
-        }
 
-        try {
-            inputStream = connection.getInputStream();
-            if (isCompressed) {
-                inputStream = new GZIPInputStream(inputStream);
+            try {
+                inputStream = connection.getInputStream();
+                if (isCompressed) {
+                    inputStream = new GZIPInputStream(inputStream);
+                }
+            } catch (IOException e) {
+                closeConnectionQuietly();
+                throw new HttpDataSourceException(
+                        e,
+                        dataSpec,
+                        PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
+                        HttpDataSourceException.TYPE_OPEN);
             }
-        } catch (IOException e) {
-            closeConnectionQuietly();
-            throw new HttpDataSourceException(
-                    e,
-                    dataSpec,
-                    PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
-                    HttpDataSourceException.TYPE_OPEN);
-        }
 
-        opened = true;
-        transferStarted(dataSpec);
+            opened = true;
+            transferStarted(dataSpec);
 
-        try {
-            skipFully(bytesToSkip, dataSpec);
-        } catch (IOException e) {
-            closeConnectionQuietly();
+            try {
+                skipFully(bytesToSkip, dataSpec);
+            } catch (IOException e) {
+                closeConnectionQuietly();
 
-            if (e instanceof HttpDataSourceException) {
-                throw (HttpDataSourceException) e;
+                if (e instanceof HttpDataSourceException) {
+                    throw (HttpDataSourceException) e;
+                }
+                throw new HttpDataSourceException(
+                        e,
+                        dataSpec,
+                        PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
+                        HttpDataSourceException.TYPE_OPEN);
             }
-            throw new HttpDataSourceException(
-                    e,
-                    dataSpec,
-                    PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
-                    HttpDataSourceException.TYPE_OPEN);
-        }
 
-        return bytesToRead;
+            return bytesToRead;
+        }
     }
 
     @Override
diff --git a/app/src/main/res/drawable/battery_full_24px.xml b/app/src/main/res/drawable/battery_full_24px.xml
new file mode 100644
index 0000000000000000000000000000000000000000..af90cb27302f983506551e1e2637b4b210945312
--- /dev/null
+++ b/app/src/main/res/drawable/battery_full_24px.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M320,880Q303,880 291.5,868.5Q280,857 280,840L280,200Q280,183 291.5,171.5Q303,160 320,160L400,160L400,80L560,80L560,160L640,160Q657,160 668.5,171.5Q680,183 680,200L680,840Q680,857 668.5,868.5Q657,880 640,880L320,880Z"/>
+</vector>
diff --git a/app/src/main/res/layout/overlay_slide_up_menu_option.xml b/app/src/main/res/layout/overlay_slide_up_menu_option.xml
index c51590952d95c85577760d28de4ae8cc8d2a3394..82b2c512f16f74d9729267b93f539fc3643f96b7 100644
--- a/app/src/main/res/layout/overlay_slide_up_menu_option.xml
+++ b/app/src/main/res/layout/overlay_slide_up_menu_option.xml
@@ -1,44 +1,81 @@
 <?xml version="1.0" encoding="utf-8"?>
-<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
     android:layout_height="wrap_content"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
-    android:layout_marginTop="10dp"
-    android:background="@drawable/background_slide_up_option"
-    android:id="@+id/slide_up_menu_item_root"
     android:gravity="center_vertical">
-
-    <ImageView
-        android:id="@+id/slide_up_menu_item_image"
-        android:layout_width="20dp"
-        android:layout_height="20dp"
-        android:layout_marginLeft="15dp"
-        android:scaleType="fitCenter"
-        app:srcCompat="@drawable/ic_share" />
-    <TextView
-        android:id="@+id/slide_up_menu_item_text"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:gravity="center_vertical"
-        android:layout_alignParentLeft="true"
-        android:layout_marginLeft="45dp"
-        tools:text="Cat videos"
-        android:textSize="11dp"
-        android:layout_marginTop="2dp"
-        android:fontFamily="@font/inter_light"
-        android:textColor="@color/white" />
-    <TextView
-        android:id="@+id/slide_up_menu_item_subtext"
-        android:layout_width="wrap_content"
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:gravity="center_vertical"
-        android:layout_alignParentRight="true"
-        android:layout_marginRight="15dp"
-        tools:text="3 videos"
-        android:textSize="11dp"
-        android:layout_marginTop="2dp"
-        android:fontFamily="@font/inter_light"
-        android:textColor="#ACACAC" />
+        android:id="@+id/slide_up_menu_item_root"
+        android:layout_marginTop="10dp"
+        android:background="@drawable/background_slide_up_option"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toTopOf="parent">
+        <ImageView
+            android:id="@+id/slide_up_menu_item_image"
+            android:layout_width="20dp"
+            android:layout_height="20dp"
+            android:layout_marginLeft="15dp"
+            android:scaleType="fitCenter"
+            android:layout_gravity="center_vertical"
+            app:layout_constraintTop_toTopOf="parent"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintLeft_toLeftOf="parent"
+            app:srcCompat="@drawable/ic_share" />
+        <LinearLayout
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:paddingTop="12dp"
+            android:paddingBottom="12dp"
+            app:layout_constraintTop_toTopOf="parent"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintLeft_toRightOf="@id/slide_up_menu_item_image"
+            app:layout_constraintRight_toLeftOf="@id/slide_up_menu_item_subtext"
+            android:gravity="start"
+            android:orientation="vertical">
+            <TextView
+                android:id="@+id/slide_up_menu_item_text"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:gravity="center_vertical"
+                android:layout_alignParentLeft="true"
+                android:layout_marginLeft="20dp"
+                tools:text="Cat videos"
+                android:textSize="11dp"
+                android:layout_marginTop="2dp"
+                android:fontFamily="@font/inter_light"
+                android:textColor="@color/white" />
+            <TextView
+                android:id="@+id/slide_up_menu_item_description"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:gravity="center_vertical"
+                android:layout_alignParentLeft="true"
+                android:layout_marginLeft="20dp"
+                tools:text="test Description"
+                android:textSize="11dp"
+                android:layout_marginTop="2dp"
+                android:fontFamily="@font/inter_light"
+                android:textColor="#ACACAC" />
+        </LinearLayout>
+        <TextView
+            android:id="@+id/slide_up_menu_item_subtext"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:gravity="center_vertical"
+            android:layout_alignParentRight="true"
+            android:layout_marginRight="15dp"
+            tools:text="3 videos"
+            android:textSize="11dp"
+            android:layout_marginTop="2dp"
+            android:fontFamily="@font/inter_light"
+            app:layout_constraintTop_toTopOf="parent"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintRight_toRightOf="parent"
+            android:textColor="#ACACAC" />
+    </androidx.constraintlayout.widget.ConstraintLayout>
 
-</RelativeLayout>
\ No newline at end of file
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a8eef4fb70c5ca591666c5d904d83c8656c276cf..90d2160dd01475d71b5bcc50acff22cc8a96e57f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -285,6 +285,8 @@
     <string name="attempt_to_utilize_byte_ranges">Attempt to utilize byte ranges</string>
     <string name="auto_update">Auto Update</string>
     <string name="auto_rotate">Auto-Rotate</string>
+    <string name="simplify_sources">Simplify sources</string>
+    <string name="simplify_sources_description">Deduplicate sources by resolution so that only more relevant sources are visible.</string>
     <string name="auto_rotate_dead_zone">Auto-Rotate Dead Zone</string>
     <string name="automatic_backup">Automatic Backup</string>
     <string name="background_behavior">Background Behavior</string>
@@ -371,6 +373,10 @@
     <string name="system_volume_descr">Gesture controls adjust system volume</string>
     <string name="live_chat_webview">Live Chat Webview</string>
     <string name="full_screen_portrait">Fullscreen portrait</string>
+    <string name="prefer_webm">Prefer Webm Video Codecs</string>
+    <string name="prefer_webm_description">If player should prefer Webm codecs (vp9/opus) over mp4 codecs (h264/AAC), may result in worse compatibility.</string>
+    <string name="prefer_webm_audio">Prefer Webm Audio Codecs</string>
+    <string name="prefer_webm_audio_description">If player should prefer Webm codecs (opus) over mp4 codecs (AAC), may result in worse compatibility.</string>
     <string name="allow_full_screen_portrait">Allow fullscreen portrait</string>
     <string name="background_switch_audio">Switch to Audio in Background</string>
     <string name="background_switch_audio_description">Optimize bandwidth usage by switching to audio-only stream in background if available, may cause stutter</string>
diff --git a/app/src/stable/assets/sources/spotify b/app/src/stable/assets/sources/spotify
index 4d554e93882d29542cb05d2956f4b2484d7df27f..c700081466038ee4782610feaa05cd4d34d024d8 160000
--- a/app/src/stable/assets/sources/spotify
+++ b/app/src/stable/assets/sources/spotify
@@ -1 +1 @@
-Subproject commit 4d554e93882d29542cb05d2956f4b2484d7df27f
+Subproject commit c700081466038ee4782610feaa05cd4d34d024d8
diff --git a/app/src/stable/assets/sources/youtube b/app/src/stable/assets/sources/youtube
index 24f9e4456faf97fbbb866e1df2df9d94628ffcc6..546d862342b10398d0737f0f2163691b611af8f2 160000
--- a/app/src/stable/assets/sources/youtube
+++ b/app/src/stable/assets/sources/youtube
@@ -1 +1 @@
-Subproject commit 24f9e4456faf97fbbb866e1df2df9d94628ffcc6
+Subproject commit 546d862342b10398d0737f0f2163691b611af8f2
diff --git a/app/src/test/java/com/futo/platformplayer/MdnsTests.kt b/app/src/test/java/com/futo/platformplayer/MdnsTests.kt
new file mode 100644
index 0000000000000000000000000000000000000000..64a37d6e3563e220c433a3ff34ae2d14b6349ee6
--- /dev/null
+++ b/app/src/test/java/com/futo/platformplayer/MdnsTests.kt
@@ -0,0 +1,394 @@
+package com.futo.platformplayer
+
+import com.futo.platformplayer.mdns.DnsOpcode
+import com.futo.platformplayer.mdns.DnsPacket
+import com.futo.platformplayer.mdns.DnsPacketHeader
+import com.futo.platformplayer.mdns.DnsQuestion
+import com.futo.platformplayer.mdns.DnsReader
+import com.futo.platformplayer.mdns.DnsResponseCode
+import com.futo.platformplayer.mdns.DnsWriter
+import com.futo.platformplayer.mdns.QueryResponse
+import com.futo.platformplayer.mdns.QuestionClass
+import com.futo.platformplayer.mdns.QuestionType
+import com.futo.platformplayer.mdns.ResourceRecordClass
+import com.futo.platformplayer.mdns.ResourceRecordType
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertTrue
+import java.io.ByteArrayOutputStream
+import java.net.InetAddress
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+
+
+class MdnsTests {
+
+    @Test
+    fun `BasicOperation`() {
+        val expectedData = byteArrayOf(
+            0x00, 0x01,
+            0x00, 0x00, 0x00, 0x02,
+            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03,
+            0x00, 0x01,
+            0x00, 0x00, 0x00, 0x02,
+            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03
+        )
+
+        val writer = DnsWriter()
+        writer.write(1.toUShort())
+        writer.write(2.toUInt())
+        writer.write(3.toULong())
+        writer.write(1.toShort())
+        writer.write(2)
+        writer.write(3L)
+
+        assertContentEquals(expectedData, writer.toByteArray())
+    }
+
+    @Test
+    fun `DnsQuestionFormat`() {
+        val expectedBytes = ubyteArrayOf(
+            0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x08u, 0x5fu, 0x61u, 0x69u, 0x72u, 0x70u, 0x6cu, 0x61u, 0x79u, 0x04u, 0x5fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6cu, 0x6fu, 0x63u, 0x61u, 0x6cu, 0x00u, 0x00u, 0x0cu, 0x00u, 0x01u
+        ).asByteArray()
+
+        val writer = DnsWriter()
+        writer.writePacket(
+            header = DnsPacketHeader(
+                identifier = 0.toUShort(),
+                queryResponse = QueryResponse.Query.value.toInt(),
+                opcode = DnsOpcode.StandardQuery.value.toInt(),
+                authoritativeAnswer = false,
+                truncated = false,
+                recursionDesired = false,
+                recursionAvailable = false,
+                answerAuthenticated = false,
+                nonAuthenticatedData = false,
+                responseCode = DnsResponseCode.NoError
+            ),
+            questionCount = 1,
+            questionWriter = { w, _ ->
+                w.write(DnsQuestion(
+                    name = "_airplay._tcp.local",
+                    type = QuestionType.PTR.value.toInt(),
+                    clazz = QuestionClass.IN.value.toInt(),
+                    queryUnicast = false
+                ))
+            }
+        )
+
+        assertContentEquals(expectedBytes, writer.toByteArray())
+    }
+
+    @Test
+    fun `BeyondTests`() {
+        val data = byteArrayOf(
+            0x00, 0x01,
+            0x00, 0x00, 0x00, 0x02,
+            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03,
+            0x00, 0x01,
+            0x00, 0x00, 0x00, 0x02,
+            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03
+        )
+
+        val reader = DnsReader(data)
+        assertEquals(1, reader.readInt16())
+        assertEquals(2, reader.readInt32())
+        assertEquals(3L, reader.readInt64())
+        assertEquals(1.toUShort(), reader.readUInt16())
+        assertEquals(2.toUInt(), reader.readUInt32())
+        assertEquals(3.toULong(), reader.readUInt64())
+    }
+
+    @Test
+    fun `ParseDnsPrinter`() {
+        val data = ubyteArrayOf(
+            0x00u, 0x00u,
+            0x84u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x06u, 0x04u, 0x5fu, 0x69u, 0x70u, 0x70u, 0x04u,
+            0x5fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6cu, 0x6fu, 0x63u, 0x61u, 0x6cu, 0x00u, 0x00u, 0x0cu, 0x00u, 0x01u, 0x00u,
+            0x00u, 0x11u, 0x94u, 0x00u, 0x1eu, 0x1bu, 0x42u, 0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u,
+            0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u,
+            0x73u, 0xc0u, 0x0cu, 0xc0u, 0x27u, 0x00u, 0x10u, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x02u, 0x53u, 0x09u,
+            0x74u, 0x78u, 0x74u, 0x76u, 0x65u, 0x72u, 0x73u, 0x3du, 0x31u, 0x08u, 0x71u, 0x74u, 0x6fu, 0x74u, 0x61u, 0x6cu,
+            0x3du, 0x31u, 0x42u, 0x70u, 0x64u, 0x6cu, 0x3du, 0x61u, 0x70u, 0x70u, 0x6cu, 0x69u, 0x63u, 0x61u, 0x74u, 0x69u,
+            0x6fu, 0x6eu, 0x2fu, 0x6fu, 0x63u, 0x74u, 0x65u, 0x74u, 0x2du, 0x73u, 0x74u, 0x72u, 0x65u, 0x61u, 0x6du, 0x2cu,
+            0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu, 0x75u, 0x72u, 0x66u, 0x2cu, 0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu,
+            0x6au, 0x70u, 0x65u, 0x67u, 0x2cu, 0x69u, 0x6du, 0x61u, 0x67u, 0x65u, 0x2fu, 0x70u, 0x77u, 0x67u, 0x2du, 0x72u,
+            0x61u, 0x73u, 0x74u, 0x65u, 0x72u, 0x0cu, 0x72u, 0x70u, 0x3du, 0x69u, 0x70u, 0x70u, 0x2fu, 0x70u, 0x72u, 0x69u,
+            0x6eu, 0x74u, 0x05u, 0x6eu, 0x6fu, 0x74u, 0x65u, 0x3du, 0x1eu, 0x74u, 0x79u, 0x3du, 0x42u, 0x72u, 0x6fu, 0x74u,
+            0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u,
+            0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u, 0x73u, 0x25u, 0x70u, 0x72u, 0x6fu, 0x64u, 0x75u, 0x63u, 0x74u, 0x3du,
+            0x28u, 0x42u, 0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x20u, 0x44u, 0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u,
+            0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u, 0x65u, 0x73u, 0x29u, 0x3cu, 0x61u, 0x64u,
+            0x6du, 0x69u, 0x6eu, 0x75u, 0x72u, 0x6cu, 0x3du, 0x68u, 0x74u, 0x74u, 0x70u, 0x3au, 0x2fu, 0x2fu, 0x42u, 0x52u,
+            0x57u, 0x31u, 0x30u, 0x35u, 0x42u, 0x41u, 0x44u, 0x34u, 0x41u, 0x31u, 0x35u, 0x37u, 0x30u, 0x2eu, 0x6cu, 0x6fu,
+            0x63u, 0x61u, 0x6cu, 0x2eu, 0x2fu, 0x6eu, 0x65u, 0x74u, 0x2fu, 0x6eu, 0x65u, 0x74u, 0x2fu, 0x61u, 0x69u, 0x72u,
+            0x70u, 0x72u, 0x69u, 0x6eu, 0x74u, 0x2eu, 0x68u, 0x74u, 0x6du, 0x6cu, 0x0bu, 0x70u, 0x72u, 0x69u, 0x6fu, 0x72u,
+            0x69u, 0x74u, 0x79u, 0x3du, 0x32u, 0x35u, 0x0fu, 0x75u, 0x73u, 0x62u, 0x5fu, 0x4du, 0x46u, 0x47u, 0x3du, 0x42u,
+            0x72u, 0x6fu, 0x74u, 0x68u, 0x65u, 0x72u, 0x1bu, 0x75u, 0x73u, 0x62u, 0x5fu, 0x4du, 0x44u, 0x4cu, 0x3du, 0x44u,
+            0x43u, 0x50u, 0x2du, 0x4cu, 0x33u, 0x35u, 0x35u, 0x30u, 0x43u, 0x44u, 0x57u, 0x20u, 0x73u, 0x65u, 0x72u, 0x69u,
+            0x65u, 0x73u, 0x19u, 0x75u, 0x73u, 0x62u, 0x5fu, 0x43u, 0x4du, 0x44u, 0x3du, 0x50u, 0x4au, 0x4cu, 0x2cu, 0x50u,
+            0x43u, 0x4cu, 0x2cu, 0x50u, 0x43u, 0x4cu, 0x58u, 0x4cu, 0x2cu, 0x55u, 0x52u, 0x46u, 0x07u, 0x43u, 0x6fu, 0x6cu,
+            0x6fu, 0x72u, 0x3du, 0x54u, 0x08u, 0x43u, 0x6fu, 0x70u, 0x69u, 0x65u, 0x73u, 0x3du, 0x54u, 0x08u, 0x44u, 0x75u,
+            0x70u, 0x6cu, 0x65u, 0x78u, 0x3du, 0x54u, 0x05u, 0x46u, 0x61u, 0x78u, 0x3du, 0x46u, 0x06u, 0x53u, 0x63u, 0x61u,
+            0x6eu, 0x3du, 0x54u, 0x0du, 0x50u, 0x61u, 0x70u, 0x65u, 0x72u, 0x43u, 0x75u, 0x73u, 0x74u, 0x6fu, 0x6du, 0x3du,
+            0x54u, 0x08u, 0x42u, 0x69u, 0x6eu, 0x61u, 0x72u, 0x79u, 0x3du, 0x54u, 0x0du, 0x54u, 0x72u, 0x61u, 0x6eu, 0x73u,
+            0x70u, 0x61u, 0x72u, 0x65u, 0x6eu, 0x74u, 0x3du, 0x54u, 0x06u, 0x54u, 0x42u, 0x43u, 0x50u, 0x3du, 0x46u, 0x3eu,
+            0x55u, 0x52u, 0x46u, 0x3du, 0x53u, 0x52u, 0x47u, 0x42u, 0x32u, 0x34u, 0x2cu, 0x57u, 0x38u, 0x2cu, 0x43u, 0x50u,
+            0x31u, 0x2cu, 0x49u, 0x53u, 0x34u, 0x2du, 0x31u, 0x2cu, 0x4du, 0x54u, 0x31u, 0x2du, 0x33u, 0x2du, 0x34u, 0x2du,
+            0x35u, 0x2du, 0x38u, 0x2du, 0x31u, 0x31u, 0x2cu, 0x4fu, 0x42u, 0x31u, 0x30u, 0x2cu, 0x50u, 0x51u, 0x34u, 0x2cu,
+            0x52u, 0x53u, 0x36u, 0x30u, 0x30u, 0x2cu, 0x56u, 0x31u, 0x2eu, 0x34u, 0x2cu, 0x44u, 0x4du, 0x31u, 0x25u, 0x6bu,
+            0x69u, 0x6eu, 0x64u, 0x3du, 0x64u, 0x6fu, 0x63u, 0x75u, 0x6du, 0x65u, 0x6eu, 0x74u, 0x2cu, 0x65u, 0x6eu, 0x76u,
+            0x65u, 0x6cu, 0x6fu, 0x70u, 0x65u, 0x2cu, 0x6cu, 0x61u, 0x62u, 0x65u, 0x6cu, 0x2cu, 0x70u, 0x6fu, 0x73u, 0x74u,
+            0x63u, 0x61u, 0x72u, 0x64u, 0x11u, 0x50u, 0x61u, 0x70u, 0x65u, 0x72u, 0x4du, 0x61u, 0x78u, 0x3du, 0x6cu, 0x65u,
+            0x67u, 0x61u, 0x6cu, 0x2du, 0x41u, 0x34u, 0x29u, 0x55u, 0x55u, 0x49u, 0x44u, 0x3du, 0x65u, 0x33u, 0x32u, 0x34u,
+            0x38u, 0x30u, 0x30u, 0x30u, 0x2du, 0x38u, 0x30u, 0x63u, 0x65u, 0x2du, 0x31u, 0x31u, 0x64u, 0x62u, 0x2du, 0x38u,
+            0x30u, 0x30u, 0x30u, 0x2du, 0x33u, 0x63u, 0x32u, 0x61u, 0x66u, 0x34u, 0x61u, 0x61u, 0x63u, 0x30u, 0x61u, 0x34u,
+            0x0cu, 0x70u, 0x72u, 0x69u, 0x6eu, 0x74u, 0x5fu, 0x77u, 0x66u, 0x64u, 0x73u, 0x3du, 0x54u, 0x14u, 0x6du, 0x6fu,
+            0x70u, 0x72u, 0x69u, 0x61u, 0x2du, 0x63u, 0x65u, 0x72u, 0x74u, 0x69u, 0x66u, 0x69u, 0x65u, 0x64u, 0x3du, 0x31u,
+            0x2eu, 0x33u, 0x0fu, 0x42u, 0x52u, 0x57u, 0x31u, 0x30u, 0x35u, 0x42u, 0x41u, 0x44u, 0x34u, 0x41u, 0x31u, 0x35u,
+            0x37u, 0x30u, 0xc0u, 0x16u, 0x00u, 0x01u, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x04u, 0xc0u, 0xa8u,
+            0x01u, 0xc5u, 0xc2u, 0xa4u, 0x00u, 0x1cu, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x10u, 0xfeu, 0x80u,
+            0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x12u, 0x5bu, 0xadu, 0xffu, 0xfeu, 0x4au, 0x15u, 0x70u, 0xc0u, 0x27u,
+            0x00u, 0x21u, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u, 0x78u, 0x00u, 0x08u, 0x00u, 0x00u, 0x00u, 0x00u, 0x02u, 0x77u,
+            0xc2u, 0xa4u, 0xc0u, 0x27u, 0x00u, 0x2fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x09u, 0xc0u, 0x27u,
+            0x00u, 0x05u, 0x00u, 0x00u, 0x80u, 0x00u, 0x40u, 0xc2u, 0xa4u, 0x00u, 0x2fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x00u,
+            0x78u, 0x00u, 0x08u, 0xc2u, 0xa4u, 0x00u, 0x04u, 0x40u, 0x00u, 0x00u, 0x08u
+        )
+
+        val packet = DnsPacket.parse(data.asByteArray())
+        assertEquals(QueryResponse.Response.value.toInt(), packet.header.queryResponse)
+        assertEquals(DnsOpcode.StandardQuery.value.toInt(), packet.header.opcode)
+        assertTrue(packet.header.authoritativeAnswer)
+        assertEquals(false, packet.header.truncated)
+        assertEquals(false, packet.header.recursionDesired)
+        assertEquals(false, packet.header.recursionAvailable)
+        assertEquals(false, packet.header.answerAuthenticated)
+        assertEquals(false, packet.header.nonAuthenticatedData)
+        assertEquals(DnsResponseCode.NoError, packet.header.responseCode)
+        assertEquals(0, packet.questions.size)
+        assertEquals(1, packet.answers.size)
+        assertEquals(0, packet.authorities.size)
+        assertEquals(6, packet.additionals.size)
+
+        val firstAnswer = packet.answers[0]
+        assertEquals("_ipp._tcp.local", firstAnswer.name)
+        assertEquals(ResourceRecordType.PTR.value.toInt(), firstAnswer.type)
+        assertEquals(ResourceRecordClass.IN.value.toInt(), firstAnswer.clazz)
+        assertEquals(false, firstAnswer.cacheFlush)
+        assertEquals(4500u, firstAnswer.timeToLive)
+        assertEquals(30, firstAnswer.dataLength)
+        assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", firstAnswer.getDataReader().readPTRRecord().domainName)
+
+        val firstAdditional = packet.additionals[0]
+        assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", firstAdditional.name)
+        assertEquals(ResourceRecordType.TXT.value.toInt(), firstAdditional.type)
+        assertEquals(ResourceRecordClass.IN.value.toInt(), firstAdditional.clazz)
+        assertEquals(true, firstAdditional.cacheFlush)
+        assertEquals(4500u, firstAdditional.timeToLive)
+        assertEquals(595, firstAdditional.dataLength)
+
+        val txtRecord = firstAdditional.getDataReader().readTXTRecord()
+        assertContentEquals(arrayOf(
+            "txtvers=1",
+            "qtotal=1",
+            "pdl=application/octet-stream,image/urf,image/jpeg,image/pwg-raster",
+            "rp=ipp/print",
+            "note=",
+            "ty=Brother DCP-L3550CDW series",
+            "product=(Brother DCP-L3550CDW series)",
+            "adminurl=http://BRW105BAD4A1570.local./net/net/airprint.html",
+            "priority=25",
+            "usb_MFG=Brother",
+            "usb_MDL=DCP-L3550CDW series",
+            "usb_CMD=PJL,PCL,PCLXL,URF",
+            "Color=T",
+            "Copies=T",
+            "Duplex=T",
+            "Fax=F",
+            "Scan=T",
+            "PaperCustom=T",
+            "Binary=T",
+            "Transparent=T",
+            "TBCP=F",
+            "URF=SRGB24,W8,CP1,IS4-1,MT1-3-4-5-8-11,OB10,PQ4,RS600,V1.4,DM1",
+            "kind=document,envelope,label,postcard",
+            "PaperMax=legal-A4",
+            "UUID=e3248000-80ce-11db-8000-3c2af4aac0a4",
+            "print_wfds=T",
+            "mopria-certified=1.3"
+        ), txtRecord.texts.toTypedArray())
+
+        val aRecord = packet.additionals[1].getDataReader().readARecord()
+        assertEquals(InetAddress.getByName("192.168.1.197"), aRecord.address)
+
+        val aaaaRecord = packet.additionals[2].getDataReader().readAAAARecord()
+        assertEquals(InetAddress.getByName("fe80::125b:adff:fe4a:1570"), aaaaRecord.address)
+
+        val srvRecord = packet.additionals[3].getDataReader().readSRVRecord()
+        assertEquals("BRW105BAD4A1570.local", srvRecord.target)
+        assertEquals(0, srvRecord.weight.toInt())
+        assertEquals(0, srvRecord.priority.toInt())
+        assertEquals(631, srvRecord.port.toInt())
+
+        val nSECRecord = packet.additionals[4].getDataReader().readNSECRecord()
+        assertEquals("Brother DCP-L3550CDW series._ipp._tcp.local", nSECRecord.ownerName)
+        assertEquals(1, nSECRecord.typeBitMaps.size)
+        assertEquals(0, nSECRecord.typeBitMaps[0].first)
+        assertContentEquals(byteArrayOf(0, 0, 128.toByte(), 0, 64), nSECRecord.typeBitMaps[0].second)
+    }
+
+    @Test
+    fun `ParseSamsungTV`() {
+        val data = loadByteArray("samsung-airplay.hex")
+        val packet = DnsPacket.parse(data)
+        assertEquals(QueryResponse.Response.value.toInt(), packet.header.queryResponse)
+        assertEquals(DnsOpcode.StandardQuery.value.toInt(), packet.header.opcode)
+        assertTrue(packet.header.authoritativeAnswer)
+        assertEquals(false, packet.header.truncated)
+        assertEquals(false, packet.header.recursionDesired)
+        assertEquals(false, packet.header.recursionAvailable)
+        assertEquals(false, packet.header.answerAuthenticated)
+        assertEquals(false, packet.header.nonAuthenticatedData)
+        assertEquals(DnsResponseCode.NoError, packet.header.responseCode)
+        assertEquals(0, packet.questions.size)
+        assertEquals(6, packet.answers.size)
+        assertEquals(0, packet.authorities.size)
+        assertEquals(4, packet.additionals.size)
+
+        assertEquals("9.1.168.192.in-addr.arpa", packet.answers[0].name)
+        assertEquals(ResourceRecordType.PTR.value.toInt(), packet.answers[0].type)
+        assertEquals(ResourceRecordClass.IN.value.toInt(), packet.answers[0].clazz)
+        assertTrue(packet.answers[0].cacheFlush)
+        assertEquals(120u, packet.answers[0].timeToLive)
+        assertEquals(15, packet.answers[0].dataLength)
+        assertEquals("Samsung.local", packet.answers[0].getDataReader().readPTRRecord().domainName)
+
+        val txtRecord = packet.answers[1].getDataReader().readTXTRecord()
+        assertContentEquals(arrayOf(
+            "acl=0",
+            "deviceid=D4:9D:C0:2F:52:16",
+            "features=0x7F8AD0,0x38BCB46",
+            "rsf=0x3",
+            "fv=p20.0.1",
+            "flags=0x244",
+            "model=URU8000",
+            "manufacturer=Samsung",
+            "serialNumber=0EQC3HDM900064X",
+            "protovers=1.1",
+            "srcvers=377.17.24.6",
+            "pi=ED:0C:A5:ED:10:08",
+            "psi=00000000-0000-0000-0000-ED0CA5ED1008",
+            "gid=00000000-0000-0000-0000-ED0CA5ED1008",
+            "gcgl=0",
+            "pk=d25488cbff1334756165cd7229a235475ef591f2595f38ed251d46b8a4d2345d"
+        ), txtRecord.texts.toTypedArray())
+
+        val srvRecord = packet.answers[4].getDataReader().readSRVRecord()
+        assertEquals(33482, srvRecord.port.toInt())
+        assertEquals(0, srvRecord.priority.toInt())
+        assertEquals(0, srvRecord.weight.toInt())
+        assertEquals("Samsung.local", srvRecord.target)
+
+        val aRecord = packet.answers[5].getDataReader().readARecord()
+        assertEquals(InetAddress.getByName("192.168.1.9"), aRecord.address)
+
+        val nSECRecord = packet.additionals[0].getDataReader().readNSECRecord()
+        assertEquals("9.1.168.192.in-addr.arpa", nSECRecord.ownerName)
+        assertEquals(1, nSECRecord.typeBitMaps.size)
+        assertEquals(0, nSECRecord.typeBitMaps[0].first)
+        assertContentEquals(byteArrayOf(0, 8), nSECRecord.typeBitMaps[0].second)
+
+        val optRecord = packet.additionals[3].getDataReader().readOPTRecord()
+        assertEquals(1, optRecord.options.size)
+        assertEquals(65001, optRecord.options[0].code.toInt())
+        assertEquals(5, optRecord.options[0].data.size)
+        assertContentEquals(byteArrayOf(0, 0, 116, 206.toByte(), 97), optRecord.options[0].data)
+    }
+
+    @Test
+    fun `UnicodeTest`() {
+        val data = ubyteArrayOf(
+            0x00u, 0x00u, 0x84u, 0x00u, 0x00u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x00u, 0x01u, 0x15u, 0x41u, 0x69u, 0x64u,
+            0x61u, 0x6Eu, 0xE2u, 0x80u, 0x99u, 0x73u, 0x20u, 0x4Du, 0x61u, 0x63u, 0x42u, 0x6Fu, 0x6Fu, 0x6Bu, 0x20u, 0x50u,
+            0x72u, 0x6Fu, 0x0Fu, 0x5Fu, 0x63u, 0x6Fu, 0x6Du, 0x70u, 0x61u, 0x6Eu, 0x69u, 0x6Fu, 0x6Eu, 0x2Du, 0x6Cu, 0x69u,
+            0x6Eu, 0x6Bu, 0x04u, 0x5Fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6Cu, 0x6Fu, 0x63u, 0x61u, 0x6Cu, 0x00u, 0x00u, 0x10u,
+            0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x5Bu, 0x16u, 0x72u, 0x70u, 0x42u, 0x41u, 0x3Du, 0x30u, 0x33u,
+            0x3Au, 0x43u, 0x32u, 0x3Au, 0x33u, 0x33u, 0x3Au, 0x38u, 0x36u, 0x3Au, 0x33u, 0x43u, 0x3Au, 0x45u, 0x45u, 0x11u,
+            0x72u, 0x70u, 0x41u, 0x44u, 0x3Du, 0x66u, 0x33u, 0x33u, 0x37u, 0x61u, 0x38u, 0x61u, 0x32u, 0x38u, 0x64u, 0x35u,
+            0x31u, 0x0Cu, 0x72u, 0x70u, 0x46u, 0x6Cu, 0x3Du, 0x30u, 0x78u, 0x32u, 0x30u, 0x30u, 0x30u, 0x30u, 0x11u, 0x72u,
+            0x70u, 0x48u, 0x4Eu, 0x3Du, 0x31u, 0x66u, 0x66u, 0x64u, 0x64u, 0x64u, 0x66u, 0x33u, 0x63u, 0x39u, 0x65u, 0x33u,
+            0x07u, 0x72u, 0x70u, 0x4Du, 0x61u, 0x63u, 0x3Du, 0x30u, 0x0Au, 0x72u, 0x70u, 0x56u, 0x72u, 0x3Du, 0x33u, 0x36u,
+            0x30u, 0x2Eu, 0x34u, 0xC0u, 0x0Cu, 0x00u, 0x2Fu, 0x80u, 0x01u, 0x00u, 0x00u, 0x11u, 0x94u, 0x00u, 0x09u, 0xC0u,
+            0x0Cu, 0x00u, 0x05u, 0x00u, 0x00u, 0x80u, 0x00u, 0x40u
+        )
+
+        val packet = DnsPacket.parse(data.asByteArray())
+        assertEquals("Aidan’s MacBook Pro._companion-link._tcp.local", packet.additionals[0].name)
+    }
+
+    /*@Test
+    fun `TestReadDomainName`() {
+        val data = ubyteArrayOf(
+            0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x04u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x00u, 0x0Bu, 0x5Fu, 0x67u, 0x6Fu,
+            0x6Fu, 0x67u, 0x6Cu, 0x65u, 0x63u, 0x61u, 0x73u, 0x74u, 0x04u, 0x5Fu, 0x74u, 0x63u, 0x70u, 0x05u, 0x6Cu, 0x6Fu,
+            0x63u, 0x61u, 0x6Cu, 0xC0u, 0x0Cu, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x08u, 0x5Fu, 0x61u, 0x69u, 0x72u, 0x70u, 0x6Cu,
+            0x61u, 0x79u, 0xC0u, 0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x09u, 0x5Fu, 0x66u, 0x61u, 0x73u, 0x74u, 0x63u, 0x61u,
+            0x73u, 0x74u, 0xC0u, 0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u, 0x06u, 0x5Fu, 0x66u, 0x63u, 0x61u, 0x73u, 0x74u, 0xC0u,
+            0x18u, 0x00u, 0x0Cu, 0x00u, 0x01u
+        )
+
+        val packet = DnsPacket.parse(data.asByteArray())
+        println()
+    }*/
+
+    private fun loadByteArray(name: String): ByteArray {
+        javaClass.classLoader.getResourceAsStream(name).use { input ->
+            requireNotNull(input) { "File not found: $name" }
+            val result = ByteArrayOutputStream()
+            val buffer = ByteArray(4096)
+            var length: Int
+
+            while ((input.read(buffer).also { length = it }) > 0) {
+                result.write(buffer, 0, length)
+            }
+            return result.toByteArray()
+        }
+    }
+
+    @Test
+    fun `ReserializeDnsPrinter`() {
+        val data = loadByteArray("samsung-airplay.hex")
+        val packet = DnsPacket.parse(data)
+        val writer = DnsWriter()
+        writer.writePacket(
+            header = packet.header,
+            questionCount = packet.questions.size,
+            questionWriter = { _, _ -> },
+            answerCount = packet.answers.size,
+            answerWriter = { w, i ->
+                w.write(packet.answers[i]) { v ->
+                    val reader = packet.answers[i].getDataReader()
+                    when (i) {
+                        0, 2, 3 -> v.write(reader.readPTRRecord())
+                        1 -> v.write(reader.readTXTRecord())
+                        4 -> v.write(reader.readSRVRecord())
+                        5 -> v.write(reader.readARecord())
+                    }
+                }
+            },
+            authorityCount = packet.authorities.size,
+            authorityWriter = { _, _ -> },
+            additionalsCount = packet.additionals.size,
+            additionalWriter = { w, i ->
+                w.write(packet.additionals[i]) { v ->
+                    val reader = packet.additionals[i].getDataReader()
+                    when (i) {
+                        0, 1, 2 -> v.write(reader.readNSECRecord())
+                        3 -> v.write(reader.readOPTRecord())
+                    }
+                }
+            }
+        )
+
+        assertContentEquals(data, writer.toByteArray())
+    }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/futo/platformplayer/UtilityTests.kt b/app/src/test/java/com/futo/platformplayer/UtilityTests.kt
index 6103a43810c385f21ac42195dacc812c10a89292..2586ed56d3ce87759a43a21a08b8b31b2fb847cd 100644
--- a/app/src/test/java/com/futo/platformplayer/UtilityTests.kt
+++ b/app/src/test/java/com/futo/platformplayer/UtilityTests.kt
@@ -1,6 +1,8 @@
 package com.futo.platformplayer
 
+import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
 import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertFalse
 import junit.framework.TestCase.assertTrue
 import org.junit.Assert.assertThrows
 import org.junit.Test
@@ -52,4 +54,21 @@ class UtilityTests {
         assertEquals("\ud83d\udc80\ud83d\udd14", "\\ud83d\\udc80\\ud83d\\udd14".decodeUnicode())
         assertEquals("String with a slash (/) in it", "String with a slash (/) in it".decodeUnicode())
     }
+
+
+    @Test
+    fun testMatchDomain() {
+        //TLD
+        assertTrue("test.abc.com".matchesDomain(".abc.com"))
+        assertTrue("abc.com".matchesDomain("abc.com"))
+        assertFalse("test.abc.com".matchesDomain("abc.com"))
+        assertThrows(IllegalStateException::class.java, { "test.uk".matchesDomain(".uk") });
+
+
+        //SLD
+        assertTrue("abc.co.uk".matchesDomain("abc.co.uk"))
+        assertTrue("test.abc.co.uk".matchesDomain("test.abc.co.uk"))
+        assertTrue("test.abc.co.uk".matchesDomain(".abc.co.uk"))
+        assertThrows(IllegalStateException::class.java, { "test.abc.co.uk".matchesDomain(".co.uk") });
+    }
 }
\ No newline at end of file
diff --git a/app/src/test/resources/samsung-airplay.hex b/app/src/test/resources/samsung-airplay.hex
new file mode 100644
index 0000000000000000000000000000000000000000..8938268ca32c62ea4b892dcaafc3dafe32cfce85
Binary files /dev/null and b/app/src/test/resources/samsung-airplay.hex differ
diff --git a/app/src/unstable/assets/sources/spotify b/app/src/unstable/assets/sources/spotify
index 4d554e93882d29542cb05d2956f4b2484d7df27f..c700081466038ee4782610feaa05cd4d34d024d8 160000
--- a/app/src/unstable/assets/sources/spotify
+++ b/app/src/unstable/assets/sources/spotify
@@ -1 +1 @@
-Subproject commit 4d554e93882d29542cb05d2956f4b2484d7df27f
+Subproject commit c700081466038ee4782610feaa05cd4d34d024d8
diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube
index 24f9e4456faf97fbbb866e1df2df9d94628ffcc6..91639d939738d9cc81ebdb1cd047ead9edd3a5e8 160000
--- a/app/src/unstable/assets/sources/youtube
+++ b/app/src/unstable/assets/sources/youtube
@@ -1 +1 @@
-Subproject commit 24f9e4456faf97fbbb866e1df2df9d94628ffcc6
+Subproject commit 91639d939738d9cc81ebdb1cd047ead9edd3a5e8