Răsfoiți Sursa

custom adapter and custom rows

custom adapter and custom rows

coil more sources

scroll horizontal lists

scroll horizontal lists

scroll horizontal lists

scroll horizontal lists

details

details

details
Milan Jurkulak 3 ani în urmă
părinte
comite
b5b08f90c8
100 a modificat fișierele cu 2722 adăugiri și 834 ștergeri
  1. 49 0
      app/build.gradle.kts
  2. 115 0
      app/objectbox-models/default.json
  3. 114 0
      app/objectbox-models/default.json.bak
  4. 53 3
      app/src/main/AndroidManifest.xml
  5. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/activity/MainActivity.kt
  6. 0 48
      app/src/main/kotlin/org/mjdev/tvapp/api/MovieAPI.kt
  7. 12 1
      app/src/main/kotlin/org/mjdev/tvapp/app/Application.kt
  8. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/base/activity/ComposableActivity.kt
  9. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/base/annotations/TvPreview.kt
  10. 22 27
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/ComposeExt.kt
  11. 54 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/CursorExt.kt
  12. 21 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/GlobalExt.kt
  13. 25 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/ListExt.kt
  14. 21 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/NavBackStackEntryExt.kt
  15. 86 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/NavControllerExt.kt
  16. 70 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/NavGraphBuilderExt.kt
  17. 0 25
      app/src/main/kotlin/org/mjdev/tvapp/base/helpers/Result.kt
  18. 63 0
      app/src/main/kotlin/org/mjdev/tvapp/base/helpers/apps/AppsManager.kt
  19. 87 0
      app/src/main/kotlin/org/mjdev/tvapp/base/helpers/cursor/AudioItem.kt
  20. 67 0
      app/src/main/kotlin/org/mjdev/tvapp/base/helpers/cursor/Cursor.kt
  21. 16 0
      app/src/main/kotlin/org/mjdev/tvapp/base/helpers/cursor/CursorItem.kt
  22. 52 0
      app/src/main/kotlin/org/mjdev/tvapp/base/helpers/cursor/PhotoItem.kt
  23. 76 0
      app/src/main/kotlin/org/mjdev/tvapp/base/helpers/cursor/VideoItem.kt
  24. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithBackground.kt
  25. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithDescription.kt
  26. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithId.kt
  27. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithImage.kt
  28. 15 0
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithIntent.kt
  29. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithMessage.kt
  30. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithSubtitle.kt
  31. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithTitle.kt
  32. 18 0
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithUri.kt
  33. 0 22
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithVideoUri.kt
  34. 30 0
      app/src/main/kotlin/org/mjdev/tvapp/base/navigation/AnyType.kt
  35. 42 0
      app/src/main/kotlin/org/mjdev/tvapp/base/network/CacheInterceptor.kt
  36. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/base/network/NetworkConnectivityService.kt
  37. 37 20
      app/src/main/kotlin/org/mjdev/tvapp/base/network/NetworkConnectivityServiceImpl.kt
  38. 3 150
      app/src/main/kotlin/org/mjdev/tvapp/base/screen/Screen.kt
  39. 16 18
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/card/CardContent.kt
  40. 16 6
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/card/ItemCard.kt
  41. 12 5
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/card/PhotoCard.kt
  42. 6 5
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/carousel/CarouselCard.kt
  43. 18 17
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/FocusableCard.kt
  44. 3 3
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/VerticalScrollableBox.kt
  45. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/image/PhotoImage.kt
  46. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/navigation/NavDrawerContent.kt
  47. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/navigation/NavHostEx.kt
  48. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/page/Page.kt
  49. 4 1
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/page/PagerScope.kt
  50. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/page/TvPager.kt
  51. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/screen/EmptyScreen.kt
  52. 66 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/text/AutoHideEmptyText.kt
  53. 66 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/AppsRow.kt
  54. 39 6
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/BrowseView.kt
  55. 30 7
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/CategoryRow.kt
  56. 66 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/CursorRow.kt
  57. 56 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/LocalAudioRow.kt
  58. 52 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/LocalPhotosRow.kt
  59. 56 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/LocalVideoRow.kt
  60. 5 7
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/PagingTvLazyRow.kt
  61. 2 7
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/ScrollableTvLazyRow.kt
  62. 1 1
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/Tabs.kt
  63. 14 7
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/Title.kt
  64. 2 2
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/theme/ThemeHelper.kt
  65. 0 24
      app/src/main/kotlin/org/mjdev/tvapp/data/Category.kt
  66. 21 6
      app/src/main/kotlin/org/mjdev/tvapp/data/Message.kt
  67. 51 31
      app/src/main/kotlin/org/mjdev/tvapp/data/Movie.kt
  68. 42 0
      app/src/main/kotlin/org/mjdev/tvapp/database/DAO.kt
  69. 32 0
      app/src/main/kotlin/org/mjdev/tvapp/database/convert/CustomPropertyConverter.kt
  70. 22 0
      app/src/main/kotlin/org/mjdev/tvapp/database/convert/EncryptionConverter.kt
  71. 23 0
      app/src/main/kotlin/org/mjdev/tvapp/database/convert/PropertyEnumConverter.kt
  72. 21 0
      app/src/main/kotlin/org/mjdev/tvapp/database/convert/PropertyJsonConverter.kt
  73. 33 0
      app/src/main/kotlin/org/mjdev/tvapp/database/convert/StringListConverter.kt
  74. 0 107
      app/src/main/kotlin/org/mjdev/tvapp/mock/MovieList.kt
  75. 40 36
      app/src/main/kotlin/org/mjdev/tvapp/module/ProvideModule.kt
  76. 24 0
      app/src/main/kotlin/org/mjdev/tvapp/repository/IMovieRepository.kt
  77. 0 22
      app/src/main/kotlin/org/mjdev/tvapp/repository/IRepository.kt
  78. 21 102
      app/src/main/kotlin/org/mjdev/tvapp/repository/MovieRepository.kt
  79. 3 3
      app/src/main/kotlin/org/mjdev/tvapp/state/DetailsLoadingState.kt
  80. 67 0
      app/src/main/kotlin/org/mjdev/tvapp/sync/Authenticator.kt
  81. 17 0
      app/src/main/kotlin/org/mjdev/tvapp/sync/AuthenticatorService.kt
  82. 116 0
      app/src/main/kotlin/org/mjdev/tvapp/sync/DataProvider.kt
  83. 81 0
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncAdapter.kt
  84. 89 0
      app/src/main/kotlin/org/mjdev/tvapp/sync/SyncService.kt
  85. 61 27
      app/src/main/kotlin/org/mjdev/tvapp/ui/components/Details.kt
  86. 14 3
      app/src/main/kotlin/org/mjdev/tvapp/ui/components/MediaPlayer.kt
  87. 15 11
      app/src/main/kotlin/org/mjdev/tvapp/ui/pages/MainPage.kt
  88. 12 11
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/DetailScreen.kt
  89. 14 12
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/PlayerScreen.kt
  90. 1 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/SplashScreen.kt
  91. 32 12
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/DetailViewModel.kt
  92. 25 13
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt
  93. 45 0
      app/src/main/res/values-cs/strings.xml
  94. 45 0
      app/src/main/res/values-sk/strings.xml
  95. 8 2
      app/src/main/res/values/strings.xml
  96. 6 0
      app/src/main/res/xml/authenticator.xml
  97. 7 0
      app/src/main/res/xml/backup_rules.xml
  98. 15 0
      app/src/main/res/xml/data_extraction_rules.xml
  99. 8 0
      app/src/main/res/xml/sync_adapter.xml
  100. 9 0
      build.gradle.kts

+ 49 - 0
app/build.gradle.kts

@@ -9,6 +9,7 @@
 @file:Suppress("UnstableApiUsage")
 
 import org.mjdev.gradle.extensions.implementation
+import org.mjdev.gradle.extensions.kapt
 import org.mjdev.gradle.plugin.MainAppPlugin.Companion.javaVersion
 import org.mjdev.gradle.plugin.MainAppPlugin.Companion.kotlinCompilerExtVersion
 import org.mjdev.gradle.plugin.MainAppPlugin.Companion.loadKeyStoreProperties
@@ -29,6 +30,7 @@ plugins {
     id("com.google.dagger.hilt.android")
     id("dagger.hilt.android.plugin")
     id("org.jetbrains.dokka") version "1.8.10"
+    id("io.objectbox")
     id("MainAppPlugin")
 }
 
@@ -59,6 +61,9 @@ android {
         versionName = project.versionName
 
         signingConfig = signingConfigs[SIGNING_CONFIG_NAME]
+
+        buildConfigField("String", "SYNC_AUTH", "\"$applicationId.sync\"")
+        resValue("string", "sync_auth", "$applicationId.sync")
     }
 
     buildTypes {
@@ -111,10 +116,33 @@ android {
         checkReleaseBuilds = false
     }
 
+    subprojects {
+        apply(plugin = "org.jetbrains.dokka")
+    }
+
     tasks.dokkaGfm {
         outputDirectory.set(File(projectDir, "../wiki/documentation"))
     }
 
+//    tasks.register("prepareReleaseNotes") {
+//        doLast {
+//            exec {
+//                workingDir(rootDir)
+//                args(getVersionName())
+//                executable("./scripts/git_log.sh")
+//            }
+//        }
+//    }
+//
+//    afterEvaluate {
+//        // all versions release notes
+//        tasks.findByName("assembleDebug")?.finalizedBy("prepareReleaseNotes")
+//        tasks.findByName("assembleRelease")?.finalizedBy("prepareReleaseNotes")
+//
+//        // dokka documentation generation
+//        tasks.findByName("prepareReleaseNotes")?.finalizedBy("dokkaGfm")
+//    }
+
 }
 
 dependencies {
@@ -143,6 +171,10 @@ dependencies {
     // navigation
     implementation("androidx.navigation:navigation-compose:2.7.0-beta02")
     // image loading
+    implementation("io.coil-kt:coil-base:2.4.0")
+    implementation("io.coil-kt:coil-gif:2.2.2")
+    implementation("io.coil-kt:coil-svg:1.2.2")
+    implementation("io.coil-kt:coil-video:1.2.2")
     implementation("io.coil-kt:coil-compose:2.4.0")
     implementation("com.github.skydoves:landscapist-coil:2.2.2")
     // constraint layout // todo remove
@@ -161,6 +193,23 @@ dependencies {
     implementation("com.google.dagger:hilt-android:2.46.1")
     implementation("androidx.hilt:hilt-navigation-compose:1.1.0-alpha01")
     kapt("com.google.dagger:hilt-compiler:2.46.1")
+    // okhttp
+    // define a BOM and its version
+    implementation(platform("com.squareup.okhttp3:okhttp-bom:4.11.0"))
+    implementation("com.squareup.okhttp3:okhttp")
+    implementation("com.squareup.okhttp3:logging-interceptor")
+    // moshi json
+    implementation("com.squareup.moshi:moshi:1.15.0")
+    implementation("com.squareup.retrofit2:converter-moshi:2.9.0")
+    kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
+    // 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")
+    // database
+
+    // db 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")

+ 115 - 0
app/objectbox-models/default.json

@@ -0,0 +1,115 @@
+{
+  "_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": "2:4443390238791095335",
+      "lastPropertyId": "5:3227702207504655113",
+      "name": "Message",
+      "properties": [
+        {
+          "id": "1:9181207467579711968",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "2:7878822690631343409",
+          "name": "title",
+          "type": 9
+        },
+        {
+          "id": "3:8698615341365709390",
+          "name": "message",
+          "type": 9
+        },
+        {
+          "id": "4:5800615359027913030",
+          "name": "read",
+          "type": 1
+        },
+        {
+          "id": "5:3227702207504655113",
+          "name": "deleted",
+          "type": 1
+        }
+      ],
+      "relations": []
+    },
+    {
+      "id": "3:4059129936998639264",
+      "lastPropertyId": "14:7902663083706437677",
+      "name": "Movie",
+      "properties": [
+        {
+          "id": "1:79872378504965607",
+          "name": "studio",
+          "type": 9
+        },
+        {
+          "id": "2:2887560408739691883",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "3:6635382699773571328",
+          "name": "title",
+          "type": 9
+        },
+        {
+          "id": "4:7978893519829993708",
+          "name": "description",
+          "type": 9
+        },
+        {
+          "id": "10:1240897976285873448",
+          "name": "category",
+          "type": 9
+        },
+        {
+          "id": "11:7524624707189609038",
+          "name": "image",
+          "type": 9
+        },
+        {
+          "id": "12:2282776854525830842",
+          "name": "background",
+          "type": 9
+        },
+        {
+          "id": "14:7902663083706437677",
+          "name": "uri",
+          "type": 9
+        }
+      ],
+      "relations": []
+    }
+  ],
+  "lastEntityId": "3:4059129936998639264",
+  "lastIndexId": "0:0",
+  "lastRelationId": "2:396422642806804570",
+  "lastSequenceId": "0:0",
+  "modelVersion": 5,
+  "modelVersionParserMinimum": 5,
+  "retiredEntityUids": [
+    3030959493509716735
+  ],
+  "retiredIndexUids": [],
+  "retiredPropertyUids": [
+    1108745223315696762,
+    6095925557554561759,
+    6414793273986255814,
+    2948458933622163117,
+    1566325096395519801,
+    711808614186853341,
+    1320881708669364482,
+    5550314592152910590
+  ],
+  "retiredRelationUids": [
+    3943431700917187926,
+    396422642806804570
+  ],
+  "version": 1
+}

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

@@ -0,0 +1,114 @@
+{
+  "_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": "2:4443390238791095335",
+      "lastPropertyId": "5:3227702207504655113",
+      "name": "Message",
+      "properties": [
+        {
+          "id": "1:9181207467579711968",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "2:7878822690631343409",
+          "name": "title",
+          "type": 9
+        },
+        {
+          "id": "3:8698615341365709390",
+          "name": "message",
+          "type": 9
+        },
+        {
+          "id": "4:5800615359027913030",
+          "name": "read",
+          "type": 1
+        },
+        {
+          "id": "5:3227702207504655113",
+          "name": "deleted",
+          "type": 1
+        }
+      ],
+      "relations": []
+    },
+    {
+      "id": "3:4059129936998639264",
+      "lastPropertyId": "13:5550314592152910590",
+      "name": "Movie",
+      "properties": [
+        {
+          "id": "1:79872378504965607",
+          "name": "studio",
+          "type": 9
+        },
+        {
+          "id": "2:2887560408739691883",
+          "name": "id",
+          "type": 6,
+          "flags": 1
+        },
+        {
+          "id": "3:6635382699773571328",
+          "name": "title",
+          "type": 9
+        },
+        {
+          "id": "4:7978893519829993708",
+          "name": "description",
+          "type": 9
+        },
+        {
+          "id": "10:1240897976285873448",
+          "name": "category",
+          "type": 9
+        },
+        {
+          "id": "11:7524624707189609038",
+          "name": "image",
+          "type": 9
+        },
+        {
+          "id": "12:2282776854525830842",
+          "name": "background",
+          "type": 9
+        },
+        {
+          "id": "13:5550314592152910590",
+          "name": "video",
+          "type": 9
+        }
+      ],
+      "relations": []
+    }
+  ],
+  "lastEntityId": "3:4059129936998639264",
+  "lastIndexId": "0:0",
+  "lastRelationId": "2:396422642806804570",
+  "lastSequenceId": "0:0",
+  "modelVersion": 5,
+  "modelVersionParserMinimum": 5,
+  "retiredEntityUids": [
+    3030959493509716735
+  ],
+  "retiredIndexUids": [],
+  "retiredPropertyUids": [
+    1108745223315696762,
+    6095925557554561759,
+    6414793273986255814,
+    2948458933622163117,
+    1566325096395519801,
+    711808614186853341,
+    1320881708669364482
+  ],
+  "retiredRelationUids": [
+    3943431700917187926,
+    396422642806804570
+  ],
+  "version": 1
+}

+ 53 - 3
app/src/main/AndroidManifest.xml

@@ -10,11 +10,29 @@
  */
 -->
 
-<manifest xmlns:tools="http://schemas.android.com/tools"
-    xmlns:android="http://schemas.android.com/apk/res/android">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
 
     <uses-permission android:name="android.permission.INTERNET" />
 
+    <uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
+    <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
+
+    <uses-permission
+        android:name="android.permission.READ_EXTERNAL_STORAGE"
+        tools:ignore="ScopedStorage" />
+    <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
+    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+    <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
+
+    <uses-permission
+        android:name="android.permission.AUTHENTICATE_ACCOUNTS"
+        android:maxSdkVersion="22" />
+    <uses-permission
+        android:name="android.permission.GET_ACCOUNTS"
+        android:maxSdkVersion="22" />
+
     <uses-feature
         android:name="android.hardware.touchscreen"
         android:required="false" />
@@ -26,12 +44,13 @@
     <application
         android:name=".app.Application"
         android:allowBackup="true"
+        android:banner="@mipmap/ic_launcher"
         android:icon="@mipmap/ic_launcher"
         android:label="@string/app_name"
         android:supportsRtl="true"
+        android:largeHeap="true"
         android:theme="@style/Theme.TvApp"
         android:usesCleartextTraffic="true"
-        android:banner= "@mipmap/ic_launcher"
         tools:ignore="UnusedAttribute">
 
         <activity
@@ -40,12 +59,43 @@
 
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
+
                 <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">
+            <intent-filter>
+                <action android:name="android.content.SyncAdapter" />
+            </intent-filter>
+            <meta-data
+                android:name="android.content.SyncAdapter"
+                android:resource="@xml/sync_adapter" />
+        </service>
+
+        <service
+            android:name=".sync.AuthenticatorService"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="android.accounts.AccountAuthenticator" />
+            </intent-filter>
+            <meta-data
+                android:name="android.accounts.AccountAuthenticator"
+                android:resource="@xml/authenticator" />
+        </service>
+
+        <provider
+            android:name=".sync.DataProvider"
+            android:authorities="@string/sync_auth"
+            android:enabled="true"
+            android:exported="true"
+            android:syncable="true"
+            tools:ignore="ExportedContentProvider" />
+
     </application>
 
 </manifest>

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/activity/MainActivity.kt

@@ -12,8 +12,8 @@ import androidx.compose.runtime.Composable
 import dagger.hilt.android.AndroidEntryPoint
 import org.mjdev.tvapp.base.activity.ComposableActivity
 import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.extensions.NavGraphBuilderExt.screen
 import org.mjdev.tvapp.base.navigation.NavGraphBuilderEx
-import org.mjdev.tvapp.base.screen.Screen.Companion.screen
 import org.mjdev.tvapp.ui.screens.DetailScreen
 import org.mjdev.tvapp.ui.screens.MainScreen
 import org.mjdev.tvapp.ui.screens.PlayerScreen

