Milan Jurkulak 3 年 前
親
コミット
b0dd740aaa
35 ファイル変更、1086 行追加、160 行削除
  1. 6 0
      app/build.gradle.kts
  2. 136 9
      app/objectbox-models/default.json
  3. 227 0
      app/objectbox-models/default.json.bak
  4. 15 0
      app/src/main/AndroidManifest.xml
  5. 35 0
      app/src/main/kotlin/org/mjdev/tvapp/activity/IPTVActivity.kt
  6. 39 0
      app/src/main/kotlin/org/mjdev/tvapp/api/ApiRepository.kt
  7. 41 0
      app/src/main/kotlin/org/mjdev/tvapp/api/ApiService.kt
  8. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/data/local/Message.kt
  9. 17 14
      app/src/main/kotlin/org/mjdev/tvapp/data/local/Movie.kt
  10. 110 0
      app/src/main/kotlin/org/mjdev/tvapp/data/local/TVChannel.kt
  11. 24 0
      app/src/main/kotlin/org/mjdev/tvapp/data/remote/Category.kt
  12. 69 0
      app/src/main/kotlin/org/mjdev/tvapp/data/remote/Channel.kt
  13. 30 0
      app/src/main/kotlin/org/mjdev/tvapp/data/remote/Country.kt
  14. 36 0
      app/src/main/kotlin/org/mjdev/tvapp/data/remote/Epg.kt
  15. 24 0
      app/src/main/kotlin/org/mjdev/tvapp/data/remote/Language.kt
  16. 30 0
      app/src/main/kotlin/org/mjdev/tvapp/data/remote/Stream.kt
  17. 10 3
      app/src/main/kotlin/org/mjdev/tvapp/database/DAO.kt
  18. 36 24
      app/src/main/kotlin/org/mjdev/tvapp/module/ProvideModule.kt
  19. 5 5
      app/src/main/kotlin/org/mjdev/tvapp/repository/IMovieRepository.kt
  20. 26 9
      app/src/main/kotlin/org/mjdev/tvapp/repository/MovieRepository.kt
  21. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/sync/DataProvider.kt
  22. 41 33
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncAdapter.kt
  23. 5 0
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncService.kt
  24. 1 23
      app/src/main/kotlin/org/mjdev/tvapp/ui/components/MediaPlayer.kt
  25. 53 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/IPTVScreen.kt
  26. 9 3
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt
  27. 5 4
      app/src/main/res/values-cs/strings.xml
  28. 5 4
      app/src/main/res/values-sk/strings.xml
  29. 5 3
      app/src/main/res/values/strings.xml
  30. 2 2
      tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/GlobalExt.kt
  31. 15 6
      tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/apps/AppsManager.kt
  32. 3 4
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/media/ExoPlayerImpl.kt
  33. 18 6
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/media/MediaPlayerContainer.kt
  34. 2 2
      tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/BrowseView.kt
  35. 4 4
      tvlib/src/main/res/values/strings.xml

+ 6 - 0
app/build.gradle.kts

@@ -61,6 +61,12 @@ android {
         versionName = project.versionName
 
         signingConfig = signingConfigs[SIGNING_CONFIG_NAME]
+
+        buildConfigField(
+            "String",
+            "IPTV_API_URL",
+            "\"https://iptv-org.github.io/api/\""
+        )
     }
 
     buildTypes {

+ 136 - 9
app/objectbox-models/default.json

@@ -39,15 +39,9 @@
     },
     {
       "id": "2:7141750768191734097",
-      "lastPropertyId": "8:6917239407098203120",
+      "lastPropertyId": "12:6138526348322531011",
       "name": "Movie",
       "properties": [
-        {
-          "id": "1:2336450843545835846",
-          "name": "id",
-          "type": 6,
-          "flags": 1
-        },
         {
           "id": "2:4325777566417003871",
           "name": "category",
@@ -82,12 +76,142 @@
           "id": "8:6917239407098203120",
           "name": "uri",
           "type": 9
+        },
+        {
+          "id": "10:3350489478053652777",
+          "name": "country",
+          "type": 9
+        },
+        {
+          "id": "11:3647500054699987607",
+          "name": "isNsfw",
+          "type": 1
+        },
+        {
+          "id": "12:6138526348322531011",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        }
+      ],
+      "relations": []
+    },
+    {
+      "id": "3:9057081142853951475",
+      "lastPropertyId": "21:621330748111634389",
+      "name": "TVChannel",
+      "properties": [
+        {
+          "id": "1:405813939682794082",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "2:2430447267540459880",
+          "name": "channelId",
+          "type": 9
+        },
+        {
+          "id": "3:335304685441134779",
+          "name": "name",
+          "type": 9
+        },
+        {
+          "id": "4:9203693742440718921",
+          "name": "altNames",
+          "type": 30
+        },
+        {
+          "id": "5:305619737814439579",
+          "name": "network",
+          "type": 9
+        },
+        {
+          "id": "6:8912183835182873798",
+          "name": "owners",
+          "type": 30
+        },
+        {
+          "id": "7:5162853828414587673",
+          "name": "country",
+          "type": 9
+        },
+        {
+          "id": "8:8318028274197840697",
+          "name": "subdivision",
+          "type": 9
+        },
+        {
+          "id": "9:82378392647908250",
+          "name": "city",
+          "type": 9
+        },
+        {
+          "id": "10:307299702247777033",
+          "name": "broadcastArea",
+          "type": 30
+        },
+        {
+          "id": "11:8982103073523114723",
+          "name": "languages",
+          "type": 30
+        },
+        {
+          "id": "12:4455671949773654996",
+          "name": "categories",
+          "type": 30
+        },
+        {
+          "id": "13:4371318260918924324",
+          "name": "isNsfw",
+          "type": 1
+        },
+        {
+          "id": "14:3415237767440866222",
+          "name": "launched",
+          "type": 9
+        },
+        {
+          "id": "15:4356958010633671746",
+          "name": "closed",
+          "type": 9
+        },
+        {
+          "id": "16:6543956435659583610",
+          "name": "replacedBy",
+          "type": 9
+        },
+        {
+          "id": "17:7749699120644604613",
+          "name": "website",
+          "type": 9
+        },
+        {
+          "id": "18:1492865986899424073",
+          "name": "logo",
+          "type": 9
+        },
+        {
+          "id": "19:3718882347239279941",
+          "name": "streamUrl",
+          "type": 9
+        },
+        {
+          "id": "20:25154459083377849",
+          "name": "httpReferrer",
+          "type": 9
+        },
+        {
+          "id": "21:621330748111634389",
+          "name": "userAgent",
+          "type": 9
         }
       ],
       "relations": []
     }
   ],
