瀏覽代碼

html scrapper

Milan Jurkulak 3 年之前
父節點
當前提交
f127e165c6
共有 50 個文件被更改,包括 2511 次插入 和 89 次删除
  1. 39 43
      app/build.gradle.kts
  2. 116 0
      app/src/main/assets/adblock.txt
  3. 12 2
      build.gradle.kts
  4. 7 0
      buildSrc/build.gradle.kts
  5. 57 0
      buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/CommonBinaryPlugin.kt
  6. 2 1
      gradle.properties
  7. 2 2
      gradle/wrapper/gradle-wrapper.properties
  8. 40 39
      tvlib/build.gradle.kts
  9. 56 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/assets/AssetDirectory.kt
  10. 51 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/assets/AssetFile.kt
  11. 28 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/assets/AssetType.kt
  12. 24 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/assets/IAssetObject.kt
  13. 2 2
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/web/WebView.kt
  14. 190 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/CustomWebChromeClient.kt
  15. 150 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/CustomWebViewClient.kt
  16. 70 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/WebClient.kt
  17. 69 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/WebScrapper.kt
  18. 80 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/adblock/AdBlock.kt
  19. 41 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/adblock/HttpCacheInterceptor.kt
  20. 160 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/AssetsLoader.kt
  21. 94 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/CacheExtensionConfig.kt
  22. 6 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/CacheType.kt
  23. 54 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/DynamicCacheLoader.kt
  24. 34 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/FileUtil.kt
  25. 25 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/HttpCacheInterceptor.kt
  26. 40 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/MD5Utils.kt
  27. 34 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/MimeTypeMapUtils.kt
  28. 48 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/OKHttpFile.kt
  29. 5 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/ResourceInterceptor.kt
  30. 379 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/WebViewCacheInterceptor.kt
  31. 86 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/WebViewCacheInterceptorInst.kt
  32. 33 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/WebViewRequestInterceptor.kt
  33. 25 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cookies/CookiesManager.kt
  34. 23 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlAnchor.kt
  35. 8 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlBody.kt
  36. 8 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlDiv.kt
  37. 25 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlDocument.kt
  38. 83 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlElement.kt
  39. 15 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlHead.kt
  40. 15 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlImage.kt
  41. 12 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlLink.kt
  42. 12 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlMeta.kt
  43. 10 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlNoScript.kt
  44. 19 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlPage.kt
  45. 13 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlScript.kt
  46. 11 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlSource.kt
  47. 11 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlTitle.kt
  48. 91 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlVideo.kt
  49. 89 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/javascript/JSI.kt
  50. 7 0
      tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/javascript/JSIListener.kt

+ 39 - 43
app/build.gradle.kts