+ 0 - 48
app/src/main/kotlin/org/mjdev/tvapp/api/MovieAPI.kt

@@ -1,48 +0,0 @@
-/*
- * 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.base.helpers.Result
-import org.mjdev.tvapp.base.helpers.Result.Companion.unwrapOr
-import org.mjdev.tvapp.data.Category
-import org.mjdev.tvapp.data.Movie
-import org.mjdev.tvapp.mock.MovieList
-
-@Suppress("RedundantSuspendModifier")
-class MovieAPI {
-
-    suspend fun loadCategoryList(): Result<List<Category>> {
-        return Result.Success(MovieList.categories.map { name ->
-            Category(
-                title  = name,
-                movieList = getMovieListByCategory(name).unwrapOr(emptyList())
-            )
-        })
-    }
-
-    suspend fun loadFeaturedMovieList(): Result<List<Movie>> {
-        return Result.Success(MovieList.featured)
-    }
-
-    suspend fun getMovieListByCategory(
-        category: String?
-    ): Result<List<Movie>> {
-        return Result.Success(MovieList.getByCategory(category))
-    }
-
-    suspend fun findMovieById(
-        id: Long
-    ): Result<Movie> {
-        return MovieList.findById(id).let { movie ->
-            if (movie == null) Result.Empty
-            else Result.Success(movie)
-        }
-    }
-
-}

+ 12 - 1
app/src/main/kotlin/org/mjdev/tvapp/app/Application.kt

@@ -10,6 +10,17 @@ package org.mjdev.tvapp.app
 
 import android.app.Application
 import dagger.hilt.android.HiltAndroidApp
+import org.mjdev.tvapp.sync.SyncService
+import org.mjdev.tvapp.sync.SyncService.Companion.createSyncAccount
 
 @HiltAndroidApp
-class Application : Application()
+class Application : Application() {
+
+    override fun onCreate() {
+        super.onCreate()
+        createSyncAccount()?.also { account ->
+            SyncService.requestSync(account)
+        }
+    }
+
+}

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/activity/ComposableActivity.kt

@@ -32,10 +32,10 @@ import androidx.tv.material3.Surface
 import org.mjdev.tvapp.base.annotations.TvPreview
 import org.mjdev.tvapp.base.extensions.ContextExt.isATv
 import org.mjdev.tvapp.base.extensions.NavExt.rememberNavControllerEx
+import org.mjdev.tvapp.base.extensions.NavGraphBuilderExt.screen
 import org.mjdev.tvapp.base.ui.components.screen.EmptyScreen
 import org.mjdev.tvapp.base.navigation.NavGraphBuilderEx
 import org.mjdev.tvapp.base.ui.components.navigation.NavHostEx
-import org.mjdev.tvapp.base.screen.Screen.Companion.screen
 import org.mjdev.tvapp.ui.theme.TVAppTheme
 import timber.log.Timber
 

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/annotations/TvPreview.kt

@@ -17,7 +17,7 @@ import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
     uiMode = UI_MODE_TYPE_TELEVISION,
     showBackground = true,
     device = Devices.TV_720p,
-    showSystemUi = true,
+    showSystemUi = false,
     backgroundColor = 0xFF444444,
     wallpaper = GREEN_DOMINATED_EXAMPLE
 )

+ 22 - 27
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/ComposeExt.kt

@@ -10,17 +10,21 @@
 
 package org.mjdev.tvapp.base.extensions
 
+import android.annotation.SuppressLint
+import android.content.ContentResolver
+import android.content.res.Configuration
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.State
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.ui.focus.FocusRequester
 import androidx.compose.ui.focus.FocusState
+import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalInspectionMode
-import kotlinx.coroutines.flow.Flow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
 import org.mjdev.tvapp.BuildConfig
 
 @Suppress("MemberVisibilityCanBePrivate")
@@ -32,9 +36,6 @@ object ComposeExt {
     @Composable
     fun isEditMode() = LocalInspectionMode.current
 
-    @Composable
-    fun <T> Flow<T>?.collectAsState(): State<T>? = this?.collectAsState()
-
     @Composable
     inline fun <reified T> textFrom(text: T?): String = when (text) {
         null -> ""
@@ -83,27 +84,21 @@ object ComposeExt {
     val FocusState?.isNotFocused
         get() = !isFocused
 
-//    @Composable
-//    fun Any?.toDrawable(): Drawable {
-//        val context = LocalContext.current
-//        val coroutine = rememberCoroutineScope()
-//        return when (this) {
-//            is Int -> ContextCompat.getDrawable(context, this) ?: ColorDrawable(0)
-//            is Color -> ColorDrawable(this.toArgb())
-//            is Drawable -> this
-//            is Bitmap -> BitmapDrawable(context.resources, this)
-//            else -> {
-//                runBlocking {
-//                    withContext(coroutine.coroutineContext) {
-//                        ImageLoader(context).execute(
-//                            ImageRequest.Builder(context)
-//                                .data(this)
-//                                .build()
-//                        ).drawable
-//                    }!!
-//                }
-//            }
-//        }
-//    }
+    @Composable
+    fun computeCardHeight(): Dp = LocalConfiguration.current.let { config ->
+        if (config.orientation == Configuration.ORIENTATION_PORTRAIT)
+            config.screenHeightDp / 7f
+        else
+            config.screenWidthDp / 7f
+    }.dp
+
+    @SuppressLint("RememberReturnType", "ComposableNaming")
+    @Composable
+    fun rememberContentResolver() : ContentResolver {
+        val context = LocalContext.current
+        return remember {
+            context.contentResolver
+        }
+    }
 
 }

+ 54 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/CursorExt.kt

@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.extensions
+
+import android.database.Cursor
+import androidx.core.database.getBlobOrNull
+import androidx.core.database.getFloatOrNull
+import androidx.core.database.getIntOrNull
+import androidx.core.database.getStringOrNull
+import org.mjdev.tvapp.base.helpers.cursor.CursorItem
+import timber.log.Timber
+
+object CursorExt {
+
+    fun Cursor.toList(
+        projection: Array<String>
+    ): List<CursorItem> {
+        val c: Cursor = this
+        return ArrayList<CursorItem>().also { list ->
+            if (c.count > 0) {
+                c.moveToFirst()
+                do {
+                    list.add(CursorItem().also { ci ->
+                        projection.forEach { cname ->
+                            val idx = c.getColumnIndex(cname)
+                            val value = try {
+                                c.get(idx)
+                            } catch (e: Exception) {
+                                Timber.e(e)
+                                null
+                            }
+                            ci[cname] = value
+                        }
+                    })
+                } while (moveToNext())
+            }
+        }
+    }
+
+    fun Cursor.get(idx: Int): Any? = when (getType(idx)) {
+        Cursor.FIELD_TYPE_FLOAT -> getFloatOrNull(idx)
+        Cursor.FIELD_TYPE_BLOB -> getBlobOrNull(idx)
+        Cursor.FIELD_TYPE_INTEGER -> getIntOrNull(idx)
+        Cursor.FIELD_TYPE_STRING -> getStringOrNull(idx)
+        else -> null
+    }
+
+}

+ 21 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/GlobalExt.kt

@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.extensions
+
+object GlobalExt {
+
+    fun <E> runSafe(what: () -> E): Result<E> = try {
+        what.invoke().let { result ->
+            Result.success(result)
+        }
+    } catch (e: Exception) {
+        Result.failure(e)
+    }
+
+}

+ 25 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/ListExt.kt

@@ -8,6 +8,8 @@
 
 package org.mjdev.tvapp.base.extensions
 
+import androidx.core.os.bundleOf
+
 object ListExt {
 
     fun <T> MutableList<T>.addUnique(element: T, replace: Boolean = true) {
@@ -26,4 +28,27 @@ object ListExt {
         }
     }
 
+    fun <T> List<T>.contains(block: (T) -> Boolean) = count(block) > 0
+
+    fun <T> List<T>.containsNot(block: (T) -> Boolean) = count(block) == 0
+
+    fun <E : Any?> List<E>.asMap(
+        block: (E) -> Pair<String?, E?>?
+    ): Map<String, List<E>> = mutableMapOf<String, MutableList<E>>().apply {
+        this@asMap.forEach { e ->
+            block.invoke(e)?.also { p ->
+                if (p.first != null && p.second != null) {
+                    var list = get(p.first)
+                    if (list == null) {
+                        list = mutableListOf()
+                        put(p.first!!, list)
+                    }
+                    list.add(p.second!!)
+                }
+            }
+        }
+    }
+
+    fun List<Pair<String, Any?>>.toBundle() = bundleOf(*this.toTypedArray())
+
 }

+ 21 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/NavBackStackEntryExt.kt

@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.extensions
+
+import androidx.navigation.NavBackStackEntry
+
+object NavBackStackEntryExt {
+
+    @Suppress("UNCHECKED_CAST", "DEPRECATION")
+    fun <T : Any> NavBackStackEntry.arg(
+        argId: String,
+        defaultValue: T?
+    ): T? = (arguments?.get(argId) as? T?) ?: defaultValue
+
+}

+ 86 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/NavControllerExt.kt

@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.extensions
+
+import android.os.Bundle
+import androidx.annotation.MainThread
+import androidx.navigation.NavController
+import androidx.navigation.NavHostController
+import androidx.navigation.NavOptions
+import androidx.navigation.Navigator
+import androidx.navigation.navOptions
+import org.mjdev.tvapp.base.extensions.ListExt.toBundle
+import org.mjdev.tvapp.base.screen.Screen
+import kotlin.reflect.full.createInstance
+
+@Suppress("MemberVisibilityCanBePrivate")
+object NavControllerExt {
+
+    val NavHostController.currentRoute get() = currentDestination?.route
+
+    @MainThread
+    fun NavHostController.open(
+        route: String?
+    ) {
+        if (route != null) {
+            val currentRoute = currentRoute
+            val equals = currentRoute?.equals(route)
+            if (equals != true) {
+                navigate(route)
+            }
+        }
+    }
+
+    // todo check route already opened
+    @MainThread
+    inline fun <reified T : Screen> NavHostController.open(
+        vararg values: Any?
+    ) {
+        val instance = T::class.createInstance()
+        val finalRoute = instance.route
+        val args = instance.pageArgs.mapIndexed { idx, arg ->
+            Pair(arg.name, values[idx])
+        }.toBundle()
+        navigate(finalRoute, args)
+    }
+
+    // todo check route already opened
+    @MainThread
+    inline fun <reified T : Screen> NavHostController.openAsTop(
+        vararg values: Any?
+    ) {
+        val instance = T::class.createInstance()
+        val finalRoute = instance.route
+        val args = instance.pageArgs.mapIndexed { idx, arg ->
+            Pair(arg.name, values[idx])
+        }.toBundle()
+        navigate(
+            finalRoute,
+            args,
+            navOptions {
+                popBackStack()
+                launchSingleTop = true
+            },
+            null
+        )
+    }
+
+    fun NavController.navigate(
+        route: String,
+        args: Bundle,
+        navOptions: NavOptions? = null,
+        navigatorExtras: Navigator.Extras? = null
+    ) {
+        val nodeId = graph.findNode(route = route)?.id
+        if (nodeId != null) {
+            navigate(nodeId, args, navOptions, navigatorExtras)
+        }
+    }
+
+}

+ 70 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/NavGraphBuilderExt.kt

@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.extensions
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDeepLink
+import androidx.navigation.compose.composable
+import org.mjdev.tvapp.base.extensions.NavBackStackEntryExt.arg
+import org.mjdev.tvapp.base.navigation.NavGraphBuilderEx
+import org.mjdev.tvapp.base.screen.Screen
+
+object NavGraphBuilderExt {
+
+    fun <T : Screen> NavGraphBuilderEx.screen(
+        route: T,
+        isHomeScreen: Boolean = false,
+        isStartScreen: Boolean = false,
+        deepLinks: List<NavDeepLink> = emptyList(),
+        enterTransition: (@JvmSuppressWildcards
+        AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = {
+            AnimExt.FadeIn
+        },
+        exitTransition: (@JvmSuppressWildcards
+        AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = {
+            AnimExt.FadeOut
+        },
+        popEnterTransition: (@JvmSuppressWildcards
+        AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
+            enterTransition,
+        popExitTransition: (@JvmSuppressWildcards
+        AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
+            exitTransition,
+    ) {
+        if (isStartScreen) {
+            splashDestinationRoute = route.route
+        }
+        if (isHomeScreen) {
+            homeDestinationRoute = route.route
+        }
+        route.menuItem?.also { menuItem ->
+            navHostController.addMenuItem(menuItem)
+        }
+        composable(
+            route = route.route,
+            arguments = route.pageArgs,
+            enterTransition = enterTransition,
+            exitTransition = exitTransition,
+            deepLinks = deepLinks,
+            popEnterTransition = popEnterTransition,
+            popExitTransition = popExitTransition
+        ) { be ->
+            val rArgs = mutableMapOf<String, Any?>().apply {
+                route.pageArgs.forEach { arg ->
+                    put(arg.name, be.arg(arg.name, null))
+                }
+            }
+            route.Compose(navHostController, be, rArgs)
+        }
+    }
+
+}

+ 0 - 25
app/src/main/kotlin/org/mjdev/tvapp/base/helpers/Result.kt

@@ -1,25 +0,0 @@
-/*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
- */
-
-package org.mjdev.tvapp.base.helpers
-
-sealed class Result<out T> {
-
-    object Empty : Result<Nothing>()
-
-    data class Success<T>(val value: T) : Result<T>()
-
-    companion object {
-
-        fun <T> Result<T>.unwrapOr(defaultValue: T): T {
-            return (this as? Success)?.value ?: defaultValue
-        }
-
-    }
-
-}

+ 63 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/helpers/apps/AppsManager.kt

@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.helpers.apps
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import android.graphics.drawable.Drawable
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import org.mjdev.tvapp.base.interfaces.ItemWithImage
+import org.mjdev.tvapp.base.interfaces.ItemWithIntent
+import org.mjdev.tvapp.base.interfaces.ItemWithTitle
+import java.util.Collections
+
+@Composable
+fun rememberAppsManager(): Flow<List<App>> {
+    val context: Context = LocalContext.current
+    return remember {
+        flow {
+            val packageManager = context.packageManager
+            val mainIntent = Intent(Intent.ACTION_MAIN, null).apply {
+                addCategory(Intent.CATEGORY_LAUNCHER)
+            }
+            val resolveInfo = packageManager.queryIntentActivities(mainIntent, 0)
+            val apps = mutableListOf<App>()
+            Collections.sort(resolveInfo, ResolveInfo.DisplayNameComparator(packageManager))
+            resolveInfo.forEach { ri ->
+                apps.add(App(ri, packageManager))
+                emit(apps)
+            }
+        }.flowOn(Dispatchers.IO)
+    }
+}
+
+class App(
+    ri: ResolveInfo,
+    packageManager: PackageManager,
+) : ItemWithTitle<String>, ItemWithImage<Drawable>, ItemWithIntent {
+
+    override var title: String? = ri.activityInfo
+        .loadLabel(packageManager).toString()
+
+    override var image: Drawable? = ri.activityInfo
+        .loadBanner(packageManager) ?: ri.activityInfo
+        .loadIcon(packageManager)
+
+    override var intent: Intent? = packageManager
+        .getLaunchIntentForPackage(ri.activityInfo.packageName)
+
+}

+ 87 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/helpers/cursor/AudioItem.kt

@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.helpers.cursor
+
+import android.content.ContentResolver
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import android.util.Size
+import org.mjdev.tvapp.base.interfaces.ItemWithImage
+import org.mjdev.tvapp.base.interfaces.ItemWithTitle
+import org.mjdev.tvapp.base.interfaces.ItemWithUri
+import timber.log.Timber
+
+class AudioItem(
+    private val contentResolver: ContentResolver,
+    ci: CursorItem
+) : CursorItem(), ItemWithTitle<String>, ItemWithImage<Any>, ItemWithUri<Uri> {
+
+    init {
+        putAll(ci)
+    }
+
+    override val title: String?
+        get() = this[MediaStore.Audio.Media.TITLE]?.toString()
+
+    override val uri: Uri
+        get() = Uri.withAppendedPath(
+            MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
+            this[MediaStore.Audio.Media._ID].toString()
+        )
+
+    override val image: Any?
+        get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            try {
+                uri.let { thumbUri ->
+                    contentResolver.loadThumbnail(
+                        thumbUri,
+                        Size(320, 240),
+                        null
+                    )
+                }
+            } catch (e: Exception) {
+                Timber.e(e)
+                null
+            }
+        } else {
+            Uri.parse(this[MediaStore.Video.Media.DATA].toString())
+        }
+
+    companion object {
+        val URI: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
+        val MEDIA_PROJECTION = arrayOf(
+            MediaStore.Audio.Media._ID,
+            MediaStore.Audio.Media.TITLE,
+            MediaStore.Audio.Media.ARTIST,
+            MediaStore.Audio.Media.ALBUM,
+            MediaStore.Audio.Media.ALBUM_ID,
+            MediaStore.Audio.Media.DATE_ADDED,
+            MediaStore.Audio.Media.COMPOSER,
+            MediaStore.Audio.Media.YEAR,
+            MediaStore.Audio.Media.DATA,
+            MediaStore.Audio.Media.DURATION,
+            MediaStore.Audio.Media.TRACK,
+            MediaStore.Audio.Media.SIZE,
+            MediaStore.Audio.Media.DATE_MODIFIED,
+            MediaStore.Audio.Media.BITRATE,
+            MediaStore.Audio.Media.AUTHOR,
+            MediaStore.Audio.Media.ALBUM_ARTIST,
+            MediaStore.Audio.Media.ARTIST_ID,
+            MediaStore.Audio.Media.BUCKET_DISPLAY_NAME,
+            MediaStore.Audio.Media.BUCKET_ID,
+            MediaStore.Audio.Media.CAPTURE_FRAMERATE,
+            MediaStore.Audio.Media.CD_TRACK_NUMBER,
+            MediaStore.Audio.Media.COMPILATION,
+            MediaStore.Audio.Media.WRITER,
+            // todo
+        )
+    }
+
+}

+ 67 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/helpers/cursor/Cursor.kt