-  "lastEntityId": "2:7141750768191734097",
+  "lastEntityId": "3:9057081142853951475",
   "lastIndexId": "0:0",
   "lastRelationId": "0:0",
   "lastSequenceId": "0:0",
@@ -95,7 +219,10 @@
   "modelVersionParserMinimum": 5,
   "retiredEntityUids": [],
   "retiredIndexUids": [],
-  "retiredPropertyUids": [],
+  "retiredPropertyUids": [
+    2336450843545835846,
+    1211648868008137353
+  ],
   "retiredRelationUids": [],
   "version": 1
 }

+ 227 - 0
app/objectbox-models/default.json.bak

@@ -0,0 +1,227 @@
+{
+  "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.",
+  "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
+  "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
+  "entities": [
+    {
+      "id": "1:3071221304691859969",
+      "lastPropertyId": "5:7380842637055147338",
+      "name": "Message",
+      "properties": [
+        {
+          "id": "1:5903489558647133731",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "2:5521220968116696283",
+          "name": "title",
+          "type": 9
+        },
+        {
+          "id": "3:153079673900562691",
+          "name": "message",
+          "type": 9
+        },
+        {
+          "id": "4:7830966794090126749",
+          "name": "read",
+          "type": 1
+        },
+        {
+          "id": "5:7380842637055147338",
+          "name": "deleted",
+          "type": 1
+        }
+      ],
+      "relations": []
+    },
+    {
+      "id": "2:7141750768191734097",
+      "lastPropertyId": "11:3647500054699987607",
+      "name": "Movie",
+      "properties": [
+        {
+          "id": "2:4325777566417003871",
+          "name": "category",
+          "type": 9
+        },
+        {
+          "id": "3:3140383628530752513",
+          "name": "studio",
+          "type": 9
+        },
+        {
+          "id": "4:7177523158231354514",
+          "name": "title",
+          "type": 9
+        },
+        {
+          "id": "5:6928102760022948176",
+          "name": "description",
+          "type": 9
+        },
+        {
+          "id": "6:3074162147955651553",
+          "name": "background",
+          "type": 9
+        },
+        {
+          "id": "7:1404798181518812606",
+          "name": "image",
+          "type": 9
+        },
+        {
+          "id": "8:6917239407098203120",
+          "name": "uri",
+          "type": 9
+        },
+        {
+          "id": "9:1211648868008137353",
+          "name": "_id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "10:3350489478053652777",
+          "name": "country",
+          "type": 9
+        },
+        {
+          "id": "11:3647500054699987607",
+          "name": "isNsfw",
+          "type": 1
+        }
+      ],
+      "relations": []
+    },
+    {
+      "id": "3:9057081142853951475",
+      "lastPropertyId": "21:621330748111634389",
+      "name": "TVChannel",
+      "properties": [
+        {
+          "id": "1:405813939682794082",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "2:2430447267540459880",
+          "name": "channelId",
+          "type": 9
+        },
+        {
+          "id": "3:335304685441134779",
+          "name": "name",
+          "type": 9
+        },
+        {
+          "id": "4:9203693742440718921",
+          "name": "altNames",
+          "type": 30
+        },
+        {
+          "id": "5:305619737814439579",
+          "name": "network",
+          "type": 9
+        },
+        {
+          "id": "6:8912183835182873798",
+          "name": "owners",
+          "type": 30
+        },
+        {
+          "id": "7:5162853828414587673",
+          "name": "country",
+          "type": 9
+        },
+        {
+          "id": "8:8318028274197840697",
+          "name": "subdivision",
+          "type": 9
+        },
+        {
+          "id": "9:82378392647908250",
+          "name": "city",
+          "type": 9
+        },
+        {
+          "id": "10:307299702247777033",
+          "name": "broadcastArea",
+          "type": 30
+        },
+        {
+          "id": "11:8982103073523114723",
+          "name": "languages",
+          "type": 30
+        },
+        {
+          "id": "12:4455671949773654996",
+          "name": "categories",
+          "type": 30
+        },
+        {
+          "id": "13:4371318260918924324",
+          "name": "isNsfw",
+          "type": 1
+        },
+        {
+          "id": "14:3415237767440866222",
+          "name": "launched",
+          "type": 9
+        },
+        {
+          "id": "15:4356958010633671746",
+          "name": "closed",
+          "type": 9
+        },
+        {
+          "id": "16:6543956435659583610",
+          "name": "replacedBy",
+          "type": 9
+        },
+        {
+          "id": "17:7749699120644604613",
+          "name": "website",
+          "type": 9
+        },
+        {
+          "id": "18:1492865986899424073",
+          "name": "logo",
+          "type": 9
+        },
+        {
+          "id": "19:3718882347239279941",
+          "name": "streamUrl",
+          "type": 9
+        },
+        {
+          "id": "20:25154459083377849",
+          "name": "httpReferrer",
+          "type": 9
+        },
+        {
+          "id": "21:621330748111634389",
+          "name": "userAgent",
+          "type": 9
+        }
+      ],
+      "relations": []
+    }
+  ],
+  "lastEntityId": "3:9057081142853951475",
+  "lastIndexId": "0:0",
+  "lastRelationId": "0:0",
+  "lastSequenceId": "0:0",
+  "modelVersion": 5,
+  "modelVersionParserMinimum": 5,
+  "retiredEntityUids": [],
+  "retiredIndexUids": [],
+  "retiredPropertyUids": [
+    2336450843545835846
+  ],
+  "retiredRelationUids": [],
+  "version": 1
+}

+ 15 - 0
app/src/main/AndroidManifest.xml

@@ -76,6 +76,21 @@
 
         </activity>
 
+        <activity
+            android:label="@string/title_iptv"
+            android:name=".activity.IPTVActivity"
+            android:exported="true">
+
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
+            </intent-filter>
+
+        </activity>
+
         <service
             android:name=".sync.SyncService"
             android:exported="false">

+ 35 - 0
app/src/main/kotlin/org/mjdev/tvapp/activity/IPTVActivity.kt