@@ -35,6 +35,7 @@ plugins {
     kotlin("kapt")
     id("com.google.devtools.ksp")
 //    id("org.jetbrains.dokka") version "1.8.10"
+//    id ("app.cash.paparazzi") version "1.3.1"
 }
 
 android {
@@ -65,8 +66,6 @@ android {
 
         signingConfig = signingConfigs[SIGNING_CONFIG_NAME]
 
-        buildConfigField("Boolean", "IS_COMPOSE_DEBUG", "false")
-
         buildConfigField(
             "String",
             "IPTV_API_URL",
@@ -102,7 +101,6 @@ android {
                 getDefaultProguardFile("proguard-android-optimize.txt"),
                 "proguard-rules.pro"
             )
-            buildConfigField("Boolean", "DEBUG_RECOMPOSE", "false")
             buildConfigField(
                 "String",
                 "SYNC_AUTH",
@@ -123,7 +121,6 @@ android {
                 getDefaultProguardFile("proguard-android-optimize.txt"),
                 "proguard-rules.pro"
             )
-            buildConfigField("Boolean", "DEBUG_RECOMPOSE", "false")
             buildConfigField(
                 "String",
                 "SYNC_AUTH",
@@ -144,7 +141,6 @@ android {
                 getDefaultProguardFile("proguard-android-optimize.txt"),
                 "proguard-rules.pro"
             )
-            buildConfigField("Boolean", "DEBUG_RECOMPOSE", "true")
             buildConfigField(
                 "String",
                 "SYNC_AUTH",
@@ -205,22 +201,22 @@ dependencies {
     // tv library by mjde milan jurkulak
     implementation(project(mapOf("path" to ":tvlib")))
     // base libs
-    implementation("androidx.core:core-ktx:1.12.0-rc01")
+    implementation("androidx.core:core-ktx:1.12.0")
     implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
     // compose base libs
-    implementation(platform("androidx.compose:compose-bom:2023.06.01"))
+    implementation(platform("androidx.compose:compose-bom:2023.09.01"))
     implementation("androidx.compose.ui:ui-tooling")
     implementation("androidx.activity:activity-compose:1.7.2")
     // more icons
-    implementation("androidx.compose.material:material-icons-extended:1.5.0")
+    implementation("androidx.compose.material:material-icons-extended:1.5.1")
     // tv compose
-    implementation("androidx.tv:tv-foundation:1.0.0-alpha08")
-    implementation("androidx.tv:tv-material:1.0.0-alpha08")
+    implementation("androidx.tv:tv-foundation:1.0.0-alpha09")
+    implementation("androidx.tv:tv-material:1.0.0-alpha09")
     // dagger - hilt
-    implementation("com.google.dagger:dagger-android:2.47")
-    implementation("com.google.dagger:dagger-android-support:2.47")
-    implementation("com.google.dagger:dagger:2.47")
-    implementation("com.google.dagger:hilt-android:2.47")
+    implementation("com.google.dagger:dagger-android:2.48")
+    implementation("com.google.dagger:dagger-android-support:2.48")
+    implementation("com.google.dagger:dagger:2.48")
+    implementation("com.google.dagger:hilt-android:2.48")
     implementation("androidx.hilt:hilt-navigation-compose:1.1.0-alpha01")
     // moshi json
     implementation("com.squareup.moshi:moshi:1.15.0")
@@ -232,24 +228,24 @@ dependencies {
     // debug
     implementation("com.jakewharton.timber:timber:5.0.1")
     // kapt
-    kapt("com.google.dagger:dagger-compiler:2.47")
-    kapt("com.google.dagger:dagger-android-processor:2.47")
-    kapt("com.google.dagger:hilt-compiler:2.47")
+    kapt("com.google.dagger:dagger-compiler:2.48")
+    kapt("com.google.dagger:dagger-android-processor:2.48")
+    kapt("com.google.dagger:hilt-compiler:2.48")
     ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
     // view model
-    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
+    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
     // navigation
-    implementation("androidx.navigation:navigation-compose:2.7.0")
+    implementation("androidx.navigation:navigation-compose:2.7.3")
     // retrofit
     implementation("com.squareup.retrofit2:retrofit:2.9.0")
     implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
     implementation("com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2")
     // sandwich
-    implementation("com.github.skydoves:sandwich:1.3.8")
+    implementation("com.github.skydoves:sandwich:1.3.9")
     // encrypt data
     implementation("com.scottyab:aescrypt:0.0.1")
     // permission
-    implementation("com.google.accompanist:accompanist-permissions:0.33.0-alpha")
+    implementation("com.google.accompanist:accompanist-permissions:0.33.2-alpha")
     // previews
     debugImplementation("androidx.customview:customview-poolingcontainer:1.0.0")
     // lottie
@@ -258,37 +254,37 @@ dependencies {
     implementation("com.auth0.android:auth0:2.10.1")
     implementation("com.auth0.android:jwtdecode:2.0.2")
     // widgets
-    implementation("androidx.glance:glance-appwidget:1.0.0-rc01")
-    implementation("androidx.glance:glance-material:1.0.0-rc01")
-    implementation("androidx.glance:glance-material3:1.0.0-rc01")
+    implementation("androidx.glance:glance-appwidget:1.0.0")
+    implementation("androidx.glance:glance-material:1.0.0")
+    implementation("androidx.glance:glance-material3:1.0.0")
     // fix duplicates
     implementation("androidx.work:work-runtime-ktx:2.8.1")
     // exoplayer
-    implementation("androidx.media3:media3-exoplayer:1.1.0")
-    implementation("androidx.media3:media3-exoplayer-dash:1.1.0")
-    implementation("androidx.media3:media3-exoplayer-hls:1.1.0")
-    implementation("androidx.media3:media3-exoplayer-rtsp:1.1.0")
+    implementation("androidx.media3:media3-exoplayer:1.1.1")
+    implementation("androidx.media3:media3-exoplayer-dash:1.1.1")
+    implementation("androidx.media3:media3-exoplayer-hls:1.1.1")
+    implementation("androidx.media3:media3-exoplayer-rtsp:1.1.1")
 //    implementation("androidx.media3:media3-exoplayer-ima:1.1.0")
-    implementation("androidx.media3:media3-datasource-cronet:1.1.0")
-    implementation("androidx.media3:media3-datasource-okhttp:1.1.0")
-    implementation("androidx.media3:media3-datasource-rtmp:1.1.0")
-    implementation("androidx.media3:media3-ui:1.1.0")
+    implementation("androidx.media3:media3-datasource-cronet:1.1.1")
+    implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
+    implementation("androidx.media3:media3-datasource-rtmp:1.1.1")
+    implementation("androidx.media3:media3-ui:1.1.1")
 //    implementation("androidx.media3:media3-ui-leanback:1.1.0")
-    implementation("androidx.media3:media3-session:1.1.0")
-    implementation("androidx.media3:media3-extractor:1.1.0")
-    implementation("androidx.media3:media3-cast:1.1.0")
-    implementation("androidx.media3:media3-exoplayer-workmanager:1.1.0")
-    implementation("androidx.media3:media3-transformer:1.1.0")
-    implementation("androidx.media3:media3-database:1.1.0")
-    implementation("androidx.media3:media3-decoder:1.1.0")
-    implementation("androidx.media3:media3-datasource:1.1.0")
-    implementation("androidx.media3:media3-common:1.1.0")
+    implementation("androidx.media3:media3-session:1.1.1")
+    implementation("androidx.media3:media3-extractor:1.1.1")
+    implementation("androidx.media3:media3-cast:1.1.1")
+    implementation("androidx.media3:media3-exoplayer-workmanager:1.1.1")
+    implementation("androidx.media3:media3-transformer:1.1.1")
+    implementation("androidx.media3:media3-database:1.1.1")
+    implementation("androidx.media3:media3-decoder:1.1.1")
+    implementation("androidx.media3:media3-datasource:1.1.1")
+    implementation("androidx.media3:media3-common:1.1.1")
     // libs mismatch
     constraints {
-        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.0")?.apply {
+        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.0").apply {
             because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
         }
-        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0")?.apply {
+        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0").apply {
             because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
         }
     }

+ 116 - 0
app/src/main/assets/adblock.txt

@@ -0,0 +1,116 @@
+####################################################################################################
+# HOSTS ############################################################################################
+####################################################################################################
+*stats.hprofits.com
+*ads.exoclick.com
+*syndication.exosrv.com
+*syndication.realsrv.com
+*ads.exosrv.com
+*ads.realsrv.com
+*a.realsrv.com
+*stats.hprofits.com
+*tsyndicate.com
+*fonts.gstatic.com
+*fonts.googleapis.com
+*stats.g.doubleclick.net
+*www.google-analytics.com
+*google-analytics.com
+*icdn02.meatyhunks.com
+*icdn03.meatyhunks.com
+*icdn01.meatyhunks.com
+*cdn5.hdtube.porn
+*cdn3.hdtube.porn
+*cdn.trafficstars.com
+*cdn.tsyndicate.com
+*www.imglnke.com
+*static.trafficjunky.com
+*i.psmimg.com
+*img.strpst.com
+*etahub.com
+*los.2hisnd.com
+*go.leojmp.com
+*www.googletagmanager.com
+*googletagmanager.com
+*creative.leojmp.com
+*s3t3d2y7.ackcdn.net
+*creative.zybrdr.com
+*pxl.tsyndicate.com
+*cdn.corot-24.com
+*cdn.2hisnd.com
+*a.adtng.com
+*creative.xxxjmp.com
+*chaturbate.com
+*ofrsrv.com
+*a.bestcontentsite.top
+*b.bestcontentsite.top
+*wendelstein-1b.com
+*hw-cdn2.ang-content.com
+*r-us.tsyndicate.com
+*lcdn.tsyndicate.com
+*cdn1.hdtube.porn
+*v.vfgte.com
+*i.bcprm.com
+*c-us24.stripst.com
+*cdn2.hdtube.porn
+*if.psmimg.com
+*go.hpyjmp.com
+*go.zybrdr.com
+*vz-cdn.trafficjunky.net
+*ads.trafficjunky.net
+*bam-cell.nr-data.net
+*bngpt.com
+*i.bimbolive.com
+*go.xxxjmp.com
+*counter.yadro.ru
+*f2.cdnxe.com
+*x1.cdnxe.com
+*69v.club
+*static.doubleclick.net
+*ssl.google-analytics.com
+*i.bngprl.com
+*browser.sentry-cdn.com
+*vcdn.tsyndicate.com
+*sentry.aechannel.com
+*a.bestcontentcost.top
+*b.bestcontentcost.top
+*bongacams.com
+####################################################################################################
+# URLS #############################################################################################
+####################################################################################################
+https://www.google.com/ads/
+https://www.google.cz/ads/
+https://www.meatyhunks.com/e/
+https://www.google-analytics.com/
+https://u3y8v8u3.ackcdn.net/library/
+https://go.bshrdr.com/smartpop/
+https://www.hdtube.porn/serp/
+https://go.hpyjmp.com/smartpop/
+https://hdtube.porn/serp/
+https://www.hdtube.porn/static/
+https://evtubescms.phncdn.com/pre_videos/
+https://di.phncdn.com/www-static/images/video_page/
+https://www.pornhub.com/_xa/ads
+https://go.xxxjmp.com/eye.gif
+https://di.phncdn.com/www-static/css/
+https://ei.phncdn.com/videos/
+https://di.phncdn.com/www-static/images/
+https://ci.phncdn.com/www-static/images/
+https://ci.phncdn.com/www-static/css
+https://go.dzhjmp.com/smartpop/
+https://www.pornhub.com/front/
+https://ssl.google-analytics.com/ga.js
+https://di.phncdn.com/videos/
+https://di.phncdn.com/www-static/favicon.ico
+https://static-assets.highwebmedia.com/
+https://tubestatic.usco1621-b.com/
+https://www.sleazyneasy.com/videos/
+https://js-agent.newrelic.com/
+https://cbjpeg.stream.highwebmedia.com/
+https://realtime.pa.highwebmedia.com/
+https://assets.bfvmedia.com/
+https://static-pub.highwebmedia.com/
+https://stream.highwebmedia.com/
+https://zbporn.com/search_videos_json.php
+https://www.sexvid.xxx/search_videos_json.php
+https://www.rat.xxx/search_videos_json.php
+https://www.pornid.xxx/search_videos_json.php

+ 12 - 2
build.gradle.kts

@@ -7,8 +7,8 @@
  */
 
 plugins {
-    id("com.android.application") version "8.1.0" apply false
-    id("com.android.library") version "8.1.0" apply false
+    id("com.android.application") version "8.1.1" apply false
+    id("com.android.library") version "8.1.1" apply false
     kotlin("android") version "1.7.20" apply false
     id("com.google.dagger.hilt.android") version "2.47" apply false
     id("com.google.devtools.ksp") version "1.9.0-1.0.11" apply false
@@ -19,10 +19,20 @@ buildscript {
         mavenCentral()
         maven(url = "https://storage.googleapis.com/r8-releases/raw")
         maven(url = "https://mvn.dailymotion.com/repository/releases/")
+        google()
     }
     dependencies {
+        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.21")
         classpath("com.android.tools:r8:8.1.56")
         classpath("io.objectbox:objectbox-gradle-plugin:3.6.0")
         classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0")
+        classpath("com.android.tools.build:gradle:8.1.1")
+        classpath("com.android.tools.build:gradle-api:8.1.1")
+//        classpath("com.newrelic.agent.android:agent-gradle-plugin:5.9.0")
+        classpath ("app.cash.paparazzi:paparazzi-gradle-plugin:1.3.1")
     }
+}
+
+tasks.register<Delete>("clean") {
+    delete(rootProject.buildDir)
 }

+ 7 - 0
buildSrc/build.gradle.kts

@@ -21,6 +21,7 @@ repositories {
 
 plugins {
     `kotlin-dsl`
+    `kotlin-dsl-precompiled-script-plugins`
 //    `java-gradle-plugin`
 //    `groovy-gradle-plugin`
 //    id("org.jetbrains.dokka") version "1.8.10"
@@ -30,6 +31,12 @@ plugins {
 dependencies {
     implementation(localGroovy())
     implementation(gradleApi())
+
+//    implementation("com.android.tools.build:gradle:3.4.2")
+//    implementation("com.android.tools.build:gradle-api:3.4.3")
+//    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0")
+//    implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.61")
+
 //    implementation(kotlin("reflect", "1.8.21"))
 //    implementation("com.google.code.gson:gson:2.10.1")
 //    implementation("com.twelvemonkeys.imageio:imageio-core:3.9.4")

+ 57 - 0
buildSrc/src/main/kotlin/org/mjdev/gradle/plugin/CommonBinaryPlugin.kt

@@ -0,0 +1,57 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.gradle.plugin
+
+//class CommonBinaryPlugin : Plugin<Project> {
+
+//    private val Project.android: BaseExtension
+//        get() = extensions.findByName("android") as? BaseExtension
+//            ?: error("Not an Android module: $name")
+
+//    override fun apply(project: Project) {
+//        with(project) {
+//            applyPlugins()
+//            androidConfig()
+//            dependenciesConfig()
+//        }
+//    }
+
+//    private fun Project.applyPlugins() {
+//        plugins.run {
+//            apply("com.android.library")
+//            apply("kotlin-android")
+//            apply("kotlin-android-extensions")
+//        }
+//    }
+
+//    private fun Project.androidConfig() {
+//        android.run {
+//            compileSdkVersion(29)
+//            defaultConfig {
+//                minSdkVersion(23)
+//                targetSdkVersion(29)
+//                versionCode = 1
+//                versionName = "1.0"
+//                testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+//            }
+//            buildTypes {
+//                getByName("debug") {
+//                    isMinifyEnabled = false
+//                }
+//            }
+//        }
+//    }
+
+//    private fun Project.dependenciesConfig() {
+//        dependencies {
+//            "implementation"("androidx.appcompat:appcompat:1.1.0")
+//        }
+//    }
+
+//}

+ 2 - 1
gradle.properties

@@ -24,4 +24,5 @@ android.nonTransitiveRClass=true
 android.defaults.buildfeatures.buildconfig=true
 org.gradle.unsafe.configuration-cache=false
 android.suppressUnsupportedCompileSdk=34
-android.nonFinalResIds=true
+android.nonFinalResIds=true
+gradle.testLauncherAPI.enabled=false

+ 2 - 2
gradle/wrapper/gradle-wrapper.properties

@@ -1,6 +1,6 @@
-#Fri Jul 07 13:34:28 CEST 2023
+#Wed Sep 27 16:14:20 CEST 2023
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists

+ 40 - 39
tvlib/build.gradle.kts

@@ -32,7 +32,6 @@ android {
         minSdk = projectMinSdk
         testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
         consumerProguardFiles("consumer-rules.pro")
-        buildConfigField("Boolean", "IS_COMPOSE_DEBUG", "false")
     }
 
     buildTypes {
@@ -85,25 +84,25 @@ android {
 
 dependencies {
     // base libs
-    implementation("androidx.core:core-ktx:1.12.0-rc01")
+    implementation("androidx.core:core-ktx:1.12.0")
     implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
     // reflect / debug purposes only
-    implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0")
+    implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.10")
     // window manager
     implementation("androidx.window:window:1.1.0")
     // compose base libs
-    implementation(platform("androidx.compose:compose-bom:2023.06.01"))
+    implementation(platform("androidx.compose:compose-bom:2023.09.01"))
     implementation("androidx.compose.ui:ui-tooling")
     implementation("androidx.activity:activity-compose:1.7.2")
     // more icons
-    implementation("androidx.compose.material:material-icons-extended:1.5.0")
+    implementation("androidx.compose.material:material-icons-extended:1.5.1")
     // tv compose
-    implementation("androidx.tv:tv-foundation:1.0.0-alpha08")
-    implementation("androidx.tv:tv-material:1.0.0-alpha08")
+    implementation("androidx.tv:tv-foundation:1.0.0-alpha09")
+    implementation("androidx.tv:tv-material:1.0.0-alpha09")
     // view model
-    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
+    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
     // navigation
-    implementation("androidx.navigation:navigation-compose:2.7.0")
+    implementation("androidx.navigation:navigation-compose:2.7.3")
     // okhttp
     implementation(platform("com.squareup.okhttp3:okhttp-bom:5.0.0-alpha.11"))
     implementation("com.squareup.okhttp3:okhttp")
@@ -117,52 +116,52 @@ dependencies {
     implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
     ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
     // sandwich
-    implementation("com.github.skydoves:sandwich:1.3.8")
+    implementation("com.github.skydoves:sandwich:1.3.9")
     // image loading
     implementation("io.coil-kt:coil-base:2.4.0")
     implementation("io.coil-kt:coil-gif:2.4.0")
     implementation("io.coil-kt:coil-svg:2.4.0")
     implementation("io.coil-kt:coil-video:2.4.0")
     implementation("io.coil-kt:coil-compose:2.4.0")
-    implementation("com.github.skydoves:landscapist-coil:2.2.5")
+    implementation("com.github.skydoves:landscapist-coil:2.2.9")
     // debug
     implementation("com.jakewharton.timber:timber:5.0.1")
     // dagger core
-    implementation("com.google.dagger:dagger:2.47")
-    implementation("androidx.compose.material3:material3:1.1.1")
-    kapt("com.google.dagger:dagger-compiler:2.47")
+    implementation("com.google.dagger:dagger:2.48")
+    implementation("androidx.compose.material3:material3:1.1.2")
+    kapt("com.google.dagger:dagger-compiler:2.48")
     // dagger android
-    implementation("com.google.dagger:dagger-android:2.47")
-    implementation("com.google.dagger:dagger-android-support:2.47")
-    kapt("com.google.dagger:dagger-android-processor:2.47")
+    implementation("com.google.dagger:dagger-android:2.48")
+    implementation("com.google.dagger:dagger-android-support:2.48")
+    kapt("com.google.dagger:dagger-android-processor:2.48")
     // dagger - hilt
-    implementation("com.google.dagger:hilt-android:2.47")
+    implementation("com.google.dagger:hilt-android:2.48")
     implementation("androidx.hilt:hilt-navigation-compose:1.1.0-alpha01")
-    kapt("com.google.dagger:hilt-compiler:2.47")
+    kapt("com.google.dagger:hilt-compiler:2.48")
     // encrypt data
     implementation("com.scottyab:aescrypt:0.0.1")
     // exoplayer
-    implementation("androidx.media3:media3-exoplayer:1.1.0")
-    implementation("androidx.media3:media3-exoplayer-dash:1.1.0")
-    implementation("androidx.media3:media3-exoplayer-hls:1.1.0")
-    implementation("androidx.media3:media3-exoplayer-rtsp:1.1.0")
+    implementation("androidx.media3:media3-exoplayer:1.1.1")
+    implementation("androidx.media3:media3-exoplayer-dash:1.1.1")
+    implementation("androidx.media3:media3-exoplayer-hls:1.1.1")
+    implementation("androidx.media3:media3-exoplayer-rtsp:1.1.1")
 //    implementation("androidx.media3:media3-exoplayer-ima:1.1.0")
-    implementation("androidx.media3:media3-datasource-cronet:1.1.0")
-    implementation("androidx.media3:media3-datasource-okhttp:1.1.0")
-    implementation("androidx.media3:media3-datasource-rtmp:1.1.0")
-    implementation("androidx.media3:media3-ui:1.1.0")
+    implementation("androidx.media3:media3-datasource-cronet:1.1.1")
+    implementation("androidx.media3:media3-datasource-okhttp:1.1.1")
+    implementation("androidx.media3:media3-datasource-rtmp:1.1.1")
+    implementation("androidx.media3:media3-ui:1.1.1")
 //    implementation("androidx.media3:media3-ui-leanback:1.1.0")
-    implementation("androidx.media3:media3-session:1.1.0")
-    implementation("androidx.media3:media3-extractor:1.1.0")
-    implementation("androidx.media3:media3-cast:1.1.0")
-    implementation("androidx.media3:media3-exoplayer-workmanager:1.1.0")
-    implementation("androidx.media3:media3-transformer:1.1.0")
-    implementation("androidx.media3:media3-database:1.1.0")
-    implementation("androidx.media3:media3-decoder:1.1.0")
-    implementation("androidx.media3:media3-datasource:1.1.0")
-    implementation("androidx.media3:media3-common:1.1.0")
+    implementation("androidx.media3:media3-session:1.1.1")
+    implementation("androidx.media3:media3-extractor:1.1.1")
+    implementation("androidx.media3:media3-cast:1.1.1")
+    implementation("androidx.media3:media3-exoplayer-workmanager:1.1.1")
+    implementation("androidx.media3:media3-transformer:1.1.1")
+    implementation("androidx.media3:media3-database:1.1.1")
+    implementation("androidx.media3:media3-decoder:1.1.1")
+    implementation("androidx.media3:media3-datasource:1.1.1")
+    implementation("androidx.media3:media3-common:1.1.1")
     // permission
-    implementation("com.google.accompanist:accompanist-permissions:0.33.0-alpha")
+    implementation("com.google.accompanist:accompanist-permissions:0.33.2-alpha")
     // for previews
     debugImplementation("androidx.customview:customview-poolingcontainer:1.0.0")
     // pallette
@@ -173,16 +172,18 @@ dependencies {
     implementation("androidx.exifinterface:exifinterface:1.3.6")
     // anr
     implementation("com.github.anrwatchdog:anrwatchdog:1.4.0")
+    // jsoup
+    implementation("org.jsoup:jsoup:1.16.1")
     // dm
     implementation("com.dailymotion.dailymotion-sdk-android:sdk:0.2.12")
     // yt
     // ...
     // constraints
     constraints {
-        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.0")?.apply {
+        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.0").apply {
             because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
         }
-        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0")?.apply {
+        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0").apply {
             because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
         }
     }

+ 56 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/assets/AssetDirectory.kt

@@ -0,0 +1,56 @@
+package org.mjdev.tvlib.assets
+
+import android.content.Context
+import android.content.res.AssetManager
+
+@Suppress("unused")
+class AssetDirectory(
+    val context: Context,
+    val path: String
+) : IAssetObject {
+
+    val assets: AssetManager = context.assets
+
+    private val elements: HashMap<String, IAssetObject> = HashMap()
+
+    override val type: AssetType get() = AssetType.DIRECTORY
+
+    init {
+        assets.list(path)?.forEach { name ->
+            if ((assets.list("$path/$name")?.size ?: 0) > 0) {
+                elements[name] = AssetDirectory(context, "$path/$name")
+            } else {
+                elements[name] = AssetFile(context, "$path/$name")
+            }
+        }
+    }
+
+    operator fun get(s: String): IAssetObject {
+        return try {
+            if (elements.containsKey(s))
+                elements[s] ?: IAssetObject.EMPTY
+            else
+                IAssetObject.EMPTY
+        } catch (e: Exception) {
+            IAssetObject.EMPTY
+        }
+    }
+
+    override val size: Int get() = elements.size
+
+    override fun contains(element: IAssetObject): Boolean = elements.containsValue(element)
+
+    override fun containsAll(elements: Collection<IAssetObject>): Boolean =
+        elements.containsAll(elements)
+
+    fun contains(name: String) = elements.containsKey(name)
+
+    override fun isEmpty(): Boolean = (size == 0)
+
+    override fun iterator(): Iterator<IAssetObject> = elements.values.iterator()
+
+    override fun toString(): String {
+        return "[$path]"
+    }
+
+}

+ 51 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/assets/AssetFile.kt

@@ -0,0 +1,51 @@
+package org.mjdev.tvlib.assets
+
+import android.content.Context
+import android.net.Uri
+import androidx.annotation.Keep
+import java.io.File
+import java.io.InputStream
+
+@Keep
+class AssetFile(
+    val context: Context,
+    private val assetsPath: String
+) : InputStream(), IAssetObject {
+
+    private val innerStream: InputStream by lazy {
+        context.assets.open(assetsPath)
+    }
+
+    val name = assetsPath.let {
+        File(Uri.parse(it).lastPathSegment ?: "").nameWithoutExtension
+    }
+
+    val file : File get() = File("file:///android_asset/$assetsPath")
+
+    override fun read(): Int {
+        return innerStream.read()
+    }
+
+    override val type: AssetType get() = AssetType.fromFileExt(assetsPath)
+
+    override val size: Int get() = 0
+
+    override fun contains(element: IAssetObject): Boolean {
+        return false
+    }
+
+    override fun containsAll(elements: Collection<IAssetObject>): Boolean {
+        return false
+    }
+
+    override fun isEmpty(): Boolean {
+        return true
+    }
+
+    override fun iterator(): Iterator<IAssetObject> {
+        return emptyList<IAssetObject>().iterator()
+    }
+
+    override fun toString(): String = assetsPath
+
+}

+ 28 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/assets/AssetType.kt

@@ -0,0 +1,28 @@
+package org.mjdev.tvlib.assets;
+
+import java.io.File
+import kotlin.reflect.KClass
+
+enum class AssetType(val fileExt: String, val parserClass: KClass<*>) {
+
+    UNKNOWN("", Unit::class),
+    DIRECTORY("", Unit::class);
+//    M3UFile("m3u", M3UParser::class),
+//    WEBSOURCE("json", JSONDescFile::class);
+
+    companion object {
+
+        fun fromFileExt(fileName: String): AssetType {
+            return File(fileName).let { file ->
+                when (file.extension) {
+//                    "json" -> WEBSOURCE
+//                    "m3u" -> M3UFile
+//                    "m3u8" -> M3UFile
+                    else -> UNKNOWN
+                }
+            }
+        }
+
+    }
+
+}

+ 24 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/assets/IAssetObject.kt

@@ -0,0 +1,24 @@
+package org.mjdev.tvlib.assets
+
+interface IAssetObject : Collection<IAssetObject> {
+
+    val type : AssetType
+
+    companion object {
+
+        val EMPTY = object: IAssetObject {
+            override val type: AssetType = AssetType.UNKNOWN
+            override val size: Int = 0
+
+            override fun contains(element: IAssetObject): Boolean = false
+
+            override fun containsAll(elements: Collection<IAssetObject>): Boolean =false
+
+            override fun isEmpty(): Boolean = true
+
+            override fun iterator(): Iterator<IAssetObject> = emptyList<IAssetObject>().iterator()
+        }
+
+    }
+
+}

+ 2 - 2
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/web/WebView.kt

@@ -16,12 +16,12 @@ import android.webkit.WebViewClient
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.viewinterop.AndroidView
-import org.mjdev.tvlib.annotations.TvPreview
+import org.mjdev.tvlib.annotations.Previews
 import org.mjdev.tvlib.extensions.ComposeExt.isEditMode
 
 @Suppress("LocalVariableName")
 @SuppressLint("SetJavaScriptEnabled")
-@TvPreview
+@Previews
 @Composable
 fun WebView(
     modifier: Modifier = Modifier,

+ 190 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/CustomWebChromeClient.kt

@@ -0,0 +1,190 @@
+package org.mjdev.tvlib.webclient
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Message
+import android.view.View
+import android.webkit.*
+import timber.log.Timber
+
+@SuppressLint("SetJavaScriptEnabled")
+@Suppress("Deprecation")
+class CustomWebChromeClient(private val webView: WebClient) : WebChromeClient() {
+    private val settings: WebSettings get() = webView.settings
+    var icon: Bitmap? = null
+        internal set
+    var title: String? = null
+        internal set
+
+    init {
+        settings.apply {
+            userAgentString = WebClient.USER_AGENT
+            javaScriptEnabled = true
+            javaScriptCanOpenWindowsAutomatically = false
+            mediaPlaybackRequiresUserGesture = false
+            allowContentAccess = false
+            allowFileAccess = false
+            allowFileAccessFromFileURLs = false
+            allowUniversalAccessFromFileURLs = false
+            databaseEnabled = false
+            displayZoomControls = false
+            domStorageEnabled = false
+//            layoutAlgorithm = WebSettings.LayoutAlgorithm.TEXT_AUTOSIZING
+//            loadWithOverviewMode = true
+            loadsImagesAutomatically = false
+            mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+//            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+//                offscreenPreRaster = true
+//            }
+//            useWideViewPort = true
+//            setAppCacheEnabled(true)
+            setSupportMultipleWindows(false)
+            setSupportZoom(false)
+            setGeolocationEnabled(false)
+            pluginState = WebSettings.PluginState.OFF
+//            setRenderPriority(WebSettings.RenderPriority.HIGH)
+        }
+    }
+
+    override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
+        Timber.e("Got console message: ${consoleMessage?.message()}")
+        return super.onConsoleMessage(consoleMessage)
+    }
+
+    override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
+    }
+
+    override fun onHideCustomView() {
+    }
+
+    override fun onProgressChanged(view: WebView?, newProgress: Int) {
+    }
+
+    override fun onCloseWindow(window: WebView?) {
+    }
+
+    override fun onCreateWindow(
+        view: WebView?,
+        isDialog: Boolean,
+        isUserGesture: Boolean,
+        resultMsg: Message?
+    ): Boolean {
+        return super.onCreateWindow(view, isDialog, isUserGesture, resultMsg)
+    }
+
+    override fun onGeolocationPermissionsHidePrompt() {
+    }
+
+    override fun onGeolocationPermissionsShowPrompt(
+        origin: String?,
+        callback: GeolocationPermissions.Callback?
+    ) {
+    }
+
+    override fun onJsAlert(
+        view: WebView?,
+        url: String?,
+        message: String?,
+        result: JsResult?
+    ): Boolean {
+        return super.onJsAlert(view, url, message, result)
+    }
+
+    override fun onJsBeforeUnload(
+        view: WebView?,
+        url: String?,
+        message: String?,
+        result: JsResult?
+    ): Boolean {
+        return super.onJsBeforeUnload(view, url, message, result)
+    }
+
+    override fun onJsConfirm(
+        view: WebView?,
+        url: String?,
+        message: String?,
+        result: JsResult?
+    ): Boolean {
+        return super.onJsConfirm(view, url, message, result)
+    }
+
+    override fun onJsPrompt(
+        view: WebView?,
+        url: String?,
+        message: String?,
+        defaultValue: String?,
+        result: JsPromptResult?
+    ): Boolean {
+        return super.onJsPrompt(view, url, message, defaultValue, result)
+    }
+
+    override fun onPermissionRequest(request: PermissionRequest?) {
+    }
+
+    override fun onPermissionRequestCanceled(request: PermissionRequest?) {
+    }
+
+    override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
+        this.icon = icon
+    }
+
+    override fun onReceivedTitle(view: WebView?, title: String?) {
+        this.title = title
+    }
+
+    override fun onReceivedTouchIconUrl(
+        view: WebView?,
+        url: String?,
+        precomposed: Boolean
+    ) {
+    }
+
+    override fun onRequestFocus(view: WebView?) {
+    }
+
+    override fun onShowFileChooser(
+        view: WebView?,
+        filePathCallback: ValueCallback<Array<Uri>>?,
+        fileChooserParams: FileChooserParams?
+    ): Boolean {
+        return super.onShowFileChooser(webView, filePathCallback, fileChooserParams)
+    }
+
+    @Deprecated("Deprecated in Java")
+    override fun onConsoleMessage(message: String?, lineNumber: Int, sourceID: String?) {
+    }
+
+    @Deprecated("Deprecated in Java")
+    override fun onExceededDatabaseQuota(
+        url: String?,
+        databaseIdentifier: String?,
+        quota: Long,
+        estimatedDatabaseSize: Long,
+        totalQuota: Long,
+        quotaUpdater: WebStorage.QuotaUpdater?
+    ) {
+    }
+
+    @Deprecated("Deprecated in Java",
+        ReplaceWith("super.onJsTimeout()", "android.webkit.WebChromeClient")
+    )
+    override fun onJsTimeout(): Boolean {
+        return super.onJsTimeout()
+    }
+
+//    override fun onReachedMaxAppCacheSize(
+//        requiredStorage: Long,
+//        quota: Long,
+//        quotaUpdater: WebStorage.QuotaUpdater?
+//    ) {
+//    }
+
+    @Deprecated("Deprecated in Java")
+    override fun onShowCustomView(
+        view: View?,
+        requestedOrientation: Int,
+        callback: CustomViewCallback?
+    ) {
+    }
+}

+ 150 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/CustomWebViewClient.kt

@@ -0,0 +1,150 @@
+package org.mjdev.tvlib.webclient
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+import android.net.http.SslError
+import android.os.Message
+import android.view.KeyEvent
+import android.webkit.*
+import org.mjdev.tvlib.webclient.html.HtmlPage
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.mjdev.tvlib.webclient.javascript.JSIListener
+import org.mjdev.tvlib.webclient.cache.WebViewCacheInterceptorInst
+import timber.log.Timber
+
+open class CustomWebViewClient(
+    private val webView: WebClient,
+//    var adBlock: AdBlock? = null
+) : WebViewClient(), JSIListener {
+
+    val jsi by lazy {
+        JSI(webView, this)
+    }
+
+    val loadedResources by lazy {
+        ArrayList<String>()
+    }
+
+    @Synchronized
+    override fun shouldInterceptRequest(
+        view: WebView?,
+        request: WebResourceRequest?
+    ): WebResourceResponse? {
+        val uri = request?.url?.toString()
+//        if (adBlock?.isBlocked(uri) == true) {
+//            Timber.d("Blocked url: ${request?.url}")
+//            return null
+//        } else {
+            uri?.let {
+                if (!loadedResources.contains(it)) {
+                    loadedResources.add(it)
+                }
+            }
+            Timber.d("Loading url: $uri")
+            return WebViewCacheInterceptorInst.instance?.interceptRequest(request)
+//        }
+    }
+
+    @Synchronized
+    override fun shouldOverrideUrlLoading(
+        view: WebView?,
+        request: WebResourceRequest?
+    ): Boolean {
+        return super.shouldOverrideUrlLoading(view, request)
+    }
+
+    override fun onLoadResource(view: WebView?, url: String?) {
+        url?.let {
+            if (!loadedResources.contains(it)) {
+                loadedResources.add(it)
+            }
+        }
+    }
+
+    override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+    }
+
+    @SuppressLint("NewApi")
+    override fun onReceivedError(
+        view: WebView?,
+        request: WebResourceRequest?,
+        error: WebResourceError?
+    ) {
+        Timber.e("Error received: (${error?.errorCode}) ${error?.description}")
+    }
+
+    override fun onReceivedHttpAuthRequest(
+        view: WebView?,
+        handler: HttpAuthHandler?,
+        host: String?,
+        realm: String?
+    ) {
+        super.onReceivedHttpAuthRequest(view, handler, host, realm)
+    }
+
+    override fun onReceivedHttpError(
+        view: WebView?,
+        request: WebResourceRequest?,
+        error: WebResourceResponse?
+    ) {
+        Timber.e("Error received: (${error?.statusCode}) ${error?.reasonPhrase}")
+    }
+
+    override fun onReceivedLoginRequest(
+        view: WebView?,
+        realm: String?,
+        account: String?,
+        args: String?
+    ) {
+        // todo login screen
+        Timber.e("Login request. Unsupported yet.")
+    }
+
+    override fun onReceivedSslError(
+        view: WebView?,
+        handler: SslErrorHandler?,
+        error: SslError?
+    ) {
+        super.onReceivedSslError(view, handler, error)
+        Timber.e("SSL error: %s", error.toString())
+    }
+
+    override fun onScaleChanged(view: WebView?, oldScale: Float, newScale: Float) {
+    }
+
+    override fun onUnhandledKeyEvent(view: WebView?, event: KeyEvent?) {
+        super.onUnhandledKeyEvent(view, event)
+    }
+
+    @Deprecated("Deprecated in Java")
+    override fun onReceivedError(
+        view: WebView?,
+        errorCode: Int,
+        description: String?,
+        failingUrl: String?
+    ) {
+        Timber.e("Error at $failingUrl:")
+        Timber.e("$errorCode : $description")
+    }
+
+    @Deprecated("Deprecated in Java",
+        ReplaceWith("Timber.e(\"Too many redirects.\")", "timber.log.Timber")
+    )
+    override fun onTooManyRedirects(
+        view: WebView?,
+        cancelMsg: Message?,
+        continueMsg: Message?
+    ) {
+        Timber.e("Too many redirects.")
+    }
+
+    override fun onPageFinished(view: WebView?, url: String?) {
+        if (!url.contentEquals("about:blank")) {
+            jsi.getHtml()
+        }
+    }
+
+    override fun onGotHtml(htmlPage: HtmlPage, loadedResources: List<String>) {
+    }
+
+}

+ 70 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/WebClient.kt

@@ -0,0 +1,70 @@
+package org.mjdev.tvlib.webclient
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.view.ViewGroup
+import android.webkit.*
+import org.mjdev.tvlib.webclient.html.HtmlPage
+import org.mjdev.tvlib.webclient.javascript.JSI
+
+@Suppress("unused")
+class WebClient constructor(
+    context: Context,
+) : WebView(context) {
+
+    val js: JSI get() = webViewClient.jsi
+
+    val webViewClient: CustomWebViewClient by lazy {
+        object : CustomWebViewClient(this) {
+            override fun onGotHtml(htmlPage: HtmlPage, loadedResources: List<String>) {
+                this@WebClient.htmlPage = htmlPage
+                if (htmlPage.url != "about:blank") {
+                    listener?.invoke(
+                        this@WebClient,
+                        htmlPage,
+                        loadedResources
+                    )
+                }
+            }
+        }
+    }
+
+    private val webChromeClient: CustomWebChromeClient by lazy {
+        CustomWebChromeClient(this)
+    }
+
+    val pageIcon: Bitmap? get() = webChromeClient.icon
+
+    val pageTitle: String? get() = webChromeClient.title
+
+    var htmlPage: HtmlPage? = null
+        internal set
+
+    var listener: (WebClient.(page: HtmlPage?, resources: List<String>) -> Unit)? = null
+
+    init {
+        setWebChromeClient(webChromeClient)
+        setWebViewClient(webViewClient)
+    }
+
+    fun getPage(url: String, block: WebClient.(page: HtmlPage?, resources: List<String>) -> Unit) {
+        this.listener = block
+        loadUrl(url)
+    }
+
+    fun close() {
+        loadUrl("about:blank")
+    }
+
+    fun attachTo(viewGroup: ViewGroup?, layoutParams: ViewGroup.LayoutParams): WebClient {
+        (parent as ViewGroup?)?.removeView(this)
+        viewGroup?.addView(this, layoutParams)
+        return this
+    }
+
+    companion object {
+        const val USER_AGENT =
+            "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0"
+    }
+
+}

+ 69 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/WebScrapper.kt

@@ -0,0 +1,69 @@
+package org.mjdev.tvlib.webclient
+
+import android.content.Context
+import org.mjdev.tvlib.webclient.html.HtmlAnchor
+import org.mjdev.tvlib.webclient.html.HtmlPage
+import org.mjdev.tvlib.webclient.html.HtmlVideo
+import java.util.*
+
+@Suppress("unused")
+class WebScrapper(
+    val context: Context,
+    urls: List<String> = listOf("https://prehraj.to/hledej/filmy"),
+    private val block: (page: HtmlPage?, data: Any?) -> Unit
+) {
+
+    private val webClient: WebClient by lazy {
+        WebClient(context)
+    }
+
+    private val parsePages: Deque<String> = LinkedList()
+
+    init {
+        parsePages.addAll(urls)
+        parseNextPage()
+    }
+
+    private fun parseNextPage() {
+        if (parsePages.size > 0) {
+            parsePageFromUrl(parsePages.pop())
+        }
+    }
+
+    @Synchronized
+    fun parsePageFromUrl(pageUrl: String?) {
+        if (pageUrl != null) {
+            webClient.getPage(pageUrl) { page, _ ->
+                if (page != null) {
+                    parsePage(page, page.videos, page.anchors)
+                }
+            }
+        }
+    }
+
+    @Synchronized
+    fun parsePage(page: HtmlPage, videos: List<HtmlVideo>, anchors: List<HtmlAnchor>) {
+        anchors.map { anchor ->
+            anchor.fullHref(page)
+        }.forEach { href ->
+            if (href.isNotEmpty()) {
+                parsePages.add(href)
+            }
+        }
+        parseVideos(page, videos)
+        parseNextPage()
+    }
+
+    private fun parseVideos(page: HtmlPage, videos: List<HtmlVideo>) {
+        if (videos.isNotEmpty()) {
+            videos.forEach { video ->
+                video.videoStreams.forEach { streamURL ->
+                    if (streamURL.isNotEmpty()) {
+                        block.invoke(page, streamURL)
+                    }
+                }
+            }
+        }
+    }
+
+}

+ 80 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/adblock/AdBlock.kt

@@ -0,0 +1,80 @@
+@file:Suppress("unused")
+
+package org.mjdev.tvlib.webclient.adblock
+
+import android.net.Uri
+import org.mjdev.tvlib.webclient.cache.MimeTypeMapUtils
+import org.mjdev.tvlib.extensions.StringExt.removeComments
+import org.mjdev.tvlib.extensions.StringExt.removeEmptyLines
+import org.mjdev.tvlib.extensions.StringExt.trimLines
+import timber.log.Timber
+import java.io.File
+import java.io.InputStream
+import java.net.URL
+
+class AdBlock(inputStream: InputStream) {
+    private var hosts = mutableListOf<String>()
+    private val urls = mutableListOf<String>()
+
+    // todo may be another
+    private val allowedMimeTypes = mutableListOf(
+        "text/html",
+        "text/plain",
+        "text/javascript",
+        "application/javascript",
+        "video/mp4",
+        "application/json",
+    )
+
+    init {
+        inputStream
+            .bufferedReader()
+            .readLines()
+            .removeEmptyLines()
+            .trimLines()
+            .removeComments('#')
+            .map { line ->
+                when (line[0]) {
+                    // host starts with '*'
+                    '*' -> hosts.add(line.substring(1))
+                    // url to block
+                    else -> urls.add(line)
+                }
+            }
+    }
+
+    @Synchronized
+    fun isBlocked(reqUrl: String?): Boolean {
+        val host = try {
+            Uri.parse(reqUrl).host
+        } catch (e: Exception) {
+            Timber.e(e)
+            null
+        }
+        Timber.d("host: $host")
+        val mimeType = MimeTypeMapUtils.getMimeTypeFromUrl(reqUrl)
+        Timber.d("mimetype: $mimeType")
+        if (mimeType?.let { allowedMimeTypes.contains(mimeType) } == false) return true
+        if (host?.let { h -> hosts.contains(h) } == true) return true
+        return reqUrl?.let { url ->
+            urls.any { blockedURL -> url.startsWith(blockedURL) }
+        } ?: false
+    }
+
+    fun isBlocked(reqUrl: URL?): Boolean = isBlocked(reqUrl.toString())
+
+    fun isBlocked(reqUrl: Uri?): Boolean = isBlocked(reqUrl.toString())
+
+    companion object {
+
+        @Suppress("MemberVisibilityCanBePrivate")
+        fun adBlock(
+            fileWithData: File = File("file:///android_asset/adblock.txt")
+        ) = AdBlock(fileWithData.inputStream())
+
+
+        val instance by lazy { adBlock() }
+
+    }
+
+}

+ 41 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/adblock/HttpCacheInterceptor.kt

@@ -0,0 +1,41 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvlib.webclient.adblock
+
+import android.text.TextUtils
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
+import org.mjdev.tvlib.webclient.cache.CacheType
+import org.mjdev.tvlib.webclient.cache.WebViewCacheInterceptor
+import java.io.IOException
+
+class AdBlockInterceptor : Interceptor {
+
+    @Throws(IOException::class)
+    override fun intercept(chain: Interceptor.Chain): Response {
+        val request: Request = chain.request()
+        val reqUrl = request.url
+        val adBlock = AdBlock.instance
+        return if (adBlock.isBlocked(reqUrl.toUrl())) {
+            Response.Builder().code(404).build()
+        } else {
+            val cache = request.header(WebViewCacheInterceptor.KEY_CACHE)
+            val originResponse: Response = chain.proceed(request)
+            if (!TextUtils.isEmpty(cache) && cache == CacheType.NORMAL.ordinal.toString() + "")
+                originResponse
+            else originResponse.newBuilder()
+                .removeHeader("pragma")
+                .removeHeader("Cache-Control")
+                .header("Cache-Control", "max-age=3153600000")
+                .build()
+        }
+    }
+
+}

+ 160 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/AssetsLoader.kt

@@ -0,0 +1,160 @@
+package org.mjdev.tvlib.webclient.cache
+
+import android.content.Context
+import android.text.TextUtils
+import timber.log.Timber
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.net.MalformedURLException
+import java.net.URL
+import java.util.*
+import java.util.concurrent.CopyOnWriteArraySet
+
+internal class AssetsLoader {
+
+    private var context: Context? = null
+    private var assetResSet: CopyOnWriteArraySet<String>? = null
+    private var dir = ""
+    private var cleared = false
+    private var isSuffixMod = false
+
+    fun isAssetsSuffixMod(suffixMod: Boolean): AssetsLoader {
+        isSuffixMod = suffixMod
+        return this
+    }
+
+    fun init(context: Context?): AssetsLoader {
+        this.context = context
+        assetResSet = CopyOnWriteArraySet()
+        cleared = false
+        return this
+    }
+
+    private fun getUrlPath(url: String): String {
+        var uPath = ""
+        try {
+            val u = URL(url)
+            uPath = u.path
+            if (uPath.startsWith("/")) {
+                if (uPath.length == 1) {
+                    return uPath
+                }
+                uPath = uPath.substring(1)
+            }
+        } catch (e: MalformedURLException) {
+            Timber.e(e)
+        }
+        return uPath
+    }
+
+    fun getResByUrl(url: String): InputStream? {
+        val uPath = getUrlPath(url)
+        if (TextUtils.isEmpty(uPath))
+            return null
+        if (!isSuffixMod) {
+            return if (TextUtils.isEmpty(dir)) {
+                getAssetFileStream(uPath)
+            } else {
+                getAssetFileStream(dir + File.separator + uPath)
+            }
+        }
+        if (assetResSet != null) {
+            for (p in assetResSet!!) {
+                if (uPath.endsWith(p)) {
+                    return if (TextUtils.isEmpty(dir)) {
+                        getAssetFileStream(p)
+                    } else {
+                        getAssetFileStream(dir + File.separator + p)
+                    }
+                }
+            }
+        }
+        return null
+    }
+
+    fun setDir(dir: String): AssetsLoader {
+        this.dir = dir
+        return this
+    }
+
+    fun initData(): AssetsLoader {
+        if (!isSuffixMod)
+            return this
+        if ((assetResSet?.size ?: 0) == 0)
+            Thread { initResourceNoneRecursion(dir) }.start()
+        return this
+    }
+
+    fun clear() {
+        cleared = true
+        assetResSet?.clear()
+    }
+
+    private fun addAssetsFile(f: String) {
+        var file = f
+        val flag = dir + File.separator
+        if (!TextUtils.isEmpty(dir)) {
+            val pos = file.indexOf(flag)
+            if (pos >= 0) {
+                file = file.substring(pos + flag.length)
+            }
+        }
+        assetResSet?.add(file)
+    }
+
+    private fun initResourceNoneRecursion(dir: String): AssetsLoader {
+        try {
+            val list = LinkedList<String>()
+            val resData =context?.assets?.list(dir) ?: emptyArray()
+            for (res in resData) {
+                val sub = dir + File.separator + res
+                val tmp = context?.assets?.list(sub) ?: emptyArray()
+                if (tmp.isEmpty()) {
+                    addAssetsFile(sub)
+                } else {
+                    list.add(sub)
+                }
+            }
+            while (!list.isEmpty()) {
+                if (cleared) break
+                val last = list.removeFirst()
+                val tmp = context?.assets?.list(last) ?: emptyArray()
+                if (tmp.isEmpty()) {
+                    addAssetsFile(last)
+                } else {
+                    for (sub in tmp) {
+                        val tmp1 =
+                            context?.assets?.list(last + File.separator + sub) ?: emptyArray()
+                        if (tmp1.isEmpty()) {
+                            addAssetsFile(last + File.separator + sub)
+                        } else {
+                            list.add(last + File.separator + sub)
+                        }
+                    }
+                }
+            }
+        } catch (e: IOException) {
+            Timber.e(e)
+        }
+        return this
+    }
+
+    private fun getAssetFileStream(path: String): InputStream? {
+        try {
+            return context?.assets?.open(path)
+        } catch (e: IOException) {
+            Timber.e(e)
+        }
+        return null
+    }
+
+    companion object {
+
+        val instance: AssetsLoader by lazy {
+            AssetsLoader()
+        }
+
+    }
+
+}

+ 94 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/CacheExtensionConfig.kt

@@ -0,0 +1,94 @@
+package org.mjdev.tvlib.webclient.cache
+
+import android.text.TextUtils
+import java.lang.reflect.Modifier.STATIC
+import java.util.Locale
+
+class CacheExtensionConfig {
+
+    private val statics: ArrayList<*> = ArrayList<Any?>(STATIC)
+    private val noCache: ArrayList<*> = ArrayList<Any?>(NO_CACHE)
+
+    fun isMedia(extension: String): Boolean {
+        if (TextUtils.isEmpty(extension)) return false
+        return if (NO_CACHE.contains(extension)) true
+        else noCache.contains(extension.lowercase(Locale.getDefault()).trim { it <= ' ' })
+    }
+
+    fun canCache(extension: String): Boolean {
+        var ext = extension
+        if (TextUtils.isEmpty(ext)) return false
+        ext = ext.lowercase(Locale.getDefault()).trim { it <= ' ' }
+        return if (STATIC.contains(ext)) true else statics.contains(extension)
+    }
+
+//    fun addExtension(extension: String): CacheExtensionConfig {
+//        add(statics, extension)
+//        return this
+//    }
+
+//    fun removeExtension(extension: String): CacheExtensionConfig {
+//        remove(statics, extension)
+//        return this
+//    }
+
+    fun isHtml(extension: String): Boolean {
+        if (TextUtils.isEmpty(extension)) {
+            return false
+        }
+        return extension.lowercase(Locale.getDefault()).contains("html") ||
+                extension.lowercase(Locale.getDefault()).contains("htm")
+    }
+
+    @Suppress("unused")
+    fun clearAll() {
+        clearDiskExtension()
+    }
+
+    private fun clearDiskExtension() {
+        statics.clear()
+    }
+
+    companion object {
+
+        private val STATIC = mutableListOf(
+            "html", "htm", "js", "ico",
+            "css", "png", "jpg", "jpeg",
+            "gif", "bmp", "ttf", "woff",
+            "woff2", "otf", "eot", "svg",
+            "xml", "swf", "txt", "text",
+            "conf", "webp",
+        )
+
+        private val NO_CACHE = mutableListOf(
+            "mp4", "mp3", "ogg",
+            "avi", "wmv", "flv",
+            "rmvb", "3gp",
+        )
+
+//        fun addGlobalExtension(extension: String) {
+//            add(STATIC, extension)
+//        }
+
+//        fun removeGlobalExtension(extension: String) {
+//            remove(STATIC, extension)
+//        }
+
+//        private fun add(set: ArrayList<*>, extension: String) {
+//            if (TextUtils.isEmpty(extension)) {
+//                return
+//            }
+//            val ext = extension.replace(".", "")
+//                .lowercase(Locale.getDefault())
+//                .trim { it <= ' ' }
+//            set.add(ext)
+//        }
+
+//        private fun remove(set: ArrayList<*>, extension: String) {
+//            if (TextUtils.isEmpty(extension)) {
+//                return
+//            }
+//            set.remove(extension.replace(".", "").lowercase(Locale.getDefault()).trim { it <= ' ' })
+//        }
+    }
+}

+ 6 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/CacheType.kt

@@ -0,0 +1,6 @@
+package org.mjdev.tvlib.webclient.cache
+
+enum class CacheType {
+    NORMAL,
+    FORCE
+}

+ 54 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/DynamicCacheLoader.kt

@@ -0,0 +1,54 @@
+package org.mjdev.tvlib.webclient.cache
+
+import timber.log.Timber
+import java.io.File
+import java.net.MalformedURLException
+import java.net.URL
+
+@Suppress("unused")
+class DynamicCacheLoader {
+
+    fun getResByUrl(file: File?, url: String?): File? {
+        val uPath = getUrlPath(url)
+        if (file?.isDirectory == true) {
+            val files = file.listFiles() ?: emptyArray()
+            for (child in files) {
+                if (child.isDirectory) {
+                    val targetFile = getResByUrl(child, url)
+                    if (targetFile != null) {
+                        return targetFile
+                    }
+                } else {
+                    if (uPath.endsWith(child.name))
+                        return child
+                }
+            }
+        } else {
+            if (uPath.endsWith(file?.name.toString()))
+                return file
+        }
+        return null
+    }
+
+    private fun getUrlPath(url: String?): String {
+        var uPath = ""
+        try {
+            val u = URL(url)
+            uPath = u.path
+            if (uPath.startsWith("/")) {
+                if (uPath.length == 1) return uPath
+                uPath = uPath.substring(1)
+            }
+        } catch (e: MalformedURLException) {
+            Timber.e(e)
+        }
+        return uPath
+    }
+
+    companion object {
+        val instance: DynamicCacheLoader by lazy {
+            DynamicCacheLoader()
+        }
+    }
+
+}

+ 34 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/FileUtil.kt

@@ -0,0 +1,34 @@
+package org.mjdev.tvlib.webclient.cache
+
+import android.text.TextUtils
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+
+object FileUtil {
+
+    fun deleteDirs(path: String, isDeleteDir: Boolean) {
+        if (TextUtils.isEmpty(path)) return
+        val dir = File(path)
+        if (!dir.exists()) return
+        val files = dir.listFiles() ?: emptyArray()
+        for (file in files) {
+            if (file.isDirectory) deleteDirs(file.absolutePath, isDeleteDir)
+            else file.delete()
+        }
+        if (isDeleteDir) dir.delete()
+    }
+
+    @Throws(IOException::class)
+    fun copy(inputStream: InputStream, out: OutputStream) {
+        val buf = ByteArray(2048)
+        var len: Int
+        while (inputStream.read(buf).also { readCnt -> len = readCnt } != -1) {
+            out.write(buf, 0, len)
+        }
+        inputStream.close()
+        out.close()
+    }
+
+}

+ 25 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/HttpCacheInterceptor.kt

@@ -0,0 +1,25 @@
+package org.mjdev.tvlib.webclient.cache
+
+import android.text.TextUtils
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
+import java.io.IOException
+
+class HttpCacheInterceptor : Interceptor {
+
+    @Throws(IOException::class)
+    override fun intercept(chain: Interceptor.Chain): Response {
+        val request: Request = chain.request()
+        val cache = request.header(WebViewCacheInterceptor.KEY_CACHE)
+        val originResponse: Response = chain.proceed(request)
+        return if (!TextUtils.isEmpty(cache) && cache == CacheType.NORMAL.ordinal.toString() + "")
+            originResponse
+        else originResponse.newBuilder()
+            .removeHeader("pragma")
+            .removeHeader("Cache-Control")
+            .header("Cache-Control", "max-age=3153600000")
+            .build()
+    }
+
+}

+ 40 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/MD5Utils.kt

@@ -0,0 +1,40 @@
+package org.mjdev.tvlib.webclient.cache
+
+import timber.log.Timber
+import java.security.MessageDigest
+import java.util.Locale
+
+@Suppress("Deprecation", "unused", "SameParameterValue")
+object MD5Utils {
+
+    fun getMD5(message: String): String {
+        return getMD5(message, true)
+    }
+
+    private fun getMD5(message: String, upperCase: Boolean = true): String {
+        var md5str = ""
+        try {
+            val md = MessageDigest.getInstance("MD5")
+            val input = message.toByteArray()
+            val buff = md.digest(input)
+            md5str = bytesToHex(buff, upperCase)
+        } catch (e: Exception) {
+            Timber.e(e)
+        }
+        return md5str
+    }
+
+    private fun bytesToHex(bytes: ByteArray, upperCase: Boolean): String {
+        val md5str = StringBuffer()
+        var digital: Int
+        for (i in bytes.indices) {
+            digital = bytes[i].toInt()
+            if (digital < 0) digital += 256
+            if (digital < 16) md5str.append("0")
+            md5str.append(Integer.toHexString(digital))
+        }
+        return if (upperCase) md5str.toString().toUpperCase(Locale.ROOT)
+        else md5str.toString().toLowerCase(Locale.ROOT)
+    }
+
+}

+ 34 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/MimeTypeMapUtils.kt

@@ -0,0 +1,34 @@
+package org.mjdev.tvlib.webclient.cache
+
+import android.webkit.MimeTypeMap
+import java.util.Locale
+
+@Suppress("Deprecation")
+object MimeTypeMapUtils {
+
+    fun getFileExtensionFromUrl(url: String?): String {
+        var burl = url?.toLowerCase(Locale.ROOT)
+        if (!burl.isNullOrEmpty()) {
+            val fragment = burl.lastIndexOf('#')
+            if (fragment > 0) burl = burl.substring(0, fragment)
+            val query = burl.lastIndexOf('?')
+            if (query > 0) burl = burl.substring(0, query)
+            val filenamePos = burl.lastIndexOf('/')
+            val filename = if (0 <= filenamePos) burl.substring(filenamePos + 1) else burl
+            if (filename.isNotEmpty()) {
+                val dotPos = filename.lastIndexOf('.')
+                if (0 <= dotPos) return filename.substring(dotPos + 1)
+            }
+        }
+        return ""
+    }
+
+    fun getMimeTypeFromUrl(url: String?): String? {
+        return MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileExtensionFromUrl(url))
+    }
+
+//    fun getMimeTypeFromExtension(extension: String?): String? {
+//        return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
+//    }
+
+}

+ 48 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/OKHttpFile.kt

@@ -0,0 +1,48 @@
+package org.mjdev.tvlib.webclient.cache
+
+import android.text.TextUtils
+import okio.ByteString.Companion.encodeUtf8
+import okio.GzipSource
+import okio.buffer
+import okio.source
+import timber.log.Timber
+import java.io.*
+
+object OKHttpFile {
+
+    private const val ENTRY_METADATA = 0
+    private const val ENTRY_BODY = 1
+
+    fun getCacheFile(path: File, url: String?): InputStream? {
+        if (TextUtils.isEmpty(url)) return null
+        val key: String? = url?.encodeUtf8()?.md5()?.hex()
+        val entryFile = File(path.absolutePath, "$key.$ENTRY_METADATA")
+        val bodyFile = File(path.absolutePath, "$key.$ENTRY_BODY")
+        if (entryFile.exists() && bodyFile.exists()) {
+            try {
+                val fr = BufferedReader(FileReader(entryFile), 1024)
+                var line : String
+                var isGzip = false
+                while (fr.readLine().also { line = it } != null) {
+                    if (line.contains("Content-Encoding") &&
+                        line.contains("gzip")
+                    ) {
+                        isGzip = true
+                        break
+                    }
+                }
+                val inputStream: InputStream = FileInputStream(bodyFile)
+                return if (!isGzip) inputStream
+                else {
+                    var source = bodyFile.source()
+                    source = GzipSource(source)
+                    source.buffer().inputStream()
+                }
+            } catch (e: Exception) {
+              Timber.e(e)
+            }
+        }
+        return null
+    }
+
+}

+ 5 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/ResourceInterceptor.kt

@@ -0,0 +1,5 @@
+package org.mjdev.tvlib.webclient.cache
+
+interface ResourceInterceptor {
+    fun interceptor(url: String?): Boolean
+}

+ 379 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/WebViewCacheInterceptor.kt

@@ -0,0 +1,379 @@
+package org.mjdev.tvlib.webclient.cache
+
+import android.content.Context
+import android.webkit.URLUtil
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import okhttp3.Cache
+import okhttp3.CacheControl
+import okhttp3.Dns
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.mjdev.tvlib.webclient.adblock.AdBlockInterceptor
+import timber.log.Timber
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileNotFoundException
+import java.io.IOException
+import java.io.InputStream
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.SSLSession
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.X509TrustManager
+
+// todo trust manager
+@Suppress("unused")
+class WebViewCacheInterceptor(builder: Builder) : WebViewRequestInterceptor {
+    override val cachePath: File
+    private val dynamicCacheFile: File?
+    private val cacheSize: Long
+    private val connectTimeout: Long
+    private val readTimeout: Long
+    private val cacheExtensionConfig: CacheExtensionConfig
+    private val context: Context
+    private var cacheType: CacheType
+    private var assetsDir: String = ""
+    private var trustAllHostname = false
+    private var sslSocketFactory: SSLSocketFactory? = null
+    private var x509TrustManager: X509TrustManager? = null
+    private var dns: Dns? = null
+    private val resourceInterceptor: ResourceInterceptor?
+    private var isSuffixMod = false
+    private var httpClient: OkHttpClient? = null
+    private var origin: String? = ""
+    private var referer: String? = ""
+    private var userAgent: String? = ""
+
+    init {
+        cacheExtensionConfig = builder.cacheExtensionConfig
+        cachePath = builder.cacheFile
+        dynamicCacheFile = builder.dynamicCacheFile
+        cacheSize = builder.cacheSize
+        cacheType = builder.cacheType
+        connectTimeout = builder.connectTimeout
+        readTimeout = builder.readTimeout
+        context = builder.context
+        assetsDir = builder.assetsDir
+        x509TrustManager = builder.x509TrustManager
+        sslSocketFactory = builder.sslSocketFactory
+        trustAllHostname = builder.trustAllHostname
+        resourceInterceptor = builder.resourceInterceptor
+        isSuffixMod = builder.isSuffixMod
+        dns = builder.dns
+        initHttpClient()
+        if (isEnableAssets) initAssetsLoader()
+    }
+
+    private val isEnableDynamicCache: Boolean
+        get() = dynamicCacheFile != null
+    private val isEnableAssets: Boolean
+        get() = assetsDir.isNotEmpty()
+
+    private fun initAssetsLoader() {
+        AssetsLoader.instance.init(context).setDir(assetsDir).isAssetsSuffixMod(isSuffixMod)
+    }
+
+    // todo use application client
+    private fun initHttpClient() {
+        val cache = Cache(cachePath, cacheSize)
+        val builder = OkHttpClient.Builder()
+            .cache(cache)
+            .connectTimeout(connectTimeout, TimeUnit.SECONDS)
+            .readTimeout(readTimeout, TimeUnit.SECONDS)
+            .addNetworkInterceptor(HttpCacheInterceptor())
+            .addNetworkInterceptor(AdBlockInterceptor())
+//            .addNetworkInterceptor(LogInterceptor())
+        if (trustAllHostname) {
+            builder.hostnameVerifier { _: String?, _: SSLSession? -> true }
+        }
+        if (sslSocketFactory != null && x509TrustManager != null) {
+            builder.sslSocketFactory(sslSocketFactory!!, x509TrustManager!!)
+        }
+        if (dns != null) {
+            builder.dns(dns!!)
+        }
+        httpClient = builder.build()
+    }
+
+    override fun interceptRequest(request: WebResourceRequest?): WebResourceResponse? {
+        val headers = mutableMapOf<String, String?>().apply {
+            request?.requestHeaders?.also { putAll(it) }
+        }
+        return interceptRequest(request?.url?.toString(), headers)
+    }
+
+    private fun buildHeaders(): MutableMap<String, String?> {
+        val headers: MutableMap<String, String?> = HashMap()
+        if (!origin.isNullOrEmpty()) {
+            headers["Origin"] = origin
+        }
+        if (!referer.isNullOrEmpty()) {
+            headers["Referer"] = referer
+        }
+        if (!userAgent.isNullOrEmpty()) {
+            headers["User-Agent"] = userAgent
+        }
+        return headers
+    }
+
+    override fun interceptRequest(url: String?): WebResourceResponse? {
+        return interceptRequest(url, buildHeaders())
+    }
+
+    private fun checkUrl(url: String?): Boolean {
+        if (url.isNullOrEmpty()) return false
+        if (!url.startsWith("http")) return false
+        if (resourceInterceptor != null && !resourceInterceptor.interceptor(url)) return false
+        val extension: String = MimeTypeMapUtils.getFileExtensionFromUrl(url)
+        if (extension.isEmpty()) return false
+        if (cacheExtensionConfig.isMedia(extension)) return false
+        return cacheExtensionConfig.canCache(extension)
+    }
+
+    override fun loadUrl(webView: WebView?, url: String?) {
+        if (!isValidUrl(url)) return
+        webView?.loadUrl(url ?: "")
+        referer = webView?.url
+        origin = NetUtils.getOriginUrl(referer)
+        userAgent = webView?.settings?.userAgentString
+    }
+
+    override fun loadUrl(url: String?, userAgent: String?) {
+        if (!isValidUrl(url)) return
+        referer = url
+        origin = NetUtils.getOriginUrl(referer)
+        this.userAgent = userAgent
+    }
+
+    override fun loadUrl(
+        url: String?,
+        additionalHttpHeaders: Map<String?, String?>?,
+        userAgent: String?
+    ) {
+        if (!isValidUrl(url)) return
+        referer = url
+        origin = NetUtils.getOriginUrl(referer)
+        this.userAgent = userAgent
+    }
+
+    override fun loadUrl(
+        webView: WebView?,
+        url: String?,
+        additionalHttpHeaders: Map<String?, String?>?
+    ) {
+        if (!isValidUrl(url)) return
+        val headers = mutableMapOf<String?, String?>().apply {
+            additionalHttpHeaders?.also { putAll(it) }
+        }
+        webView?.loadUrl(url ?: "", headers)
+        referer = webView?.url
+        origin = NetUtils.getOriginUrl(referer)
+        userAgent = webView?.settings?.userAgentString
+    }
+
+    override fun clearCache() {
+        FileUtil.deleteDirs(cachePath.absolutePath, false)
+        AssetsLoader.instance.clear()
+    }
+
+    override fun enableForce(force: Boolean) {
+        cacheType = if (force) CacheType.FORCE else CacheType.NORMAL
+    }
+
+    override fun getCacheFile(url: String?): InputStream? {
+        return OKHttpFile.getCacheFile(cachePath, url)
+    }
+
+    override fun initAssetsData() {
+        AssetsLoader.instance.initData()
+    }
+
+    private fun addHeader(reqBuilder: Request.Builder, headers: Map<String, String?>?) {
+        if (headers == null) return
+        for ((key, value) in headers) {
+            reqBuilder.addHeader(key, value ?: "")
+        }
+    }
+
+    private fun interceptRequest(
+        url: String?,
+        headers: MutableMap<String, String?>
+    ): WebResourceResponse? {
+        if (cacheType === CacheType.NORMAL) return null
+        if (!checkUrl(url)) return null
+        if (isEnableDynamicCache) {
+            val file: File? = DynamicCacheLoader.instance.getResByUrl(dynamicCacheFile, url)
+            if (file != null) {
+                Timber.d(String.format("from dynamic file: %s", url))
+                val mimeType: String? = MimeTypeMapUtils.getMimeTypeFromUrl(url)
+                var inputStream: InputStream? = null
+                try {
+                    inputStream = FileInputStream(file)
+                } catch (e: FileNotFoundException) {
+                    Timber.e(e)
+                }
+                return WebResourceResponse(mimeType, "", inputStream)
+            }
+        }
+        if (isEnableAssets) {
+            val inputStream = AssetsLoader.instance.getResByUrl(url!!)
+            if (inputStream != null) {
+                Timber.d("from assets: %s", url)
+                val mimeType: String? = MimeTypeMapUtils.getMimeTypeFromUrl(url)
+                return WebResourceResponse(mimeType, "", inputStream)
+            }
+        }
+        try {
+            val reqBuilder: Request.Builder = Request.Builder().url(url ?: "")
+            val extension: String = MimeTypeMapUtils.getFileExtensionFromUrl(url)
+            if (cacheExtensionConfig.isHtml(extension)) headers[KEY_CACHE] =
+                cacheType.ordinal.toString() + ""
+            addHeader(reqBuilder, headers)
+            if (!NetUtils.isConnected(context)) reqBuilder.cacheControl(CacheControl.FORCE_CACHE)
+            val request: Request = reqBuilder.build()
+            val response: Response? = httpClient?.newCall(request)?.execute()
+            val cacheRes = response?.cacheResponse
+            if (cacheRes != null) Timber.d(
+                String.format(
+                    "from cache: %s",
+                    url
+                )
+            ) else Timber.d(String.format("from server: %s", url))
+            val mimeType: String? = MimeTypeMapUtils.getMimeTypeFromUrl(url)
+            val webResourceResponse = WebResourceResponse(mimeType, "", response?.body?.byteStream())
+            if (response?.code == 504 && !NetUtils.isConnected(context)) return null
+            var message = response?.message
+            if (message.isNullOrEmpty()) message = "OK"
+            try {
+                webResourceResponse.setStatusCodeAndReasonPhrase(
+                    response?.code ?: -1,
+                    message
+                )
+            } catch (e: Exception) {
+                return null
+            }
+            webResourceResponse.responseHeaders =
+                NetUtils.multimapToSingle(response?.headers?.toMultimap() ?: emptyMap())
+            return webResourceResponse
+        } catch (e: IOException) {
+            Timber.e(e)
+        }
+        return null
+    }
+
+    class Builder(val context: Context) {
+        var cacheFile: File
+        var dynamicCacheFile: File? = null
+        var cacheSize = (100 * 1024 * 1024).toLong()
+        var connectTimeout: Long = 20
+        var readTimeout: Long = 20
+        var cacheExtensionConfig: CacheExtensionConfig
+        var debug = true
+        var cacheType = CacheType.FORCE
+        var trustAllHostname = false
+        var sslSocketFactory: SSLSocketFactory? = null
+        var x509TrustManager: X509TrustManager? = null
+        var resourceInterceptor: ResourceInterceptor? = null
+        private set
+        var assetsDir: String = ""
+        var isSuffixMod = false
+        var dns: Dns? = null
+        private set
+
+        init {
+            cacheFile = File(context.cacheDir.toString(), "CacheWebViewCache")
+            cacheExtensionConfig = CacheExtensionConfig()
+        }
+
+        fun setResourceInterceptor(resourceInterceptor: ResourceInterceptor?) {
+            this.resourceInterceptor = resourceInterceptor
+        }
+
+        fun setTrustAllHostname(): Builder {
+            trustAllHostname = true
+            return this
+        }
+
+        fun setSSLSocketFactory(
+            sslSocketFactory: SSLSocketFactory?,
+            trustManager: X509TrustManager?
+        ): Builder {
+            if (sslSocketFactory != null && trustManager != null) {
+                this.sslSocketFactory = sslSocketFactory
+                x509TrustManager = trustManager
+            }
+            return this
+        }
+
+        fun setCachePath(file: File?): Builder {
+            if (file != null) cacheFile = file
+            return this
+        }
+
+        fun setDynamicCachePath(file: File?): Builder {
+            if (file != null) dynamicCacheFile = file
+            return this
+        }
+
+        fun setCacheSize(cacheSize: Long): Builder {
+            if (cacheSize > 1024) this.cacheSize = cacheSize
+            return this
+        }
+
+        fun setReadTimeoutSecond(time: Long): Builder {
+            if (time >= 0) readTimeout = time
+            return this
+        }
+
+        fun setConnectTimeoutSecond(time: Long): Builder {
+            if (time >= 0) connectTimeout = time
+            return this
+        }
+
+        fun setCacheExtensionConfig(config: CacheExtensionConfig?): Builder {
+            if (config != null) cacheExtensionConfig = config
+            return this
+        }
+
+        fun setDebug(debug: Boolean): Builder {
+            this.debug = debug
+            return this
+        }
+
+        fun setCacheType(cacheType: CacheType): Builder {
+            this.cacheType = cacheType
+            return this
+        }
+
+        fun isAssetsSuffixMod(suffixMod: Boolean): Builder {
+            isSuffixMod = suffixMod
+            return this
+        }
+
+        fun setAssetsDir(dir: String?): Builder {
+            if (dir != null) {
+                assetsDir = dir
+            }
+            return this
+        }
+
+        fun setDns(dns: Dns?) {
+            this.dns = dns
+        }
+
+        fun build(): WebViewRequestInterceptor {
+            return WebViewCacheInterceptor(this)
+        }
+    }
+
+    private fun isValidUrl(url: String?): Boolean {
+        return URLUtil.isValidUrl(url)
+    }
+
+    companion object {
+        const val KEY_CACHE = "WebResourceInterceptor-Key-Cache"
+    }
+
+}

+ 86 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/WebViewCacheInterceptorInst.kt

@@ -0,0 +1,86 @@
+package org.mjdev.tvlib.webclient.cache
+
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import java.io.File
+import java.io.InputStream
+
+@Suppress("unused")
+class WebViewCacheInterceptorInst : WebViewRequestInterceptor {
+
+    private var interceptor: WebViewRequestInterceptor? = null
+
+    fun init(builder: WebViewCacheInterceptor.Builder?) {
+        if (builder != null) interceptor = builder.build()
+    }
+
+    override fun interceptRequest(request: WebResourceRequest?): WebResourceResponse? {
+        return interceptor?.interceptRequest(request)
+    }
+
+    override fun interceptRequest(url: String?): WebResourceResponse? {
+        return interceptor?.interceptRequest(url)
+    }
+
+    override fun loadUrl(webView: WebView?, url: String?) {
+        interceptor?.loadUrl(webView, url)
+    }
+
+    override fun loadUrl(url: String?, userAgent: String?) {
+        interceptor?.loadUrl(url, userAgent)
+    }
+
+    override fun loadUrl(
+        url: String?,
+        additionalHttpHeaders: Map<String?, String?>?,
+        userAgent: String?
+    ) {
+        interceptor?.loadUrl(url, additionalHttpHeaders, userAgent)
+    }
+
+    override fun loadUrl(
+        webView: WebView?,
+        url: String?,
+        additionalHttpHeaders: Map<String?, String?>?
+    ) {
+        interceptor?.loadUrl(webView, url, additionalHttpHeaders)
+    }
+
+    override fun clearCache() {
+        interceptor?.clearCache()
+    }
+
+    override fun enableForce(force: Boolean) {
+        interceptor?.enableForce(force)
+    }
+
+    override fun getCacheFile(url: String?): InputStream? {
+        return interceptor?.getCacheFile(url)
+    }
+
+    override fun initAssetsData() {
+        AssetsLoader.instance.initData()
+    }
+
+    override val cachePath: File?
+        get() = interceptor?.cachePath
+
+    companion object {
+
+        private var webViewCacheInterceptorInst: WebViewCacheInterceptorInst? = null
+
+        val instance: WebViewCacheInterceptorInst?
+            get() {
+                if (webViewCacheInterceptorInst == null) {
+                    synchronized(WebViewCacheInterceptorInst::class.java) {
+                        if (webViewCacheInterceptorInst == null) {
+                            webViewCacheInterceptorInst = WebViewCacheInterceptorInst()
+                        }
+                    }
+                }
+                return webViewCacheInterceptorInst
+            }
+    }
+
+}

+ 33 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cache/WebViewRequestInterceptor.kt

@@ -0,0 +1,33 @@
+package org.mjdev.tvlib.webclient.cache
+
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import java.io.File
+import java.io.InputStream
+
+interface WebViewRequestInterceptor {
+
+    val cachePath: File?
+
+    fun interceptRequest(request: WebResourceRequest?): WebResourceResponse?
+
+    fun interceptRequest(url: String?): WebResourceResponse?
+
+    fun clearCache()
+
+    fun enableForce(force: Boolean)
+
+    fun getCacheFile(url: String?): InputStream?
+
+    fun initAssetsData()
+
+    fun loadUrl(webView: WebView?, url: String?)
+
+    fun loadUrl(url: String?, userAgent: String?)
+
+    fun loadUrl(url: String?, additionalHttpHeaders: Map<String?, String?>?, userAgent: String?)
+
+    fun loadUrl(webView: WebView?, url: String?, additionalHttpHeaders: Map<String?, String?>?)
+
+}

+ 25 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/cookies/CookiesManager.kt

@@ -0,0 +1,25 @@
+package org.mjdev.tvlib.webclient.cookies
+
+import okhttp3.Cookie
+import okhttp3.CookieJar
+import okhttp3.HttpUrl
+
+@Suppress("unused")
+class CookiesManager : HashMap<String, ArrayList<Cookie>>(), CookieJar {
+
+    override fun loadForRequest(url: HttpUrl): List<Cookie> {
+        return url.toString().let { u ->
+            this[u] ?: emptyList()
+        }
+    }
+
+    override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
+        url.toString().let { u ->
+            if (!containsKey(u)) {
+                put(u, ArrayList())
+            }
+            this[u]?.addAll(cookies)
+        }
+    }
+
+}

+ 23 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlAnchor.kt

@@ -0,0 +1,23 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+import org.mjdev.tvlib.extensions.GlobalExt.isUrl
+
+class HtmlAnchor(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "a"
+
+    val href get() = attr("href")
+
+    fun fullHref(page: HtmlPage): String {
+        val href = this.href.let {
+            if(it.first() == '/') it.replaceFirst("/", "")
+            else it
+        }
+        return if(href.isUrl) {
+            href
+        } else {
+            "${page.url}$href"
+        }
+    }
+}

+ 8 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlBody.kt

@@ -0,0 +1,8 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+
+class HtmlBody(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "body"
+}

+ 8 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlDiv.kt

@@ -0,0 +1,8 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+
+class HtmlDiv(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "div"
+}

+ 25 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlDocument.kt

@@ -0,0 +1,25 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Element
+import timber.log.Timber
+import java.lang.Exception
+
+class HtmlDocument(val html: String?, jsi: JSI) : HtmlElement(parseElement(html), jsi) {
+    override val tag: String get() = "html"
+
+    val head: HtmlHead get() = getElement()
+    val body: HtmlBody get() = getElement()
+
+    companion object {
+        fun parseElement(html: String?): Element {
+            return try {
+                Jsoup.parse(html ?: "<html />")
+            } catch (e: Exception) {
+                Timber.e(e)
+                Element("")
+            }
+        }
+    }
+}

+ 83 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlElement.kt

@@ -0,0 +1,83 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+
+abstract class HtmlElement(var element: Element, val jsi: JSI) {
+    abstract val tag: String
+
+    val cssClass: String get() = attr("class")
+    val id: String get() = attr("href")
+    val name get() = attr("name")
+    val childElementCount: Int get() = childrenSize()
+    val childs: List<HtmlElement>
+        get() = element.children().map { e -> parseElement(e, jsi) }
+    val firstChild: HtmlElement
+        get() = element.child(0).let { e -> parseElement(e, jsi) }
+
+    fun childrenSize(): Int = element.childrenSize()
+
+    fun getElementsByTagName(tagName: String): List<HtmlElement> {
+        return element.getElementsByTag(tagName).map {
+            parseElement(it, jsi)
+        }
+    }
+
+    fun ownText(): String = element.ownText() ?: ""
+
+    inline fun <reified T> newInstance(jsi: JSI): T = T::class
+        .java
+        .constructors
+        .first()
+        .newInstance(
+            Element("unknown"),
+            jsi
+        ) as T
+
+    inline fun <reified T : HtmlElement> getAllElements(): List<T> {
+        val instance: T = newInstance(jsi)
+        return element.getElementsByTag(instance.tag).map {
+            parseElement(it, jsi) as T?
+        }.filterNotNull()
+    }
+
+    inline fun <reified T : HtmlElement> getElement(): T {
+        val instance: HtmlElement = newInstance(jsi)
+        return element.getElementsByTag(instance.tag).first()?.let {
+            parseElement(it, jsi) as? T?
+        } ?: newInstance(jsi)
+    }
+
+    fun attr(attrName: String, defaultValue: String = ""): String =
+        element.attr(attrName) ?: defaultValue
+
+    fun attr(attrName: String, value: Any?) {
+        element.attr(attrName, value.toString())
+    }
+
+    override fun toString(): String {
+        return element.toString()
+    }
+
+    companion object {
+        fun parseElement(element: Element, jsi: JSI): HtmlElement {
+            return when (element.tag().toString()) {
+                "a" -> HtmlAnchor(element, jsi)
+                "body" -> HtmlBody(element, jsi)
+                "div" -> HtmlDiv(element, jsi)
+                "head" -> HtmlHead(element, jsi)
+                "img" -> HtmlImage(element, jsi)
+                "link" -> HtmlLink(element, jsi)
+                "meta" -> HtmlMeta(element, jsi)
+                "noscript" -> HtmlNoScript(element, jsi)
+                "script" -> HtmlScript(element, jsi)
+                "source" -> HtmlSource(element, jsi)
+                "title" -> HtmlTitle(element, jsi)
+                "video" -> HtmlVideo(element, jsi)
+                else -> object : HtmlElement(element, jsi) {
+                    override val tag: String get() = ""
+                }
+            }
+        }
+    }
+}

+ 15 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlHead.kt

@@ -0,0 +1,15 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+
+class HtmlHead(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "head"
+
+    val title: HtmlTitle get() = getElement()
+
+    val meta: List<HtmlMeta> get() = getAllElements()
+    val links: List<HtmlLink> get() = getAllElements()
+    val scripts: List<HtmlScript> get() = getAllElements()
+    val noScript: List<HtmlNoScript> get() = getAllElements()
+}

+ 15 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlImage.kt

@@ -0,0 +1,15 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+import org.mjdev.tvlib.webclient.html.HtmlElement
+
+class HtmlImage(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "img"
+
+    val src get() = attr("src")
+    val alt get() = attr("alt")
+
+    val width get() = attr("width") // todo css
+    val height get() = attr("height") // todo css
+}

+ 12 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlLink.kt

@@ -0,0 +1,12 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+import org.mjdev.tvlib.webclient.html.HtmlElement
+
+class HtmlLink(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "link"
+
+    val rel: String get() = attr("rel")
+    val href: String get() = attr("href")
+}

+ 12 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlMeta.kt

@@ -0,0 +1,12 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+import org.mjdev.tvlib.webclient.html.HtmlElement
+
+class HtmlMeta(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "meta"
+
+    val charset : String get() = attr("charset")
+    val content : String get() = attr("content")
+}

+ 10 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlNoScript.kt

@@ -0,0 +1,10 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+
+class HtmlNoScript(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "noscript"
+
+    val links: List<HtmlLink> get() = getAllElements()
+}

+ 19 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlPage.kt

@@ -0,0 +1,19 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+
+class HtmlPage(val url: String?, val html: String?, val jsi: JSI) {
+    val documentElement get() = HtmlDocument(html, jsi)
+
+    val head: HtmlHead get() = documentElement.head
+    val body: HtmlBody get() = documentElement.body
+
+    val title get() = head.title.text
+
+    val videos: List<HtmlVideo> get() = body.getAllElements()
+    val anchors: List<HtmlAnchor> get() = body.getAllElements()
+
+    override fun toString(): String {
+        return "${HtmlPage::class.simpleName} : ${url ?: ""}"
+    }
+}

+ 13 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlScript.kt

@@ -0,0 +1,13 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+import org.mjdev.tvlib.webclient.html.HtmlElement
+
+class HtmlScript(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "script"
+
+    val type:String get() = attr("type")
+    val async:String get() = attr("async")
+    val src :String get() = attr("src")
+}

+ 11 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlSource.kt

@@ -0,0 +1,11 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+import org.mjdev.tvlib.webclient.html.HtmlElement
+
+class HtmlSource(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "source"
+
+    val src: String get() = attr("src")
+}

+ 11 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlTitle.kt

@@ -0,0 +1,11 @@
+package org.mjdev.tvlib.webclient.html
+
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+import org.mjdev.tvlib.webclient.html.HtmlElement
+
+class HtmlTitle(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "title"
+
+    val text: String get() = ownText()
+}

+ 91 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/html/HtmlVideo.kt

@@ -0,0 +1,91 @@
+package org.mjdev.tvlib.webclient.html
+
+import android.webkit.MimeTypeMap
+import org.mjdev.tvlib.webclient.javascript.JSI
+import org.jsoup.nodes.Element
+
+class HtmlVideo(element: Element, jsi: JSI) : HtmlElement(element, jsi) {
+    override val tag: String get() = "video"
+
+    val playsInline: String get() = attr("playsinline")
+    val poster: String get() = attr("poster")
+    val controlsList: String get() = attr("controlslist")
+    val preload: String get() = attr("preload")
+    val tabindex: String get() = attr("tabindex")
+    val controls: String get() = attr("controls")
+
+    val sources: List<String> get() = childs.filterIsInstance<HtmlSource>().let { list ->
+        if (list.isEmpty()) {
+            listOf(attr("src"))
+        } else {
+            list.map { htmlSource ->
+                htmlSource.src
+            }
+        }
+    }
+
+    val videoStreams: List<String> get() = sources.filter {
+        isVideoLink(it)
+    }
+
+    val hasStreams: Boolean get() = videoStreams.isNotEmpty()
+
+    companion object {
+
+        private val VIDEO_EXTENSIONS = listOf(
+            "mp4",
+            "m3u8",
+            "hls",
+            "avi",
+            "mpg",
+            "mpeg",
+            "avi"
+        )
+
+        private val VIDEO_EXCLUDE_EXTENSIONS = listOf(
+            "jpg",
+            "jpeg",
+            "js",
+            "ico",
+            "png",
+            "webp",
+            "css"
+        )
+
+        fun String.nonVideoExluded(): Boolean {
+            val ext = MimeTypeMap.getFileExtensionFromUrl(this)
+            return !VIDEO_EXCLUDE_EXTENSIONS.contains(ext)
+        }
+
+        fun <T : List<String>> T.sortVideosByImportance(): List<String> {
+            val pairs = map {
+                Pair(indexOf(VIDEO_EXTENSIONS, it), it)
+            }
+            val filterred = pairs.filter {
+                (it.first >= 0) && it.second.nonVideoExluded()
+            }
+            val sorted = filterred.sortedBy {
+                it.first
+            }
+            return sorted.map {
+                it.second
+            }
+        }
+
+        private fun indexOf(where: List<String>, what: String): Int {
+            for (i in where.indices) {
+                if (what.contains(where[i])) {
+                    return i
+                }
+            }
+            return -1
+        }
+
+        fun isVideoLink(url: String?): Boolean {
+            val isExtOK: Boolean = url?.nonVideoExluded() ?: true
+            return isExtOK && url?.let { u ->
+                VIDEO_EXTENSIONS.map { vext -> (u.contains(vext)) }.isNotEmpty()
+            } ?: false
+        }
+    }
+}

+ 89 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/javascript/JSI.kt

@@ -0,0 +1,89 @@
+package org.mjdev.tvlib.webclient.javascript
+
+import android.webkit.JavascriptInterface
+import org.mjdev.tvlib.webclient.WebClient
+import org.mjdev.tvlib.webclient.html.HtmlPage
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+@Suppress("SameParameterValue")
+class JSI(
+    private val webView: WebClient,
+    val listener: JSIListener
+) {
+    val name: String get() = JSI::class.simpleName!!.lowercase()
+
+    init {
+        webView.addJavascriptInterface(this, name)
+    }
+
+    private fun callJavascript(s: String) {
+        Timber.e("Calling javascript command: $s")
+        webView.loadUrl("javascript:$s")
+    }
+
+    private fun callJSI(fnc: String, vararg params: String?) = callJavascript(
+        StringBuilder().apply {
+            append("jsi.$fnc(")
+            params.forEach { p ->
+                append("$p${if (p == params.last()) "" else ","}")
+            }
+            append(");")
+        }.toString()
+    )
+
+    fun getHtml() {
+        callJSI(
+            "onHtml",
+            "'<html>'+document.getElementsByTagName('html')[0].innerHTML+'</html>'"
+        )
+    }
+
+    // ---------------------------------------------------------------------------------------------
+
+    @JavascriptInterface
+    fun onHtml(html: String?) {
+        CoroutineScope(Dispatchers.Main).launch {
+            listener.onGotHtml(
+                HtmlPage(
+                    webView.url,
+                    html,
+                    webView.webViewClient.jsi
+                ), webView.webViewClient.loadedResources
+            )
+        }
+    }
+
+//    @JavascriptInterface
+//    fun showToast(text: String?) {
+//        onUI {
+//            Toast.makeText(webView.context, text.toString(), Toast.LENGTH_LONG)
+//        }
+//    }
+
+    // ---------------------------------------------------------------------------------------------
+
+//    class Video(val jsi: JSI) {
+//        fun play(ve: VideoElement?) {
+//            val id = ve?.id
+//            val idx = ve?.idx ?: 0
+//            if (id == null) {
+//                jsi.callJavascript("document.getElementsByTagName(\"video\")[$idx].play();")
+//            } else {
+//                jsi.callJavascript("document.getElementById(\"$id\").play();")
+//            }
+//        }
+//
+//        fun fullScreen(ve: VideoElement?) {
+//            val id = ve?.id
+//            val idx = ve?.idx ?: 0
+//            if (id == null) {
+//                jsi.callJavascript("document.getElementsByTagName(\"video\")[$idx].webkitRequestFullscreen();")
+//            } else {
+//                jsi.callJavascript("var video = document.getElementById(\"$id\").webkitRequestFullscreen();")
+//            }
+//        }
+//    }
+}

+ 7 - 0
tvlib/src/main/kotlin/org/mjdev/tvlib/webclient/javascript/JSIListener.kt

@@ -0,0 +1,7 @@
+package org.mjdev.tvlib.webclient.javascript
+
+import org.mjdev.tvlib.webclient.html.HtmlPage
+
+interface JSIListener {
+    fun onGotHtml(htmlPage: HtmlPage, loadedResources: List<String>)
+}