@@ -0,0 +1,67 @@
+/*
+ * 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.base.helpers.cursor
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.database.ContentObserver
+import android.net.Uri
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.flowOn
+import org.mjdev.tvapp.base.extensions.CursorExt.toList
+import timber.log.Timber
+
+@Composable
+fun rememberMediaCursor(
+    uri: Uri?,
+    projection: Array<String>,
+    selection: String? = null,
+    selectionArgs: Array<String>? = null,
+    sortOrder: String? = null,
+): Flow<List<CursorItem>> {
+    val context: Context = LocalContext.current
+    return remember {
+        callbackFlow {
+            val resolver = context.contentResolver
+            val observer = object : ContentObserver(null) {
+                @SuppressLint("Recycle")
+                override fun onChange(selfChange: Boolean) {
+                    if (uri != null) {
+                        try {
+                            resolver.query(
+                                uri, projection, selection, selectionArgs, sortOrder
+                            )?.toList(projection)?.let { data ->
+                                trySend(data)
+                            }
+                        } catch (e: Exception) {
+                            Timber.e(e)
+                        }
+                    } else {
+                        trySend(emptyList())
+                    }
+                }
+            }
+            if (uri != null) {
+                resolver.registerContentObserver(uri, true, observer)
+                observer.onChange(true)
+                awaitClose {
+                    resolver.unregisterContentObserver(observer)
+                }
+            }
+        }.flowOn(Dispatchers.IO)
+    }
+}

+ 16 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/helpers/cursor/CursorItem.kt

@@ -0,0 +1,16 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.helpers.cursor
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import java.io.Serializable
+
+@Parcelize
+open class CursorItem : HashMap<String, Any?>(), Serializable, Parcelable

+ 52 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/helpers/cursor/PhotoItem.kt

@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.helpers.cursor
+
+import android.net.Uri
+import android.provider.MediaStore
+import org.mjdev.tvapp.base.interfaces.ItemWithImage
+import org.mjdev.tvapp.base.interfaces.ItemWithTitle
+
+class PhotoItem(
+    ci: CursorItem
+) : CursorItem(), ItemWithTitle<String>, ItemWithImage<Any> {
+
+    init {
+        putAll(ci)
+    }
+
+    override val title: String?
+        get() = this[MediaStore.Images.Media.TITLE]?.toString()
+
+    override val image: Any?
+        get() = this[MediaStore.Images.Media.DATA]
+
+    companion object {
+        val URI: Uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+        val MEDIA_PROJECTION = arrayOf(
+            MediaStore.Images.Media._ID,
+            MediaStore.Images.Media.TITLE,
+            MediaStore.Images.Media.ARTIST,
+            MediaStore.Images.Media.ALBUM,
+            MediaStore.Images.Media.DATE_ADDED,
+            MediaStore.Images.Media.COMPOSER,
+            MediaStore.Images.Media.YEAR,
+            MediaStore.Images.Media.DATA,
+            MediaStore.Images.Media.DURATION,
+            MediaStore.Images.Media.MIME_TYPE,
+            MediaStore.Images.Media.SIZE,
+            MediaStore.Images.Media.DATE_MODIFIED,
+            MediaStore.Images.Media.ALBUM_ARTIST,
+            MediaStore.Images.Media.AUTHOR,
+            MediaStore.Images.Media.BITRATE,
+            // todo
+        )
+    }
+
+}

+ 76 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/helpers/cursor/VideoItem.kt

@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.helpers.cursor
+
+import android.content.ContentResolver
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import android.util.Size
+import org.mjdev.tvapp.base.interfaces.ItemWithImage
+import org.mjdev.tvapp.base.interfaces.ItemWithTitle
+import org.mjdev.tvapp.base.interfaces.ItemWithUri
+import timber.log.Timber
+
+class VideoItem(
+    private val contentResolver: ContentResolver,
+    ci: CursorItem
+) : CursorItem(), ItemWithTitle<String>, ItemWithImage<Any>, ItemWithUri<Uri> {
+
+    override val title: String?
+        get() = this[MediaStore.Video.Media.TITLE]?.toString()
+
+    override val uri: Uri
+        get() = Uri.withAppendedPath(
+            MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
+            this[MediaStore.Video.Media._ID].toString()
+        )
+
+    override val image: Any?
+        get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            try {
+                uri.let { thumbUri ->
+                    contentResolver.loadThumbnail(
+                        thumbUri,
+                        Size(320, 240),
+                        null
+                    )
+                }
+            } catch (e: Exception) {
+                Timber.e(e)
+                null
+            }
+        } else {
+            Uri.parse(this[MediaStore.Video.Media.DATA].toString())
+        }
+
+    init {
+        putAll(ci)
+    }
+
+    companion object {
+        val URI: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+        val MEDIA_PROJECTION = arrayOf(
+            MediaStore.Video.Media._ID,
+            MediaStore.Video.Media.TITLE,
+            MediaStore.Video.Media.ARTIST,
+            MediaStore.Video.Media.ALBUM,
+            MediaStore.Video.Media.DATE_ADDED,
+            MediaStore.Video.Media.COMPOSER,
+            MediaStore.Video.Media.YEAR,
+            MediaStore.Video.Media.DATA,
+            MediaStore.Video.Media.DURATION,
+            MediaStore.Video.Media.MIME_TYPE,
+            MediaStore.Video.Media.SIZE,
+            MediaStore.Video.Media.DATE_MODIFIED,
+            // todo
+        )
+    }
+
+}

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithBackground.kt

@@ -8,6 +8,6 @@
 
 package org.mjdev.tvapp.base.interfaces
 
-interface ItemWithBackground {
-    var backgroundImageUrl: Any?
+interface ItemWithBackground<T> {
+    val background: T?
 }

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithDescription.kt

@@ -8,6 +8,6 @@
 
 package org.mjdev.tvapp.base.interfaces
 
-interface ItemWithDescription {
-    var description: Any?
+interface ItemWithDescription<T> {
+    val description: T?
 }

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithId.kt

@@ -9,7 +9,7 @@
 package org.mjdev.tvapp.base.interfaces
 
 interface ItemWithId {
-    var id: Long
+    val id: Long
 
     val hasId: Boolean get() = (id != -1L)
 }

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithImage.kt

@@ -8,6 +8,6 @@
 
 package org.mjdev.tvapp.base.interfaces
 
-interface ItemWithImage {
-    var imageUrl : Any?
+interface ItemWithImage<T> {
+    val image : T?
 }

+ 15 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithIntent.kt

@@ -0,0 +1,15 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.interfaces
+
+import android.content.Intent
+
+interface ItemWithIntent {
+    val intent: Intent?
+}

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithMessage.kt

@@ -8,6 +8,6 @@
 
 package org.mjdev.tvapp.base.interfaces
 
-interface ItemWithMessage {
-    var message: Any?
+interface ItemWithMessage<T> {
+    val message: T?
 }

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithSubtitle.kt

@@ -8,6 +8,6 @@
 
 package org.mjdev.tvapp.base.interfaces
 
-interface ItemWithSubtitle {
-    var subtitle: Any?
+interface ItemWithSubtitle <T>{
+    val subtitle: T?
 }

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithTitle.kt

@@ -8,6 +8,6 @@
 
 package org.mjdev.tvapp.base.interfaces
 
-interface ItemWithTitle {
-    var title : Any?
+interface ItemWithTitle<T> {
+    val title : T?
 }

+ 18 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithUri.kt

@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.interfaces
+
+interface ItemWithUri<T> {
+
+    val uri: T?
+
+    val hasUri
+        get() = (uri != null) && (uri.toString().isNotEmpty())
+
+}

+ 0 - 22
app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithVideoUri.kt

@@ -1,22 +0,0 @@
-/*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
- */
-
-package org.mjdev.tvapp.base.interfaces
-
-import android.net.Uri
-
-interface ItemWithVideoUri {
-
-    var videoUri: Any?
-
-    val hasVideoUri
-        get() = (videoUri != null) &&
-            (videoUri != Uri.EMPTY) &&
-            (videoUri.toString().isNotEmpty())
-
-}

+ 30 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/navigation/AnyType.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.base.navigation
+
+import android.os.Bundle
+import androidx.core.os.bundleOf
+import androidx.navigation.NavType
+
+@Suppress("DEPRECATION")
+object AnyType : NavType<Any?>(isNullableAllowed = true) {
+
+    override fun get(bundle: Bundle, key: String): Any? {
+        return bundle.get(key)
+    }
+
+    override fun parseValue(value: String): Any? {
+        TODO("Not yet implemented")
+    }
+
+    override fun put(bundle: Bundle, key: String, value: Any?) {
+        bundle.putAll(bundleOf(key to value))
+    }
+
+}

+ 42 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/network/CacheInterceptor.kt

@@ -0,0 +1,42 @@
+package org.mjdev.tvapp.base.network
+
+import okhttp3.CacheControl
+import okhttp3.Interceptor
+import okhttp3.Response
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+/**
+ * Cache interceptor.
+ * Made to cache result from internet.
+ * Basically for an one hour, to avoid multiple calls.
+ *
+ * @constructor Create [CacheInterceptor]
+ * @property maxAge
+ * @property timeUnit
+ */
+class CacheInterceptor(
+    private val maxAge: Int = 1,
+    private val timeUnit: TimeUnit = TimeUnit.HOURS
+) : Interceptor {
+
+    /**
+     * Intercept network calls.
+     *
+     * @param chain Chain
+     * @return [Response]
+     */
+    @Throws(IOException::class)
+    override fun intercept(chain: Interceptor.Chain): Response {
+        val response: Response = chain.proceed(chain.request())
+        val cacheControl: CacheControl = CacheControl.Builder()
+            .maxAge(maxAge, timeUnit)
+            .build()
+        return response.newBuilder()
+            .removeHeader("Pragma")
+            .removeHeader("Cache-Control")
+            .header("Cache-Control", cacheControl.toString())
+            .build()
+    }
+
+}

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/network/NetworkConnectivityService.kt

@@ -12,6 +12,6 @@ import kotlinx.coroutines.flow.Flow
 
 interface NetworkConnectivityService {
 
-    val networkStatus: Flow<NetworkStatus>
+    val networkStatus: Flow<NetworkStatus?>
 
 }

+ 37 - 20
app/src/main/kotlin/org/mjdev/tvapp/base/network/NetworkConnectivityServiceImpl.kt

@@ -9,36 +9,53 @@
 package org.mjdev.tvapp.base.network
 
 import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.isActive
 