@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.activity
+
+import androidx.compose.runtime.Composable
+import dagger.hilt.android.AndroidEntryPoint
+import org.mjdev.tvlib.activity.ComposableActivity
+import org.mjdev.tvlib.annotations.TvPreview
+import org.mjdev.tvlib.extensions.NavGraphBuilderExt.screen
+import org.mjdev.tvlib.navigation.NavGraphBuilderEx
+import org.mjdev.tvapp.ui.screens.IPTVScreen
+
+// todo
+@AndroidEntryPoint
+class IPTVActivity : ComposableActivity() {
+
+    @TvPreview
+    @Composable
+    override fun Compose() = super.Compose()
+
+    override val navGraphBuilder: NavGraphBuilderEx.() -> Unit = {
+
+        screen(
+            route = IPTVScreen(),
+            isHomeScreen = true
+        )
+
+    }
+}

+ 39 - 0
app/src/main/kotlin/org/mjdev/tvapp/api/ApiRepository.kt

@@ -0,0 +1,39 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:Suppress("unused")
+
+package org.mjdev.tvapp.api
+
+import org.mjdev.tvapp.data.remote.Category
+import org.mjdev.tvapp.data.remote.Channel
+import org.mjdev.tvapp.data.remote.Epg
+import org.mjdev.tvapp.data.remote.Stream
+import org.mjdev.tvlib.extensions.GlobalExt.runSafe
+
+class ApiRepository(
+    private val apiService: ApiService
+) {
+
+    suspend fun channels(): Result<List<Channel>> = runSafe {
+        apiService.channels().execute().body() ?: emptyList()
+    }
+
+    suspend fun streams(): Result<List<Stream>> = runSafe {
+        apiService.streams().execute().body() ?: emptyList()
+    }
+
+    suspend fun epg(): Result<List<Epg>> = runSafe {
+        apiService.epg().execute().body() ?: emptyList()
+    }
+
+    suspend fun categories(): Result<List<Category>> = runSafe {
+        apiService.categories().execute().body() ?: emptyList()
+    }
+
+}

+ 41 - 0
app/src/main/kotlin/org/mjdev/tvapp/api/ApiService.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.tvapp.api
+
+import org.mjdev.tvapp.data.remote.Category
+import org.mjdev.tvapp.data.remote.Channel
+import org.mjdev.tvapp.data.remote.Country
+import org.mjdev.tvapp.data.remote.Epg
+import org.mjdev.tvapp.data.remote.Language
+import org.mjdev.tvapp.data.remote.Stream
+import retrofit2.Call
+import retrofit2.http.GET
+
+@Suppress("unused")
+interface ApiService {
+
+    @GET("channels.json")
+    fun channels(): Call<List<Channel>>
+
+    @GET("streams.json")
+    fun streams(): Call<List<Stream>>
+
+    @GET("guides.json")
+    fun epg(): Call<List<Epg>>
+
+    @GET("categories.json")
+    fun categories(): Call<List<Category>>
+
+    @GET("languages.json")
+    fun languages(): Call<List<Language>>
+
+    @GET("countries.json")
+    fun countries(): Call<List<Country>>
+
+}

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/data/Message.kt → app/src/main/kotlin/org/mjdev/tvapp/data/local/Message.kt

@@ -6,7 +6,7 @@
  * w: https://mjdev.org
  */
 
-package org.mjdev.tvapp.data
+package org.mjdev.tvapp.data.local
 
 import com.squareup.moshi.Json
 import com.squareup.moshi.JsonClass

+ 17 - 14
app/src/main/kotlin/org/mjdev/tvapp/data/Movie.kt → app/src/main/kotlin/org/mjdev/tvapp/data/local/Movie.kt

@@ -1,12 +1,12 @@
 /*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
  */
 
-package org.mjdev.tvapp.data
+package org.mjdev.tvapp.data.local
 
 import com.squareup.moshi.Json
 import com.squareup.moshi.JsonClass
@@ -20,7 +20,9 @@ import org.mjdev.tvlib.interfaces.ItemWithSubtitle
 import org.mjdev.tvlib.interfaces.ItemWithTitle
 import org.mjdev.tvlib.interfaces.ItemWithUri
 import java.io.Serializable
+import java.util.Locale
 
+@Suppress("unused")
 @Entity
 @JsonClass(generateAdapter = true)
 class Movie :
@@ -39,10 +41,19 @@ class Movie :
 
     @Json(name = "category")
     var category: String? = ""
+        get() = (field ?: country)?.replaceFirstChar { ch ->
+            ch.titlecase(Locale.ROOT)
+        }
 
     @Json(name = "studio")
     var studio: String? = ""
 
+    @Json(name = "country")
+    var country: String? = ""
+
+    @Json(name = "isNsfw")
+    var isNsfw: Boolean = false
+
     @Json(name = "title")
     override var title: String? = ""
 
@@ -64,12 +75,4 @@ class Movie :
             studio = value
         }
 
-    override fun toString(): String = "Movie{" +
-        "id=" + id +
-        ", title='" + title + '\'' +
-        ", videoUrl='" + uri.toString() + '\'' +
-        ", backgroundImageUrl='" + background + '\'' +
-        ", cardImageUrl='" + image + '\'' +
-        '}'
-
 }

+ 110 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/local/TVChannel.kt

@@ -0,0 +1,110 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.local
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import io.objectbox.annotation.Entity
+import io.objectbox.annotation.Id
+import org.mjdev.tvlib.interfaces.ItemWithDescription
+import org.mjdev.tvlib.interfaces.ItemWithId
+import org.mjdev.tvlib.interfaces.ItemWithImage
+import org.mjdev.tvlib.interfaces.ItemWithSubtitle
+import org.mjdev.tvlib.interfaces.ItemWithTitle
+import org.mjdev.tvlib.interfaces.ItemWithUri
+import java.io.Serializable
+
+// todo
+
+@Suppress("unused")
+@Entity
+@JsonClass(generateAdapter = true)
+class TVChannel :
+    Serializable,
+    ItemWithId,
+    ItemWithTitle<String>,
+    ItemWithSubtitle<String>,
+    ItemWithImage<String>,
+    ItemWithUri<String>,
+    ItemWithDescription<String> {
+
+    @Id
+    override var id: Long = 0
+
+    @Json(name = "id")
+    var channelId: String? = null
+
+    @Json(name = "name")
+    var name: String? = null
+
+    @Json(name = "alt_names")
+    var altNames: List<String>? = null
+
+    @Json(name = "network")
+    var network: String? = null
+
+    @Json(name = "owners")
+    var owners: List<String>? = null
+
+    @Json(name = "country")
+    var country: String? = null
+
+    @Json(name = "subdivision")
+    var subdivision: String? = null
+
+    @Json(name = "city")
+    var city: String? = null
+
+    @Json(name = "broadcast_area")
+    var broadcastArea: List<String>? = null
+
+    @Json(name = "languages")
+    var languages: List<String>? = null
+
+    @Json(name = "categories")
+    var categories: List<String>? = null
+
+    @Json(name = "is_nsfw")
+    var isNsfw: Boolean? = null
+
+    @Json(name = "launched")
+    var launched: String? = null
+
+    @Json(name = "closed")
+    var closed: String? = null
+
+    @Json(name = "replaced_by")
+    var replacedBy: String? = null
+
+    @Json(name = "website")
+    var website: String? = null
+
+    @Json(name = "logo")
+    var logo: String? = null
+
+    @Json(name = "url")
+    var streamUrl: String? = null
+
+    @Json(name = "http_referrer")
+    var httpReferrer: String? = null
+
+    @Json(name = "user_agent")
+    var userAgent: String? = null
+
+    override val description: String? get() = null
+
+    override val image: String? get() = logo
+
+    override val subtitle: String? get() = network
+
+    override val title: String? get() = name
+
+    override val uri: String? get() = streamUrl
+
+}

+ 24 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/remote/Category.kt

@@ -0,0 +1,24 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.remote
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@Suppress("unused")
+@JsonClass(generateAdapter = true)
+class Category {
+
+    @Json(name = "code")
+    var code: String? = null
+
+    @Json(name = "name")
+    var name: String? = null
+
+}

+ 69 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/remote/Channel.kt

@@ -0,0 +1,69 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.remote
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@Suppress("unused")
+@JsonClass(generateAdapter = true)
+class Channel {
+
+    @Json(name = "id")
+    var id: String? = null
+
+    @Json(name = "name")
+    var name: String? = null
+
+    @Json(name = "alt_names")
+    var altNames: List<String>? = null
+
+    @Json(name = "network")
+    var network: String? = null
+
+    @Json(name = "owners")
+    var owners: List<String>? = null
+
+    @Json(name = "country")
+    var country: String? = null
+
+    @Json(name = "subdivision")
+    var subdivision: String? = null
+
+    @Json(name = "city")
+    var city: String? = null
+
+    @Json(name = "broadcast_area")
+    var broadcastArea: List<String>? = null
+
+    @Json(name = "languages")
+    var languages: List<String>? = null
+
+    @Json(name = "categories")
+    var categories: List<String>? = null
+
+    @Json(name = "is_nsfw")
+    var isNsfw: Boolean? = null
+
+    @Json(name = "launched")
+    var launched: String? = null
+
+    @Json(name = "closed")
+    var closed: String? = null
+
+    @Json(name = "replaced_by")
+    var replacedBy: String? = null
+
+    @Json(name = "website")
+    var website: String? = null
+
+    @Json(name = "logo")
+    var logo: String? = null
+
+}

+ 30 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/remote/Country.kt

@@ -0,0 +1,30 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.remote
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@Suppress("unused")
+@JsonClass(generateAdapter = true)
+class Country {
+
+    @Json(name = "code")
+    var code: String? = null
+
+    @Json(name = "name")
+    var name: String? = null
+
+    @Json(name = "languages")
+    var languages: List<String>? = null
+
+    @Json(name = "flag")
+    var flag: String? = null
+
+}

+ 36 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/remote/Epg.kt

@@ -0,0 +1,36 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.remote
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@Suppress("unused")
+@JsonClass(generateAdapter = true)
+class Epg {
+
+    @Json(name = "id")
+    var id: Long = 0
+
+    @Json(name = "channel")
+    val channel: String? = null
+
+    @Json(name = "site")
+    val site: String? = null
+
+    @Json(name = "lang")
+    val lang: String? = null
+
+    @Json(name = "days")
+    val days: Int? = null
+
+    @Json(name = "url")
+    val url: String? = null
+
+}

+ 24 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/remote/Language.kt

@@ -0,0 +1,24 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.remote
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@Suppress("unused")
+@JsonClass(generateAdapter = true)
+class Language {
+
+    @Json(name = "code")
+    var code : String? = null
+
+    @Json(name = "name")
+    var name : String? = null
+
+}

+ 30 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/remote/Stream.kt

@@ -0,0 +1,30 @@
+/*
+ *  Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data.remote
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@Suppress("unused")
+@JsonClass(generateAdapter = true)
+class Stream {
+
+    @Json(name = "channel")
+    var channel: String? = null
+
+    @Json(name = "url")
+    var url: String? = null
+
+    @Json(name = "http_referrer")
+    var httpReferrer: String? = null
+
+    @Json(name = "user_agent")
+    var userAgent: String? = null
+
+}

+ 10 - 3
app/src/main/kotlin/org/mjdev/tvapp/database/DAO.kt

@@ -6,11 +6,12 @@ import android.content.Context
 import dagger.hilt.android.qualifiers.ApplicationContext
 import io.objectbox.Box
 import io.objectbox.BoxStore
+import io.objectbox.kotlin.awaitCallInTx
 import io.objectbox.kotlin.boxFor
 import org.mjdev.tvapp.BuildConfig
-import org.mjdev.tvapp.data.Message
-import org.mjdev.tvapp.data.Movie
-import org.mjdev.tvapp.data.MyObjectBox
+import org.mjdev.tvapp.data.local.Message
+import org.mjdev.tvapp.data.local.Movie
+import org.mjdev.tvapp.data.local.MyObjectBox
 
 class DAO(
     @ApplicationContext
@@ -22,6 +23,12 @@ class DAO(
         val DATABASE_NAME = BuildConfig.APPLICATION_ID
             .replace(".", "_")
 
+        suspend inline fun <reified V, T> Box<T>.tx(
+            crossinline transaction: Box<T>.() -> V
+        ) = store.awaitCallInTx {
+            transaction(this)
+        }
+
     }
 
     private val store: BoxStore by lazy {

+ 36 - 24
app/src/main/kotlin/org/mjdev/tvapp/module/ProvideModule.kt

@@ -10,7 +10,9 @@
 
 package org.mjdev.tvapp.module
 
+import android.content.ComponentName
 import android.content.Context
+import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
 import com.squareup.moshi.Moshi
 import dagger.Module
 import dagger.Provides
@@ -20,6 +22,9 @@ import dagger.hilt.components.SingletonComponent
 import okhttp3.OkHttpClient
 import okhttp3.logging.HttpLoggingInterceptor
 import org.mjdev.tvapp.BuildConfig
+import org.mjdev.tvapp.activity.MainActivity
+import org.mjdev.tvapp.api.ApiRepository
+import org.mjdev.tvapp.api.ApiService
 import org.mjdev.tvlib.helpers.apps.AppsManager
 import org.mjdev.tvlib.helpers.cursor.AudioCursor
 import org.mjdev.tvlib.helpers.cursor.PhotoCursor
@@ -30,6 +35,8 @@ import org.mjdev.tvlib.network.NetworkConnectivityServiceImpl
 import org.mjdev.tvapp.database.DAO
 import org.mjdev.tvapp.repository.IMovieRepository
 import org.mjdev.tvapp.repository.MovieRepository
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
 import javax.inject.Singleton
 
 @Suppress("unused")
@@ -38,7 +45,7 @@ import javax.inject.Singleton
 class ProvideModule {
 
     private val isDebug = BuildConfig.DEBUG
-    private val BASE_URL = ""
+    private val BASE_URL = BuildConfig.IPTV_API_URL
 
 
     @Singleton
@@ -46,7 +53,12 @@ class ProvideModule {
     fun providesAppsManager(
         @ApplicationContext
         context: Context
-    ): AppsManager = AppsManager(context, BuildConfig.APPLICATION_ID)
+    ): AppsManager = AppsManager(
+        context, ComponentName(
+            BuildConfig.APPLICATION_ID,
+            MainActivity::class.java.name
+        )
+    )
 
     @Singleton
     @Provides
@@ -115,27 +127,27 @@ class ProvideModule {
         context: Context
     ): DAO = DAO(context)
 
-//    @Singleton
-//    @Provides
-//    fun providesRetrofit(
-//        okHttpClient: OkHttpClient
-//    ): Retrofit = Retrofit.Builder()
-//        .baseUrl(BASE_URL)
-//        .client(okHttpClient)
-//        .addConverterFactory(MoshiConverterFactory.create())
-//        .addCallAdapterFactory(CoroutineCallAdapterFactory())
-//        .build()
-
-//    @Singleton
-//    @Provides
-//    fun providesApiService(
-//        retrofit: Retrofit
-//    ): ApiService = retrofit.create(ApiService::class.java)
-
-//    @Singleton
-//    @Provides
-//    fun providesApiRepository(
-//        apiService: ApiService
-//    ): INetworkRepository = ApiRepository(apiService)
+    @Singleton
+    @Provides
+    fun providesRetrofit(
+        okHttpClient: OkHttpClient
+    ): Retrofit = Retrofit.Builder()
+        .baseUrl(BASE_URL)
+        .client(okHttpClient)
+        .addConverterFactory(MoshiConverterFactory.create())
+        .addCallAdapterFactory(CoroutineCallAdapterFactory())
+        .build()
+
+    @Singleton
+    @Provides
+    fun providesApiService(
+        retrofit: Retrofit
+    ): ApiService = retrofit.create(ApiService::class.java)
+
+    @Singleton
+    @Provides
+    fun providesApiRepository(
+        apiService: ApiService
+    ): ApiRepository = ApiRepository(apiService)
 
 }

+ 5 - 5
app/src/main/kotlin/org/mjdev/tvapp/repository/IMovieRepository.kt

@@ -10,15 +10,15 @@
 
 package org.mjdev.tvapp.repository
 
-import org.mjdev.tvapp.data.Message
-import org.mjdev.tvapp.data.Movie
+import org.mjdev.tvapp.data.local.Message
+import org.mjdev.tvapp.data.local.Movie
 
 interface IMovieRepository {
 
-    fun getMovies(page: Int = 0, count: Int = 8): Result<List<Movie>>
+    suspend fun getMovies(page: Int = 0, count: Int = Int.MAX_VALUE): Result<List<Movie>>
 
-    fun findMovieById(id: Long?): Result<Movie>
+    suspend fun findMovieById(id: Long?): Result<Movie>
 
-    fun getMessages(): Result<List<Message>>
+    suspend fun getMessages(): Result<List<Message>>
 
 }

+ 26 - 9
app/src/main/kotlin/org/mjdev/tvapp/repository/MovieRepository.kt

@@ -8,9 +8,10 @@
 
 package org.mjdev.tvapp.repository
 
-import org.mjdev.tvapp.data.Message
-import org.mjdev.tvapp.data.Movie
+import org.mjdev.tvapp.data.local.Message
+import org.mjdev.tvapp.data.local.Movie
 import org.mjdev.tvapp.database.DAO
+import org.mjdev.tvapp.database.DAO.Companion.tx
 import org.mjdev.tvlib.extensions.GlobalExt.runSafe
 import javax.inject.Inject
 
@@ -18,22 +19,38 @@ class MovieRepository @Inject constructor(
     var dao: DAO
 ) : IMovieRepository {
 
-    override fun getMovies(
+    override suspend fun getMovies(
         page: Int,
         count: Int
     ): Result<List<Movie>> = runSafe {
-        dao.movieDao.all.subList(page * count, page)
+        val fromIdx = page * count
+        var toIdx = fromIdx + count
+        dao.movieDao.tx {
+            all.let { list ->
+                if (fromIdx >= list.size)
+                    emptyList()
+                else if (toIdx >= list.size) {
+                    toIdx = list.size - 1
+                    list.subList(fromIdx, toIdx)
+                } else {
+                    list.subList(fromIdx, toIdx)
+                }
+            }
+        } ?: emptyList()
     }
 
-    override fun findMovieById(
+    override suspend fun findMovieById(
         id: Long?
     ): Result<Movie> = runSafe {
-        if ((id != null) && (id > 0)) dao.movieDao.get(id)
-        else throw (Exception("Movie not found."))
+        dao.movieDao.tx {
+            get(id!!)
+        } ?: throw (Exception("Movie not found."))
     }
 
-    override fun getMessages(): Result<List<Message>> = runSafe {
-        dao.messagesDao.all
+    override suspend fun getMessages(): Result<List<Message>> = runSafe {
+        dao.messagesDao.tx {
+            all
+        } ?: emptyList()
     }
 
 }

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/sync/DataProvider.kt

@@ -7,7 +7,7 @@ import android.database.Cursor
 import android.net.Uri
 import com.squareup.moshi.Moshi
 import org.mjdev.tvapp.BuildConfig
-import org.mjdev.tvapp.data.Movie
+import org.mjdev.tvapp.data.local.Movie
 import org.mjdev.tvapp.database.DAO
 import javax.inject.Inject
 

+ 41 - 33
app/src/main/kotlin/org/mjdev/tvapp/sync/SyncAdapter.kt

@@ -10,16 +10,20 @@ import io.objectbox.Box
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.runBlocking
-import org.mjdev.tvapp.data.Movie
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.api.ApiRepository
+import org.mjdev.tvapp.data.local.Movie
 import org.mjdev.tvapp.database.DAO
+import org.mjdev.tvapp.database.DAO.Companion.tx
 import org.mjdev.tvapp.repository.IMovieRepository
-import org.mjdev.tvlib.extensions.ListExt.containsNot
+import org.mjdev.tvlib.extensions.ListExt.contains
 import timber.log.Timber
 
 @Suppress("unused", "PrivatePropertyName")
 class SyncAdapter(
     context: Context,
     val repository: IMovieRepository,
+    val apiRepository: ApiRepository,
     val dao: DAO
 ) : AbstractThreadedSyncAdapter(context, true, false) {
 
@@ -34,42 +38,46 @@ class SyncAdapter(
         provider: ContentProviderClient?,
         syncResult: SyncResult?
     ) {
+        val noCategoryString = context.getString(R.string.no_category)
         try {
-            val movieUris = movieDao.all.map { m ->
-                m.uri
-            }.toMutableList()
-
             runBlocking(Dispatchers.IO) {
+                val movieUris = movieDao.all.map { m -> m.uri }.toMutableList()
                 flow {
-                    var page = 0
-                    var totalCnt: Long = 0
-                    var readCnt: Long = 0
-                    do {
-                        repository.getMovies(page, READ_COUNT).let { result ->
-                            if (result.isSuccess) {
-                                result.getOrNull()?.let { moviesData ->
-                                    readCnt = moviesData.size.toLong()
-                                    syncResult?.let { sr ->
-                                        sr.stats.numEntries = readCnt
-                                        moviesData.forEach { movie ->
-                                            emit(movie)
-                                            sr.stats.numInserts = readCnt
-                                            totalCnt += readCnt
-                                        }
-                                    }
-                                    page += 1
-                                }
-                            } else {
-                                Timber.e(result.exceptionOrNull() ?: Exception("Unknown error"))
-                            }
+                    val streams = apiRepository.streams().getOrNull()
+                    val channels = apiRepository.channels().getOrNull()
+                    streams?.forEach { stream ->
+                        val channel = channels?.firstOrNull { channel ->
+                            channel.id == stream.channel
                         }
-                    } while (readCnt > 0)
-                }.collect { data ->
-                    if (movieUris.containsNot { uri -> uri == data.uri }) {
-                        dao.movieDao.put(data)
-                        Timber.d("Movie: $data stored.")
-                    } else {
+                        if (channel != null) {
+                            emit(Movie().apply {
+                                title = channel.name
+                                uri = stream.url
+                                category = channel.categories?.firstOrNull() ?: noCategoryString
+                                image = channel.logo
+                                country = channel.country
+                                isNsfw = channel.isNsfw ?: false
+                            })
+                        } else {
+                            emit(Movie().apply {
+                                title = stream.channel
+                                uri = stream.url
+                                category = noCategoryString
+                                image = null
+                                country = null
+                                isNsfw = false
+                            })
+                        }
+                    }
+                }.collect { movie ->
+                    val exists = movieUris.contains { uri -> uri == movie.uri }
+                    if (exists) {
                         // todo duplicities
+                    } else {
+                        dao.movieDao.tx {
+                            put(movie)
+                        }
+                        Timber.d("Movie: $movie stored.")
                     }
                 }
             }

+ 5 - 0
app/src/main/kotlin/org/mjdev/tvapp/sync/SyncService.kt

@@ -12,6 +12,7 @@ import android.os.Bundle
 import android.os.IBinder
 import dagger.hilt.android.AndroidEntryPoint
 import org.mjdev.tvapp.BuildConfig
+import org.mjdev.tvapp.api.ApiRepository
 import org.mjdev.tvapp.database.DAO
 import org.mjdev.tvapp.repository.IMovieRepository
 import timber.log.Timber
@@ -23,6 +24,9 @@ class SyncService : Service() {
     @Inject
     lateinit var repository: IMovieRepository
 
+    @Inject
+    lateinit var apiRepository: ApiRepository
+
     @Inject
     lateinit var dao: DAO
 
@@ -32,6 +36,7 @@ class SyncService : Service() {
             sSyncAdapter = sSyncAdapter ?: SyncAdapter(
                 context = applicationContext,
                 repository = repository,
+                apiRepository = apiRepository,
                 dao = dao
             )
         }

+ 1 - 23
app/src/main/kotlin/org/mjdev/tvapp/ui/components/MediaPlayer.kt

@@ -1,4 +1,4 @@
-/*
+ /*
  * Copyright (c) Milan Jurkulák 2023.
  * Contact:
  * e: mimoccc@gmail.com
@@ -14,17 +14,12 @@ import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.RectangleShape
-import androidx.compose.ui.layout.ContentScale
 import org.mjdev.tvlib.annotations.TvPreview
 import org.mjdev.tvlib.extensions.StringExt.parseUri
-import org.mjdev.tvlib.interfaces.ItemWithBackground
-import org.mjdev.tvlib.interfaces.ItemWithImage
 import org.mjdev.tvlib.interfaces.ItemWithUri
-import org.mjdev.tvlib.ui.components.image.ImageAny
 import org.mjdev.tvlib.ui.components.media.MediaPlayerContainer
 import java.net.URL
 
@@ -44,28 +39,11 @@ fun MediaPlayer(
         else -> data.toString()
     }
 
-    val background = when (data) {
-        is ItemWithBackground<*> -> data.background
-        is ItemWithImage<*> -> data.image
-        is ItemWithUri<*> -> data.uri
-        is String -> data.toString()
-        is URL -> data.toString()
-        is Uri -> data.toString()
-        else -> data.toString()
-    }
-
     Box(
         modifier = modifier
             .fillMaxSize()
             .background(Color.Black, RectangleShape),
     ) {
-        // todo: does not show
-        ImageAny(
-            modifier = Modifier.fillMaxSize(),
-            src = background,
-            contentScale = ContentScale.FillWidth,
-            alignment = Alignment.Center
-        )
         MediaPlayerContainer(
             modifier = modifier.fillMaxSize(),
             uri = uriToPlay?.parseUri(),

+ 53 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/IPTVScreen.kt

@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.screens
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.navigation.navArgument
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.ui.components.MediaPlayer
+import org.mjdev.tvlib.annotations.TvPreview
+import org.mjdev.tvlib.navigation.AnyType
+import org.mjdev.tvlib.screen.Screen
+
+class IPTVScreen : Screen() {
+
+    private val data = "data"
+
+    override val title = R.string.title_iptv
+    override val menuIcon: ImageVector get() = Icons.Filled.PlayArrow
+    override val immersive: Boolean = true
+
+    override val pageArgs = listOf(
+        navArgument(data) {
+            nullable = true
+            type = AnyType
+        }
+    )
+
+    @TvPreview
+    @Composable
+    override fun ComposeScreen() {
+
+        val data: Any? = remember { args[data] }
+
+        MediaPlayer(
+            modifier = Modifier.fillMaxSize(),
+            data = data
+        )
+
+    }
+
+}

+ 9 - 3
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt

@@ -24,8 +24,8 @@ import org.mjdev.tvlib.helpers.cursor.VideoCursor
 import org.mjdev.tvlib.network.NetworkConnectivityService
 import org.mjdev.tvlib.network.NetworkConnectivityServiceImpl
 import org.mjdev.tvlib.viewmodel.BaseViewModel
-import org.mjdev.tvapp.data.Message
-import org.mjdev.tvapp.data.Movie
+import org.mjdev.tvapp.data.local.Message
+import org.mjdev.tvapp.data.local.Movie
 import org.mjdev.tvapp.database.DAO
 import org.mjdev.tvapp.repository.IMovieRepository
 import org.mjdev.tvapp.repository.MovieRepository
@@ -79,7 +79,13 @@ class MainViewModel @Inject constructor(
     )
 
     val categoryList: StateFlow<Map<String, List<Movie>>> = flow {
-        emit(repository.getMovies().getOrThrow().asMap { m -> Pair(m.category, m) })
+        repository.getMovies().getOrThrow().sortedBy { m ->
+            m.category
+        }.asMap { m ->
+            Pair(m.category, m)
+        }.also { map ->
+            emit(map)
+        }
     }.stateIn(
         viewModelScope,
         SharingStarted.WhileSubscribed(5000L),

+ 5 - 4
app/src/main/res/values-cs/strings.xml

@@ -35,13 +35,14 @@
     <string name="title_movie_player">Prehravač videa</string>
     <string name="title_plugins">Doplnky</string>
     <string name="title_subscription">Nákup</string>
-
-    <string name="author">Vytvorené autorem Milan Jurkulák © 2023</string>
-
-    <string name="no_items">Žádne položky …</string>
     <string name="title_audio_local">Lokálni audio subory</string>
     <string name="title_video_local">Lokálni video subory</string>
     <string name="title_photo_local">Lokálni fotografie</string>
+    <string name="title_iptv">Televize</string>
 
+    <string name="author">Vytvorené autorem Milan Jurkulák © 2023</string>
+
+    <string name="no_items">Žádne položky …</string>
+    <string name="no_category">Nekategorizovano</string>
 
 </resources>

+ 5 - 4
app/src/main/res/values-sk/strings.xml

@@ -35,13 +35,14 @@
     <string name="title_movie_player">Prehrávač videa</string>
     <string name="title_plugins">Doplnky</string>
     <string name="title_subscription">Nákup</string>
-
-    <string name="author">Vytvorené autorom Milan Jurkulák © 2023</string>
-
-    <string name="no_items">Žiadne položky …</string>
     <string name="title_audio_local">Lokálne audio súbory</string>
     <string name="title_video_local">Lokálne video súbory</string>
     <string name="title_photo_local">Lokálne fotografie</string>
+    <string name="title_iptv">Televízia</string>
 
+    <string name="author">Vytvorené autorom Milan Jurkulák © 2023</string>
+
+    <string name="no_items">Žiadne položky …</string>
+    <string name="no_category">Bez Kategórie</string>
 
 </resources>

+ 5 - 3
app/src/main/res/values/strings.xml

@@ -35,12 +35,14 @@
     <string name="title_movie_player">Movie player</string>
     <string name="title_plugins">Plugins</string>
     <string name="title_subscription">Subscription</string>
+    <string name="title_audio_local">Local audio files</string>
+    <string name="title_video_local">Local video files</string>
+    <string name="title_photo_local">Local photo files</string>
+    <string name="title_iptv">Television</string>
 
     <string name="author">Created by Milan Jurkulak © 2023</string>
 
     <string name="no_items">No items …</string>
-    <string name="title_audio_local">Local audio files</string>
-    <string name="title_video_local">Local video files</string>
-    <string name="title_photo_local">Local photo files</string>
+    <string name="no_category">Uncategorized</string>
 
 </resources>

+ 2 - 2
tvlib/src/main/kotlin/org/mjdev/tvlib/extensions/GlobalExt.kt

@@ -14,8 +14,8 @@ import timber.log.Timber
 @Suppress("unused")
 object GlobalExt {
 
-    fun <E> runSafe(what: () -> E): Result<E> = try {
-        what.invoke().let { result ->
+    suspend fun <E> runSafe(block: suspend () -> E): Result<E> = try {
+        block.invoke().let { result ->
             Result.success(result)
         }
     } catch (e: Exception) {

+ 15 - 6
tvlib/src/main/kotlin/org/mjdev/tvlib/helpers/apps/AppsManager.kt

@@ -8,6 +8,7 @@
 
 package org.mjdev.tvlib.helpers.apps
 
+import android.content.ComponentName
 import android.content.Context
 import android.content.Intent
 import android.content.pm.ResolveInfo
@@ -22,17 +23,17 @@ import java.util.Collections
 
 @Composable
 fun rememberAppsManager(
-    vararg excludedPackages: String?
+    vararg excluded: ComponentName
 ): List<App> {
     val context: Context = LocalContext.current
     return remember {
-        AppsManager(context, *excludedPackages)
+        AppsManager(context, *excluded)
     }
 }
 
 class AppsManager(
     context: Context,
-    vararg excludedPackages: String?
+    vararg excluded: ComponentName
 ) : ArrayList<App>() {
     init {
         val packageManager = context.packageManager
@@ -42,13 +43,21 @@ class AppsManager(
         val resolveInfo = packageManager.queryIntentActivities(mainIntent, 0)
         Collections.sort(resolveInfo, ResolveInfo.DisplayNameComparator(packageManager))
         resolveInfo.forEach { ri ->
-            if (!excludedPackages.contains(ri.activityInfo.packageName)) {
+            val isExcluded = excluded.any { cn ->
+                cn.packageName == ri.activityInfo.packageName &&
+                        cn.shortClassName == ri.activityInfo.name
+            }
+            if (!isExcluded) {
                 val title = ri.activityInfo.loadLabel(packageManager).toString()
                 val image = ri.activityInfo
                     .loadBanner(packageManager) ?: ri.activityInfo
                     .loadIcon(packageManager)
-                val intent: Intent? = packageManager
-                    .getLaunchIntentForPackage(ri.activityInfo.packageName)
+                val intent = Intent().apply {
+                    setClassName(
+                        ri.activityInfo.packageName,
+                        ri.activityInfo.name
+                    )
+                }
                 add(App(title, image, intent))
             }
         }

+ 3 - 4
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/media/ExoPlayerImpl.kt

@@ -45,7 +45,6 @@ import androidx.media3.common.util.UnstableApi
 import androidx.media3.exoplayer.ExoPlayer
 import androidx.media3.ui.PlayerView
 import org.mjdev.tvlib.extensions.ComposeExt.isEditMode
-import org.mjdev.tvlib.extensions.ContextExt.isATv
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 import org.mjdev.tvlib.extensions.ViewExt.controller
 
@@ -62,11 +61,11 @@ class ExoPlayerImpl(
         val width = LocalConfiguration.current.screenWidthDp
         val height = LocalConfiguration.current.screenHeightDp
         val isEdit = isEditMode()
-        val isAtv = context.isATv
         val playerView = remember {
             PlayerView(context).apply {
-                controllerAutoShow = !isAtv
-                controllerHideOnTouch = !isAtv
+                controllerAutoShow = true
+                controllerHideOnTouch = true
+                useController = true
                 useArtwork = true
                 artworkDisplayMode = PlayerView.ARTWORK_DISPLAY_MODE_FIT
                 player = exoPlayer

+ 18 - 6
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/media/MediaPlayerContainer.kt

@@ -1,10 +1,10 @@
 /*
- * Copyright (c) Milan Jurkulák 2023. 
- *  Contact:
- *  e: mimoccc@gmail.com
- *  e: mj@mjdev.org
- *  w: https://mjdev.org
- */
+* Copyright (c) Milan Jurkulák 2023.
+*  Contact:
+*  e: mimoccc@gmail.com
+*  e: mj@mjdev.org
+*  w: https://mjdev.org
+*/
 
 @file:Suppress("unused")
 
@@ -16,7 +16,10 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.ui.PlayerControlView
 import org.mjdev.tvlib.annotations.TvPreview
+import org.mjdev.tvlib.extensions.ComposeExt.isEditMode
 import org.mjdev.tvlib.extensions.ModifierExt.recomposeHighlighter
 import org.mjdev.tvlib.ui.components.media.MediaPlayerState.Companion.rememberMediaPlayerState
 
@@ -29,6 +32,7 @@ fun MediaPlayerContainer(
     startSeek: Long = 0L,
     mediaPlayer: IMediaPlayer = MediaPlayerContainerDefaults.exoPlayer,
 ) {
+    val isEdit = isEditMode()
     val state = rememberMediaPlayerState(
         mediaPlayer,
         uri ?: Uri.EMPTY,
@@ -41,6 +45,14 @@ fun MediaPlayerContainer(
             .fillMaxSize()
     ) {
         mediaPlayer.GetPlayerView()
+        if (isEdit) {
+            AndroidView(
+                modifier = Modifier.fillMaxSize(),
+                factory = { context ->
+                    PlayerControlView(context)
+                }
+            )
+        }
     }
     DisposableEffect(state) {
         if (state.hasMediaToPlay) {

+ 2 - 2
tvlib/src/main/kotlin/org/mjdev/tvlib/ui/components/tv/BrowseView.kt

@@ -95,7 +95,7 @@ fun BrowseView(
         if (isEdit || categories.isNotEmpty()) item {
             Tabs(
                 items = categories.map { category ->
-                    (category as? ItemWithTitle<*>)?.title
+                    (category as? ItemWithTitle<*>)?.title ?: category?.toString()
                 },
                 onItemClick = { //category ->
                     // todo
@@ -115,7 +115,7 @@ fun BrowseView(
             Pair(entry.key, entry.value)
         }) { entry ->
             CategoryRow(
-                title = (entry.first as? ItemWithTitle<*>)?.title,
+                title = (entry.first as? ItemWithTitle<*>)?.title ?: entry.first.toString(),
                 items = entry.second,
                 onItemFocus = {
                     onItemFocused(it)

+ 4 - 4
tvlib/src/main/res/values/strings.xml

@@ -8,6 +8,8 @@
 
 <resources>
 
+    <string name="author">Created by Milan Jurkulak © 2023</string>
+
     <string name="app_name">Tv App</string>
 
     <string name="bt_dismiss">Dismiss</string>
@@ -33,12 +35,10 @@
     <string name="title_movie_player">Movie player</string>
     <string name="title_plugins">Plugins</string>
     <string name="title_subscription">Subscription</string>
-
-    <string name="author">Created by Milan Jurkulak © 2023</string>
-
-    <string name="no_items">No items …</string>
     <string name="title_audio_local">Local audio files</string>
     <string name="title_video_local">Local video files</string>
     <string name="title_photo_local">Local photo files</string>
 
+    <string name="no_items">No items …</string>
+
 </resources>