-@Suppress("UNUSED_PARAMETER")
 class NetworkConnectivityServiceImpl constructor(
-    context: Context
+    val context: Context
 ) : NetworkConnectivityService {
-//    private val connectivityManager = context
-//        .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
-
-    // todo error ConnectivityManager$TooManyRequestsException
-    override val networkStatus: Flow<NetworkStatus> = callbackFlow {
-        trySend(NetworkStatus.Connected(null))
-//        val connectivityCallback = ConnectivityCallback { status ->
-//            trySend(status)
-//        }
-//        val request = NetworkRequest.Builder()
-//            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
-//            .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
-//            .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
-//            .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
-//            .addTransportType(NetworkCapabilities.TRANSPORT_VPN)
-//            .build()
-//        connectivityManager.registerNetworkCallback(request, connectivityCallback)
+
+    private val connectivityManager by lazy {
+        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+    }
+
+    private val request by lazy {
+        NetworkRequest.Builder()
+            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+            .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+            .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+            .addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
+            .addTransportType(NetworkCapabilities.TRANSPORT_VPN)
+            .build()
+    }
+
+    private var networkState: NetworkStatus? = null
+
+    private val connectivityCallback = ConnectivityCallback { status ->
+        networkState = status
+    }
+
+    init {
+        connectivityManager.registerNetworkCallback(request, connectivityCallback)
+    }
+
+    override val networkStatus: Flow<NetworkStatus?> = callbackFlow {
+        while (isActive) {
+            trySend(networkState)
+            delay(200L)
+        }
         awaitClose {
-//            connectivityManager.unregisterNetworkCallback(connectivityCallback)
+            connectivityManager.unregisterNetworkCallback(connectivityCallback)
         }
     }.distinctUntilChanged().flowOn(Dispatchers.IO)
 

+ 3 - 150
app/src/main/kotlin/org/mjdev/tvapp/base/screen/Screen.kt

@@ -11,10 +11,6 @@
 package org.mjdev.tvapp.base.screen
 
 import androidx.annotation.CallSuper
-import androidx.annotation.MainThread
-import androidx.compose.animation.AnimatedContentTransitionScope
-import androidx.compose.animation.EnterTransition
-import androidx.compose.animation.ExitTransition
 import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
@@ -32,34 +28,17 @@ import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
 import androidx.navigation.NamedNavArgument
 import androidx.navigation.NavBackStackEntry
-import androidx.navigation.NavDeepLink
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.composable
-import androidx.navigation.navOptions
 import androidx.tv.material3.Text
 import org.mjdev.tvapp.base.annotations.TvPreview
-import org.mjdev.tvapp.base.extensions.AnimExt.FadeIn
-import org.mjdev.tvapp.base.extensions.AnimExt.FadeOut
 import org.mjdev.tvapp.base.extensions.NavExt.rememberNavControllerEx
 import org.mjdev.tvapp.base.navigation.MenuItem
-import org.mjdev.tvapp.base.navigation.NavGraphBuilderEx
 import org.mjdev.tvapp.base.navigation.NavHostControllerEx
-import kotlin.reflect.full.createInstance
 
 @Suppress("unused", "LeakingThis")
 open class Screen {
 
-    private val routeBase: String
-        get() = (this::class.simpleName ?: "-") + "?"
-
-    open val completeRoute: String
-        get() = routeBase.let { rb ->
-            var routeImpl = rb
-            pageArgs.forEach { arg ->
-                routeImpl = routeImpl.plus("${arg.name}={${arg.name}}")
-            }
-            routeImpl
-        }
+    open val route: String
+        get() = (this::class.simpleName ?: "none")
 
     open val pageArgs: List<NamedNavArgument> = emptyList()
 
@@ -83,7 +62,7 @@ open class Screen {
         get() = if (menuTitle != null) MenuItem(
             menuText = menuTitle,
             menuIcon = menuIcon,
-            menuRoute = completeRoute
+            menuRoute = route
         ) else null
 
     var navController: NavHostControllerEx? = null
@@ -133,130 +112,4 @@ open class Screen {
         }
     }
 
-    companion object {
-
-        @Suppress("UNCHECKED_CAST", "DEPRECATION")
-        private fun <T> NavBackStackEntry.arg(
-            argId: String,
-            defaultValue: T
-        ): T = (arguments?.get(argId) as? T?) ?: defaultValue
-
-        @MainThread
-        fun NavHostController.open(
-            route: String?
-        ) {
-            if (route != null) {
-                val currentRoute = currentRoute
-                val equals = currentRoute?.equals(route)
-                if (equals != true) {
-                    navigate(route)
-                }
-            }
-        }
-
-        @MainThread
-        inline fun <reified T : Screen> NavHostController.openAsTop(
-            vararg values: Any?
-        ) {
-            val instance = T::class.createInstance()
-            instance.completeRoute.let { r ->
-                var routeImpl = r
-                instance.pageArgs.forEachIndexed { idx, arg ->
-                    routeImpl = routeImpl.replace(
-                        "{${arg.name}}",
-                        (values[idx] ?: "").toString()
-                    )
-                }
-                routeImpl
-            }.also { finalRoute ->
-                val currentRoute = currentRoute
-                val equals = currentRoute?.equals(finalRoute)
-                if (equals != true) {
-                    navigate(
-                        finalRoute,
-                        navOptions {
-                            popBackStack()
-                            launchSingleTop = true
-                        },
-                        null
-                    )
-                }
-            }
-        }
-
-        @MainThread
-        inline fun <reified T : Screen> NavHostController.open(
-            vararg values: Any?
-        ) {
-            val instance = T::class.createInstance()
-            instance.completeRoute.let { r ->
-                var routeImpl = r
-                instance.pageArgs.forEachIndexed { idx, arg ->
-                    routeImpl = routeImpl.replace(
-                        "{${arg.name}}",
-                        (values[idx] ?: "").toString()
-                    )
-                }
-                routeImpl
-            }.also { finalRoute ->
-                val currentRoute = currentRoute
-                val equals = currentRoute?.equals(finalRoute)
-                if (equals != true) {
-                    navigate(finalRoute)
-                }
-            }
-        }
-
-        val NavHostController.currentRoute
-            get() = currentDestination?.route
-
-        fun <T : Screen> NavGraphBuilderEx.screen(
-            route: T,
-            isHomeScreen: Boolean = false,
-            isStartScreen: Boolean = false,
-            deepLinks: List<NavDeepLink> = emptyList(),
-            enterTransition: (@JvmSuppressWildcards
-            AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = {
-                FadeIn
-            },
-            exitTransition: (@JvmSuppressWildcards
-            AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = {
-                FadeOut
-            },
-            popEnterTransition: (@JvmSuppressWildcards
-            AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? =
-                enterTransition,
-            popExitTransition: (@JvmSuppressWildcards
-            AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? =
-                exitTransition,
-        ) {
-            if (isStartScreen) {
-                splashDestinationRoute = route.completeRoute
-            }
-            if (isHomeScreen) {
-                homeDestinationRoute = route.completeRoute
-            }
-            route.menuItem?.also { menuItem ->
-                navHostController.addMenuItem(menuItem)
-            }
-            composable(
-                route = route.completeRoute,
-                arguments = route.pageArgs,
-                enterTransition = enterTransition,
-                exitTransition = exitTransition,
-                deepLinks = deepLinks,
-                popEnterTransition = popEnterTransition,
-                popExitTransition = popExitTransition
-            ) { be ->
-                val rArgs = mutableMapOf<String, Any?>().apply {
-                    route.pageArgs.forEach { arg ->
-                        put(arg.name, be.arg(arg.name, null))
-                    }
-                }
-                route.Compose(navHostController, be, rArgs)
-            }
-        }
-
-    }
-
 }

+ 16 - 18
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/card/CardContent.kt

@@ -16,45 +16,43 @@ import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.tv.material3.MaterialTheme
 import androidx.tv.material3.ProvideTextStyle
-import org.mjdev.tvapp.base.ui.components.text.TextAny
+import org.mjdev.tvapp.base.ui.components.text.AutoHideEmptyText
 
 @Preview
 @Composable
 fun CardContent(
     modifier: Modifier = Modifier,
+    subtitleAlpha: Float = 0.6f,
+    descriptionAlpha: Float = 0.8f,
     title: @Composable () -> Unit = {
-        TextAny("title")
+        AutoHideEmptyText("title")
     },
     subtitle: @Composable () -> Unit = {
-        TextAny("subtitle")
+        Box(Modifier.graphicsLayer {
+            alpha = subtitleAlpha
+        }) {
+            AutoHideEmptyText("subtitle")
+        }
     },
     description: @Composable () -> Unit = {
-        TextAny("description")
+        Box(Modifier.graphicsLayer {
+            alpha = descriptionAlpha
+        }) {
+            AutoHideEmptyText("description")
+        }
     },
-    subtitleAlpha: Float = 0.6f,
-    descriptionAlpha: Float = 0.8f,
 ) {
-
     Column(
         modifier = modifier
     ) {
-
         ProvideTextStyle(MaterialTheme.typography.titleMedium) {
             title.invoke()
         }
-
         ProvideTextStyle(MaterialTheme.typography.bodySmall) {
-            Box(Modifier.graphicsLayer { alpha = subtitleAlpha }) {
-                subtitle.invoke()
-            }
+            subtitle.invoke()
         }
-
         ProvideTextStyle(MaterialTheme.typography.bodySmall) {
-            Box(Modifier.graphicsLayer { alpha = descriptionAlpha }) {
-                description.invoke()
-            }
+            description.invoke()
         }
-
     }
-
 }

+ 16 - 6
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/card/ItemCard.kt

@@ -9,11 +9,15 @@
 package org.mjdev.tvapp.base.ui.components.card
 
 import android.annotation.SuppressLint
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusState
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
 import androidx.tv.material3.CardBorder
 import androidx.tv.material3.CardColors
 import androidx.tv.material3.CardDefaults
@@ -21,7 +25,8 @@ import androidx.tv.material3.CardGlow
 import androidx.tv.material3.CardScale
 import androidx.tv.material3.CardShape
 import androidx.tv.material3.ExperimentalTvMaterial3Api
-import org.mjdev.tvapp.base.extensions.ModifierExt.tvAspectRatio
+import org.mjdev.tvapp.base.extensions.ComposeExt.computeCardHeight
+import org.mjdev.tvapp.base.extensions.ComposeExt.rememberFocusState
 import org.mjdev.tvapp.base.interfaces.ItemWithDescription
 import org.mjdev.tvapp.base.interfaces.ItemWithImage
 import org.mjdev.tvapp.base.ui.components.complex.FocusableCard
@@ -41,26 +46,31 @@ fun ItemCard(
     border: CardBorder = CardDefaults.colorFocusBorder(Color.Green),
     glow: CardGlow = CardDefaults.colorFocusGlow(Color.Green),
     placeholder: @Composable () -> Unit = {},
-    aspectRatio: Float? = 16f / 9f,
-    imageRenderer: @Composable (modifier: Modifier) -> Unit = {
+    aspectRatio: Float = 16f / 9f,
+    imageRenderer: @Composable () -> Unit = {
         ImageAny(
-            modifier = modifier.tvAspectRatio(aspectRatio),
-            src = (item as? ItemWithImage)?.imageUrl,
-            contentDescription = (item as? ItemWithDescription)?.description?.toString(),
+            modifier = modifier.fillMaxSize(),
+            src = (item as? ItemWithImage<*>)?.image,
+            contentDescription = (item as? ItemWithDescription<*>)?.description?.toString(),
             contentScale = contentScale,
             placeholder = placeholder
         )
     },
+    cardHeight: Dp = computeCardHeight(),
+    focusState: MutableState<FocusState?> = rememberFocusState(item),
     onClick: (item: Any?) -> Unit = {},
 ) = FocusableCard(
     modifier = modifier,
     item = item,
+    focusState = focusState,
     contentScale = contentScale,
+    aspectRatio = aspectRatio,
     scale = scale,
     shape = shape,
     colors = colors,
     border = border,
     glow = glow,
+    cardHeight = cardHeight,
     imageRenderer = imageRenderer,
     placeholder = placeholder,
     onClick = onClick,

+ 12 - 5
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/card/PhotoCard.kt

@@ -9,15 +9,18 @@
 package org.mjdev.tvapp.base.ui.components.card
 
 import android.annotation.SuppressLint
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.ui.Modifier
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.MutableState
 import androidx.compose.ui.focus.FocusState
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
 import androidx.tv.material3.CardDefaults
 import androidx.tv.material3.CardScale
 import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvapp.base.extensions.ComposeExt.computeCardHeight
 import org.mjdev.tvapp.base.extensions.ComposeExt.rememberFocusState
 import org.mjdev.tvapp.base.interfaces.ItemWithDescription
 import org.mjdev.tvapp.base.interfaces.ItemWithImage
@@ -32,14 +35,16 @@ fun PhotoCard(
     modifier: Modifier = Modifier,
     contentScale: ContentScale = ContentScale.Crop,
     focusState: MutableState<FocusState?> = rememberFocusState(item),
-    imageRenderer: @Composable (modifier: Modifier) -> Unit = { m ->
+    imageRenderer: @Composable () -> Unit = {
         PhotoImage(
-            modifier = m,
-            src = (item as? ItemWithImage)?.imageUrl,
-            contentDescription = (item as? ItemWithDescription)?.description?.toString(),
+            modifier = Modifier.fillMaxSize(),
+            src = (item as? ItemWithImage<*>)?.image,
+            contentScale = contentScale,
+            contentDescription = (item as? ItemWithDescription<*>)?.description?.toString(),
         )
     },
-    aspectRatio: Float? = 16f / 9f,
+    cardHeight: Dp = computeCardHeight(),
+    aspectRatio: Float = 16f / 9f,
     placeholder: @Composable () -> Unit = {},
     scale: CardScale = CardDefaults.scale(),
     onClick: (item: Any?) -> Unit = {},
@@ -48,7 +53,9 @@ fun PhotoCard(
         item = item,
         modifier = modifier,
         contentScale = contentScale,
+        focusState = focusState,
         aspectRatio = aspectRatio,
+        cardHeight = cardHeight,
         scale = scale,
         imageRenderer = imageRenderer,
         placeholder = placeholder,

+ 6 - 5
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/carousel/CarouselCard.kt

@@ -10,6 +10,7 @@ package org.mjdev.tvapp.base.ui.components.carousel
 
 import android.annotation.SuppressLint
 import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.MutableState
 import androidx.compose.ui.Modifier
@@ -38,11 +39,11 @@ fun CarouselCard(
     scale: CardScale = CardScale.None,
     focusState: MutableState<FocusState?> = rememberFocusState(item),
     placeholder: @Composable () -> Unit = {}, // todo
-    imageRenderer: @Composable (modifier: Modifier) -> Unit = {
+    imageRenderer: @Composable () -> Unit = {
         ImageAny(
-            modifier = modifier,
-            src = (item as? ItemWithBackground)?.backgroundImageUrl,
-            contentDescription = (item as? ItemWithDescription)?.description?.toString(),
+            modifier = Modifier.fillMaxSize(),
+            src = (item as? ItemWithBackground<*>)?.background,
+            contentDescription = (item as? ItemWithDescription<*>)?.description?.toString(),
             contentScale = contentScale,
             placeholder = placeholder
         )
@@ -55,7 +56,7 @@ fun CarouselCard(
         modifier = modifier.conditional(isEdit) {
             defaultMinSize(260.dp)
         },
-        aspectRatio = null,
+        aspectRatio = 1f,
         contentScale = contentScale,
         scale = scale,
         focusState = focusState,

+ 18 - 17
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/FocusableCard.kt

@@ -9,8 +9,9 @@
 package org.mjdev.tvapp.base.ui.components.complex
 
 import android.annotation.SuppressLint
-import android.content.res.Configuration.ORIENTATION_PORTRAIT
 import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.runtime.Composable
@@ -21,8 +22,8 @@ import androidx.compose.ui.focus.FocusState
 import androidx.compose.ui.focus.onFocusChanged
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.tv.material3.CardBorder
 import androidx.tv.material3.CardColors
@@ -32,6 +33,7 @@ import androidx.tv.material3.CardScale
 import androidx.tv.material3.CardShape
 import androidx.tv.material3.CompactCard
 import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvapp.base.extensions.ComposeExt.computeCardHeight
 import org.mjdev.tvapp.base.extensions.ComposeExt.isEditMode
 import org.mjdev.tvapp.base.extensions.ComposeExt.isFocused
 import org.mjdev.tvapp.base.extensions.ComposeExt.rememberFocusRequester
@@ -46,7 +48,7 @@ import org.mjdev.tvapp.base.interfaces.ItemWithTitle
 import org.mjdev.tvapp.base.ui.components.card.colorFocusBorder
 import org.mjdev.tvapp.base.ui.components.card.colorFocusGlow
 import org.mjdev.tvapp.base.ui.components.image.ImageAny
-import org.mjdev.tvapp.base.ui.components.text.TextAny
+import org.mjdev.tvapp.base.ui.components.text.AutoHideEmptyText
 
 @SuppressLint("ModifierParameter")
 @OptIn(ExperimentalTvMaterial3Api::class)
@@ -62,11 +64,11 @@ fun FocusableCard(
     border: CardBorder = CardDefaults.colorFocusBorder(Color.Green),
     glow: CardGlow = CardDefaults.colorFocusGlow(Color.Green),
     placeholder: @Composable () -> Unit = {},
-    imageRenderer: @Composable (modifier: Modifier) -> Unit = {
+    imageRenderer: @Composable () -> Unit = {
         ImageAny(
-            modifier = modifier,
-            src = (item as? ItemWithImage)?.imageUrl,
-            contentDescription = (item as? ItemWithDescription)?.description?.toString(),
+            modifier = Modifier.fillMaxSize(),
+            src = (item as? ItemWithImage<*>)?.image,
+            contentDescription = (item as? ItemWithDescription<*>)?.description?.toString(),
             contentScale = contentScale,
             placeholder = placeholder
         )
@@ -74,13 +76,11 @@ fun FocusableCard(
     focusRequester: FocusRequester = rememberFocusRequester(item),
     focusState: MutableState<FocusState?> = rememberFocusState(item),
     onFocusChange: (state: FocusState) -> Unit = {},
+    cardHeight: Dp = computeCardHeight(),
+    aspectRatio: Float = 16f / 9f,
     onClick: (item: Any?) -> Unit = {},
 ) {
     val isEdit = isEditMode()
-    val width = LocalConfiguration.current.let { config ->
-        if (config.orientation == ORIENTATION_PORTRAIT) config.screenWidthDp * 0.5f
-        else config.screenHeightDp * 0.5f
-    }
     CompactCard(
         scale = scale,
         shape = shape,
@@ -88,7 +88,8 @@ fun FocusableCard(
         border = border,
         glow = glow,
         modifier = modifier
-            .width(width.dp)
+            .height(cardHeight)
+            .width(cardHeight * aspectRatio)
             .focusState(focusState)
             .onFocusChanged { state ->
                 onFocusChange(state)
@@ -102,18 +103,18 @@ fun FocusableCard(
                 defaultMinSize(80.dp)
             },
         image = {
-            imageRenderer(modifier)
+            imageRenderer()
         },
         title = {
-            TextAny(
+            AutoHideEmptyText(
                 modifier = Modifier.padding(4.dp),
-                text = (item as? ItemWithTitle)?.title
+                text = (item as? ItemWithTitle<*>)?.title
             )
         },
         subtitle = {
-            TextAny(
+            AutoHideEmptyText(
                 modifier = Modifier.padding(4.dp),
-                text = (item as? ItemWithSubtitle)?.subtitle
+                text = (item as? ItemWithSubtitle<*>)?.subtitle
             )
         },
         description = {

+ 3 - 3
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/TouchBox.kt → app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/VerticalScrollableBox.kt

@@ -23,21 +23,21 @@ import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.tv.foundation.lazy.list.TvLazyListState
-import org.mjdev.tvapp.base.extensions.ComposeExt
+import org.mjdev.tvapp.base.extensions.ComposeExt.isEditMode
 import org.mjdev.tvapp.base.extensions.ModifierExt.conditional
 import org.mjdev.tvapp.base.extensions.ModifierExt.tvAspectRatio
 
 @SuppressLint("AutoboxingStateValueProperty")
 @Preview
 @Composable
-fun TouchBox(
+fun VerticalScrollableBox(
     modifier: Modifier = Modifier,
     contentAlignment: Alignment = Alignment.TopStart,
     propagateMinConstraints: Boolean = false,
     state: TvLazyListState? = null,
     content: @Composable BoxScope.() -> Unit = {}
 ) {
-    val isEdit = ComposeExt.isEditMode()
+    val isEdit = isEditMode()
     val scrollDelta = remember { mutableFloatStateOf(0f) }
     Box(
         modifier = modifier

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/image/PhotoImage.kt

@@ -46,7 +46,7 @@ fun PhotoImage(
     placeholder: @Composable () -> Unit = {},
     modifier: Modifier = Modifier,
     alignment: Alignment = Alignment.Center,
-    contentScale: ContentScale = ContentScale.Fit,
+    contentScale: ContentScale = ContentScale.Crop,
     alpha: Float = DefaultAlpha,
     @FloatRange(from = 0.0, to = 10.0)
     contrast: Float = 5f,

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/navigation/NavDrawerContent.kt

@@ -21,10 +21,10 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvapp.base.extensions.NavControllerExt.open
 import org.mjdev.tvapp.base.extensions.NavExt.rememberNavControllerEx
 import org.mjdev.tvapp.base.navigation.MenuItem
 import org.mjdev.tvapp.base.navigation.NavHostControllerEx
-import org.mjdev.tvapp.base.screen.Screen.Companion.open
 
 @SuppressLint("AutoboxingStateValueProperty")
 @OptIn(ExperimentalTvMaterial3Api::class)

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/navigation/NavHostEx.kt

@@ -23,9 +23,9 @@ import androidx.navigation.compose.NavHost
 import org.mjdev.tvapp.base.annotations.TvPreview
 import org.mjdev.tvapp.base.extensions.NavExt.createGraph
 import org.mjdev.tvapp.base.extensions.NavExt.rememberNavControllerEx
+import org.mjdev.tvapp.base.extensions.NavGraphBuilderExt.screen
 import org.mjdev.tvapp.base.navigation.NavGraphBuilderEx
 import org.mjdev.tvapp.base.navigation.NavHostControllerEx
-import org.mjdev.tvapp.base.screen.Screen.Companion.screen
 import org.mjdev.tvapp.base.ui.components.screen.EmptyScreen
 import org.mjdev.tvapp.base.ui.components.screen.ScreenView
 

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/page/Page.kt

@@ -29,7 +29,7 @@ import androidx.compose.ui.unit.sp
 import org.mjdev.tvapp.base.annotations.TvPreview
 import org.mjdev.tvapp.base.extensions.ComposeExt.rememberFocusRequester
 import org.mjdev.tvapp.base.extensions.ModifierExt.requestFocusOnTouch
-import org.mjdev.tvapp.base.ui.components.complex.TouchBox
+import org.mjdev.tvapp.base.ui.components.complex.VerticalScrollableBox
 import org.mjdev.tvapp.base.navigation.MenuItem
 import org.mjdev.tvapp.base.navigation.NavHostControllerEx
 import org.mjdev.tvapp.base.ui.components.text.TextAny
@@ -55,7 +55,7 @@ open class Page {
         Column(
             Modifier.fillMaxSize()
         ) {
-            TouchBox(
+            VerticalScrollableBox(
                 modifier = Modifier
                     .fillMaxSize()
                     .background(backgroundColor, background)

+ 4 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/page/PagerScope.kt

@@ -19,10 +19,11 @@ import org.mjdev.tvapp.base.navigation.NavHostControllerEx
 @Suppress("unused")
 class PagerScope(
     val navController: NavHostControllerEx,
+    startIndex:Int = 0,
     pages: PagerScope.() -> Unit = {},
 ) : ArrayList<Page>() {
 
-    val currentPage: MutableState<Int> = mutableIntStateOf(0)
+    val currentPage: MutableState<Int> = mutableIntStateOf(startIndex)
 
     init {
         pages.invoke(this)
@@ -70,10 +71,12 @@ class PagerScope(
 @Composable
 fun rememberPagerScope(
     navController: NavHostControllerEx,
+    startIndex:Int = 0,
     pages: PagerScope.() -> Unit = {}
 ) = remember {
     PagerScope(
         navController,
+        startIndex,
         pages,
     )
 }

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/page/TvPager.kt

@@ -47,7 +47,7 @@ fun TvPager(
 
     val currentPage = remember { mutableStateOf<Page?>(null) }
     val coroutineScope = rememberCoroutineScope()
-    val pagerScope = rememberPagerScope(navController, pages)
+    val pagerScope = rememberPagerScope(navController, startIndex, pages)
 
     val goToPage: (
         page: Page?

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/screen/EmptyScreen.kt

@@ -14,7 +14,7 @@ import org.mjdev.tvapp.base.screen.Screen
 
 internal class EmptyScreen : Screen() {
 
-    override val completeRoute: String = ROUTE_NONE
+    override val route: String = ROUTE_NONE
 
     @TvPreview
     @Composable

+ 66 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/text/AutoHideEmptyText.kt

@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.ui.components.text
+
+import android.annotation.SuppressLint
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.TextUnit
+import androidx.tv.material3.LocalTextStyle
+
+@SuppressLint("ModifierParameter")
+@Composable
+fun AutoHideEmptyText(
+    text: Any? = "test",
+    modifier: Modifier = Modifier,
+    color: Color = Color.Unspecified,
+    fontSize: TextUnit = TextUnit.Unspecified,
+    fontStyle: FontStyle? = null,
+    fontWeight: FontWeight? = null,
+    fontFamily: FontFamily? = null,
+    letterSpacing: TextUnit = TextUnit.Unspecified,
+    textDecoration: TextDecoration? = null,
+    textAlign: TextAlign? = null,
+    lineHeight: TextUnit = TextUnit.Unspecified,
+    overflow: TextOverflow = TextOverflow.Clip,
+    softWrap: Boolean = true,
+    maxLines: Int = Int.MAX_VALUE,
+    onTextLayout: (TextLayoutResult) -> Unit = {},
+    style: TextStyle = LocalTextStyle.current
+) {
+    if ((text != null) && (text.toString().isNotEmpty())) {
+        TextAny(
+            text,
+            modifier,
+            color,
+            fontSize,
+            fontStyle,
+            fontWeight,
+            fontFamily,
+            letterSpacing,
+            textDecoration,
+            textAlign,
+            lineHeight,
+            overflow,
+            softWrap,
+            maxLines,
+            onTextLayout,
+            style
+        )
+    }
+}

+ 66 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/AppsRow.kt

@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.ui.components.tv
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.lazy.list.TvLazyListState
+import androidx.tv.foundation.lazy.list.rememberTvLazyListState
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.helpers.apps.App
+import org.mjdev.tvapp.base.helpers.apps.rememberAppsManager
+import org.mjdev.tvapp.base.ui.components.card.ItemCard
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@TvPreview
+@Composable
+fun AppsRow(
+    title: Any? = R.string.title_apps,
+    rowState: TvLazyListState = rememberTvLazyListState(),
+    padding: Dp = 16.dp,
+    backgroundColor: Color = Color.DarkGray,
+    startApp: Context.(app: App?) -> Unit = { app ->
+        startActivity(app?.intent)
+    },
+) {
+    val context: Context = LocalContext.current
+    val apps = rememberAppsManager().collectAsState(emptyList())
+    if (apps.value.isNotEmpty()) {
+        CategoryRow(
+            title = title,
+            items = apps.value,
+            rowState = rowState,
+            padding = padding,
+            cardHeight = 80.dp,
+            contentScale = ContentScale.Fit,
+            backgroundColor = backgroundColor,
+            onItemClick = { app ->
+                startApp(context, app as? App)
+            },
+            contentOfItem = { item ->
+                ItemCard(
+                    item = item,
+                    contentScale = ContentScale.Fit,
+                    cardHeight = 80.dp,
+                    onClick = { app ->
+                        startApp(context, app as? App)
+                    },
+                )
+            },
+        )
+    }
+}

+ 39 - 6
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/BrowseView.kt

@@ -37,6 +37,10 @@ fun BrowseView(
     modifier: Modifier = Modifier,
     showHeader: Boolean = true,
     showNetworkState: Boolean = true,
+    showApps: Boolean = true,
+    showLocalAudio: Boolean = true,
+    showLocalVideo: Boolean = true,
+    showLocalPhotos: Boolean = true,
     title: Any? = "test",
     messages: List<Any?> = listOf(Unit, Unit, Unit),
     categories: List<Any?> = listOf(Unit, Unit, Unit),
@@ -53,11 +57,15 @@ fun BrowseView(
     backgroundShape: Shape = RoundedCornerShape(roundCornerSize),
     verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(32.dp),
     contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+    onTitleClick : () ->Unit = {},
+    onClockClick: () -> Unit = {},
+    onMessageBadgeClick: () -> Unit = {},
+    onUserPicClick: () -> Unit = {},
     onItemClick: (item: Any?) -> Unit = {}
 ) {
     val isEdit = isEditMode()
     ScrollableTvLazyRow(
-        modifier = Modifier
+        modifier = modifier
             .fillMaxSize()
             .background(backgroundColor, backgroundShape),
         verticalArrangement = verticalArrangement,
@@ -66,7 +74,11 @@ fun BrowseView(
         if (isEdit || showHeader) item {
             Header(
                 title = title,
-                messagesCount = messages.size
+                messagesCount = messages.size,
+                onTitleClick = onTitleClick,
+                onClockClick = onClockClick,
+                onMessageBadgeClick = onMessageBadgeClick,
+                onUserPicClick = onUserPicClick
             )
         }
         if (showNetworkState && (isEdit || networkState.isNotConnected)) item {
@@ -85,7 +97,10 @@ fun BrowseView(
         if (isEdit || categories.isNotEmpty()) item {
             Tabs(
                 items = categories.map { category ->
-                    (category as? ItemWithTitle)?.title
+                    (category as? ItemWithTitle<*>)?.title
+                },
+                onItemClick = { category ->
+                    // todo
                 }
             )
         }
@@ -96,11 +111,29 @@ fun BrowseView(
                 onItemClicked = onItemClick
             )
         }
-        items(categoriesAndItemsMap.map {
-            Pair(it.key, it.value)
+        if (showApps) item {
+            AppsRow()
+        }
+        if (showLocalAudio) item {
+            LocalAudioRow(
+                openItem = { item -> onItemClick(item) }
+            )
+        }
+        if (showLocalVideo) item {
+            LocalVideoRow(
+                openItem = { item -> onItemClick(item) }
+            )
+        }
+        if (showLocalPhotos) item {
+            LocalPhotosRow(
+                openItem = { item -> onItemClick(item) }
+            )
+        }
+        items(categoriesAndItemsMap.map { entry ->
+            Pair(entry.key, entry.value)
         }) { entry ->
             CategoryRow(
-                title = (entry.first as? ItemWithTitle)?.title,
+                title = (entry.first as? ItemWithTitle<*>)?.title,
                 items = entry.second,
                 onItemClick = onItemClick
             )

+ 30 - 7
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/CategoryRow.kt

@@ -8,15 +8,22 @@
 
 package org.mjdev.tvapp.base.ui.components.tv
 
+import android.annotation.SuppressLint
 import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGestures
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.Dp
@@ -24,10 +31,11 @@ import androidx.compose.ui.unit.dp
 import androidx.tv.foundation.lazy.list.TvLazyListState
 import androidx.tv.foundation.lazy.list.rememberTvLazyListState
 import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvapp.base.extensions.ComposeExt.computeCardHeight
 import org.mjdev.tvapp.base.ui.components.card.PhotoCard
 import org.mjdev.tvapp.base.ui.components.text.TextAny
 
-@Suppress("UNUSED_PARAMETER")
+@SuppressLint("AutoboxingStateValueProperty")
 @OptIn(ExperimentalTvMaterial3Api::class)
 @Preview
 @Composable
@@ -36,10 +44,20 @@ fun CategoryRow(
     items: List<Any?> = listOf(Unit, Unit, Unit),
     rowState: TvLazyListState = rememberTvLazyListState(),
     padding: Dp = 16.dp,
+    cardHeight: Dp = computeCardHeight(),
+    contentScale: ContentScale = ContentScale.Crop,
     backgroundColor: Color = Color.DarkGray,
     onItemClick: (item: Any?) -> Unit = {},
+    contentOfItem: @Composable (item: Any?) -> Unit = { item ->
+        PhotoCard(
+            item = item,
+            contentScale = contentScale,
+            cardHeight = cardHeight,
+            onClick = onItemClick,
+        )
+    },
 ) {
-// todo better design & scroll horizontally
+    val scrollDelta = remember { mutableFloatStateOf(0f) }
     Column(
         modifier = Modifier
             .fillMaxWidth()
@@ -55,15 +73,20 @@ fun CategoryRow(
         )
         TVRow(
             horizontalArrangement = Arrangement.spacedBy(padding),
-            modifier = Modifier.fillMaxWidth(),
+            modifier = Modifier.fillMaxWidth().pointerInput(Unit) {
+                detectDragGestures { _, dragAmount ->
+                    scrollDelta.value = dragAmount.x
+                }
+            },
+            state = rowState,
             contentPadding = (padding.value * 1.3).dp, // todo due border & glow
         ) {
             items(items) { item ->
-                PhotoCard(
-                    item = item,
-                    onClick = onItemClick,
-                )
+                contentOfItem(item)
             }
         }
+        LaunchedEffect(scrollDelta.value) {
+            rowState.dispatchRawDelta(-scrollDelta.value)
+        }
     }
 }

+ 66 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/CursorRow.kt

@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.ui.components.tv
+
+import android.content.Context
+import android.net.Uri
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.lazy.list.TvLazyListState
+import androidx.tv.foundation.lazy.list.rememberTvLazyListState
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.extensions.ComposeExt.computeCardHeight
+import org.mjdev.tvapp.base.helpers.cursor.CursorItem
+import org.mjdev.tvapp.base.helpers.cursor.rememberMediaCursor
+
+@TvPreview
+@Composable
+fun CursorRow(
+    title: Any? = null,
+    rowState: TvLazyListState = rememberTvLazyListState(),
+    padding: Dp = 16.dp,
+    backgroundColor: Color = Color.DarkGray,
+    uri: Uri? = null,
+    projection: Array<String> = emptyArray(),
+    selection: String? = null,
+    selectionArgs: Array<String>? = null,
+    sortOrder: String? = null,
+    transform: (CursorItem) -> Any? = { it },
+    cardHeight: Dp = computeCardHeight(),
+    contentScale: ContentScale = ContentScale.Crop,
+    openItem: Context.(item: CursorItem?) -> Unit = {},
+) {
+    val context: Context = LocalContext.current
+    val items = rememberMediaCursor(
+        uri,
+        projection,
+        selection,
+        selectionArgs,
+        sortOrder
+    ).collectAsState(emptyList())
+    if (items.value.isNotEmpty()) {
+        CategoryRow(
+            title = title,
+            items = items.value.map { item -> transform(item) },
+            rowState = rowState,
+            padding = padding,
+            cardHeight = cardHeight,
+            contentScale = contentScale,
+            backgroundColor = backgroundColor,
+            onItemClick = { item ->
+                openItem(context, item as? CursorItem)
+            },
+        )
+    }
+}

+ 56 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/LocalAudioRow.kt

@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.ui.components.tv
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.lazy.list.TvLazyListState
+import androidx.tv.foundation.lazy.list.rememberTvLazyListState
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.extensions.ComposeExt
+import org.mjdev.tvapp.base.extensions.ComposeExt.computeCardHeight
+import org.mjdev.tvapp.base.helpers.cursor.AudioItem
+import org.mjdev.tvapp.base.helpers.cursor.CursorItem
+
+@TvPreview
+@Composable
+fun LocalAudioRow(
+    title: Any? = R.string.title_audio_local,
+    rowState: TvLazyListState = rememberTvLazyListState(),
+    padding: Dp = 16.dp,
+    backgroundColor: Color = Color.DarkGray,
+    selection: String? = null,
+    selectionArgs: Array<String>? = null,
+    sortOrder: String? = null,
+    cardHeight: Dp = computeCardHeight(),
+    contentScale: ContentScale = ContentScale.Crop,
+    openItem: Context.(item: CursorItem?) -> Unit = {},
+) {
+    val contentResolver = ComposeExt.rememberContentResolver()
+    CursorRow(
+        title = title,
+        rowState = rowState,
+        padding = padding,
+        backgroundColor = backgroundColor,
+        cardHeight = cardHeight,
+        contentScale = contentScale,
+        uri = AudioItem.URI,
+        projection = AudioItem.MEDIA_PROJECTION,
+        selection = selection,
+        selectionArgs = selectionArgs,
+        sortOrder = sortOrder,
+        transform = { AudioItem(contentResolver, it) },
+        openItem = openItem,
+    )
+}

+ 52 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/LocalPhotosRow.kt

@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.ui.components.tv
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.lazy.list.TvLazyListState
+import androidx.tv.foundation.lazy.list.rememberTvLazyListState
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.extensions.ComposeExt.computeCardHeight
+import org.mjdev.tvapp.base.helpers.cursor.CursorItem
+import org.mjdev.tvapp.base.helpers.cursor.PhotoItem
+
+@TvPreview
+@Composable
+fun LocalPhotosRow(
+    title: Any? = R.string.title_photo_local,
+    rowState: TvLazyListState = rememberTvLazyListState(),
+    padding: Dp = 16.dp,
+    backgroundColor: Color = Color.DarkGray,
+    selection: String? = null,
+    selectionArgs: Array<String>? = null,
+    sortOrder: String? = null,
+    cardHeight: Dp = computeCardHeight(),
+    contentScale: ContentScale = ContentScale.Crop,
+    openItem: Context.(item: CursorItem?) -> Unit = {},
+) = CursorRow(
+    title = title,
+    rowState = rowState,
+    padding = padding,
+    backgroundColor = backgroundColor,
+    cardHeight = cardHeight,
+    contentScale = contentScale,
+    uri = PhotoItem.URI,
+    projection = PhotoItem.MEDIA_PROJECTION,
+    selection = selection,
+    selectionArgs = selectionArgs,
+    sortOrder = sortOrder,
+    transform = { PhotoItem(it) },
+    openItem = openItem,
+)

+ 56 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/LocalVideoRow.kt

@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.ui.components.tv
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.lazy.list.TvLazyListState
+import androidx.tv.foundation.lazy.list.rememberTvLazyListState
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.extensions.ComposeExt.computeCardHeight
+import org.mjdev.tvapp.base.extensions.ComposeExt.rememberContentResolver
+import org.mjdev.tvapp.base.helpers.cursor.CursorItem
+import org.mjdev.tvapp.base.helpers.cursor.VideoItem
+
+@TvPreview
+@Composable
+fun LocalVideoRow(
+    title: Any? = R.string.title_video_local,
+    rowState: TvLazyListState = rememberTvLazyListState(),
+    padding: Dp = 16.dp,
+    backgroundColor: Color = Color.DarkGray,
+    selection: String? = null,
+    selectionArgs: Array<String>? = null,
+    sortOrder: String? = null,
+    cardHeight: Dp = computeCardHeight(),
+    contentScale: ContentScale = ContentScale.Crop,
+    openItem: Context.(item: CursorItem?) -> Unit = {},
+) {
+    val contentResolver = rememberContentResolver()
+    CursorRow(
+        title = title,
+        rowState = rowState,
+        padding = padding,
+        backgroundColor = backgroundColor,
+        cardHeight = cardHeight,
+        contentScale = contentScale,
+        uri = VideoItem.URI,
+        projection = VideoItem.MEDIA_PROJECTION,
+        selection = selection,
+        selectionArgs = selectionArgs,
+        sortOrder = sortOrder,
+        transform = { VideoItem(contentResolver, it) },
+        openItem = openItem,
+    )
+}

+ 5 - 7
app/src/main/kotlin/org/mjdev/tvapp/base/paging/PagingTvLazyRow.kt → app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/PagingTvLazyRow.kt

@@ -1,4 +1,4 @@
-package org.mjdev.tvapp.base.paging
+package org.mjdev.tvapp.base.ui.components.tv
 
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.PaddingValues
@@ -21,6 +21,8 @@ import androidx.tv.foundation.lazy.list.TvLazyRow
 import androidx.tv.foundation.lazy.list.rememberTvLazyListState
 import org.mjdev.tvapp.R
 import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.paging.ListPagingSource
+import org.mjdev.tvapp.base.paging.SOURCE
 import org.mjdev.tvapp.base.ui.components.text.TextAny
 
 @Suppress("ModifierParameter")
@@ -37,14 +39,10 @@ fun <T : Any> PagingTvLazyRow(
     userScrollEnabled: Boolean = true,
     pivotOffsets: PivotOffsets = PivotOffsets(),
     perPage: Int = 8,
-    source: SOURCE<T> = { _, _ ->
-        emptyList()
-    },
+    source: SOURCE<T> = { _, _ -> emptyList() },
     loadStateHandler: (state: LoadState) -> Unit = {},
     onItemClick: (data: T) -> Unit = { _ -> },
-    emptyContent: @Composable () -> Unit = {
-        TextAny(text = R.string.no_items)
-    },
+    emptyContent: @Composable () -> Unit = { TextAny(text = R.string.no_items) },
     itemBlock: @Composable (
         idx: Int, item: T, onItemClick: (data: T) -> Unit
     ) -> Unit = { _, _, _ -> }

+ 2 - 7
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/ScrollableTvLazyRow.kt

@@ -20,7 +20,7 @@ import androidx.tv.foundation.lazy.list.TvLazyListScope
 import androidx.tv.foundation.lazy.list.TvLazyListState
 import androidx.tv.foundation.lazy.list.rememberTvLazyListState
 import org.mjdev.tvapp.base.annotations.TvPreview
-import org.mjdev.tvapp.base.ui.components.complex.TouchBox
+import org.mjdev.tvapp.base.ui.components.complex.VerticalScrollableBox
 
 @TvPreview
 @Composable
@@ -36,12 +36,10 @@ fun ScrollableTvLazyRow(
     state: TvLazyListState = rememberTvLazyListState(),
     content: TvLazyListScope.() -> Unit = {}
 ) {
-
-    TouchBox(
+    VerticalScrollableBox(
         modifier = modifier,
         state = state
     ) {
-
         TvLazyColumn(
             modifier = modifier,
             state = state,
@@ -55,8 +53,5 @@ fun ScrollableTvLazyRow(
                 content.invoke(this)
             }
         )
-
     }
-
-
 }

+ 1 - 1
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/Tabs.kt

@@ -105,7 +105,7 @@ fun Tabs(
                 ) {
                     TextAny(
                         modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
-                        text = if (tab is ItemWithTitle) tab.title else tab.toString(),
+                        text = if (tab is ItemWithTitle<*>) tab.title else tab.toString(),
                         fontSize = fontSize,
                         color = Color.White,
                         fontWeight = FontWeight.Bold

+ 14 - 7
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/tv/Title.kt

@@ -12,11 +12,13 @@ import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment.Companion.CenterVertically
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.scale
+import androidx.compose.ui.draw.shadow
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.tooling.preview.Preview
@@ -41,13 +43,13 @@ fun Title(
     color: Color = Color.White,
     focusShadowColor: Color = Color.Green,
     unfocusedShadowColor: Color = Color.Transparent,
-    shadowSize: Dp = 6.dp,
+    shadowSize: Dp = 10.dp,
     title: Any? = R.string.app_name,
     onClick: () -> Unit = {},
 ) {
     val isEdit = isEditMode()
     val focusState = rememberFocusState(title)
-
+    val shadowColor =if (isEdit || focusState.isFocused) focusShadowColor else unfocusedShadowColor
     FocusableBox(
         modifier = modifier,
         onClick = onClick,
@@ -63,16 +65,21 @@ fun Title(
                 modifier = Modifier
                     .height((fontSize.value).dp)
                     .aspectRatio(1f)
-                    .scale(1.3f),
+                    .scale(1.3f)
+                    .shadow(
+                        elevation = shadowSize,
+                        shape = CircleShape,
+                        ambientColor = shadowColor,
+                        spotColor = shadowColor
+                    ),
                 borderColor = Color.White,
-                borderSize = 1.dp,
+                borderSize = 1.3.dp,
                 src = R.mipmap.ic_launcher
             )
             TextWithShadow(
                 modifier = modifier,
-                shadowColor = if (isEdit || focusState.isFocused) focusShadowColor
-                else unfocusedShadowColor,
-                shadowSize = 10.dp,
+                shadowColor = shadowColor,
+                shadowSize = shadowSize,
                 text = title ?: R.string.app_name,
                 fontWeight = fontWeight,
                 fontSize = fontSize,

+ 2 - 2
app/src/main/kotlin/org/mjdev/tvapp/base/ui/theme/ThemeHelper.kt

@@ -93,10 +93,10 @@ class ThemeProvider {
     val detailTextBackgroundColor: Color = Color.Black
     val detailsTitleTextStyle: TextStyle
         @Composable
-        get() = MaterialTheme.typography.headlineLarge
+        get() = MaterialTheme.typography.headlineMedium
     val detailsTextStyle: TextStyle
         @Composable
-        get() = MaterialTheme.typography.headlineMedium
+        get() = MaterialTheme.typography.headlineSmall
 
     val headerTitleTextSize: TextUnit = 0.sp
     val headerTextSize: TextUnit = 0.sp

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

@@ -1,24 +0,0 @@
-/*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
- */
-
-package org.mjdev.tvapp.data
-
-import androidx.compose.runtime.Stable
-import org.mjdev.tvapp.base.interfaces.ItemWithId
-import org.mjdev.tvapp.base.interfaces.ItemWithTitle
-import java.io.Serializable
-
-@Stable
-data class Category(
-
-    override var id: Long = 0L,
-    override var title: Any? = "",
-
-    var movieList: List<Movie> = emptyList(),
-
-) : Serializable, ItemWithTitle, ItemWithId

+ 21 - 6
app/src/main/kotlin/org/mjdev/tvapp/data/Message.kt

@@ -8,17 +8,32 @@
 
 package org.mjdev.tvapp.data
 
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import io.objectbox.annotation.Entity
+import io.objectbox.annotation.Id
 import org.mjdev.tvapp.base.interfaces.ItemWithId
 import org.mjdev.tvapp.base.interfaces.ItemWithMessage
 import org.mjdev.tvapp.base.interfaces.ItemWithTitle
 
-data class Message(
+@Entity
+@JsonClass(generateAdapter = true)
+class Message : ItemWithTitle<String>, ItemWithMessage<String>, ItemWithId {
 
-    override var id: Long = 0,
-    override var title: Any? = "",
-    override var message: Any? = "",
+    @Id
+    @Json(name = "id")
+    override var id: Long = 0
 
-    val read: Boolean = false,
+    @Json(name = "title")
+    override var title: String? = ""
+
+    @Json(name = "message")
+    override var message: String? = ""
+
+    @Json(name = "read")
+    val read: Boolean = false
+
+    @Json(name = "deleted")
     val deleted: Boolean = false
 
-) : ItemWithTitle, ItemWithMessage, ItemWithId
+}

+ 51 - 31
app/src/main/kotlin/org/mjdev/tvapp/data/Movie.kt

@@ -8,48 +8,68 @@
 
 package org.mjdev.tvapp.data
 
-import android.net.Uri
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import io.objectbox.annotation.Entity
+import io.objectbox.annotation.Id
 import org.mjdev.tvapp.base.interfaces.ItemWithBackground
 import org.mjdev.tvapp.base.interfaces.ItemWithDescription
 import org.mjdev.tvapp.base.interfaces.ItemWithId
 import org.mjdev.tvapp.base.interfaces.ItemWithImage
 import org.mjdev.tvapp.base.interfaces.ItemWithSubtitle
 import org.mjdev.tvapp.base.interfaces.ItemWithTitle
-import org.mjdev.tvapp.base.interfaces.ItemWithVideoUri
+import org.mjdev.tvapp.base.interfaces.ItemWithUri
 import java.io.Serializable
 
-data class Movie(
+@Entity
+@JsonClass(generateAdapter = true)
+class Movie :
+    Serializable,
+    ItemWithId,
+    ItemWithTitle<String>,
+    ItemWithSubtitle<String>,
+    ItemWithImage<String>,
+    ItemWithUri<String>,
+    ItemWithBackground<String>,
+    ItemWithDescription<String> {
 
-    var studio: Any? = "",
+    @Id
+    @Json(name = "id")
+    override var id: Long = 0
 
-    override var id: Long = 0,
-    override var title: Any? = "",
-    override var description: Any? = "",
-    override var backgroundImageUrl: Any? = "",
-    override var imageUrl: Any? = "",
-    override var videoUri: Any? = Uri.EMPTY,
+    @Json(name = "category")
+    var category: String? = ""
 
-    ) : Serializable,
-    ItemWithId,
-    ItemWithTitle,
-    ItemWithSubtitle,
-    ItemWithImage,
-    ItemWithVideoUri,
-    ItemWithBackground,
-    ItemWithDescription {
-
-    override var subtitle: Any?
+    @Json(name = "studio")
+    var studio: String? = ""
+
+    @Json(name = "title")
+    override var title: String? = ""
+
+    @Json(name = "description")
+    override var description: String? = ""
+
+    @Json(name = "backgroundImageUrl")
+    override var background: String? = ""
+
+    @Json(name = "imageUrl")
+    override var image: String? = ""
+
+    @Json(name = "videoUri")
+    override var uri: String? = ""
+
+    override var subtitle: String?
         get() = studio
-        set(value) { studio = value }
-
-    override fun toString(): String {
-        return "Movie{" +
-            "id=" + id +
-            ", title='" + title + '\'' +
-            ", videoUrl='" + videoUri.toString() + '\'' +
-            ", backgroundImageUrl='" + backgroundImageUrl + '\'' +
-            ", cardImageUrl='" + imageUrl + '\'' +
-            '}'
-    }
+        set(value) {
+            studio = value
+        }
+
+    override fun toString(): String = "Movie{" +
+        "id=" + id +
+        ", title='" + title + '\'' +
+        ", videoUrl='" + uri.toString() + '\'' +
+        ", backgroundImageUrl='" + background + '\'' +
+        ", cardImageUrl='" + image + '\'' +
+        '}'
 
 }

+ 42 - 0
app/src/main/kotlin/org/mjdev/tvapp/database/DAO.kt

@@ -0,0 +1,42 @@
+@file:Suppress("unused")
+
+package org.mjdev.tvapp.database
+
+import android.content.Context
+import dagger.hilt.android.qualifiers.ApplicationContext
+import io.objectbox.Box
+import io.objectbox.BoxStore
+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
+
+class DAO(
+    @ApplicationContext
+    val context: Context,
+) {
+
+    companion object {
+
+        val DATABASE_NAME = BuildConfig.APPLICATION_ID
+            .replace(".", "_")
+
+    }
+
+    private val store: BoxStore by lazy {
+        MyObjectBox.builder()
+            .androidContext(context.applicationContext)
+            .name(DATABASE_NAME)
+            .build()
+    }
+
+    val movieDao: Box<Movie> by lazy {
+        store.boxFor()
+    }
+
+    val messagesDao: Box<Message> by lazy {
+        store.boxFor()
+    }
+
+}

+ 32 - 0
app/src/main/kotlin/org/mjdev/tvapp/database/convert/CustomPropertyConverter.kt

@@ -0,0 +1,32 @@
+/*
+ * 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.database.convert
+
+import io.objectbox.converter.PropertyConverter
+
+open class ConvertEntity : Any() {
+    open val id: Long = 0L
+}
+
+open class CustomPropertyConverter<T : ConvertEntity>(
+
+    private val supplier: (id: Long) -> T?
+
+) : PropertyConverter<T, Long> {
+
+    override fun convertToEntityProperty(databaseValue: Long?): T? =
+        if (databaseValue == null) null
+        else supplier.invoke(databaseValue)
+
+    override fun convertToDatabaseValue(entityProperty: T): Long =
+        entityProperty.id
+
+}

+ 22 - 0
app/src/main/kotlin/org/mjdev/tvapp/database/convert/EncryptionConverter.kt

@@ -0,0 +1,22 @@
+@file:Suppress("unused")
+
+package org.mjdev.tvapp.database.convert
+
+import com.scottyab.aescrypt.AESCrypt
+import io.objectbox.converter.PropertyConverter
+
+class EncryptionConverter : PropertyConverter<String, String> {
+
+    companion object {
+
+        const val AES_KEY = "simply key to encrypt data"
+
+    }
+
+    override fun convertToDatabaseValue(entityProperty: String): String =
+        AESCrypt.encrypt(AES_KEY, entityProperty)
+
+    override fun convertToEntityProperty(databaseValue: String?): String =
+        AESCrypt.decrypt(AES_KEY, databaseValue)
+
+}

+ 23 - 0
app/src/main/kotlin/org/mjdev/tvapp/database/convert/PropertyEnumConverter.kt

@@ -0,0 +1,23 @@
+package org.mjdev.tvapp.database.convert
+
+import io.objectbox.converter.PropertyConverter
+
+@Suppress("unused")
+open class PropertyEnumConverter<T, R>(
+
+    private val values: Array<T>,
+    private val default: T,
+    private val predicate: T.(R) -> Boolean,
+    private val supplier: (T) -> R?
+
+) : PropertyConverter<T, R> {
+
+    override fun convertToEntityProperty(databaseValue: R?): T? =
+        if (databaseValue == null) null
+        else values.firstOrNull { p -> p.predicate(databaseValue) } ?: default
+
+    override fun convertToDatabaseValue(entityProperty: T?): R? =
+        if (entityProperty == null) null
+        else supplier(entityProperty)
+
+}

+ 21 - 0
app/src/main/kotlin/org/mjdev/tvapp/database/convert/PropertyJsonConverter.kt

@@ -0,0 +1,21 @@
+package org.mjdev.tvapp.database.convert
+
+import com.squareup.moshi.Moshi
+import io.objectbox.converter.PropertyConverter
+
+@Suppress("unused")
+open class PropertyJsonConverter<T : Any>(
+
+    private val cls: Class<T>
+
+    ) : PropertyConverter<T, String> {
+
+    override fun convertToEntityProperty(databaseValue: String?): T? =
+        if (databaseValue == null) null
+        else Moshi.Builder().build().adapter(cls).fromJson(databaseValue) as T
+
+    override fun convertToDatabaseValue(entityProperty: T?): String? =
+        if (entityProperty == null) null
+        else Moshi.Builder().build().adapter(cls).toJson(entityProperty)
+
+}

+ 33 - 0
app/src/main/kotlin/org/mjdev/tvapp/database/convert/StringListConverter.kt

@@ -0,0 +1,33 @@
+package org.mjdev.tvapp.database.convert
+
+import io.objectbox.converter.PropertyConverter
+import org.json.JSONArray
+
+@Suppress("unused")
+class StringListConverter : PropertyConverter<List<String?>?, String?> {
+
+    override fun convertToEntityProperty(
+        databaseValue: String?
+    ): List<String?> = if (databaseValue == null) ArrayList()
+    else try {
+        val array = JSONArray(databaseValue)
+        val ret = ArrayList<String?>()
+        for (i in 0 until array.length()) {
+            ret.add(array.getString(i))
+        }
+        ret
+    } catch (e: Exception) {
+        ArrayList()
+    }
+
+    override fun convertToDatabaseValue(
+        entityProperty: List<String?>?
+    ): String? {
+        return try {
+            if (entityProperty == null) null else JSONArray(entityProperty).toString()
+        } catch (e: Exception) {
+            null
+        }
+    }
+
+}

+ 0 - 107
app/src/main/kotlin/org/mjdev/tvapp/mock/MovieList.kt

@@ -1,107 +0,0 @@
-/*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
- */
-
-package org.mjdev.tvapp.mock
-
-import android.net.Uri
-import org.mjdev.tvapp.data.Movie
-
-@Suppress("SpellCheckingInspection")
-internal object MovieList {
-
-    val categories = listOf(
-        "Category Zero",
-        "Category One",
-        "Category Two",
-        "Category Three",
-        "Category Four",
-        "Category Five"
-    )
-
-    private val all: List<Movie> by lazy {
-        setupMovies()
-    }
-
-    val featured by lazy {
-        (all + all).shuffled()
-    }
-
-    private val categoryMoviesMap: Map<String, List<Movie>> by lazy {
-        categories.associateWith { all.shuffled() }
-    }
-
-    fun getByCategory(category: String?): List<Movie> {
-        return categoryMoviesMap[category] ?: all
-    }
-
-    fun findById(id: Long): Movie? {
-        return all.find { id == it.id }
-    }
-
-    private fun setupMovies(): List<Movie> {
-        var count: Long = 0
-
-        val title = arrayOf(
-            "Zeitgeist 2010_ Year in Review",
-            "Google Demo Slam_ 20ft Search",
-            "Introducing Gmail Blue",
-            "Introducing Google Fiber to the Pole",
-            "Introducing Google Nose"
-        )
-
-        val description = "Fusce id nisi turpis. Praesent viverra bibendum semper. " +
-                "Donec tristique, orci sed semper lacinia, quam erat rhoncus massa, non congue tellus est " +
-                "quis tellus. Sed mollis orci venenatis quam scelerisque accumsan. Curabitur a massa sit " +
-                "amet mi accumsan mollis sed et magna. Vivamus sed aliquam risus. Nulla eget dolor in elit " +
-                "facilisis mattis. Ut aliquet luctus lacus. Phasellus nec commodo erat. Praesent tempus id " +
-                "lectus ac scelerisque. Maecenas pretium cursus lectus id volutpat."
-        val studio = arrayOf(
-            "Studio Zero",
-            "Studio One",
-            "Studio Two",
-            "Studio Three",
-            "Studio Four"
-        )
-        val videoUrl = arrayOf(
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/Zeitgeist/Zeitgeist%202010_%20Year%20in%20Review.mp4",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%2020ft%20Search.mp4",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Gmail%20Blue.mp4",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Fiber%20to%20the%20Pole.mp4",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Nose.mp4"
-        )
-        val bgImageUrl = arrayOf(
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/Zeitgeist/Zeitgeist%202010_%20Year%20in%20Review/bg.jpg",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%2020ft%20Search/bg.jpg",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Gmail%20Blue/bg.jpg",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Fiber%20to%20the%20Pole/bg.jpg",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Nose/bg.jpg"
-        )
-        val cardImageUrl = arrayOf(
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/Zeitgeist/Zeitgeist%202010_%20Year%20in%20Review/card.jpg",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%2020ft%20Search/card.jpg",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Gmail%20Blue/card.jpg",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Fiber%20to%20the%20Pole/card.jpg",
-            "https://commondatastorage.googleapis.com/android-tv/Sample%20videos/April%20Fool's%202013/Introducing%20Google%20Nose/card.jpg"
-        )
-
-        val list = title.indices.map {
-            Movie(
-                id = count++,
-                title = title[it],
-                description = description,
-                studio = studio[it],
-                videoUri = Uri.parse(videoUrl[it]),
-                imageUrl = cardImageUrl[it],
-                backgroundImageUrl = bgImageUrl[it],
-            )
-        }
-
-        return list
-    }
-
-}

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

@@ -6,19 +6,25 @@
  * w: https://mjdev.org
  */
 
+@file:Suppress("PrivatePropertyName")
+
 package org.mjdev.tvapp.module
 
 import android.content.Context
+import com.squareup.moshi.Moshi
 import dagger.Module
 import dagger.Provides
 import dagger.hilt.InstallIn
 import dagger.hilt.android.qualifiers.ApplicationContext
 import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
 import org.mjdev.tvapp.BuildConfig
-import org.mjdev.tvapp.api.MovieAPI
+import org.mjdev.tvapp.base.network.CacheInterceptor
 import org.mjdev.tvapp.base.network.NetworkConnectivityService
 import org.mjdev.tvapp.base.network.NetworkConnectivityServiceImpl
-import org.mjdev.tvapp.repository.IRepository
+import org.mjdev.tvapp.database.DAO
+import org.mjdev.tvapp.repository.IMovieRepository
 import org.mjdev.tvapp.repository.MovieRepository
 import javax.inject.Singleton
 
@@ -28,6 +34,7 @@ import javax.inject.Singleton
 class ProvideModule {
 
     private val isDebug = BuildConfig.DEBUG
+    private val BASE_URL = ""
 
     @Singleton
     @Provides
@@ -38,34 +45,43 @@ class ProvideModule {
 
     @Singleton
     @Provides
-    fun providesMovieRepository(movieApi: MovieAPI): IRepository = MovieRepository(movieApi)
+    fun providesMovieRepository(
+        dao: DAO
+    ): IMovieRepository = MovieRepository(dao)
 
     @Singleton
     @Provides
-    fun providesMovieAPI() = MovieAPI()
+    fun providesHttpLoggingInterceptor(
+    ) = HttpLoggingInterceptor().apply {
+        level = HttpLoggingInterceptor.Level.BODY
+    }
 
-//    @Singleton
-//    @Provides
-//    fun providesHttpLoggingInterceptor(
-//    ) = HttpLoggingInterceptor().apply {
-//        level = HttpLoggingInterceptor.Level.BODY
-//    }
+    @Singleton
+    @Provides
+    fun providesCacheInterceptor() = CacheInterceptor()
 
-//    @Singleton
-//    @Provides
-//    fun providesCacheInterceptor() = CacheInterceptor()
+    @Singleton
+    @Provides
+    fun providesOkHttpClient(
+        cacheInterceptor: CacheInterceptor,
+        httpLoggingInterceptor: HttpLoggingInterceptor
+    ): OkHttpClient = OkHttpClient.Builder().apply {
+        addNetworkInterceptor(cacheInterceptor)
+        if (isDebug) {
+            addInterceptor(httpLoggingInterceptor)
+        }
+    }.build()
 
-//    @Singleton
-//    @Provides
-//    fun providesOkHttpClient(
-//        cacheInterceptor: CacheInterceptor,
-//        httpLoggingInterceptor: HttpLoggingInterceptor
-//    ): OkHttpClient = OkHttpClient.Builder().apply {
-//        addNetworkInterceptor(cacheInterceptor)
-//        if (isDebug) {
-//            addInterceptor(httpLoggingInterceptor)
-//        }
-//    }.build()
+    @Singleton
+    @Provides
+    fun providesMoshi(): Moshi = Moshi.Builder().build()
+
+    @Singleton
+    @Provides
+    fun provideDAO(
+        @ApplicationContext
+        context: Context
+    ): DAO = DAO(context)
 
 //    @Singleton
 //    @Provides
@@ -84,22 +100,10 @@ class ProvideModule {
 //        retrofit: Retrofit
 //    ): ApiService = retrofit.create(ApiService::class.java)
 
-
-//    @Singleton
-//    @Provides
-//    fun providesMoshi(): Moshi = Moshi.Builder().build()
-
 //    @Singleton
 //    @Provides
 //    fun providesApiRepository(
 //        apiService: ApiService
 //    ): INetworkRepository = ApiRepository(apiService)
 
-//    @Singleton
-//    @Provides
-//    fun provideDAO(
-//        @ApplicationContext
-//        context: Context
-//    ): DAO = OpenHelperManager.getHelper(context, DAO::class.java)
-
 }

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

@@ -0,0 +1,24 @@
+/*
+ * 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.repository
+
+import org.mjdev.tvapp.data.Message
+import org.mjdev.tvapp.data.Movie
+
+interface IMovieRepository {
+
+    fun getMovies(page: Int = 0, count: Int = 8): Result<List<Movie>>
+
+    fun findMovieById(id: Long?): Result<Movie>
+
+    fun getMessages(): Result<List<Message>>
+
+}

+ 0 - 22
app/src/main/kotlin/org/mjdev/tvapp/repository/IRepository.kt

@@ -1,22 +0,0 @@
-/*
- * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
- */
-
-package org.mjdev.tvapp.repository
-
-import org.mjdev.tvapp.data.Category
-import org.mjdev.tvapp.data.Movie
-
-interface IRepository {
-
-    suspend fun findMovieById(id: Long?): Movie?
-
-    suspend fun getFeaturedMovieList(): List<Movie>
-
-    suspend fun getCategoryList(): List<Category>
-
-}

+ 21 - 102
app/src/main/kotlin/org/mjdev/tvapp/repository/MovieRepository.kt

@@ -1,120 +1,39 @@
 /*
  * Copyright (c) Milan Jurkulák 2023.
- * Contact:
- * e: mimoccc@gmail.com
- * e: mj@mjdev.org
- * w: https://mjdev.org
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
  */
 
 package org.mjdev.tvapp.repository
 
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import org.mjdev.tvapp.api.MovieAPI
-import org.mjdev.tvapp.base.helpers.Result.Companion.unwrapOr
-import org.mjdev.tvapp.data.Category
+import org.mjdev.tvapp.base.extensions.GlobalExt.runSafe
+import org.mjdev.tvapp.data.Message
 import org.mjdev.tvapp.data.Movie
-import java.lang.Integer.max
-import java.lang.Integer.min
+import org.mjdev.tvapp.database.DAO
 import javax.inject.Inject
 
-@Suppress("unused")
 class MovieRepository @Inject constructor(
-    private val dataSource: MovieAPI
-) : IRepository {
+    var dao: DAO
+) : IMovieRepository {
 
-    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
-
-    private val categoryMovieListMap: MutableMap<Category, List<Movie>> = mutableMapOf()
-    private val idMovieMap: MutableMap<Long, Movie?> = mutableMapOf()
-
-    private var categoryList: List<Category> = listOf()
-
-    private val categoryMovieListMapMutex = Mutex()
-    private val idMovieMapMutex = Mutex()
-    private val categoryListMutex = Mutex()
-
-    override suspend fun getFeaturedMovieList(): List<Movie> {
-        val movieList = dataSource.loadFeaturedMovieList().unwrapOr(listOf())
-        updateCache(movieList)
-        return movieList
-    }
-
-    override suspend fun getCategoryList(): List<Category> {
-        if (categoryList.isEmpty()) {
-            categoryListMutex.withLock {
-                categoryList = dataSource.loadCategoryList().unwrapOr(categoryList)
-            }
-        }
-        prefetchMovieListByCategory(category = categoryList[0])
-        return categoryListMutex.withLock { categoryList }
-    }
-
-    override suspend fun findMovieById(id: Long?): Movie? {
-        idMovieMapMutex.withLock {
-            if (id == null) return null
-            if (!idMovieMap.contains(id)) {
-                idMovieMap[id] = dataSource.findMovieById(id).unwrapOr(null)
-            }
-        }
-        return idMovieMapMutex.withLock { idMovieMap[id] }
-    }
-
-    suspend fun getMovieListByCategory(category: Category): List<Movie> {
-        loadMovieListByCategory(category)
-        prefetchNeighborCategories(category = category)
-        return categoryMovieListMapMutex.withLock {
-            categoryMovieListMap[category] ?: emptyList()
-        }
-    }
-
-    private suspend fun loadMovieListByCategory(category: Category, force: Boolean = false) {
-        if (force || !categoryMovieListMap.contains(category)) {
-            updateCache(
-                category = category,
-                movieList = dataSource.getMovieListByCategory(category.title?.toString()).unwrapOr(listOf())
-            )
-        }
-    }
-
-    private suspend fun updateCache(category: Category, movieList: List<Movie>) {
-        categoryMovieListMapMutex.withLock {
-            categoryMovieListMap[category] = movieList
-        }
-        updateCache(movieList)
-    }
-
-    private suspend fun updateCache(movieList: List<Movie>) {
-        idMovieMapMutex.withLock {
-            movieList.forEach {
-                idMovieMap[it.id] = it
-            }
-        }
+    override fun getMovies(
+        page: Int,
+        count: Int
+    ): Result<List<Movie>> = runSafe {
+        dao.movieDao.all.subList(page * count, page)
     }
 
-    private fun prefetchMovieListByCategory(category: Category) {
-        CoroutineScope(dispatcher).launch {
-            loadMovieListByCategory(category)
-        }
+    override fun findMovieById(
+        id: Long?
+    ): Result<Movie> = runSafe {
+        if ((id != null) && (id > 0)) dao.movieDao.get(id)
+        else throw (Exception("Movie not found."))
     }
 
-    private fun prefetchNeighborCategories(
-        category: Category,
-        neighborCount: Int = 1,
-    ) {
-        val indexForCategory = max(categoryList.indexOf(category), 0)
-        val neighbors =
-            (indexForCategory..min(
-                indexForCategory + neighborCount,
-                categoryList.size - 1
-            )).map { categoryList[it] }
-        CoroutineScope(dispatcher).launch {
-            neighbors.forEach { loadMovieListByCategory(it) }
-        }
+    override fun getMessages(): Result<List<Message>> = runSafe {
+        dao.messagesDao.all
     }
 
 }

+ 3 - 3
app/src/main/kotlin/org/mjdev/tvapp/state/DetailsLoadingState.kt

@@ -8,14 +8,14 @@
 
 package org.mjdev.tvapp.state
 
-import org.mjdev.tvapp.data.Movie
-
 sealed class DetailsLoadingState {
 
     object Loading : DetailsLoadingState()
 
     object NotFound : DetailsLoadingState()
 
-    class Ready(val movie: Movie) : DetailsLoadingState()
+    class Ready(val data: Any?) : DetailsLoadingState()
+
+    class Error(val error: Exception) : DetailsLoadingState()
 
 }

+ 67 - 0
app/src/main/kotlin/org/mjdev/tvapp/sync/Authenticator.kt

@@ -0,0 +1,67 @@
+package org.mjdev.tvapp.sync
+
+import android.accounts.AbstractAccountAuthenticator
+import android.accounts.Account
+import android.accounts.AccountAuthenticatorResponse
+import android.accounts.NetworkErrorException
+import android.content.Context
+import android.os.Bundle
+
+class Authenticator(
+    context: Context
+) : AbstractAccountAuthenticator(context) {
+
+    override fun editProperties(r: AccountAuthenticatorResponse, s: String): Bundle {
+        throw UnsupportedOperationException()
+    }
+
+    @Throws(NetworkErrorException::class)
+    override fun addAccount(
+        r: AccountAuthenticatorResponse,
+        s: String,
+        s2: String,
+        strings: Array<String>,
+        bundle: Bundle
+    ): Bundle? = null
+
+    @Throws(NetworkErrorException::class)
+    override fun confirmCredentials(
+        r: AccountAuthenticatorResponse,
+        account: Account,
+        bundle: Bundle
+    ): Bundle? = null
+
+    @Throws(NetworkErrorException::class)
+    override fun getAuthToken(
+        r: AccountAuthenticatorResponse,
+        account: Account,
+        s: String,
+        bundle: Bundle
+    ): Bundle {
+        throw UnsupportedOperationException()
+    }
+
+    override fun getAuthTokenLabel(s: String): String {
+        throw UnsupportedOperationException()
+    }
+
+    @Throws(NetworkErrorException::class)
+    override fun updateCredentials(
+        r: AccountAuthenticatorResponse,
+        account: Account,
+        s: String,
+        bundle: Bundle
+    ): Bundle {
+        throw UnsupportedOperationException()
+    }
+
+    @Throws(NetworkErrorException::class)
+    override fun hasFeatures(
+        r: AccountAuthenticatorResponse,
+        account: Account,
+        strings: Array<String>
+    ): Bundle {
+        throw UnsupportedOperationException()
+    }
+
+}

+ 17 - 0
app/src/main/kotlin/org/mjdev/tvapp/sync/AuthenticatorService.kt

@@ -0,0 +1,17 @@
+package org.mjdev.tvapp.sync
+
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+
+class AuthenticatorService : Service() {
+
+    private lateinit var authenticator: Authenticator
+
+    override fun onCreate() {
+        authenticator = Authenticator(this)
+    }
+
+    override fun onBind(intent: Intent?): IBinder = authenticator.iBinder
+
+}

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

@@ -0,0 +1,116 @@
+package org.mjdev.tvapp.sync
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.UriMatcher
+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.database.DAO
+import javax.inject.Inject
+
+@Suppress("MemberVisibilityCanBePrivate")
+class DataProvider : ContentProvider() {
+
+    @Inject
+    lateinit var dao: DAO
+
+    @Inject
+    lateinit var moshi: Moshi
+
+    companion object {
+
+        const val MOVIES = 0
+        const val MOVIE = 1
+
+        const val CONTENT_TYPE_MOVIES = ""
+        const val CONTENT_TYPE_MOVIE = ""
+
+        val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
+            addURI(BuildConfig.SYNC_AUTH, "movies", MOVIES)
+            addURI(BuildConfig.SYNC_AUTH, "movie/#", MOVIE)
+        }
+
+        const val DATA = "DATA"
+
+    }
+
+    override fun onCreate(): Boolean = true
+
+    override fun query(
+        uri: Uri,
+        projection: Array<out String>?,
+        selection: String?,
+        selectionArgs: Array<out String>?,
+        sortOrder: String?
+    ): Cursor? = when (uriMatcher.match(uri)) {
+        else -> null
+    }
+
+    override fun getType(
+        uri: Uri
+    ): String? = when (uriMatcher.match(uri)) {
+        MOVIES -> CONTENT_TYPE_MOVIES
+        MOVIE -> CONTENT_TYPE_MOVIE
+        else -> null
+    }
+
+    override fun insert(
+        uri: Uri,
+        values: ContentValues?
+    ): Uri? = when (uriMatcher.match(uri)) {
+        MOVIES ->
+            throw UnsupportedOperationException("Only one movie can be inserted at one time.")
+
+        MOVIE -> {
+            if (values?.containsKey(DATA) == true) {
+                moshi.adapter(Movie::class.java)
+                    .fromJson(values.getAsString(DATA))
+                    .also { m ->
+                        if (m != null) dao.movieDao.put(m)
+                    }
+                uri // todo
+            } else {
+                throw UnsupportedOperationException("No data field with json in values.")
+            }
+        }
+
+        else -> null
+    }
+
+    override fun delete(
+        uri: Uri,
+        selection: String?,
+        selectionArgs: Array<out String>?
+    ): Int = when (uriMatcher.match(uri)) {
+        MOVIES ->
+            throw UnsupportedOperationException("Only one movie can be deleted at one time.")
+
+        MOVIE -> {
+            // todo
+            -1
+        }
+
+        else -> -1
+    }
+
+    override fun update(
+        uri: Uri,
+        values: ContentValues?,
+        selection: String?,
+        selectionArgs: Array<out String>?
+    ): Int = when (uriMatcher.match(uri)) {
+        MOVIES ->
+            throw UnsupportedOperationException("Only one movie can be updated at one time.")
+
+        MOVIE -> {
+            // todo
+            -1
+        }
+
+        else -> -1
+    }
+
+}

+ 81 - 0
app/src/main/kotlin/org/mjdev/tvapp/sync/SyncAdapter.kt

@@ -0,0 +1,81 @@
+package org.mjdev.tvapp.sync
+
+import android.accounts.Account
+import android.content.AbstractThreadedSyncAdapter
+import android.content.ContentProviderClient
+import android.content.Context
+import android.content.SyncResult
+import android.os.Bundle
+import io.objectbox.Box
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.runBlocking
+import org.mjdev.tvapp.base.extensions.ListExt.containsNot
+import org.mjdev.tvapp.data.Movie
+import org.mjdev.tvapp.database.DAO
+import org.mjdev.tvapp.repository.IMovieRepository
+import timber.log.Timber
+
+@Suppress("unused", "PrivatePropertyName")
+class SyncAdapter(
+    context: Context,
+    val repository: IMovieRepository,
+    val dao: DAO
+) : AbstractThreadedSyncAdapter(context, true, false) {
+
+    private val READ_COUNT = 8
+
+    private val movieDao: Box<Movie> get() = dao.movieDao
+
+    override fun onPerformSync(
+        account: Account?,
+        extras: Bundle?,
+        authority: String?,
+        provider: ContentProviderClient?,
+        syncResult: SyncResult?
+    ) {
+        try {
+            val movieUris = movieDao.all.map { m ->
+                m.uri
+            }.toMutableList()
+
+            runBlocking(Dispatchers.IO) {
+                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"))
+                            }
+                        }
+                    } while (readCnt > 0)
+                }.collect { data ->
+                    if (movieUris.containsNot { uri -> uri == data.uri }) {
+                        dao.movieDao.put(data)
+                        Timber.d("Movie: $data stored.")
+                    } else {
+                        // todo duplicities
+                    }
+                }
+            }
+        } catch (e: Exception) {
+            Timber.e(e)
+        }
+    }
+
+}

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

@@ -0,0 +1,89 @@
+package org.mjdev.tvapp.sync
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.app.Service
+import android.content.ContentResolver
+import android.content.ContentResolver.setIsSyncable
+import android.content.ContentResolver.setSyncAutomatically
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.os.IBinder
+import dagger.hilt.android.AndroidEntryPoint
+import org.mjdev.tvapp.BuildConfig
+import org.mjdev.tvapp.database.DAO
+import org.mjdev.tvapp.repository.IMovieRepository
+import timber.log.Timber
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class SyncService : Service() {
+
+    @Inject
+    lateinit var repository: IMovieRepository
+
+    @Inject
+    lateinit var dao: DAO
+
+    override fun onCreate() {
+        super.onCreate()
+        synchronized(sSyncAdapterLock) {
+            sSyncAdapter = sSyncAdapter ?: SyncAdapter(
+                context = applicationContext,
+                repository = repository,
+                dao = dao
+            )
+        }
+    }
+
+    override fun onBind(intent: Intent): IBinder {
+        return sSyncAdapter?.syncAdapterBinder ?: throw IllegalStateException()
+    }
+
+    companion object {
+
+        private const val ACCOUNT_NAME: String = BuildConfig.SYNC_AUTH
+        private const val ACCOUNT_TYPE: String = BuildConfig.SYNC_AUTH
+
+        private const val AUTHORITY: String = BuildConfig.SYNC_AUTH
+
+        private var sSyncAdapter: SyncAdapter? = null
+        private val sSyncAdapterLock = Any()
+
+        fun Context.createSyncAccount(): Account? {
+            return try {
+                val accountManager = getSystemService(Context.ACCOUNT_SERVICE) as AccountManager
+                Account(ACCOUNT_NAME, ACCOUNT_TYPE).also { account ->
+                    val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE)
+                    if (accounts.isEmpty()) {
+                        if (accountManager.addAccountExplicitly(account, "", Bundle())) {
+                            setIsSyncable(account, AUTHORITY, 1)
+                            setSyncAutomatically(account, AUTHORITY, true)
+                        } else {
+                            Timber.e("Can not create sync account")
+                        }
+                    } else {
+                        Timber.d("Account already exists.")
+                    }
+                }
+            } catch (e: Throwable) {
+                Timber.e(e)
+                null
+            }
+        }
+
+        fun requestSync(account: Account) {
+            ContentResolver.requestSync(
+                account,
+                AUTHORITY,
+                Bundle().apply {
+                    putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
+                    putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
+                }
+            )
+        }
+
+    }
+
+}

+ 61 - 27
app/src/main/kotlin/org/mjdev/tvapp/ui/components/Details.kt

@@ -9,64 +9,98 @@
 package org.mjdev.tvapp.ui.components
 
 import android.annotation.SuppressLint
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
 import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.layout.ContentScale
 import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.interfaces.ItemWithBackground
+import org.mjdev.tvapp.base.interfaces.ItemWithDescription
+import org.mjdev.tvapp.base.interfaces.ItemWithImage
+import org.mjdev.tvapp.base.interfaces.ItemWithSubtitle
+import org.mjdev.tvapp.base.interfaces.ItemWithTitle
 import org.mjdev.tvapp.base.ui.components.image.ImageAny
 import org.mjdev.tvapp.base.ui.components.text.TextAny
 import org.mjdev.tvapp.base.ui.theme.LocalThemeProvider
-import org.mjdev.tvapp.data.Movie
 
 @SuppressLint("ModifierParameter")
 @TvPreview
 @Composable
 fun Details(
-    movie: Movie? = null,
+    data: Any? = null,
     modifier: Modifier = Modifier,
 ) {
     val themeProvider = LocalThemeProvider.current
+    val backgroundImage = (data as? ItemWithBackground<*>)?.background
+    val image = (data as? ItemWithImage<*>)?.image
+    val title = (data as? ItemWithTitle<*>)?.title
+    val subtitle = (data as? ItemWithSubtitle<*>)?.subtitle
+    val description = (data as? ItemWithDescription<*>)?.description
+
+    var infoVisible by remember { mutableStateOf(true) }
+
     Box(
-        modifier = modifier.fillMaxSize(),
+        modifier = modifier
+            .fillMaxSize()
+            .pointerInput(Unit) {
+                detectTapGestures {
+                    infoVisible = !infoVisible
+                }
+            },
         contentAlignment = Alignment.BottomStart,
     ) {
         ImageAny(
-            src = movie?.imageUrl,
+            src = backgroundImage ?: image,
             contentDescription = null,
             contentScale = ContentScale.Crop,
             modifier = Modifier.fillMaxSize()
         )
-        Column(
-            modifier = Modifier
-                .fillMaxWidth()
-                .background(themeProvider.detailTextBackgroundColor)
-                .padding(
-                    vertical = themeProvider.detailTextPaddingVertical,
-                    horizontal = themeProvider.detailTextPaddingHorizontal
-                )
+        AnimatedVisibility(
+            visible = infoVisible,
+            enter = fadeIn(),
+            exit = fadeOut(),
         ) {
-            TextAny(
-                text = movie?.title ?: "-",
-                style = themeProvider.detailsTitleTextStyle,
-                color = themeProvider.detailsTextColor
-            )
-            TextAny(
-                text = movie?.studio ?: "-",
-                style = themeProvider.detailsTextStyle,
-                color = themeProvider.detailsTextColor
-            )
-            TextAny(
-                text = movie?.title ?: "-",
-                style = themeProvider.detailsTextStyle,
-                color = themeProvider.detailsTextColor
-            )
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth().alpha(0.8f)
+                    .background(themeProvider.detailTextBackgroundColor)
+                    .padding(
+                        vertical = themeProvider.detailTextPaddingVertical,
+                        horizontal = themeProvider.detailTextPaddingHorizontal
+                    )
+            ) {
+                TextAny(
+                    text = title ?: "-",
+                    style = themeProvider.detailsTitleTextStyle,
+                    color = themeProvider.detailsTextColor
+                )
+                TextAny(
+                    text = subtitle ?: "-",
+                    style = themeProvider.detailsTextStyle,
+                    color = themeProvider.detailsTextColor
+                )
+                TextAny(
+                    text = description ?: "-",
+                    style = themeProvider.detailsTextStyle,
+                    color = themeProvider.detailsTextColor
+                )
+            }
         }
     }
 }

+ 14 - 3
app/src/main/kotlin/org/mjdev/tvapp/ui/components/MediaPlayer.kt

@@ -9,25 +9,36 @@
 package org.mjdev.tvapp.ui.components
 
 import android.annotation.SuppressLint
+import android.net.Uri
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import org.mjdev.tvapp.base.annotations.TvPreview
 import org.mjdev.tvapp.base.extensions.StringExt.parseUri
-import org.mjdev.tvapp.base.interfaces.ItemWithVideoUri
+import org.mjdev.tvapp.base.interfaces.ItemWithUri
 import org.mjdev.tvapp.base.ui.components.media.MediaPlayerContainer
+import java.net.URL
 
 @SuppressLint("ModifierParameter")
 @TvPreview
 @Composable
 fun MediaPlayer(
-    movie: ItemWithVideoUri? = null,
+    data: Any? = null,
     modifier: Modifier = Modifier
 ) {
 
+    val uriToPlay : String? = when (data) {
+        null -> null
+        is ItemWithUri<*> -> data.uri.toString()
+        is String -> data.toString()
+        is URL -> data.toString()
+        is Uri -> data.toString()
+        else -> data.toString()
+    }
+
     MediaPlayerContainer(
         modifier = modifier.fillMaxSize(),
-        uri = movie?.videoUri.toString().parseUri()
+        uri = uriToPlay?.parseUri()
     )
 
 }

+ 15 - 11
app/src/main/kotlin/org/mjdev/tvapp/ui/pages/MainPage.kt

@@ -22,10 +22,10 @@ import androidx.compose.ui.graphics.vector.ImageVector
 import org.mjdev.tvapp.R
 import org.mjdev.tvapp.base.annotations.TvPreview
 import org.mjdev.tvapp.base.extensions.HiltExt.appViewModel
+import org.mjdev.tvapp.base.extensions.NavControllerExt.open
 import org.mjdev.tvapp.base.extensions.StringExt.asException
 import org.mjdev.tvapp.base.interfaces.ItemWithId
-import org.mjdev.tvapp.base.interfaces.ItemWithVideoUri
-import org.mjdev.tvapp.base.screen.Screen.Companion.open
+import org.mjdev.tvapp.base.interfaces.ItemWithUri
 import org.mjdev.tvapp.base.ui.components.page.Page
 import org.mjdev.tvapp.base.ui.components.tv.BrowseView
 import org.mjdev.tvapp.ui.screens.DetailScreen
@@ -65,15 +65,16 @@ class MainPage : Page() {
         }
 
         val onItemClick: (item: Any?) -> Unit = { item ->
-            val isVideo = ((item as? ItemWithVideoUri)?.hasVideoUri == true)
-            val isId = ((item as? ItemWithId)?.hasId == true)
-            val id = (item as? ItemWithId)?.id
-            if ((item == null) || (!isId)) {
+            val dataUri = (item as? ItemWithUri<*>)?.uri
+            val dataId = (item as? ItemWithId)?.id
+            if (item == null) {
                 viewModel.postError("No media found.".asException())
-            } else if (isVideo) {
-                navController?.open<PlayerScreen>(id)
+            } else if (dataId != null) {
+                navController?.open<PlayerScreen>(dataId)
+            } else if (dataUri != null) {
+                navController?.open<PlayerScreen>(dataUri)
             } else {
-                navController?.open<DetailScreen>(id)
+                navController?.open<DetailScreen>(item)
             }
         }
 
@@ -85,12 +86,15 @@ class MainPage : Page() {
             modifier = Modifier.fillMaxSize(),
             title = titleState.value,
             messages = messages.value,
-            categories = categoryList.value,
+            categories = categoryList.value.map { it.key }.distinct(),
             featuredItems = featuredMovieList.value,
-            categoriesAndItemsMap = categoryList.value.associateWith { it.movieList },
+            categoriesAndItemsMap = categoryList.value,
             networkState = networkState,
             errorState = errorState,
             backgroundColor = Color.DarkGray,
+            onTitleClick = {
+                navController?.openMenu()
+            },
             onItemClick = onItemClick
         )
 

+ 12 - 11
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/DetailScreen.kt

@@ -14,12 +14,12 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.navigation.NavType
 import androidx.navigation.navArgument
 import org.mjdev.tvapp.R
 import org.mjdev.tvapp.base.annotations.TvPreview
 import org.mjdev.tvapp.base.extensions.HiltExt.appViewModel
 import org.mjdev.tvapp.base.extensions.StringExt.asException
+import org.mjdev.tvapp.base.navigation.AnyType
 import org.mjdev.tvapp.base.screen.Screen
 import org.mjdev.tvapp.state.DetailsLoadingState
 import org.mjdev.tvapp.ui.components.Details
@@ -28,7 +28,7 @@ import org.mjdev.tvapp.viewmodel.DetailViewModel
 
 class DetailScreen : Screen() {
 
-    private val movieId = "movieId"
+    private val data = "data"
 
     override val title = R.string.title_movie_detail
 
@@ -37,9 +37,9 @@ class DetailScreen : Screen() {
     override val immersive: Boolean = true
 
     override val pageArgs = listOf(
-        navArgument(movieId) {
-            defaultValue = -1
-            type = NavType.LongType
+        navArgument(data) {
+            nullable = true
+            type = AnyType
         }
     )
 
@@ -51,22 +51,23 @@ class DetailScreen : Screen() {
             DetailViewModel.mockDetailViewModel(context)
         }
 
-        val movieId: Long? = remember { args[movieId] as Long? }
-        val movieState = remember { viewModel.detailsLoadingState(movieId) }.collectAsState()
+        val data: Any? = args[data]
+        val dataState = remember {
+            viewModel.detailsLoadingState(data)
+        }.collectAsState()
 
         fun postError(error: String) = viewModel.postError(error.asException())
 
-        when (movieState.value) {
+        when (dataState.value) {
             is DetailsLoadingState.Ready ->
-                Details(movie = (movieState.value as DetailsLoadingState.Ready).movie)
+                Details((dataState.value as DetailsLoadingState.Ready).data)
 
             is DetailsLoadingState.NotFound -> {
-                postError("Movie for id: $movieId not found.")
+                postError("File or address $data not found.")
                 Loading()
             }
 
             else -> {
-                postError("Movie for id: $movieId not found.")
                 Loading()
             }
         }

+ 14 - 12
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/PlayerScreen.kt

@@ -16,13 +16,13 @@ import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.navigation.NavType
 import androidx.navigation.navArgument
 import org.mjdev.tvapp.R
 import org.mjdev.tvapp.base.annotations.TvPreview
 import org.mjdev.tvapp.base.extensions.ComposeExt.isEditMode
 import org.mjdev.tvapp.base.extensions.HiltExt.appViewModel
 import org.mjdev.tvapp.base.extensions.StringExt.asException
+import org.mjdev.tvapp.base.navigation.AnyType
 import org.mjdev.tvapp.base.screen.Screen
 import org.mjdev.tvapp.state.DetailsLoadingState
 import org.mjdev.tvapp.ui.components.Loading
@@ -31,16 +31,16 @@ import org.mjdev.tvapp.viewmodel.DetailViewModel
 
 class PlayerScreen : Screen() {
 
-    private val movieId = "movieId"
+    private val data = "data"
 
     override val title = R.string.title_movie_player
     override val menuIcon: ImageVector get() = Icons.Filled.PlayArrow
     override val immersive: Boolean = true
 
     override val pageArgs = listOf(
-        navArgument(movieId) {
-            defaultValue = -1
-            type = NavType.LongType
+        navArgument(data) {
+            nullable = true
+            type = AnyType
         }
     )
 
@@ -54,21 +54,23 @@ class PlayerScreen : Screen() {
             DetailViewModel.mockDetailViewModel(context)
         }
 
-        val movieId: Long? = remember { args[movieId] as Long? }
-        val movieState = remember { viewModel.detailsLoadingState(movieId) }.collectAsState()
+        val data: Any? = remember { args[data] }
+        val dataState = remember {
+            viewModel.detailsLoadingState(data)
+        }.collectAsState()
 
         fun postError(error: String) = viewModel.postError(error.asException())
 
-        if (isEdit || (movieState.value is DetailsLoadingState.Ready)) {
+        if (isEdit || (dataState.value is DetailsLoadingState.Ready)) {
             MediaPlayer(
                 modifier = Modifier.fillMaxSize(),
-                movie = (movieState.value as? DetailsLoadingState.Ready)?.movie
+                data = (dataState.value as? DetailsLoadingState.Ready)?.data
             )
-        } else if (movieState.value is DetailsLoadingState.NotFound) {
-            postError("Movie for id: $movieId not found.")
+        } else if (dataState.value is DetailsLoadingState.NotFound) {
+            postError("Can not play: ${dataState.value}. File not found.")
             Loading()
         } else {
-            postError("Movie for id: $movieId not found.")
+            postError("Can not play: ${dataState.value}. File unknown type.")
             Loading()
         }
 

+ 1 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/SplashScreen.kt

@@ -31,6 +31,7 @@ import kotlinx.coroutines.delay
 import org.mjdev.tvapp.R
 import org.mjdev.tvapp.base.annotations.TvPreview
 import org.mjdev.tvapp.base.extensions.ComposeExt.isEditMode
+import org.mjdev.tvapp.base.extensions.NavControllerExt.openAsTop
 import org.mjdev.tvapp.base.screen.Screen
 import org.mjdev.tvapp.base.ui.components.text.TextAny
 import org.mjdev.tvapp.base.ui.components.text.TextWithShadow

+ 32 - 12
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/DetailViewModel.kt

@@ -9,34 +9,54 @@
 package org.mjdev.tvapp.viewmodel
 
 import android.content.Context
+import android.net.Uri
 import androidx.lifecycle.viewModelScope
 import dagger.hilt.android.lifecycle.HiltViewModel
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.stateIn
-import org.mjdev.tvapp.api.MovieAPI
 import org.mjdev.tvapp.base.viewmodel.BaseViewModel
-import org.mjdev.tvapp.repository.IRepository
+import org.mjdev.tvapp.database.DAO
+import org.mjdev.tvapp.repository.IMovieRepository
 import org.mjdev.tvapp.repository.MovieRepository
 import org.mjdev.tvapp.state.DetailsLoadingState
+import java.net.URL
 import javax.inject.Inject
 
 @HiltViewModel
 class DetailViewModel @Inject constructor(
-    private val movieRepository: IRepository
+    private val movieRepository: IMovieRepository
 ) : BaseViewModel() {
 
     fun detailsLoadingState(
-        movieId: Long?
+        data: Any?
     ): StateFlow<DetailsLoadingState> = flow {
-        val movie = movieRepository.findMovieById(id = movieId)
-        val state = if (movie == null) {
-            DetailsLoadingState.NotFound
-        } else {
-            DetailsLoadingState.Ready(movie = movie)
+        try {
+            when (data) {
+                is Int, is Long -> {
+                    val movieId: Long? = data as? Long
+                    val result = movieRepository.findMovieById(movieId)
+                    val state = if (result.isSuccess) {
+                        DetailsLoadingState.Ready(result.getOrThrow())
+                    } else {
+                        DetailsLoadingState.NotFound
+                    }
+                    emit(state)
+                }
+                is String -> {
+                    emit(DetailsLoadingState.Ready(data))
+                }
+                is Uri, is URL -> {
+                    emit(DetailsLoadingState.Ready(data))
+                }
+                else -> {
+                    emit(DetailsLoadingState.Ready(data))
+                }
+            }
+        } catch (e: Exception) {
+            emit(DetailsLoadingState.Error(e))
         }
-        emit(state)
     }.stateIn(
         viewModelScope,
         SharingStarted.WhileSubscribed(5000L),
@@ -45,10 +65,10 @@ class DetailViewModel @Inject constructor(
 
     companion object {
 
-        @Suppress("unused", "UNUSED_PARAMETER")
+        @Suppress("unused")
         fun mockDetailViewModel(
             context: Context
-        ): DetailViewModel = DetailViewModel(MovieRepository(MovieAPI()))
+        ): DetailViewModel = DetailViewModel(MovieRepository(DAO(context)))
 
     }
 

+ 25 - 13
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt

@@ -16,40 +16,52 @@ import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.channelFlow
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.stateIn
-import org.mjdev.tvapp.api.MovieAPI
+import org.mjdev.tvapp.base.extensions.ListExt.asMap
 import org.mjdev.tvapp.base.network.NetworkConnectivityService
 import org.mjdev.tvapp.base.network.NetworkConnectivityServiceImpl
 import org.mjdev.tvapp.base.viewmodel.BaseViewModel
-import org.mjdev.tvapp.data.Category
 import org.mjdev.tvapp.data.Message
 import org.mjdev.tvapp.data.Movie
-import org.mjdev.tvapp.repository.IRepository
+import org.mjdev.tvapp.database.DAO
+import org.mjdev.tvapp.repository.IMovieRepository
 import org.mjdev.tvapp.repository.MovieRepository
 import javax.inject.Inject
 
 @HiltViewModel
 class MainViewModel @Inject constructor(
-    private val movieRepository: IRepository,
+    private val repository: IMovieRepository,
     var networkInfo: NetworkConnectivityService
 ) : BaseViewModel() {
 
-    val messages: StateFlow<List<Message>> = channelFlow<List<Message>> {
-        send(listOf())
-    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), listOf())
+    val messages: StateFlow<List<Message>> = channelFlow {
+        send(repository.getMessages().getOrThrow())
+    }.stateIn(
+        viewModelScope,
+        SharingStarted.WhileSubscribed(5000L),
+        listOf()
+    )
 
     val featuredMovieList: StateFlow<List<Movie>> = flow {
-        emit(movieRepository.getFeaturedMovieList())
-    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), listOf())
+        emit(repository.getMovies().getOrThrow().takeLast(32))
+    }.stateIn(
+        viewModelScope,
+        SharingStarted.WhileSubscribed(5000L),
+        listOf()
+    )
 
-    val categoryList: StateFlow<List<Category>> = flow {
-        emit(movieRepository.getCategoryList())
-    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), listOf())
+    val categoryList: StateFlow<Map<String, List<Movie>>> = flow {
+        emit(repository.getMovies().getOrThrow().asMap { m -> Pair(m.category, m) })
+    }.stateIn(
+        viewModelScope,
+        SharingStarted.WhileSubscribed(5000L),
+        mapOf()
+    )
 
     companion object {
 
         @Suppress("unused")
         fun mockMainViewModel(context: Context): MainViewModel = MainViewModel(
-            MovieRepository(MovieAPI()),
+            MovieRepository(DAO(context)),
             NetworkConnectivityServiceImpl(context)
         )
 

+ 45 - 0
app/src/main/res/values-cs/strings.xml

@@ -0,0 +1,45 @@
+<!--
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+-->
+
+<resources>
+
+    <string name="app_name">Tv App</string>
+
+    <string name="bt_dismiss">Zrušit</string>
+
+    <string name="error_no_network">Internet není k dispozici.</string>
+    <string name="error_unknown">Neznáma chyba.</string>
+
+    <string name="menu_title_search">Hledat</string>
+    <string name="menu_title_settings">Nastavení</string>
+    <string name="menu_title_exit">Ukončit</string>
+    <string name="menu_title_home">Domov</string>
+
+    <string name="text_app_description">\n\n\nSimple TV Application for AndroidTV or SetTopBox with Android or any tablet or mobile device.\nAll in one solution for mobile app responsive to device.\nThis app is just an simple demonstration of Jetpack Compose and all other modern architecture components.\n\n\n</string>
+    <string name="text_unimplemented_yet">Tato funkce zatím nebyla implementovaná.</string>
+
+    <string name="title_about">O aplikáci</string>
+    <string name="title_apps">Aplikace</string>
+    <string name="title_error">Chyba</string>
+    <string name="title_home">Domov</string>
+    <string name="title_movie_detail">Detail videa</string>
+    <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>
+
+
+</resources>

+ 45 - 0
app/src/main/res/values-sk/strings.xml

@@ -0,0 +1,45 @@
+<!--
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+-->
+
+<resources>
+
+    <string name="app_name">Tv App</string>
+
+    <string name="bt_dismiss">Zrušiť</string>
+
+    <string name="error_no_network">Internet nie je pripojený.</string>
+    <string name="error_unknown">Neznáma chyba.</string>
+
+    <string name="menu_title_search">Hľadať</string>
+    <string name="menu_title_settings">Nastavenia</string>
+    <string name="menu_title_exit">Ukončiť</string>
+    <string name="menu_title_home">Domov</string>
+
+    <string name="text_app_description">\n\n\nSimple TV Application for AndroidTV or SetTopBox with Android or any tablet or mobile device.\nAll in one solution for mobile app responsive to device.\nThis app is just an simple demonstration of Jetpack Compose and all other modern architecture components.\n\n\n</string>
+    <string name="text_unimplemented_yet">Táto funkcia žiaľ ešte nie je implementovaná.</string>
+
+    <string name="title_about">O aplikácií</string>
+    <string name="title_apps">Aplikácie</string>
+    <string name="title_error">Chyba</string>
+    <string name="title_home">Domov</string>
+    <string name="title_movie_detail">Detail videa</string>
+    <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>
+
+
+</resources>

+ 8 - 2
app/src/main/res/values/strings.xml

@@ -26,14 +26,20 @@
     <string name="text_unimplemented_yet">This feature is not yet implemented.</string>
 
     <string name="title_about">About</string>
+    <string name="title_apps">Applications</string>
     <string name="title_error">Error</string>
     <string name="title_home">Home</string>
     <string name="title_movie_detail">Movie detail</string>
     <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="title_plugins">Plugins</string>
-    <string name="no_items">no items</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>
+
 
 </resources>

+ 6 - 0
app/src/main/res/xml/authenticator.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
+    android:accountType="@string/sync_auth"
+    android:icon="@mipmap/ic_launcher"
+    android:label="@string/app_name"
+    android:smallIcon="@mipmap/ic_launcher" />

+ 7 - 0
app/src/main/res/xml/backup_rules.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<full-backup-content>
+    <!--
+   <include domain="sharedpref" path="."/>
+   <exclude domain="sharedpref" path="device.xml"/>
+-->
+</full-backup-content>

+ 15 - 0
app/src/main/res/xml/data_extraction_rules.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<data-extraction-rules>
+    <cloud-backup>
+        <!-- TODO: Use <include> and <exclude> to control what is backed up.
+        <include .../>
+        <exclude .../>
+        -->
+    </cloud-backup>
+    <!--
+    <device-transfer>
+        <include .../>
+        <exclude .../>
+    </device-transfer>
+    -->
+</data-extraction-rules>

+ 8 - 0
app/src/main/res/xml/sync_adapter.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
+    android:accountType="@string/sync_auth"
+    android:allowParallelSyncs="false"
+    android:contentAuthority="@string/sync_auth"
+    android:isAlwaysSyncable="true"
+    android:supportsUploading="false"
+    android:userVisible="true" />

+ 9 - 0
build.gradle.kts

@@ -11,4 +11,13 @@ plugins {
     id("com.android.library") version "8.0.2" apply false
     kotlin("android") version "1.7.20" apply false
     id("com.google.dagger.hilt.android") version "2.44" apply false
+}
+
+buildscript {
+    repositories {
+        mavenCentral()
+    }
+    dependencies {
+        classpath("io.objectbox:objectbox-gradle-plugin:3.6.0")
+    }
 }

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff