瀏覽代碼

initial commit

Milan Jurkulak 3 年之前
當前提交
64ac56e366
共有 100 個文件被更改,包括 5698 次插入 和 0 次删除
  1. 78 0
      README.md
  2. 1 0
      app/.gitignore
  3. 191 0
      app/build.gradle.kts
  4. 21 0
      app/proguard-rules.pro
  5. 51 0
      app/src/main/AndroidManifest.xml
  6. 29 0
      app/src/main/kotlin/org/mjdev/tvapp/activity/MainActivity.kt
  7. 48 0
      app/src/main/kotlin/org/mjdev/tvapp/api/MovieAPI.kt
  8. 15 0
      app/src/main/kotlin/org/mjdev/tvapp/app/Application.kt
  9. 184 0
      app/src/main/kotlin/org/mjdev/tvapp/base/activity/ComposableActivity.kt
  10. 20 0
      app/src/main/kotlin/org/mjdev/tvapp/base/annotations/TvPreview.kt
  11. 45 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/ComposeExt.kt
  12. 64 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/ContextExt.kt
  13. 23 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/DrawableExt.kt
  14. 124 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/HiltExt.kt
  15. 40 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/ModifierExt.kt
  16. 56 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/NavExt.kt
  17. 15 0
      app/src/main/kotlin/org/mjdev/tvapp/base/extensions/StringExt.kt
  18. 25 0
      app/src/main/kotlin/org/mjdev/tvapp/base/helpers/Option.kt
  19. 41 0
      app/src/main/kotlin/org/mjdev/tvapp/base/helpers/StateContextWrapper.kt
  20. 13 0
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithImage.kt
  21. 13 0
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithTitle.kt
  22. 17 0
      app/src/main/kotlin/org/mjdev/tvapp/base/interfaces/ItemWithVideoUri.kt
  23. 35 0
      app/src/main/kotlin/org/mjdev/tvapp/base/media/IMediaPlayer.kt
  24. 63 0
      app/src/main/kotlin/org/mjdev/tvapp/base/media/MediaPlayerContainer.kt
  25. 39 0
      app/src/main/kotlin/org/mjdev/tvapp/base/media/MediaPlayerContainerDefaults.kt
  26. 91 0
      app/src/main/kotlin/org/mjdev/tvapp/base/media/MediaPlayerImpl.kt
  27. 79 0
      app/src/main/kotlin/org/mjdev/tvapp/base/media/MediaPlayerState.kt
  28. 49 0
      app/src/main/kotlin/org/mjdev/tvapp/base/media/NativePlayerImpl.kt
  29. 85 0
      app/src/main/kotlin/org/mjdev/tvapp/base/media/SimpleMediaPlayerView.kt
  30. 28 0
      app/src/main/kotlin/org/mjdev/tvapp/base/navigation/MenuItem.kt
  31. 46 0
      app/src/main/kotlin/org/mjdev/tvapp/base/navigation/NavGraphBuilderEx.kt
  32. 44 0
      app/src/main/kotlin/org/mjdev/tvapp/base/navigation/NavHostEx.kt
  33. 241 0
      app/src/main/kotlin/org/mjdev/tvapp/base/navigation/Screen.kt
  34. 30 0
      app/src/main/kotlin/org/mjdev/tvapp/base/network/ConectivityCallback.kt
  35. 17 0
      app/src/main/kotlin/org/mjdev/tvapp/base/network/NetworkConnectivityService.kt
  36. 44 0
      app/src/main/kotlin/org/mjdev/tvapp/base/network/NetworkConnectivityServiceImpl.kt
  37. 21 0
      app/src/main/kotlin/org/mjdev/tvapp/base/network/NetworkStatus.kt
  38. 104 0
      app/src/main/kotlin/org/mjdev/tvapp/base/state/ActivityViewState.kt
  39. 59 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/button/Button.kt
  40. 58 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/Badge.kt
  41. 100 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/BigCarousel.kt
  42. 133 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/Clock.kt
  43. 157 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/ErrorMessage.kt
  44. 104 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/FocusableBox.kt
  45. 157 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/Header.kt
  46. 83 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/ListWithPreview.kt
  47. 70 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/ScreenView.kt
  48. 61 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/ScrollableTvLayRow.kt
  49. 124 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/Tabs.kt
  50. 78 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/Title.kt
  51. 53 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/TouchBox.kt
  52. 51 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/UserPic.kt
  53. 127 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/icon/IconAny.kt
  54. 73 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/image/CircleImage.kt
  55. 157 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/image/ImageAny.kt
  56. 159 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/navigation/Navigation.kt
  57. 98 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/navigation/NavigationRow.kt
  58. 85 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/text/CircleText.kt
  59. 66 0
      app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/text/TextAny.kt
  60. 47 0
      app/src/main/kotlin/org/mjdev/tvapp/base/viewmodel/BaseViewModel.kt
  61. 17 0
      app/src/main/kotlin/org/mjdev/tvapp/data/Category.kt
  62. 17 0
      app/src/main/kotlin/org/mjdev/tvapp/data/Message.kt
  63. 45 0
      app/src/main/kotlin/org/mjdev/tvapp/data/Movie.kt
  64. 107 0
      app/src/main/kotlin/org/mjdev/tvapp/mock/MovieList.kt
  65. 105 0
      app/src/main/kotlin/org/mjdev/tvapp/module/ProvideModule.kt
  66. 22 0
      app/src/main/kotlin/org/mjdev/tvapp/repository/IRepository.kt
  67. 120 0
      app/src/main/kotlin/org/mjdev/tvapp/repository/MovieRepository.kt
  68. 21 0
      app/src/main/kotlin/org/mjdev/tvapp/state/DetailsLoadingState.kt
  69. 81 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/components/Details.kt
  70. 34 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/components/Loading.kt
  71. 38 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/components/MediaPlayer.kt
  72. 96 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/components/MovieCard.kt
  73. 95 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/components/MyComposeView.kt
  74. 44 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/AboutScreen.kt
  75. 84 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/DetailScreen.kt
  76. 172 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/MainScreen.kt
  77. 90 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/PlayerScreen.kt
  78. 15 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/SplashScreen.kt
  79. 44 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/screens/SubscriptionScreen.kt
  80. 36 0
      app/src/main/kotlin/org/mjdev/tvapp/ui/theme/Theme.kt
  81. 52 0
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/DetailViewModel.kt
  82. 58 0
      app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt
  83. 二進制
      app/src/main/res/drawable/milanj.png
  84. 二進制
      app/src/main/res/drawable/person.png
  85. 二進制
      app/src/main/res/drawable/placeholder.png
  86. 二進制
      app/src/main/res/mipmap-hdpi/ic_launcher.webp
  87. 二進制
      app/src/main/res/mipmap-mdpi/ic_launcher.webp
  88. 二進制
      app/src/main/res/mipmap-xhdpi/ic_launcher.webp
  89. 二進制
      app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
  90. 二進制
      app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
  91. 15 0
      app/src/main/res/values/colors.xml
  92. 24 0
      app/src/main/res/values/strings.xml
  93. 22 0
      app/src/main/res/values/themes.xml
  94. 14 0
      build.gradle.kts
  95. 二進制
      buildSrc/.gradle/8.0/checksums/checksums.lock
  96. 二進制
      buildSrc/.gradle/8.0/checksums/md5-checksums.bin
  97. 二進制
      buildSrc/.gradle/8.0/checksums/sha1-checksums.bin
  98. 二進制
      buildSrc/.gradle/8.0/dependencies-accessors/dependencies-accessors.lock
  99. 0 0
      buildSrc/.gradle/8.0/dependencies-accessors/gc.properties
  100. 二進制
      buildSrc/.gradle/8.0/executionHistory/executionHistory.bin

+ 78 - 0
README.md

@@ -0,0 +1,78 @@
+# Android TV test app
+
+[![Build & Publish Release APK](https://github.com/mimoccc/tvapp/actions/workflows/build-gradle-project.yml/badge.svg)](https://github.com/mimoccc/tvapp/actions/workflows/build-gradle-project.yml)
+[![GitHub all releases](https://img.shields.io/github/downloads/mimoccc/tvapp/total)](https://github.com/mimoccc/tvapp/releases)
+
+## Stats
+
+[![Last commit](https://img.shields.io/github/last-commit/mimoccc/tvapp?color=FFC877)](#)
+[![Lang](https://img.shields.io/github/languages/top/mimoccc/tvapp?color=FFC877)](#)
+[![Platform](https://img.shields.io/badge/Platform-Android-FFC877.svg)](#)
+[![Min sdk](https://img.shields.io/badge/minSdkVersion-21-FFC877.svg)](#)
+[![Languages](https://img.shields.io/github/languages/count/mimoccc/tvapp?color=FFC877)](#)
+[![Issues](https://img.shields.io/github/issues-raw/mimoccc/tvapp?color=FFC877)](#)
+[![PR's](https://img.shields.io/badge/PRs-welcome-FFC877.svg)](#)
+[![Code size](https://img.shields.io/github/languages/code-size/mimoccc/tvapp?color=FFC877)](#)
+[![Total lines](https://img.shields.io/tokei/lines/github/mimoccc/tvapp?color=FFC877)](#)
+[![Dependencies](https://img.shields.io/librariesio/github/mimoccc/tvapp?color=FFC877)](#)
+
+## Project documentation
+
+[App code documentation](./wiki/documentation/index.md)
+
+## Description
+
+Just an test app to skills showcase of coding skills, using
+
+- Compose
+- Dagger/Hilt
+- ViewModels MVVM pattern
+- Glide
+- Kotlin
+- LifeCycle
+- Solve problems with compose previews
+- Coroutines
+- Flow
+- Paging
+- Navigation
+- Retrofit
+- Json serialization (Moshi)
+- Gradle scripting / configuration
+- MarkDown editing
+- Github actions
+- Shell scripting
+- Yaml scripting
+- CI/CD
+- SyncAdapter
+
+## Table of Contents (Optional)
+
+- [Compilation](#compilation)
+- [Usage](#usage)
+- [Credits](#credits)
+- [License](#license)
+
+## Compilation
+
+Use Android studio or use command:
+
+- debug app version :
+  ./gradlew assembleDebug
+- mocked app version :
+  ./gradlew assembleMock
+- minified debuggable app version :
+  ./gradlew assembleMinified
+- release app version :
+  ./gradlew assembleRelease
+
+## Usage
+
+Use as you want.
+
+## Credits
+
+Milan Jurkulak
+
+## License
+
+No licenced.

+ 1 - 0
app/.gitignore

@@ -0,0 +1 @@
+/build

+ 191 - 0
app/build.gradle.kts

@@ -0,0 +1,191 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+@file:Suppress("UnstableApiUsage")
+
+import org.mjdev.gradle.extensions.implementation
+import org.mjdev.gradle.plugin.MainAppPlugin.Companion.javaVersion
+import org.mjdev.gradle.plugin.MainAppPlugin.Companion.kotlinCompilerExtVersion
+import org.mjdev.gradle.plugin.MainAppPlugin.Companion.loadKeyStoreProperties
+import org.mjdev.gradle.plugin.MainAppPlugin.Companion.versionCode
+import org.mjdev.gradle.plugin.MainAppPlugin.Companion.versionName
+
+@Suppress("PropertyName")
+val CONFIG_KEYSTORE_PROPERTIES_FILE = "config/keystore.properties"
+
+@Suppress("PropertyName")
+val SIGNING_CONFIG_NAME = "Any"
+
+plugins {
+    id("com.android.application")
+    kotlin("android")
+    kotlin("kapt")
+    id("kotlin-parcelize")
+    id("com.google.dagger.hilt.android")
+    id("dagger.hilt.android.plugin")
+    id("org.jetbrains.dokka") version "1.8.10"
+    id("MainAppPlugin")
+}
+
+android {
+    namespace = "org.mjdev.tvapp"
+    compileSdk = 33
+
+    signingConfigs {
+        loadKeyStoreProperties(
+            CONFIG_KEYSTORE_PROPERTIES_FILE
+        ) { aliasKey, passwordKey, fileStore, passwordStore ->
+            create(SIGNING_CONFIG_NAME) {
+                keyAlias = aliasKey
+                keyPassword = passwordKey
+                storeFile = fileStore
+                storePassword = passwordStore
+            }
+        }
+    }
+
+    defaultConfig {
+        applicationId = "org.mjdev.tvapp.tvcomposeintroduction"
+
+        minSdk = 21
+        targetSdk = 33
+
+        versionCode = project.versionCode
+        versionName = project.versionName
+
+        signingConfig = signingConfigs[SIGNING_CONFIG_NAME]
+    }
+
+    buildTypes {
+
+        debug {
+            applicationIdSuffix = ".debug"
+            isDebuggable = true
+            isMinifyEnabled = false
+
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+
+        release {
+            applicationIdSuffix = ""
+            isDebuggable = true
+            isMinifyEnabled = false
+
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+
+    }
+
+    buildFeatures {
+        compose = true
+        buildConfig = true
+    }
+
+    compileOptions {
+        sourceCompatibility = javaVersion
+        targetCompatibility = javaVersion
+    }
+
+    kotlinOptions {
+        jvmTarget = javaVersion.toString()
+    }
+
+    composeOptions {
+        kotlinCompilerExtensionVersion = kotlinCompilerExtVersion
+    }
+
+    packaging {
+        resources.excludes.add("/META-INF/{AL2.0,LGPL2.1}")
+    }
+
+    lint {
+        checkReleaseBuilds = false
+    }
+
+    tasks.dokkaGfm {
+        outputDirectory.set(File(projectDir, "../wiki/documentation"))
+    }
+
+}
+
+dependencies {
+
+    // base libs
+    implementation("androidx.core:core-ktx:1.11.0-beta02")
+    // reflect / debug purposes only
+    implementation(kotlin("reflect", "1.8.21"))
+    // compose base libs
+    implementation(
+        platform("androidx.compose:compose-bom:2023.06.00")
+    )
+    implementation("androidx.compose.ui:ui-tooling")
+    implementation("androidx.activity:activity-compose:1.7.2")
+//    implementation( "com.google.android.material:material:1.9.0")
+//    implementation( "androidx.compose.ui:ui:1.7.2")
+//    implementation( "androidx.compose.material:material:1.7.2")
+    // tv compose
+    implementation("androidx.tv:tv-foundation:1.0.0-alpha07")
+    implementation("androidx.tv:tv-material:1.0.0-alpha07")
+    // view model
+    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1")
+    // navigation
+    implementation("androidx.navigation:navigation-compose:2.6.0")
+    // async image loading
+    implementation("io.coil-kt:coil-compose:2.4.0")
+    // constraint layout
+    implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
+    // debug
+    implementation("com.jakewharton.timber:timber:5.0.1")
+    // dagger core
+    implementation("com.google.dagger:dagger:2.46.1")
+    kapt("com.google.dagger:dagger-compiler:2.46.1")
+    // dagger android
+    implementation("com.google.dagger:dagger-android:2.46.1")
+    implementation("com.google.dagger:dagger-android-support:2.46.1")
+    kapt("com.google.dagger:dagger-android-processor:2.46.1")
+    // dagger - hilt
+    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")
+    // exoplayer
+    implementation("androidx.media3:media3-exoplayer:1.0.2")
+    implementation("androidx.media3:media3-exoplayer-dash:1.0.2")
+    implementation("androidx.media3:media3-exoplayer-hls:1.0.2")
+    implementation("androidx.media3:media3-exoplayer-rtsp:1.0.2")
+    implementation("androidx.media3:media3-exoplayer-ima:1.0.2")
+    implementation("androidx.media3:media3-datasource-cronet:1.0.2")
+    implementation("androidx.media3:media3-datasource-okhttp:1.0.2")
+    implementation("androidx.media3:media3-datasource-rtmp:1.0.2")
+    implementation("androidx.media3:media3-ui:1.0.2")
+    implementation("androidx.media3:media3-ui-leanback:1.0.2")
+    implementation("androidx.media3:media3-session:1.0.2")
+    implementation("androidx.media3:media3-extractor:1.0.2")
+    implementation("androidx.media3:media3-cast:1.0.2")
+    implementation("androidx.media3:media3-exoplayer-workmanager:1.0.2")
+    implementation("androidx.media3:media3-transformer:1.0.2")
+    implementation("androidx.media3:media3-database:1.0.2")
+    implementation("androidx.media3:media3-decoder:1.0.2")
+    implementation("androidx.media3:media3-datasource:1.0.2")
+    implementation("androidx.media3:media3-common:1.0.2")
+
+    constraints {
+        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.7.0")?.apply {
+            because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
+        }
+        implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.0")?.apply {
+            because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
+        }
+    }
+
+}

+ 21 - 0
app/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.kts.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

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

@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+-->
+
+<manifest xmlns:tools="http://schemas.android.com/tools"
+    xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+
+    <uses-feature
+        android:name="android.hardware.touchscreen"
+        android:required="false" />
+
+    <uses-feature
+        android:name="android.software.leanback"
+        android:required="false" />
+
+    <application
+        android:name=".app.Application"
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.TvApp"
+        android:usesCleartextTraffic="true"
+        android:banner= "@mipmap/ic_launcher"
+        tools:ignore="UnusedAttribute">
+
+        <activity
+            android:name=".activity.MainActivity"
+            android:exported="true">
+
+            <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>
+
+    </application>
+
+</manifest>

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

@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.activity
+
+import dagger.hilt.android.AndroidEntryPoint
+import org.mjdev.tvapp.base.activity.ComposableActivity
+import org.mjdev.tvapp.base.navigation.Screen.Companion.screen
+import org.mjdev.tvapp.ui.screens.AboutScreen
+import org.mjdev.tvapp.ui.screens.DetailScreen
+import org.mjdev.tvapp.ui.screens.MainScreen
+import org.mjdev.tvapp.ui.screens.PlayerScreen
+import org.mjdev.tvapp.ui.screens.SubscriptionScreen
+
+@AndroidEntryPoint
+class MainActivity : ComposableActivity ({
+
+    screen(route = MainScreen(), isHomeScreen = true)
+    screen(route = DetailScreen())
+    screen(route = PlayerScreen())
+    screen(route = SubscriptionScreen())
+    screen(route = AboutScreen())
+
+})

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

@@ -0,0 +1,48 @@
+/*
+ * 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.Option
+import org.mjdev.tvapp.base.helpers.Option.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(): Option<List<Category>> {
+        return Option.Some(MovieList.categories.map { name ->
+            Category(
+                name = name,
+                movieList = getMovieListByCategory(name).unwrapOr(emptyList())
+            )
+        })
+    }
+
+    suspend fun loadFeaturedMovieList(): Option<List<Movie>> {
+        return Option.Some(MovieList.featured)
+    }
+
+    suspend fun getMovieListByCategory(
+        category: String
+    ): Option<List<Movie>> {
+        return Option.Some(MovieList.getByCategory(category))
+    }
+
+    suspend fun findMovieById(
+        id: Long
+    ): Option<Movie> {
+        return MovieList.findById(id).let { movie ->
+            if (movie == null) Option.None
+            else Option.Some(movie)
+        }
+    }
+
+}

+ 15 - 0
app/src/main/kotlin/org/mjdev/tvapp/app/Application.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.app
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class Application : Application()

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

@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.activity
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.core.view.WindowCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.navigation.compose.rememberNavController
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.NonInteractiveSurfaceDefaults
+import androidx.tv.material3.Surface
+import org.mjdev.tvapp.base.extensions.ContextExt.isATv
+import org.mjdev.tvapp.base.navigation.NavGraphBuilderEx
+import org.mjdev.tvapp.base.navigation.NavHostEx
+import org.mjdev.tvapp.ui.theme.TVAppTheme
+import timber.log.Timber
+
+/**
+ * Composable activity.
+ *
+ * Activity that holds all the logic for navigation, to simplify it.
+ *
+ * @constructor Create [ComposableActivity]
+ * @property navGraphBuilder
+ */
+@Suppress("MemberVisibilityCanBePrivate", "unused")
+abstract class ComposableActivity(
+    val navGraphBuilder: NavGraphBuilderEx.() -> Unit
+) : ComponentActivity() {
+
+    /**
+     * Custom activity result listeners
+     */
+    val activityResultListeners = mutableListOf<ActivityResultHandler<*>>()
+
+    /**
+     * {@inheritDoc}
+     */
+    @OptIn(ExperimentalTvMaterial3Api::class)
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        WindowCompat.setDecorFitsSystemWindows(window, !isATv)
+
+        setContent {
+
+            TVAppTheme {
+
+                Surface(
+                    modifier = Modifier.fillMaxSize(),
+                    colors = NonInteractiveSurfaceDefaults.colors(
+                        containerColor = MaterialTheme.colorScheme.background
+                    )
+                ) {
+
+                    val navController = rememberNavController()
+
+                    NavHostEx(
+                        modifier = Modifier.fillMaxSize(),
+                        navController = navController,
+                        builder = navGraphBuilder
+                    )
+
+                }
+
+            }
+
+        }
+
+    }
+
+    /**
+     * On activity result handler.
+     *
+     * @param requestCode Request code
+     * @param resultCode Result code
+     * @param intent Intent
+     */
+    @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
+    override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
+        super.onActivityResult(requestCode, resultCode, intent)
+        activityResultListeners.forEach { listener ->
+            try {
+                listener.postResult(requestCode, resultCode, intent)
+            } catch (e: Throwable) {
+                Timber.e(e)
+            }
+        }
+    }
+
+    /**
+     * Register for activity result.
+     *
+     * Custom handler registration.
+     *
+     * @param onLaunch On launch method, to start custom activity.
+     * @param onActivityResult On activity result handler.
+     * @param T T result type
+     * @return [ActivityResultHandler] handler generated
+     */
+    fun <T> registerForActivityResult(
+        onLaunch: (args: List<T>) -> Unit,
+        onActivityResult: (requestCode: Int, resultCode: Int, intent: Intent?) -> Unit
+    ): ActivityResultHandler<T> = ActivityResultHandler(
+        lifecycle,
+        onLaunch,
+        onActivityResult
+    )
+
+    /**
+     * Activity result handler.
+     *
+     * @param I
+     * @constructor Create [ActivityResultHandler]
+     * @property lifecycle lifecycle handler
+     * @property onLaunch function to start activity
+     * @property onActivityResult result handler
+     */
+    @Suppress("unused")
+    inner class ActivityResultHandler<I>(
+        val lifecycle: Lifecycle,
+        val onLaunch: (args: List<I>) -> Unit,
+        val onActivityResult: (requestCode: Int, resultCode: Int, intent: Intent?) -> Unit,
+    ) {
+        val observer = LifecycleEventObserver { _, event ->
+            when (event) {
+                Lifecycle.Event.ON_DESTROY -> {
+                    unregister()
+                }
+
+                else -> {}
+            }
+        }
+
+        init {
+            lifecycle.addObserver(observer)
+            activityResultListeners.add(this)
+        }
+
+        /**
+         * Function to register result handler.
+         * It is called automatically upon lifecycle state.
+         * */
+        fun unregister() {
+            lifecycle.removeObserver(observer)
+            activityResultListeners.remove(this)
+        }
+
+        /**
+         * Post result to handler.
+         *
+         * @param requestCode Request code
+         * @param resultCode Result code
+         * @param intent Intent result data
+         */
+        fun postResult(requestCode: Int, resultCode: Int, intent: Intent?) {
+            onActivityResult(requestCode, resultCode, intent)
+        }
+
+        /**
+         * Launch the onLaunch handler.
+         *
+         * @param args Args
+         */
+        fun launch(vararg args: I) {
+            onLaunch(args.toList())
+        }
+
+    }
+
+}

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

@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.annotations
+
+import android.content.res.Configuration.UI_MODE_NIGHT_NO
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+
+@Preview(
+    uiMode = UI_MODE_NIGHT_NO,
+    showBackground = true,
+    device= Devices.TV_720p
+)
+annotation class TvPreview

+ 45 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/ComposeExt.kt

@@ -0,0 +1,45 @@
+/*
+ * 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.extensions
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import org.mjdev.tvapp.BuildConfig
+
+@Suppress("MemberVisibilityCanBePrivate")
+object ComposeExt {
+
+    @Composable
+    fun isDebug() = BuildConfig.DEBUG
+
+    @Composable
+    fun isEditMode() = LocalInspectionMode.current
+
+    /**
+     * Helper function to return text from any object is given as input.
+     * Mainly: Int -> is represented as resource text id String -> String null
+     * -> empty string other -> toString() call result
+     *
+     * @param text Text
+     * @param T T type of result
+     * @return String generated from any resource given
+     */
+    @Composable
+    inline fun <reified T> textFrom(text: T?): String = when (text) {
+        null -> ""
+        is Unit -> ""
+        is Int -> LocalContext.current.getString(text)
+        is String -> text
+        else -> text.toString()
+    }
+
+}

+ 64 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/ContextExt.kt

@@ -0,0 +1,64 @@
+/*
+ * 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.app.UiModeManager
+import android.content.Context
+import android.content.res.Configuration
+import android.text.format.DateFormat
+import java.util.Date
+
+object ContextExt {
+
+//    @Suppress("DEPRECATION")
+//    @Dimension(unit = Dimension.DP)
+//    fun Context.pxToDp(@Dimension(unit = Dimension.PX) px: Int): Int {
+//        val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
+//        val displayMetrics = DisplayMetrics()
+//        windowManager.defaultDisplay.getRealMetrics(displayMetrics)
+//        return (px / displayMetrics.densityDpi.toFloat() * DisplayMetrics.DENSITY_DEFAULT)
+//            .roundToInt()
+//    }
+
+//    val Context.isPhone: Boolean
+//        get() = packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
+
+    val Context.isATv: Boolean
+        get() {
+            val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
+            return uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
+        }
+
+//    @Suppress("DEPRECATION")
+//    val Context.isTablet: Boolean
+//        get() {
+//            val outSize = Point()
+//            val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
+//            windowManager.defaultDisplay.getRealSize(outSize)
+//            outSize.x = pxToDp(outSize.x)
+//            outSize.y = pxToDp(outSize.y)
+//            val shorterSideDp: Int
+//            val longerSideDp: Int
+//            if (outSize.x > outSize.y) {
+//                shorterSideDp = outSize.y
+//                longerSideDp = outSize.x
+//            } else {
+//                shorterSideDp = outSize.x
+//                longerSideDp = outSize.y
+//            }
+//            return (shorterSideDp > 480) && (longerSideDp > 640) && (!isPhone) && (!isATv)
+//        }
+
+    val Context.timeAsString: String
+        get() = DateFormat.getTimeFormat(this).format(Date())
+
+    val Context.dateAsString: String
+        get() = DateFormat.getDateFormat(this).format(Date())
+
+}

+ 23 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/DrawableExt.kt

@@ -0,0 +1,23 @@
+/*
+ * 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.graphics.drawable.Drawable
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.core.graphics.drawable.toBitmap
+
+object DrawableExt {
+
+    fun Drawable.asImageBitmap(
+        width: Int = intrinsicWidth,
+        height: Int = intrinsicHeight
+    ): ImageBitmap = toBitmap(width, height).asImageBitmap()
+
+}

+ 124 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/HiltExt.kt

@@ -0,0 +1,124 @@
+/*
+ * 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.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.os.Bundle
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavBackStackEntry
+import androidx.savedstate.SavedStateRegistryOwner
+import org.mjdev.tvapp.base.extensions.ComposeExt.isEditMode
+import org.mjdev.tvapp.base.helpers.StateContextWrapper
+import org.mjdev.tvapp.base.viewmodel.BaseViewModel
+import kotlin.reflect.full.companionObjectInstance
+import kotlin.reflect.full.memberFunctions
+
+object HiltExt {
+
+    @Suppress("LocalVariableName")
+    @Composable
+    inline fun <reified VM : BaseViewModel> appViewModel(
+        viewModelStoreOwner: ViewModelStoreOwner? = null,
+        key: String? = null
+    ): VM {
+        return if (isEditMode())
+            createMockModel()
+        else {
+            val _viewModelStoreOwner = viewModelStoreOwner
+                ?: LocalViewModelStoreOwner.current!!
+            val factory = createHiltViewModelFactory(
+                _viewModelStoreOwner
+            )
+            viewModel(
+                viewModelStoreOwner = _viewModelStoreOwner,
+                key = key,
+                factory = factory
+            )
+        }
+    }
+
+    // todo
+    @Composable
+    inline fun <reified VM : BaseViewModel> createMockModel(): VM {
+        val context = StateContextWrapper(LocalContext.current)
+        return VM::class.memberFunctions.first { fn ->
+            fn.name == "mock"
+        }.call(VM::class.companionObjectInstance, context) as? VM
+            ?: throw (RuntimeException(
+                "ViewModel ${
+                    VM::class.simpleName
+                } does not contain MOCK companion function." +
+                    "Please define companion function fun MOCK(context:Context)in this view model."
+            ))
+    }
+
+    @Composable
+    @PublishedApi
+    internal fun createHiltViewModelFactory(
+        viewModelStoreOwner: ViewModelStoreOwner
+    ): ViewModelProvider.Factory? = if (viewModelStoreOwner is NavBackStackEntry) {
+        HiltViewModelFactory(
+            context = LocalContext.current,
+            navBackStackEntry = viewModelStoreOwner
+        )
+    } else {
+        null
+    }
+
+    @Suppress("FunctionName")
+    private fun HiltViewModelFactory(
+        context: Context,
+        navBackStackEntry: NavBackStackEntry
+    ): ViewModelProvider.Factory? {
+        try {
+            val activity = context.let {
+                var ctx = it
+                while (ctx is ContextWrapper) {
+                    if (ctx is Activity) {
+                        return@let ctx
+                    }
+                    ctx = ctx.baseContext
+                }
+                throw IllegalStateException(
+                    "Expected an activity context for creating a HiltViewModelFactory for a " +
+                        "NavBackStackEntry but instead found: $ctx"
+                )
+            }
+            return createInternal(
+                activity,
+                navBackStackEntry,
+                navBackStackEntry.arguments,
+                navBackStackEntry.defaultViewModelProviderFactory,
+            )
+        } catch (e: Throwable) {
+            return null
+        }
+    }
+
+    @Suppress("UNUSED_PARAMETER")
+    private fun createInternal(
+        activity: Activity,
+        owner: SavedStateRegistryOwner,
+        defaultArgs: Bundle?,
+        delegateFactory: ViewModelProvider.Factory
+    ): ViewModelProvider.Factory? {
+        return dagger.hilt.android.internal.lifecycle.HiltViewModelFactory.createInternal(
+            activity,
+            delegateFactory
+        )
+    }
+
+}

+ 40 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/ModifierExt.kt

@@ -0,0 +1,40 @@
+/*
+ * 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.foundation.gestures.detectTapGestures
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.pointer.pointerInput
+import timber.log.Timber
+
+object ModifierExt {
+
+    fun Modifier.touchable(
+        onTouch: () -> Unit = {}
+    ): Modifier = composed {
+        val focusRequester = remember { FocusRequester() }
+        this
+            .focusRequester(focusRequester)
+            .pointerInput(Unit) {
+                detectTapGestures {
+                    try {
+                        onTouch()
+                        focusRequester.requestFocus()
+                    } catch (e: Exception) {
+                        Timber.e(e)
+                    }
+                }
+            }
+    }
+
+}

+ 56 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/NavExt.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.extensions
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraph
+import androidx.navigation.NavHostController
+import androidx.navigation.NavigatorProvider
+import androidx.navigation.navigation
+import org.mjdev.tvapp.base.navigation.NavGraphBuilderEx
+
+object NavExt {
+
+    /**
+     * Create navigation from extra builder to simplify generation of routes.
+     * @see: [androidx.navigation.NavigatorProvider.navigation]
+     *
+     * @param navController Nav controller
+     * @param route Route
+     * @param builder Builder
+     * @return [NavGraph]
+     * @receiver [NavigatorProvider]
+     */
+    inline fun NavigatorProvider.navigation(
+        navController: NavHostController,
+        route: String? = null,
+        builder: NavGraphBuilderEx.() -> Unit
+    ): NavGraph = NavGraphBuilderEx(this, route, navController)
+        .apply(builder)
+        .build()
+
+    /**
+     * Create navigation graph from nav controller.
+     * Extended just with custom builder.
+     *
+     * @see: [androidx.navigation.NavController.createGraph]
+     *
+     * @param route Route
+     * @param navController Nav controller
+     * @param builder Builder
+     * @return [NavGraph]
+     * @receiver [NavController]
+     */
+    inline fun NavController.createGraph(
+        route: String? = null,
+        navController: NavHostController,
+        builder: NavGraphBuilderEx.() -> Unit
+    ): NavGraph = navigatorProvider.navigation(navController, route, builder)
+
+}

+ 15 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/extensions/StringExt.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.extensions
+
+object StringExt {
+
+    fun String.asException() = Exception(this)
+
+}

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

@@ -0,0 +1,25 @@
+/*
+ * 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 Option<out T> {
+
+    object None : Option<Nothing>()
+
+    data class Some<T>(val value: T) : Option<T>()
+
+    companion object {
+
+        fun <T> Option<T>.unwrapOr(defaultValue: T): T {
+            return (this as? Some)?.value ?: defaultValue
+        }
+
+    }
+
+}

+ 41 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/helpers/StateContextWrapper.kt

@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.helpers
+
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+
+class StateContextWrapper(
+    baseContext: Context
+) : ContextWrapper(baseContext) , LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
+
+    private val savedStateRegistryController by lazy {
+        SavedStateRegistryController.create(this)
+    }
+
+    override val lifecycle: Lifecycle by lazy {
+        LifecycleRegistry(this)
+    }
+
+    override val viewModelStore: ViewModelStore by lazy {
+        ViewModelStore()
+    }
+
+    override val savedStateRegistry: SavedStateRegistry
+        get() = savedStateRegistryController.savedStateRegistry
+
+}

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

@@ -0,0 +1,13 @@
+/*
+ * 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 ItemWithImage {
+    val backgroundImageUrl : String
+}

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

@@ -0,0 +1,13 @@
+/*
+ * 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 ItemWithTitle {
+    val title : String
+}

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

@@ -0,0 +1,17 @@
+/*
+ * 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 {
+
+    val videoUri : Uri
+
+}

+ 35 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/media/IMediaPlayer.kt

@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.media
+
+import android.net.Uri
+import androidx.compose.runtime.Composable
+
+interface IMediaPlayer {
+
+    @Composable
+    fun PlayerView()
+
+    fun setMediaUri(uri: Uri)
+
+    fun prepare()
+
+    fun play()
+
+    fun pause()
+
+    fun resume()
+
+    fun stop()
+
+    fun dispose()
+
+    fun seekTo(value: Long)
+
+}

+ 63 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/media/MediaPlayerContainer.kt

@@ -0,0 +1,63 @@
+/*
+ * 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.media
+
+import android.net.Uri
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import org.mjdev.tvapp.base.media.MediaPlayerState.Companion.rememberMediaPlayerState
+
+@Composable
+fun MediaPlayerContainer(
+    modifier: Modifier = Modifier,
+    uri: Uri? = Uri.EMPTY,
+    autoPlay: Boolean = true,
+    startSeek: Long = 0L,
+    mediaPlayer: IMediaPlayer = MediaPlayerContainerDefaults.exoPlayer,
+    background: @Composable (state: MediaPlayerState) -> Unit = {},
+    mediaPlayerOverlay: @Composable (state: MediaPlayerState) -> Unit = {},
+    mediaPlayerControls: @Composable (state: MediaPlayerState) -> Unit = {}
+) {
+
+    val state = rememberMediaPlayerState(uri ?: Uri.EMPTY, autoPlay, startSeek)
+
+    Box(modifier = modifier) {
+
+        background(state)
+        mediaPlayer.PlayerView()
+        mediaPlayerOverlay(state)
+        mediaPlayerControls(state)
+
+    }
+
+    DisposableEffect(state) {
+
+        if (state.hasMediaToPlay) {
+            mediaPlayer.setMediaUri(state.mediaUri.value)
+            mediaPlayer.prepare()
+            if (state.seek.value > 0L) {
+                mediaPlayer.seekTo(state.seek.value)
+            }
+            if (state.isAutoPlay) {
+                mediaPlayer.play()
+            }
+        }
+
+        onDispose {
+            mediaPlayer.stop()
+            mediaPlayer.dispose()
+        }
+
+    }
+
+}

+ 39 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/media/MediaPlayerContainerDefaults.kt

@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.media
+
+import android.media.MediaPlayer
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.media3.exoplayer.ExoPlayer
+
+@Suppress("unused", "MemberVisibilityCanBePrivate")
+object MediaPlayerContainerDefaults {
+
+    val engines
+        @Composable
+        get() = arrayOf(
+            exoPlayer, internalPlayer
+        )
+
+    val exoPlayer
+        @Composable
+        get() = run {
+            val context = LocalContext.current
+            remember { ExoPlayerImpl(ExoPlayer.Builder(context).build()) }
+        }
+
+    val internalPlayer
+        @Composable
+        get() = run {
+            remember { NativePlayerImpl(MediaPlayer()) }
+        }
+
+}

+ 91 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/media/MediaPlayerImpl.kt

@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.media
+
+import android.net.Uri
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.exoplayer.ExoPlayer
+import org.mjdev.tvapp.base.extensions.ComposeExt
+
+class ExoPlayerImpl(
+    private val exoPlayer: ExoPlayer
+) : IMediaPlayer {
+
+    @Composable
+    override fun PlayerView() {
+
+        val isEdit = ComposeExt.isEditMode()
+        val context = LocalContext.current
+
+        if (!isEdit) {
+
+            AndroidView(
+                modifier = Modifier
+                    .background(Color.Transparent, RectangleShape)
+                    .fillMaxSize(),
+                factory = {
+                    androidx.media3.ui.PlayerView(context).apply {
+                        this.player = exoPlayer
+                    }
+                }
+            )
+
+        } else {
+
+            Box(
+                modifier = Modifier
+                    .background(Color.Black, RectangleShape)
+                    .fillMaxSize()
+            )
+
+        }
+
+    }
+
+    override fun setMediaUri(uri: Uri) {
+        exoPlayer.setMediaItem(MediaItem.fromUri(uri))
+    }
+
+    override fun prepare() {
+        exoPlayer.prepare()
+    }
+
+    override fun play() {
+        exoPlayer.playWhenReady = true
+    }
+
+    override fun pause() {
+        exoPlayer.playWhenReady = false
+    }
+
+    override fun resume() {
+        exoPlayer.playWhenReady = true
+    }
+
+    override fun stop() {
+        exoPlayer.playWhenReady = false
+    }
+
+    override fun dispose() {
+    }
+
+    override fun seekTo(value: Long) {
+        exoPlayer.seekTo(value)
+    }
+
+}

+ 79 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/media/MediaPlayerState.kt

@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.media
+
+import android.net.Uri
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+
+@Suppress("unused", "MemberVisibilityCanBePrivate")
+class MediaPlayerState(
+    uri: Uri = Uri.EMPTY,
+    autoPlay: Boolean = true,
+    startSeek: Long = 0,
+) {
+
+    constructor(
+        uri: String? = null,
+        autoPlay: Boolean = true,
+        startSeek: Long = 0
+    ) : this(
+        if (uri === null) Uri.EMPTY else Uri.parse(uri),
+        autoPlay,
+        startSeek
+    )
+
+    val mediaUri: MutableState<Uri> = mutableStateOf(uri)
+    val isPlaying: MutableState<Boolean> = mutableStateOf(autoPlay)
+    val seek: MutableState<Long> = mutableLongStateOf(startSeek)
+
+    val hasMediaToPlay = mediaUri.value != Uri.EMPTY
+    val isAutoPlay = isPlaying.value
+
+    fun play() {
+        isPlaying.value = true
+    }
+
+    fun stop() {
+        isPlaying.value = false
+    }
+
+    fun pause() {
+        isPlaying.value = false
+    }
+
+    fun resume() {
+        isPlaying.value = true
+    }
+
+    fun seekTo(ms: Long) {
+        seek.value = ms
+    }
+
+    companion object {
+
+        @Composable
+        fun rememberMediaPlayerState(
+            uri: Uri = Uri.EMPTY,
+            autoPlay: Boolean = true,
+            startSeek: Long = 0
+        ) = remember {
+            MediaPlayerState(
+                uri,
+                autoPlay,
+                startSeek
+            )
+        }
+
+    }
+
+}

+ 49 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/media/NativePlayerImpl.kt

@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.media
+
+import android.media.MediaPlayer
+import android.net.Uri
+import androidx.compose.runtime.Composable
+
+// todo
+@Suppress("unused")
+class NativePlayerImpl(
+    val mediaPlayer: MediaPlayer
+) : IMediaPlayer {
+
+    @Composable
+    override fun PlayerView() {
+    }
+
+    override fun setMediaUri(uri: Uri) {
+    }
+
+    override fun prepare() {
+    }
+
+    override fun play() {
+    }
+
+    override fun pause() {
+    }
+
+    override fun resume() {
+    }
+
+    override fun stop() {
+    }
+
+    override fun dispose() {
+    }
+
+    override fun seekTo(value: Long) {
+    }
+
+}

+ 85 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/media/SimpleMediaPlayerView.kt

@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.media
+
+import android.net.Uri
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+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.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import org.mjdev.tvapp.base.extensions.ComposeExt
+
+@Composable
+@Deprecated("Deprecated, use MediaPlayerContainer instead.")
+fun SimpleMediaPlayerView(
+    modifier: Modifier = Modifier,
+    uri: Uri?,
+    autoPlay: Boolean = true,
+    background: @Composable (player: ExoPlayer) -> Unit = {},
+    mediaPlayerOverlay: @Composable (player: ExoPlayer) -> Unit = {},
+    mediaPlayerControls: @Composable (player: ExoPlayer) -> Unit = {}
+) {
+
+    val isEdit = ComposeExt.isEditMode()
+    val context = LocalContext.current
+
+    val player = remember { ExoPlayer.Builder(context).build() }
+
+    Box(modifier = modifier) {
+
+        background(player)
+
+        if (!isEdit) {
+            AndroidView(
+                modifier = Modifier
+                    .background(Color.Transparent, RectangleShape)
+                    .fillMaxSize(),
+                factory = {
+                    PlayerView(context).apply {
+                        this.player = player
+                    }
+                }
+            )
+        }
+
+        mediaPlayerOverlay(player)
+
+        mediaPlayerControls(player)
+
+    }
+
+    if (!isEdit) {
+
+        DisposableEffect(player) {
+
+            if (uri != null) {
+                player.playWhenReady = autoPlay
+                player.setMediaItem(MediaItem.fromUri(uri))
+                player.prepare()
+            }
+
+            onDispose {
+                player.playWhenReady = false
+            }
+
+        }
+
+    }
+
+}

+ 28 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/navigation/MenuItem.kt

@@ -0,0 +1,28 @@
+/*
+ * 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
+
+/**
+ * Menu item helper class.
+ *
+ * Normally you do not need to use this class, it is used to autogenerate menu items in
+ * navigation drawer of an application.
+ *
+ * @constructor Create [MenuItem]
+ * @property menuText
+ * @property menuIcon
+ * @property route
+ */
+data class MenuItem(
+
+    val menuText: Any?,
+    val menuIcon: Any?,
+    var route: String
+
+)

+ 46 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/navigation/NavGraphBuilderEx.kt

@@ -0,0 +1,46 @@
+/*
+ * 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 androidx.navigation.NavGraph
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.NavigatorProvider
+
+/**
+ * Custom nav graph builder.
+ *
+ * Just extended of [navHostController] for simplify navigation auto creation.
+ * @see : [androidx.navigation.NavGraphBuilder]
+ *
+ * @param provider
+ * @param route
+ * @constructor Create [NavGraphBuilderEx]
+ * @property navHostController
+ */
+class NavGraphBuilderEx(
+    provider: NavigatorProvider,
+    route: String?,
+    val navHostController: NavHostController
+) : NavGraphBuilder(provider, "none", route) {
+
+    var startDestinationRouteEx: String? = null
+
+    var menuItems: MutableList<MenuItem> = mutableListOf()
+
+    override fun build(): NavGraph {
+        val navGraph = super.build()
+        if (startDestinationRouteEx == null) {
+            throw(RuntimeException("One class in ComposeActivity should have start property set."))
+        }
+        navGraph.setStartDestination(startDestinationRouteEx!!)
+        return navGraph
+    }
+
+}

+ 44 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/navigation/NavHostEx.kt

@@ -0,0 +1,44 @@
+/*
+ * 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 androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavHostController
+import org.mjdev.tvapp.base.extensions.NavExt.createGraph
+
+/**
+ * Custom NavHost object to extend functionality and simplify
+ * navigation across application.
+ *
+ * @param modifier Modifier modifier as usual for composable
+ * @param route Route route expected
+ * @param navController Nav controller navigation controller
+ * @param builder Builder builder for routes
+ */
+@Composable
+fun NavHostEx(
+    modifier: Modifier = Modifier,
+    route: String? = null,
+    navController: NavHostController,
+    builder: NavGraphBuilderEx.() -> Unit
+) {
+    androidx.navigation.compose.NavHost(
+        navController,
+        remember(route, builder) {
+            navController.createGraph(
+                route,
+                navController,
+                builder
+            )
+        },
+        modifier
+    )
+}

+ 241 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/navigation/Screen.kt

@@ -0,0 +1,241 @@
+/*
+ * 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 androidx.annotation.CallSuper
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.composable
+import androidx.tv.material3.Text
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import kotlin.reflect.full.createInstance
+
+/**
+ * Screen class.
+ *
+ * This is base of all screens in application and auto creation of route and nav graph.
+ * This object represents one screen at time.
+ * Can contain menu that is exported to navigation drawer.
+ *
+ * @constructor Create empty constructor for screen
+ */
+@Suppress("unused")
+open class Screen {
+
+    /**
+     * Autogenerated path / route
+     */
+    private val routeBase: String
+        get() = (this::class.simpleName ?: "-") + "?"
+
+    /**
+     * Complete autogenerated path / route with parameters
+     */
+    val completeRoute: String
+        get() = routeBase.let { rb ->
+            var routeImpl = rb
+            args.forEach { arg ->
+                routeImpl = routeImpl.plus("${arg.name}={${arg.name}}")
+            }
+            routeImpl
+        }
+
+    /**
+     * Screen title
+     */
+    open val titleResId: Int = R.string.app_name
+
+    /**
+     * Screen arguments if any
+     */
+    open val args: List<NamedNavArgument> = emptyList()
+
+    /**
+     * Menu string resource id
+     * menu is generated only if it returns id > 0
+     */
+    open val menuTitleResId: Int = -1
+
+    /**
+     * Menu icon if any
+     */
+    open val menuIcon: ImageVector? = null
+
+    /**
+     * Autogenerated menu item, if any
+     * Generated only if [menuTitleResId] > 0
+     */
+    val menuItem
+        get() = if (menuTitleResId >= 0) MenuItem(menuTitleResId, menuIcon, completeRoute)
+        else null
+
+    /**
+     * Compose function.
+     *
+     * Helper function to preview without parameters as preview of compose needed.
+     *
+     * @return
+     */
+    @TvPreview
+    @Composable
+    @CallSuper
+    open fun Compose() {
+        Compose(null, null, emptyList(), mapOf())
+    }
+
+    /**
+     * Compose function for compose screen.
+     * This should be only a method overriden in super classes.
+     *
+     * @param navController Nav controller
+     * @param backStackEntry Back stack entry
+     * @param menuItems Menu items
+     */
+    @Composable
+    open fun Compose(
+        navController: NavHostController?,
+        backStackEntry: NavBackStackEntry?,
+        menuItems: List<MenuItem>,
+        args: Map<String, Any?>
+    ) {
+
+        Column(
+            modifier = Modifier.fillMaxSize(),
+            verticalArrangement = Arrangement.Center,
+            horizontalAlignment = Alignment.CenterHorizontally
+        ) {
+            Text(
+                text = "Empty Screen",
+                fontSize = 32.sp,
+                color = Color.White
+            )
+        }
+
+    }
+
+    companion object {
+
+        /**
+         * Helper function to get params from route.
+         *
+         * @param argId Arg id
+         * @param defaultValue Default value
+         * @return
+         * @receiver [NavBackStackEntry]
+         */
+        @Suppress("UNCHECKED_CAST", "DEPRECATION")
+        private fun <T> NavBackStackEntry.arg(
+            argId: String,
+            defaultValue: T
+        ): T = (arguments?.get(argId) as? T?) ?: defaultValue
+
+        /**
+         * Open screen extension.
+         *
+         * Function opens a screen with parameters given, if any,
+         * from another screen.
+         *
+         * @param navController Nav controller
+         * @param values Values
+         * @param T T
+         */
+        inline fun <reified T : Screen> open(
+            navController: NavHostController?,
+            vararg values: Any?
+        ) {
+            val instance = T::class.createInstance()
+            instance.completeRoute.let { r ->
+                var routeImpl = r
+                instance.args.forEachIndexed { idx, arg ->
+                    routeImpl = routeImpl.replace(
+                        "{${arg.name}}",
+                        (values[idx] ?: "").toString()
+                    )
+                }
+                routeImpl
+            }.also { finalRoute ->
+                val currentRoute = navController?.currentRoute
+                val equals = currentRoute?.equals(finalRoute)
+                if (equals != true) {
+                    navController?.navigate(finalRoute)
+                }
+            }
+        }
+
+        /**
+         * Current route nav host helper
+         *
+         * Function returns current route that is shown.
+         *
+         * @return current route on screen
+         */
+        val NavHostController.currentRoute
+            get() = currentDestination?.route
+
+        /**
+         * Open route.
+         *
+         * @param route Route
+         * @receiver [NavHostController]
+         */
+        fun NavHostController.open(route: String) {
+            val equals = currentRoute?.equals(route)
+            if (equals != true) {
+                navigate(route)
+            }
+        }
+
+        /**
+         * Custom function to generate screen in nav graph.
+         *
+         * @param route Route route expected
+         * @param isHomeScreen Is home screen, indicates that this screen will
+         * be used as home screen. Should be used once, or in case that is defined multiple times,
+         * the last one is used as home screen.
+         *
+         * @param T T Screen object generated for route.
+         * @receiver [NavGraphBuilderEx]
+         */
+        fun <T : Screen> NavGraphBuilderEx.screen(
+            route: T,
+            isHomeScreen: Boolean = false
+        ) {
+            if (isHomeScreen) {
+                startDestinationRouteEx = route.completeRoute
+            }
+            route.menuItem?.also { menuItem ->
+                menuItems.add(menuItem)
+            }
+            composable(
+                route = route.completeRoute,
+                arguments = route.args
+            ) { be ->
+                val rArgs = mutableMapOf<String, Any?>().apply {
+                    route.args.forEach { arg ->
+                        put(arg.name, be.arg(arg.name, null))
+                    }
+                }
+                route.Compose(navHostController, be, menuItems, rArgs)
+            }
+        }
+
+    }
+
+}

+ 30 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/network/ConectivityCallback.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.network
+
+import android.net.ConnectivityManager
+import android.net.Network
+
+class ConectivityCallback(
+    val trySend: (data: NetworkStatus) -> Unit
+) : ConnectivityManager.NetworkCallback() {
+
+    override fun onAvailable(network: Network) {
+        trySend(NetworkStatus.Connected(network))
+    }
+
+    override fun onUnavailable() {
+        trySend(NetworkStatus.Disconnected)
+    }
+
+    override fun onLost(network: Network) {
+        trySend(NetworkStatus.Disconnected)
+    }
+
+}

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

@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.network
+
+import kotlinx.coroutines.flow.Flow
+
+interface NetworkConnectivityService {
+
+    val networkStatus: Flow<NetworkStatus>
+
+}

+ 44 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/network/NetworkConnectivityServiceImpl.kt

@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+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.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
+
+class NetworkConnectivityServiceImpl constructor(
+    context: Context
+) : NetworkConnectivityService {
+
+    private val connectivityManager = context
+        .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+    override val networkStatus: Flow<NetworkStatus> = callbackFlow {
+        val connectivityCallback = ConectivityCallback { status ->
+            trySend(status)
+        }
+        val request = NetworkRequest.Builder()
+            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+            .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+            .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+            .build()
+        connectivityManager.registerNetworkCallback(request, connectivityCallback)
+        awaitClose {
+            connectivityManager.unregisterNetworkCallback(connectivityCallback)
+        }
+    }.distinctUntilChanged().flowOn(Dispatchers.IO)
+
+}

+ 21 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/network/NetworkStatus.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.network
+
+import android.net.Network
+
+sealed class NetworkStatus {
+
+    object Unknown: NetworkStatus()
+
+    class Connected(val network: Network): NetworkStatus()
+
+    object Disconnected: NetworkStatus()
+
+}

+ 104 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/state/ActivityViewState.kt

@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.state
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+
+/**
+ * Activity view state.
+ * Used to keep states and change in activity.
+ *
+ * @param title
+ * @param error
+ * @constructor Create [ActivityViewState]
+ */
+@Suppress( "unused")
+class ActivityViewState(
+    title: Any? = null,
+    error: Throwable? = null
+) {
+
+    val errorState: MutableState<Throwable?> = mutableStateOf(error)
+    val titleState: MutableState<Any?> = mutableStateOf(title)
+
+    /**
+     * Clear error.
+     * */
+    fun clearError() {
+        errorState.value = null
+    }
+
+    /**
+     * Set Error.
+     *
+     * @param message Message of an error
+     */
+    fun error(message: String) {
+        errorState.value = Exception(message)
+    }
+
+    /**
+     * Set Error.
+     *
+     * @param e E Throwable to set error for
+     */
+    fun error(e: Throwable?) {
+        errorState.value = Exception(e)
+    }
+
+    /**
+     * Set info message.
+     *
+     * @param message Message
+     */
+    fun info(message: String) {
+        errorState.value = Exception(message)
+    }
+
+    /**
+     * Set info from throwable.
+     *
+     * @param e E info throwable
+     */
+    fun info(e: Throwable) {
+        errorState.value = Exception(e)
+    }
+
+    /**
+     * Set title.
+     *
+     * @param title Title
+     */
+    fun setTitle(title: Any?) {
+        titleState.value = title
+    }
+
+    companion object {
+
+        /**
+         * Remember activity view state.
+         *
+         * Composable helper
+         *
+         * @param title Title
+         * @param error Error
+         * @return [ActivityViewState]
+         */
+        @Composable
+        fun rememberActivityViewState(
+            title: Any? = null,
+            error: Throwable? = null
+        ): ActivityViewState = remember { ActivityViewState(title, error) }
+
+    }
+
+}

+ 59 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/button/Button.kt

@@ -0,0 +1,59 @@
+/*
+ * 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.button
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.Button
+import androidx.tv.material3.ButtonDefaults
+import androidx.tv.material3.ButtonGlow
+import androidx.tv.material3.ButtonScale
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvapp.base.extensions.ModifierExt.touchable
+
+@Preview
+@Suppress("UNUSED_PARAMETER")
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun Button(
+    modifier: Modifier = Modifier,
+    fontSize: TextUnit = 24.sp,
+    fontWeight: FontWeight = FontWeight.Bold,
+    color: Color = Color.White,
+    containerColor: Color = Color.Transparent,
+    contentPadding: PaddingValues = PaddingValues(0.dp),
+    shape: Shape = RectangleShape,
+    glow: ButtonGlow = ButtonDefaults.glow(),
+    scale: ButtonScale = ButtonDefaults.scale(),
+    onClick: () -> Unit = {},
+    content: @Composable RowScope.() -> Unit = {}
+) {
+
+    Button(
+        modifier = modifier.touchable(),
+        glow = glow,
+        scale = scale,
+        onClick = onClick,
+        colors = ButtonDefaults.colors(containerColor = containerColor),
+        shape = ButtonDefaults.shape(shape),
+        contentPadding = contentPadding,
+        content = content
+    )
+
+}

+ 58 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/Badge.kt

@@ -0,0 +1,58 @@
+/*
+ * 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.complex
+
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import org.mjdev.tvapp.base.ui.components.text.CircleText
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Preview
+@Composable
+fun Badge(
+    modifier: Modifier = Modifier,
+    shape: Shape = CircleShape,
+    count: Int = 0,
+    backGroundColor: Color = MaterialTheme.colorScheme.secondary,
+    borderColor: Color = Color.White,
+    borderSize: Dp = 2.dp,
+    textColor: Color = Color.White,
+    onClick: () -> Unit = {}
+) {
+
+    FocusableBox(
+        modifier = modifier
+            .defaultMinSize(40.dp)
+            .clip(CircleShape)
+    ) {
+
+        CircleText(
+            modifier = modifier
+                    .defaultMinSize(40.dp)
+                    .clip(CircleShape),
+            backGroundColor = backGroundColor,
+            borderSize = borderSize,
+            borderColor = borderColor,
+            text = count.toString(),
+            textColor = textColor
+        )
+
+    }
+
+}

+ 100 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/BigCarousel.kt

@@ -0,0 +1,100 @@
+/*
+ * 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.complex
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+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.TvLazyListScope
+import androidx.tv.material3.Card
+import androidx.tv.material3.CardScale
+import androidx.tv.material3.Carousel
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.interfaces.ItemWithImage
+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
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@TvPreview
+@Composable
+fun <T> BigCarousel(
+    modifier: Modifier = Modifier,
+    items: List<T> = emptyList(),
+    height: Dp = 376.dp,
+    onItemSelected: (T?) -> Unit = {}
+) = FocusableBox(
+    modifier = modifier
+        .fillMaxWidth()
+        .height(height),
+    focusedColor = Color.Transparent
+) {
+
+    Carousel(
+        itemCount = items.size,
+        modifier = modifier
+            .fillMaxWidth()
+            .height(height),
+    ) { indexOfCarouselItem ->
+        val item = items[indexOfCarouselItem]
+
+        Card(
+            scale = CardScale.None,
+            modifier = modifier,
+            onClick = {
+                onItemSelected(item)
+            }
+        ) {
+
+            ImageAny(
+                src = if (item is ItemWithImage) item.backgroundImageUrl else null,
+                contentDescription = null,
+                placeholder = R.drawable.placeholder,
+                contentScale = ContentScale.Crop,
+                modifier = Modifier.fillMaxSize(),
+            )
+
+        }
+
+        TextAny(
+            text = if (item is ItemWithTitle) item.title else "",
+            style =
+            MaterialTheme.typography.headlineLarge,
+            modifier = Modifier
+                .padding(36.dp)
+                .background(MaterialTheme.colorScheme.background)
+        )
+
+    }
+
+}
+
+@Suppress("unused")
+fun <T> TvLazyListScope.bigCarousel(
+    items: List<T?> = emptyList(),
+    height: Dp = 280.dp,
+    onItemSelected: (T?) -> Unit = {}
+) = item {
+    BigCarousel(
+        items = items,
+        height = height,
+        onItemSelected = onItemSelected
+    )
+}

+ 133 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/Clock.kt

@@ -0,0 +1,133 @@
+/*
+ * 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.complex
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.launch
+import org.mjdev.tvapp.base.extensions.ContextExt.dateAsString
+import org.mjdev.tvapp.base.extensions.ContextExt.timeAsString
+import org.mjdev.tvapp.base.ui.components.text.TextAny
+
+@Preview
+@Composable
+fun Clock(
+    modifier: Modifier = Modifier,
+    timeTextSize: TextUnit = 24.sp,
+    timeTextWeight: FontWeight = FontWeight.Bold,
+    timeTextColor: Color = Color.White,
+    dateTextSize: TextUnit = 12.sp,
+    dateTextWeight: FontWeight = FontWeight.Bold,
+    dateTextColor: Color = Color.White,
+    horizontalAlignment: Alignment.Horizontal = Alignment.End,
+    backgroundColor: Color = Color.Transparent,
+    borderColor: Color = Color.White,
+    borderSize: Dp = 0.dp,
+    roundSize: Dp = 8.dp,
+    showTime: Boolean = true,
+    showDate: Boolean = true,
+    onClick: () -> Unit = {},
+) {
+
+    val context = LocalContext.current
+
+    val timeFlow = if (showTime) channelFlow {
+        launch {
+            var time = "00:00:00"
+            do {
+                context.timeAsString.also { t ->
+                    if (time != t) {
+                        time = t
+                        send(time)
+                    }
+                }
+                delay(200L)
+            } while (true)
+        }
+    }.collectAsState(initial = "00:00:00") else null
+
+    val dateFlow = if (showDate) channelFlow {
+        launch {
+            var date = "1.1.1970"
+            do {
+                context.dateAsString.also { t ->
+                    if (date != t) {
+                        date = t
+                        send(date)
+                    }
+                }
+                delay(5000L)
+            } while (true)
+        }
+    }.collectAsState(initial = "1.1.1970") else null
+
+    FocusableBox(
+        modifier = modifier.padding(2.dp),
+        shape = RoundedCornerShape(roundSize),
+        onClick = onClick
+    ) {
+
+        Column(
+            horizontalAlignment = horizontalAlignment,
+        ) {
+
+            if (showTime) TextAny(
+                modifier = modifier
+                    .wrapContentSize()
+                    .apply {
+                        if (borderSize > 0.dp) {
+                            border(borderSize, borderColor)
+                        }
+                    }
+                    .background(backgroundColor, RoundedCornerShape(roundSize)),
+                text = timeFlow?.value,
+                fontWeight = timeTextWeight,
+                fontSize = timeTextSize,
+                color = timeTextColor
+            )
+
+            if (showDate) TextAny(
+                modifier = modifier
+                    .apply {
+                        if (borderSize > 0.dp) {
+                            border(borderSize, borderColor)
+                        }
+                    }
+                    .background(backgroundColor, RoundedCornerShape(roundSize)),
+                text = dateFlow?.value,
+                textAlign = TextAlign.End,
+                fontWeight = dateTextWeight,
+                fontSize = dateTextSize,
+                color = dateTextColor
+            )
+
+        }
+
+    }
+
+}

+ 157 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/ErrorMessage.kt

@@ -0,0 +1,157 @@
+/*
+ * 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.complex
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.takeOrElse
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.tv.foundation.lazy.list.TvLazyListScope
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.LocalContentColor
+import androidx.tv.material3.LocalTextStyle
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.ui.components.button.Button
+import org.mjdev.tvapp.base.ui.components.text.TextAny
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Preview
+@Composable
+fun ErrorMessage(
+    error: Throwable? = null,
+    title: Any? = null,
+    fontSizeTitle: TextUnit = 24.sp,
+    fontSizeMessage: TextUnit = 14.sp,
+    fontWeight: FontWeight = FontWeight.Bold,
+    color: Color = Color.White,
+    paddingTitle: PaddingValues = PaddingValues(4.dp),
+    paddingMessage: PaddingValues = PaddingValues(4.dp),
+    backgroundColor: Color = Color.Red,
+    roundSize: Dp = 8.dp,
+    borderSize: Dp = 1.dp,
+    borderColor: Color = Color.White,
+    cancelText: Any? = stringResource(id = R.string.dismiss),
+    onDismiss: () -> Unit = {}
+) {
+    ConstraintLayout(
+        modifier = Modifier
+            .apply {
+                if (borderSize > 0.dp) {
+                    border(borderSize, borderColor, RoundedCornerShape(roundSize))
+                }
+            }
+            .fillMaxWidth()
+            .background(backgroundColor, RoundedCornerShape(roundSize))
+    ) {
+
+        val style = LocalTextStyle.current
+
+        val (_title, _message, _button) = createRefs()
+
+        val textColor = color.takeOrElse {
+            style.color.takeOrElse {
+                LocalContentColor.current
+            }
+        }
+
+        TextAny(
+            modifier = Modifier
+                .wrapContentSize()
+                .constrainAs(_title) {
+                    top.linkTo(parent.top)
+                    start.linkTo(parent.start, margin = roundSize)
+                    bottom.linkTo(parent.bottom)
+                }
+                .padding(paddingTitle),
+            text = title ?: stringResource(id = R.string.error),
+            fontWeight = fontWeight,
+            fontSize = fontSizeTitle,
+            color = color
+        )
+
+        BasicText(
+            modifier = Modifier
+                .wrapContentSize()
+                .constrainAs(_message) {
+                    top.linkTo(parent.top)
+                    start.linkTo(_title.end)
+                    end.linkTo(_button.start)
+                    bottom.linkTo(parent.bottom)
+                }
+                .padding(paddingMessage),
+            style = style.merge(
+                color = textColor,
+                fontSize = fontSizeMessage,
+                fontWeight = fontWeight,
+                textAlign = TextAlign.Left,
+            ),
+            minLines = 1,
+            maxLines = 4,
+            text =
+            error?.message ?: (stringResource(id = R.string.error_unknown) +
+                    "\nType: " +
+                    (error?.javaClass?.simpleName ?: Exception::class.simpleName))
+        )
+// todo touchable
+        if (cancelText != null) {
+            Button(
+                modifier = Modifier
+                    .apply {
+                        if (borderSize > 0.dp) {
+                            border(borderSize, borderColor, RoundedCornerShape(roundSize))
+                        }
+                    }
+                    .background(
+                        Color.White.copy(
+                            alpha = 0.5f
+                        ),
+                        RoundedCornerShape(roundSize)
+                    )
+                    .constrainAs(_button) {
+                        top.linkTo(parent.top, margin = 4.dp)
+                        end.linkTo(parent.end, margin = roundSize)
+                        bottom.linkTo(parent.bottom, margin = 4.dp)
+                    },
+                onClick = {
+                    onDismiss()
+                }
+            ) {
+
+                TextAny(
+                    text = cancelText,
+                    color = color
+                )
+
+            }
+
+        }
+
+    }
+
+}
+
+@Suppress("unused")
+fun TvLazyListScope.errorMessage(error: Throwable?) = item { ErrorMessage(error) }

+ 104 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/FocusableBox.kt

@@ -0,0 +1,104 @@
+/*
+ * 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.complex
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusEvent
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Border
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Surface
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.extensions.ModifierExt.touchable
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@TvPreview
+@Composable
+fun FocusableBox(
+    modifier: Modifier = Modifier,
+    onClick: () -> Unit = {},
+    onFocus: () -> Unit = {},
+    enabled: Boolean = true,
+    tonalElevation: Dp = 0.dp,
+    focusedColor: Color = Color.Black,
+    unFocusedColor: Color = Color.Transparent,
+    pressedColor: Color = focusedColor.copy(alpha = 0.5f),
+    disabledColor: Color = Color.Gray.copy(alpha = 0.5f),
+    shape: Shape = RectangleShape,
+    borderColor: Color = Color.Transparent,
+    borderSize: Dp = 0.dp,
+    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+    isFocused: MutableState<Boolean> = remember { mutableStateOf(false) },
+    onTouched: (focused: Boolean) -> Unit = { focused ->
+        if (focused) onClick() else onFocus()
+    },
+    content: @Composable BoxScope.() -> Unit = {}
+) {
+
+
+
+    Surface(
+        onClick,
+        modifier
+            .touchable {
+                onTouched(isFocused.value)
+            }
+            .onFocusEvent { focusState ->
+                isFocused.value = (focusState.isFocused || focusState.hasFocus)
+                if (isFocused.value) {
+                    onFocus()
+                }
+            },
+        null,
+        enabled,
+        tonalElevation,
+        ClickableSurfaceDefaults.shape(shape),
+        ClickableSurfaceDefaults.colors(
+            containerColor = unFocusedColor,
+            contentColor = unFocusedColor,
+            focusedContainerColor = focusedColor,
+            focusedContentColor = focusedColor,
+            pressedContainerColor = pressedColor,
+            pressedContentColor = pressedColor,
+            disabledContainerColor = disabledColor,
+            disabledContentColor = disabledColor
+        ),
+        ClickableSurfaceDefaults.scale(
+            1f,
+            1f,
+            1f,
+            1f,
+            1f
+        ),
+        Border(
+            border = BorderStroke(
+                width = borderSize,
+                color = borderColor
+            ),
+            inset = 0.dp,
+            shape = shape
+        ).let { b -> ClickableSurfaceDefaults.border(b, b, b, b, b) },
+        ClickableSurfaceDefaults.glow(),
+        interactionSource,
+        content
+    )
+
+}

+ 157 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/Header.kt

@@ -0,0 +1,157 @@
+/*
+ * 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.complex
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.tv.foundation.lazy.list.TvLazyListScope
+import org.mjdev.tvapp.R
+
+@Preview
+@Composable
+fun Header(
+    modifier: Modifier = Modifier,
+    title: Any? = R.string.app_name,
+    fontSize: TextUnit = 24.sp,
+    fontWeight: FontWeight = FontWeight.Bold,
+    color: Color = Color.White,
+    backgroundColor: Color = Color(0xFF202020),
+    roundSize: Dp = 8.dp,
+    borderSize: Dp = 0.dp,
+    borderColor: Color = Color.White,
+    padding: PaddingValues = PaddingValues(roundSize),
+    contentPadding: PaddingValues = PaddingValues(2.dp),
+    messagesCount: Int = 0,
+    onTitleClick: () -> Unit = {},
+    onClockClick: () -> Unit = {},
+    onMessageBadgeClick: () -> Unit = {},
+    onUserPicClick: () -> Unit = {},
+) = ConstraintLayout(
+    modifier = modifier
+        .fillMaxWidth()
+        .background(backgroundColor, RoundedCornerShape(roundSize))
+        .apply {
+            if (borderSize > 0.dp) {
+                border(borderSize, borderColor, RoundedCornerShape(roundSize))
+            }
+        }
+        .padding(padding)
+) {
+
+    val (_title, _clock, _badge, _settings) = createRefs()
+
+    Title(
+        modifier = Modifier
+            .constrainAs(_title) {
+                top.linkTo(parent.top)
+                start.linkTo(parent.start)
+                bottom.linkTo(parent.bottom)
+            }
+            .padding(contentPadding),
+        title = title ?: R.string.app_name,
+        fontWeight = fontWeight,
+        fontSize = fontSize,
+        color = color
+    )
+
+    Clock(
+        modifier = Modifier
+            .constrainAs(_clock) {
+                top.linkTo(parent.top)
+                end.linkTo(_badge.start)
+                bottom.linkTo(parent.bottom)
+            }
+            .padding(contentPadding),
+        timeTextColor = color,
+        dateTextColor = color
+    )
+
+    Badge(
+        modifier = Modifier
+            .defaultMinSize(40.dp)
+            .constrainAs(_badge) {
+                top.linkTo(parent.top)
+                end.linkTo(_settings.start)
+                bottom.linkTo(parent.bottom)
+            }
+            .padding(contentPadding)
+            .clip(CircleShape),
+        count = messagesCount
+    )
+
+    UserPic(
+        modifier = Modifier
+            .size(64.dp)
+            .constrainAs(_settings) {
+                top.linkTo(parent.top)
+                end.linkTo(parent.end)
+                bottom.linkTo(parent.bottom)
+            }
+            .padding(contentPadding)
+            .clip(CircleShape),
+        onClick = { }
+    )
+
+}
+
+fun TvLazyListScope.header(
+    modifier: Modifier = Modifier,
+    title: Any? = R.string.app_name,
+    fontSize: TextUnit = 24.sp,
+    fontWeight: FontWeight = FontWeight.Bold,
+    color: Color = Color.White,
+    backgroundColor: Color = Color(0xFF202020),
+    roundSize: Dp = 8.dp,
+    borderSize: Dp = 0.dp,
+    borderColor: Color = Color.White,
+    padding: PaddingValues = PaddingValues(roundSize),
+    contentPadding: PaddingValues = PaddingValues(2.dp),
+    messagesCount: Int = 0,
+    onTitleClick: () -> Unit = {},
+    onClockClick: () -> Unit = {},
+    onMessageBadgeClick: () -> Unit = {},
+    onUserPicClick: () -> Unit = {},
+) = item {
+    Header(
+        modifier,
+        title,
+        fontSize,
+        fontWeight,
+        color,
+        backgroundColor,
+        roundSize,
+        borderSize,
+        borderColor,
+        padding,
+        contentPadding,
+        messagesCount,
+        onTitleClick,
+        onClockClick,
+        onMessageBadgeClick,
+        onUserPicClick
+    )
+}

+ 83 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/ListWithPreview.kt

@@ -0,0 +1,83 @@
+/*
+ * 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.complex
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.ImmersiveList
+import org.mjdev.tvapp.base.interfaces.ItemWithImage
+import org.mjdev.tvapp.base.ui.components.image.ImageAny
+import timber.log.Timber
+
+// todo
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Preview
+@Composable
+fun <T> ListWithPreview(
+    modifier: Modifier = Modifier,
+    items: List<T> = listOf(),
+    immersiveListHeight: Dp = 300.dp,
+    cardSpacing: Dp = 10.dp,
+    cardWidth: Dp = 200.dp,
+    cardHeight: Dp = 150.dp
+) {
+    val itemBackground: (index: Int) -> Any? = { index ->
+        (if (items.size > index)
+            (items[index] as? ItemWithImage?)?.backgroundImageUrl
+        else null) ?: Color.Transparent
+    }
+    ImmersiveList(
+        modifier = modifier
+            .height(immersiveListHeight + cardHeight / 2)
+            .fillMaxWidth(),
+        background = { index, _ ->
+            ImageAny(
+                modifier = Modifier
+                    .height(immersiveListHeight)
+                    .fillMaxWidth(),
+                src = itemBackground.invoke(index)
+            )
+        }
+    ) {
+        Row(horizontalArrangement = Arrangement.spacedBy(cardSpacing)) {
+            items.forEachIndexed { index, item ->
+                val isFocused = remember { mutableStateOf(false) }
+                ImageAny(
+                    modifier = Modifier
+                        .width(cardWidth)
+                        .height(cardHeight)
+                        .border(5.dp, Color.White.copy(alpha = if (isFocused.value) 1f else 0.3f))
+                        .onFocusChanged { focusState ->
+                            isFocused.value = focusState.isFocused
+                        }
+                        .immersiveListItem(index)
+                        .clickable {
+                            Timber.tag("ImmersiveList: Item $index was clicked")
+                        },
+                    src = itemBackground.invoke(index)
+                )
+            }
+        }
+    }
+}

+ 70 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/ScreenView.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.ui.components.complex
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavHostController
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.navigation.MenuItem
+import org.mjdev.tvapp.base.navigation.Screen
+import org.mjdev.tvapp.base.state.ActivityViewState
+import org.mjdev.tvapp.base.state.ActivityViewState.Companion.rememberActivityViewState
+import org.mjdev.tvapp.base.ui.components.navigation.Navigation
+
+@Suppress("TrailingComma", "UNUSED_ANONYMOUS_PARAMETER")
+@TvPreview
+@Composable
+fun ScreenView(
+    navController: NavHostController? = null,
+    navigationIconMain: Any? = Icons.Filled.Menu,
+    navigationIconBack: Any? = Icons.Filled.ArrowBack,
+    actions: @Composable RowScope.() -> Unit = {},
+    title: Any? = R.string.app_name,
+    menuItems: List<MenuItem> = listOf(),
+    content: @Composable (
+        state: ActivityViewState,
+    ) -> Unit = { state ->
+        Screen()
+    }
+) {
+
+    val activityViewState = rememberActivityViewState(title)
+    val errorState = remember { activityViewState.errorState }
+
+    Box(
+        modifier = Modifier.fillMaxSize(),
+        contentAlignment = Alignment.BottomStart,
+    ) {
+        Navigation(
+            items = menuItems,
+            content = {
+                Column(
+                    modifier = Modifier.fillMaxSize()
+                ) {
+                    content(activityViewState)
+                }
+            }
+        )
+        if (errorState.value != null) {
+            ErrorMessage(errorState.value)
+        }
+    }
+
+}

+ 61 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/ScrollableTvLayRow.kt

@@ -0,0 +1,61 @@
+/*
+ * 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.complex
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.PivotOffsets
+import androidx.tv.foundation.lazy.list.TvLazyColumn
+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
+
+@TvPreview
+@Composable
+fun ScrollableTvLazyRow(
+    modifier: Modifier = Modifier,
+    contentPadding: PaddingValues = PaddingValues(0.dp),
+    reverseLayout: Boolean = false,
+    verticalArrangement: Arrangement.Vertical = if (!reverseLayout) Arrangement.Top
+    else Arrangement.Bottom,
+    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+    userScrollEnabled: Boolean = true,
+    pivotOffsets: PivotOffsets = PivotOffsets(),
+    state: TvLazyListState = rememberTvLazyListState(),
+    content: TvLazyListScope.() -> Unit = {}
+) {
+
+    TouchBox(
+        modifier = modifier,
+        state = state
+    ) {
+
+        TvLazyColumn(
+            modifier = modifier,
+            state = state,
+            contentPadding = contentPadding,
+            reverseLayout = reverseLayout,
+            verticalArrangement = verticalArrangement,
+            horizontalAlignment = horizontalAlignment,
+            userScrollEnabled = userScrollEnabled,
+            pivotOffsets = pivotOffsets,
+            content = {
+                content.invoke(this)
+            }
+        )
+
+    }
+
+
+}

+ 124 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/Tabs.kt

@@ -0,0 +1,124 @@
+/*
+ * 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.complex
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.foundation.lazy.list.TvLazyListScope
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Tab
+import androidx.tv.material3.TabDefaults
+import androidx.tv.material3.TabRow
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.interfaces.ItemWithTitle
+import org.mjdev.tvapp.base.ui.components.text.TextAny
+
+@SuppressLint("AutoboxingStateValueProperty")
+@OptIn(ExperimentalTvMaterial3Api::class)
+@TvPreview
+@Composable
+fun <T> Tabs(
+    modifier: Modifier = Modifier,
+    items: List<T> = emptyList(),
+    fontSize: TextUnit = 12.sp,
+    itemsSpacing: Dp = 8.dp,
+    activeContentColor: Color = Color.Black,
+    selectedContentColor: Color = activeContentColor,
+    focusedContentColor: Color = activeContentColor,
+    onItemClick: (index: Int) -> Unit = {}
+) {
+
+    val selectedTabIndex = remember { mutableIntStateOf(0) }
+
+    TabRow(
+        modifier = modifier.fillMaxWidth(),
+        selectedTabIndex = selectedTabIndex.value,
+
+        separator = {
+            Spacer(modifier = Modifier.width(itemsSpacing))
+        },
+    ) {
+
+        items.forEachIndexed { index, tab ->
+
+            Tab(
+                modifier = Modifier,
+                colors = TabDefaults.pillIndicatorTabColors(
+                    activeContentColor,
+                    activeContentColor.copy(alpha = 0.4f),
+                    selectedContentColor,
+                    focusedContentColor,
+                    focusedContentColor,
+                    activeContentColor,
+                    activeContentColor.copy(alpha = 0.4f),
+                    selectedContentColor,
+                ),
+                selected = index == selectedTabIndex.value,
+                onFocus = {
+                    selectedTabIndex.value = index
+                },
+            ) {
+
+                FocusableBox(
+                    focusedColor = Color.Transparent,
+                    onFocus = {
+                        selectedTabIndex.value = index
+                    }
+                ) {
+
+                    TextAny(
+                        modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
+                        text = if (tab is ItemWithTitle) tab.title else tab.toString(),
+                        fontSize = fontSize,
+                        color = Color.Black
+                    )
+
+                }
+
+            }
+
+        }
+
+    }
+
+}
+
+fun <T> TvLazyListScope.tabs(
+    modifier: Modifier = Modifier,
+    items: List<T> = emptyList(),
+    fontSize: TextUnit = 12.sp,
+    itemsSpacing: Dp = 8.dp,
+    activeContentColor: Color = Color.Black,
+    selectedContentColor: Color = activeContentColor,
+    focusedContentColor: Color = activeContentColor,
+    onItemClick: (index: Int) -> Unit = {}
+) = item {
+    Tabs(
+        modifier,
+        items,
+        fontSize,
+        itemsSpacing,
+        activeContentColor,
+        selectedContentColor,
+        focusedContentColor,
+        onItemClick
+    )
+}

+ 78 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/Title.kt

@@ -0,0 +1,78 @@
+/*
+ * 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.complex
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.MaterialTheme
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.ui.components.text.TextAny
+
+@Suppress("LocalVariableName")
+@TvPreview
+@Composable
+fun Title(
+    modifier: Modifier = Modifier,
+    fontWeight: FontWeight = FontWeight.Bold,
+    fontSize: TextUnit = 24.sp,
+    color: Color = Color.White,
+    focusShadowColor: Color = Color.Black,
+    unfocusedShadowColor: Color = Color.Transparent,
+    shadowSize: Dp = 4.dp,
+    title: Any? = R.string.app_name,
+    onFocus: () -> Unit = {},
+    onClick: () -> Unit = {},
+) {
+
+    val isFocused: MutableState<Boolean> = remember { mutableStateOf(false) }
+    val onTouched: (focused: Boolean) -> Unit = { focused ->
+        if (focused) onClick() else onFocus()
+    }
+
+    FocusableBox(
+        isFocused = isFocused,
+        onTouched = onTouched,
+        focusedColor = Color.Transparent,
+    ) {
+
+        isFocused.value.also { focused ->
+
+            TextAny(
+                modifier = modifier.padding(shadowSize),
+                style = MaterialTheme.typography.bodyMedium.copy(
+                    shadow = Shadow(
+                        color = if (focused) focusShadowColor else unfocusedShadowColor,
+                        offset = Offset(shadowSize.value, shadowSize.value),
+                        blurRadius = shadowSize.value
+                    )
+                ),
+                text = title ?: R.string.app_name,
+                fontWeight = fontWeight,
+                fontSize = fontSize,
+                color = color
+            )
+
+        }
+
+    }
+
+}

+ 53 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/TouchBox.kt

@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.ui.components.complex
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.tv.foundation.lazy.list.TvLazyListState
+
+@SuppressLint("AutoboxingStateValueProperty")
+@Composable
+fun TouchBox(
+        modifier: Modifier = Modifier,
+        contentAlignment: Alignment = Alignment.TopStart,
+        propagateMinConstraints: Boolean = false,
+        state: TvLazyListState? = null,
+        content: @Composable BoxScope.() -> Unit
+) {
+    val scrollDelta = remember { mutableFloatStateOf(0f) }
+
+    Box(
+        modifier = modifier.pointerInput(Unit) {
+            detectDragGestures { _, dragAmount ->
+                scrollDelta.value = dragAmount.y
+            }
+        },
+        contentAlignment = contentAlignment,
+        propagateMinConstraints = propagateMinConstraints,
+    ) {
+
+        content.invoke(this)
+
+    }
+
+    LaunchedEffect(scrollDelta.value) {
+        state?.dispatchRawDelta(-scrollDelta.value)
+    }
+
+}

+ 51 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/complex/UserPic.kt

@@ -0,0 +1,51 @@
+/*
+ * 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.complex
+
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.ui.components.image.CircleImage
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Preview
+@Composable
+fun UserPic(
+    modifier: Modifier = Modifier,
+    shape: Shape = RoundedCornerShape(100),
+    src: Any? = R.drawable.milanj,
+    backGroundColor: Color = MaterialTheme.colorScheme.secondary,
+    borderColor: Color = Color.White,
+    borderSize: Dp = 2.dp,
+    onClick: () -> Unit = {}
+) = FocusableBox(
+    modifier = modifier
+        .wrapContentSize(),
+    shape = shape,
+    onClick = onClick
+) {
+
+    CircleImage(
+        modifier = modifier,
+        backGroundColor = backGroundColor,
+        borderSize = borderSize,
+        borderColor = borderColor,
+        src = src,
+    )
+
+}

+ 127 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/icon/IconAny.kt

@@ -0,0 +1,127 @@
+/*
+ * 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.icon
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.painterResource
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Icon
+import androidx.tv.material3.LocalContentColor
+import coil.compose.AsyncImage
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.extensions.DrawableExt.asImageBitmap
+import java.net.URL
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@SuppressLint("ModifierParameter")
+@TvPreview
+@Composable
+fun IconAny(
+    modifier: Modifier = Modifier,
+    src: Any? = Icons.Default.Menu,
+    contentDescription: String? = null,
+    tint: Color = LocalContentColor.current
+) = BoxWithConstraints(
+    modifier = modifier
+) {
+
+    val width = constraints.minWidth
+    val height = constraints.minHeight
+
+    when (src) {
+        null -> Icon(
+            ColorDrawable(0).asImageBitmap(),
+            contentDescription,
+            modifier,
+            tint
+        )
+
+        is Bitmap -> Icon(
+            src.asImageBitmap(),
+            contentDescription,
+            modifier,
+            tint
+        )
+
+        is ImageBitmap -> Icon(
+            src,
+            contentDescription,
+            modifier,
+            tint
+        )
+
+        is Drawable -> Icon(
+            src.asImageBitmap(width, height),
+            contentDescription,
+            modifier,
+            tint
+        )
+
+        is Int -> Icon(
+            painterResource(src),
+            contentDescription,
+            modifier,
+            tint
+        )
+
+        is Color -> Icon(
+            ColorDrawable(0).asImageBitmap(width, height),
+            contentDescription,
+            modifier,
+            tint
+        )
+
+        // todo tint
+        is URL -> AsyncImage(
+            model = src,
+            contentDescription = contentDescription,
+            modifier = modifier,
+//            tint = tint
+        )
+
+        // todo tint
+        is Uri -> AsyncImage(
+            model = src,
+            contentDescription = contentDescription,
+            modifier = modifier,
+//            tint = tint
+        )
+
+// todo tint
+        is String -> AsyncImage(
+            model = src,
+            contentDescription = contentDescription,
+            modifier = modifier,
+//            tint = tint
+        )
+
+        is ImageVector -> Icon(
+            src,
+            contentDescription,
+            modifier,
+            tint
+        )
+
+        else -> throw (RuntimeException("Unknown image format."))
+    }
+
+}

+ 73 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/image/CircleImage.kt

@@ -0,0 +1,73 @@
+/*
+ * 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.image
+
+import android.annotation.SuppressLint
+import android.graphics.drawable.ColorDrawable
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import org.mjdev.tvapp.R
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@SuppressLint("ModifierParameter")
+@Preview
+@Composable
+fun CircleImage(
+    modifier: Modifier = Modifier,
+    backGroundColor: Color = MaterialTheme.colorScheme.secondary,
+    borderColor: Color = Color.Black,
+    borderSize: Dp = 2.dp,
+    contentDescription: String = "",
+    src: Any? = R.drawable.milanj
+) {
+
+    Box(
+        modifier = modifier,
+        contentAlignment = Alignment.Center
+    ) {
+
+        ImageAny(
+            modifier = modifier
+                .padding(borderSize)
+                .clip(CircleShape),
+            src = ColorDrawable(backGroundColor.toArgb()),
+            contentDescription = contentDescription,
+            contentScale = ContentScale.Crop
+        )
+
+        ImageAny(
+            modifier = modifier
+                .clip(CircleShape)
+                .apply {
+                    if (borderSize > 0.dp) {
+                        border(borderSize, borderColor, CircleShape)
+                    }
+                },
+            src = src,
+            contentDescription = contentDescription,
+            contentScale = ContentScale.Crop,
+        )
+
+    }
+
+}

+ 157 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/image/ImageAny.kt

@@ -0,0 +1,157 @@
+/*
+ * 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.image
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.Drawable
+import android.net.Uri
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.DefaultAlpha
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import coil.compose.AsyncImage
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.extensions.DrawableExt.asImageBitmap
+import java.net.URL
+
+// todo placeholder
+@SuppressLint("ModifierParameter")
+@Preview
+@Composable
+fun ImageAny(
+    modifier: Modifier = Modifier,
+    src: Any? = R.drawable.milanj,
+    contentDescription: String? = null,
+    alignment: Alignment = Alignment.Center,
+    contentScale: ContentScale = ContentScale.Fit,
+    alpha: Float = DefaultAlpha,
+    colorFilter: ColorFilter? = null,
+    placeholder: Int? = null
+) = BoxWithConstraints(
+    modifier = modifier
+) {
+
+    when (src) {
+
+        null -> Image(
+            ColorDrawable(0).asImageBitmap(),
+            contentDescription,
+            modifier,
+            alignment,
+            contentScale,
+            alpha,
+            colorFilter
+        )
+
+        is Bitmap -> Image(
+            src.asImageBitmap(),
+            contentDescription,
+            modifier,
+            alignment,
+            contentScale,
+            alpha,
+            colorFilter
+        )
+
+        is ImageBitmap -> Image(
+            src,
+            contentDescription,
+            modifier,
+            alignment,
+            contentScale,
+            alpha,
+            colorFilter
+        )
+
+        is ColorDrawable -> Image(
+            src.asImageBitmap(1, 1),
+            contentDescription,
+            modifier,
+            alignment,
+            contentScale,
+            alpha,
+            colorFilter
+        )
+
+        is Drawable -> Image(
+            src.asImageBitmap(),
+            contentDescription,
+            modifier,
+            alignment,
+            contentScale,
+            alpha,
+            colorFilter
+        )
+
+        is Int -> Image(
+            painterResource(src),
+            contentDescription,
+            modifier,
+            alignment,
+            contentScale,
+            alpha,
+            colorFilter
+        )
+
+        is URL -> AsyncImage(
+            modifier = modifier,
+            model = src,
+            contentDescription = contentDescription,
+            alignment = alignment,
+            contentScale = contentScale,
+            alpha = alpha,
+            colorFilter = colorFilter
+        )
+
+        is Uri -> AsyncImage(
+            modifier = modifier,
+            model = src,
+            contentDescription = contentDescription,
+            alignment = alignment,
+            contentScale = contentScale,
+            alpha = alpha,
+            colorFilter = colorFilter
+        )
+
+        is String -> AsyncImage(
+            modifier = modifier,
+            model = src,
+            contentDescription = contentDescription,
+            alignment = alignment,
+            contentScale = contentScale,
+            alpha = alpha,
+            colorFilter = colorFilter
+        )
+
+        is ImageVector -> Image(
+            src,
+            contentDescription,
+            modifier,
+            alignment,
+            contentScale,
+            alpha,
+            colorFilter
+        )
+
+        else -> throw (RuntimeException("Unknown image format."))
+
+    }
+
+}

+ 159 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/navigation/Navigation.kt

@@ -0,0 +1,159 @@
+/*
+ * 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.navigation
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Search
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.DrawerValue
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.NavigationDrawer
+import androidx.tv.material3.rememberDrawerState
+import org.mjdev.tvapp.base.extensions.ComposeExt.isEditMode
+import org.mjdev.tvapp.base.navigation.MenuItem
+
+const val SETTINGS_ITEM = 65535
+const val SEARCH_ITEM = 65534
+
+@SuppressLint("AutoboxingStateValueProperty")
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Preview
+@Composable
+fun Navigation(
+    modifier: Modifier = Modifier,
+    items: List<MenuItem> = listOf(),
+    backgroundColor: Color = Color(0xFF202020),
+    roundSize: Dp = 8.dp,
+    borderSize: Dp = 0.dp,
+    borderColor: Color = Color.White,
+    showSearch: Boolean = true,
+    showSettings: Boolean = true,
+    searchArrangement: Arrangement.Vertical = Arrangement.Top,
+    settingsArrangement: Arrangement.Vertical = Arrangement.Bottom,
+    onDrawerItemClick: (id: Int) -> Unit = {},
+    content: @Composable () -> Unit = {}
+) {
+
+    val isEdit = isEditMode()
+    val drawerState = rememberDrawerState(if (isEdit) DrawerValue.Open else DrawerValue.Closed)
+    val focused = remember { mutableIntStateOf(-1) }
+
+    NavigationDrawer(
+        modifier = modifier
+            .fillMaxHeight()
+            .background(backgroundColor)
+            .apply {
+                if (borderSize > 0.dp) {
+                    border(borderSize, borderColor, RoundedCornerShape(roundSize))
+                }
+            },
+        content = content,
+        drawerState = drawerState,
+        drawerContent = { state ->
+
+            focused.value.apply {
+
+                if (showSettings) {
+                    Column(
+                        modifier = Modifier.fillMaxHeight(),
+                        verticalArrangement = settingsArrangement,
+                        horizontalAlignment = Alignment.Start,
+                    ) {
+
+                        NavigationRow(
+                            id = SETTINGS_ITEM,
+                            focused = (focused.value == SETTINGS_ITEM),
+                            drawerValue = state,
+                            icon = Icons.Outlined.Settings,
+                            text = "Settings",
+                            onFocus = { id ->
+                                drawerState.setValue(DrawerValue.Open)
+                                focused.value = id
+                            },
+                            onClick = { id ->
+                                onDrawerItemClick(id)
+                            }
+                        )
+
+                    }
+                }
+
+                if (showSearch) {
+                    Column(
+                        modifier = Modifier
+                            .fillMaxHeight(),
+                        verticalArrangement = searchArrangement,
+                        horizontalAlignment = Alignment.Start,
+                    ) {
+
+                        NavigationRow(
+                            id = SEARCH_ITEM,
+                            focused = (focused.value == SEARCH_ITEM),
+                            drawerValue = state,
+                            icon = Icons.Outlined.Search,
+                            text = "Search",
+                            onFocus = { id ->
+                                drawerState.setValue(DrawerValue.Open)
+                                focused.value = id
+                            },
+                            onClick = { id ->
+                                onDrawerItemClick(id)
+                            }
+                        )
+
+                    }
+                }
+
+                Column(
+                    modifier = Modifier
+                        .fillMaxHeight(),
+                    verticalArrangement = Arrangement.Center,
+                    horizontalAlignment = Alignment.Start,
+                ) {
+
+                    items.forEachIndexed { idx, menuItem ->
+                        NavigationRow(
+                            id = idx,
+                            focused = (focused.value == idx),
+                            drawerValue = state,
+                            text = menuItem.menuText,
+                            icon = menuItem.menuIcon,
+                            onFocus = { id ->
+                                drawerState.setValue(DrawerValue.Open)
+                                focused.value = id
+                            },
+                            onClick = { id ->
+                                onDrawerItemClick(id)
+                            }
+                        )
+                    }
+
+                }
+
+            }
+        }
+    )
+
+}

+ 98 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/navigation/NavigationRow.kt

@@ -0,0 +1,98 @@
+/*
+ * 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.navigation
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.DrawerValue
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import org.mjdev.tvapp.base.ui.components.complex.FocusableBox
+import org.mjdev.tvapp.base.ui.components.icon.IconAny
+import org.mjdev.tvapp.base.ui.components.text.TextAny
+
+@Suppress("LocalVariableName")
+@Preview
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun NavigationRow(
+    id: Int = -1,
+    focused: Boolean = false,
+    drawerValue: DrawerValue = DrawerValue.Open,
+    text: Any? = "test",
+    icon: Any? = Icons.Default.AccountCircle,
+    textColor: Color = Color.White,
+    iconColor: Color = Color.White,
+    focusedColor: Color = Color.Black,
+    unFocusedColor: Color = Color.Transparent,
+    expandedWidth: Dp = 150.dp,
+    onFocus: (id: Int) -> Unit = {},
+    onClick: (id: Int) -> Unit = {},
+) {
+    FocusableBox(
+        focusedColor = focusedColor,
+        unFocusedColor = unFocusedColor,
+        onFocus = {
+            onFocus(id)
+        },
+        onClick = {
+            onClick(id)
+        },
+        modifier = Modifier.padding(2.dp)
+    ) {
+
+        Row(
+            horizontalArrangement = Arrangement.Start,
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+
+            Box(
+                modifier = Modifier.padding(4.dp)
+            ) {
+
+                IconAny(
+                    src = icon,
+                    contentDescription = null,
+                    tint = iconColor
+                )
+
+            }
+
+            AnimatedVisibility(visible = (drawerValue == DrawerValue.Open)) {
+
+                TextAny(
+                    text = text,
+                    softWrap = false,
+                    modifier = Modifier
+                        .padding(8.dp)
+                        .width(expandedWidth),
+                    color = textColor,
+                    textAlign = TextAlign.Left
+                )
+
+            }
+
+        }
+
+    }
+
+}

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

@@ -0,0 +1,85 @@
+/*
+ * 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 android.graphics.drawable.ColorDrawable
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import org.mjdev.tvapp.base.ui.components.image.ImageAny
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@SuppressLint("ModifierParameter")
+@Preview
+@Composable
+fun CircleText(
+    modifier: Modifier = Modifier.defaultMinSize(40.dp),
+    backGroundColor: Color = MaterialTheme.colorScheme.secondary,
+    borderColor: Color = Color.White,
+    borderSize: Dp = 2.dp,
+    text: String = "0",
+    textColor: Color = Color.White,
+    textSize: TextUnit = 40.sp
+) {
+
+    Box(
+        modifier = modifier
+            .defaultMinSize(40.dp)
+            .clip(CircleShape),
+        contentAlignment = Alignment.Center
+    ) {
+
+        ImageAny(
+            modifier = modifier
+                .clip(CircleShape),
+            src = ColorDrawable(backGroundColor.toArgb()),
+            contentDescription = null,
+            contentScale = ContentScale.Crop
+        )
+
+        ImageAny(
+            modifier = modifier
+                .clip(CircleShape).apply {
+                    if (borderSize > 0.dp) {
+                        border(borderSize, borderColor, CircleShape)
+                    }
+                },
+            src = ColorDrawable(0),
+            contentDescription = null,
+            contentScale = ContentScale.Crop,
+        )
+
+        TextAny(
+            modifier = modifier,
+            textAlign = TextAlign.Center,
+            fontWeight = FontWeight.Bold,
+            color = textColor,
+            text = text
+        )
+
+    }
+}

+ 66 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/ui/components/text/TextAny.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
+import androidx.tv.material3.Text
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.extensions.ComposeExt.textFrom
+
+@SuppressLint("ModifierParameter")
+@TvPreview
+@Composable
+fun TextAny(
+    text: Any? = "",
+    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
+) = Text(
+    text = textFrom(text),
+    modifier = modifier,
+    color = color,
+    fontSize = fontSize,
+    fontStyle = fontStyle,
+    fontWeight = fontWeight,
+    fontFamily = fontFamily,
+    letterSpacing = letterSpacing,
+    textDecoration = textDecoration,
+    textAlign = textAlign,
+    lineHeight = lineHeight,
+    overflow = overflow,
+    softWrap = softWrap,
+    maxLines = maxLines,
+    onTextLayout = onTextLayout,
+    style = style
+)

+ 47 - 0
app/src/main/kotlin/org/mjdev/tvapp/base/viewmodel/BaseViewModel.kt

@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.base.viewmodel
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.onEach
+import timber.log.Timber
+
+open class BaseViewModel : ViewModel() {
+
+    private val error: MutableStateFlow<Throwable?> = MutableStateFlow(null)
+
+    private var errorHandler: (error: Throwable) -> Unit = {}
+
+    init {
+        error.onEach { error ->
+            if (error != null) errorHandler.invoke(error)
+        }
+    }
+
+    @Suppress("MemberVisibilityCanBePrivate")
+    fun postError(e: Throwable) {
+        Timber.e(e)
+        error.tryEmit(e)
+    }
+
+    @Suppress("UNCHECKED_CAST", "unused")
+    protected suspend fun <T> runSafe(
+        block: suspend () -> T
+    ): T = try {
+        block.invoke()
+    } catch (t: Throwable) {
+        postError(t)
+    } as T
+
+    fun handleError(block: (error: Throwable) -> Unit) {
+        errorHandler = block
+    }
+
+}

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

@@ -0,0 +1,17 @@
+/*
+ * 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
+
+@Stable
+data class Category(
+    var name: String,
+    var movieList: List<Movie>
+)

+ 17 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/Message.kt

@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.data
+
+data class Message(
+    val id: Int = 0,
+    val title: String = "",
+    val message: String,
+    val read: Boolean = false,
+    val deleted: Boolean = false
+)

+ 45 - 0
app/src/main/kotlin/org/mjdev/tvapp/data/Movie.kt

@@ -0,0 +1,45 @@
+/*
+ * 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 android.net.Uri
+import org.mjdev.tvapp.base.interfaces.ItemWithImage
+import org.mjdev.tvapp.base.interfaces.ItemWithTitle
+import org.mjdev.tvapp.base.interfaces.ItemWithVideoUri
+import java.io.Serializable
+
+data class Movie(
+
+    var id: Long = 0,
+    override var title: String = "",
+    var description: String = "",
+    override var backgroundImageUrl: String = "",
+    var cardImageUrl: String = "",
+    override var videoUri: Uri = Uri.EMPTY,
+    var studio: String = ""
+
+) : Serializable, ItemWithTitle, ItemWithImage, ItemWithVideoUri {
+
+    val hasVideoUri get() = videoUri != Uri.EMPTY
+
+    override fun toString(): String {
+        return "Movie{" +
+                "id=" + id +
+                ", title='" + title + '\'' +
+                ", videoUrl='" + videoUri.toString() + '\'' +
+                ", backgroundImageUrl='" + backgroundImageUrl + '\'' +
+                ", cardImageUrl='" + cardImageUrl + '\'' +
+                '}'
+    }
+
+    companion object {
+        internal const val serialVersionUID = 727566175075960653L
+    }
+
+}

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

@@ -0,0 +1,107 @@
+/*
+ * 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]),
+                cardImageUrl = cardImageUrl[it],
+                backgroundImageUrl = bgImageUrl[it],
+            )
+        }
+
+        return list
+    }
+
+}

+ 105 - 0
app/src/main/kotlin/org/mjdev/tvapp/module/ProvideModule.kt

@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.module
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import org.mjdev.tvapp.BuildConfig
+import org.mjdev.tvapp.api.MovieAPI
+import org.mjdev.tvapp.base.network.NetworkConnectivityService
+import org.mjdev.tvapp.base.network.NetworkConnectivityServiceImpl
+import org.mjdev.tvapp.repository.IRepository
+import org.mjdev.tvapp.repository.MovieRepository
+import javax.inject.Singleton
+
+@Suppress("unused")
+@Module
+@InstallIn(SingletonComponent::class)
+class ProvideModule {
+
+    private val isDebug = BuildConfig.DEBUG
+
+    @Singleton
+    @Provides
+    fun providesNetworkConnectivityService(
+        @ApplicationContext
+        context: Context
+    ): NetworkConnectivityService = NetworkConnectivityServiceImpl(context)
+
+    @Singleton
+    @Provides
+    fun providesMovieRepository(movieApi: MovieAPI): IRepository = MovieRepository(movieApi)
+
+    @Singleton
+    @Provides
+    fun providesMovieAPI() = MovieAPI()
+
+//    @Singleton
+//    @Provides
+//    fun providesHttpLoggingInterceptor(
+//    ) = HttpLoggingInterceptor().apply {
+//        level = HttpLoggingInterceptor.Level.BODY
+//    }
+
+//    @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 providesRetrofit(
+//        okHttpClient: OkHttpClient
+//    ): Retrofit = Retrofit.Builder()
+//        .baseUrl(BASE_URL)
+//        .client(okHttpClient)
+//        .addConverterFactory(MoshiConverterFactory.create())
+//        .addCallAdapterFactory(CoroutineCallAdapterFactory())
+//        .build()
+
+//    @Singleton
+//    @Provides
+//    fun providesApiService(
+//        retrofit: Retrofit
+//    ): ApiService = retrofit.create(ApiService::class.java)
+
+
+//    @Singleton
+//    @Provides
+//    fun 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)
+
+}

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

@@ -0,0 +1,22 @@
+/*
+ * 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>
+
+}

+ 120 - 0
app/src/main/kotlin/org/mjdev/tvapp/repository/MovieRepository.kt

@@ -0,0 +1,120 @@
+/*
+ * 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 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.Option.Companion.unwrapOr
+import org.mjdev.tvapp.data.Category
+import org.mjdev.tvapp.data.Movie
+import java.lang.Integer.max
+import java.lang.Integer.min
+import javax.inject.Inject
+
+@Suppress("unused")
+class MovieRepository @Inject constructor(
+    private val dataSource: MovieAPI
+) : IRepository {
+
+    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<Movie>())
+        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.name).unwrapOr(listOf<Movie>())
+            )
+        }
+    }
+
+    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
+            }
+        }
+    }
+
+    private fun prefetchMovieListByCategory(category: Category) {
+        CoroutineScope(dispatcher).launch {
+            loadMovieListByCategory(category)
+        }
+    }
+
+    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) }
+        }
+    }
+
+}

+ 21 - 0
app/src/main/kotlin/org/mjdev/tvapp/state/DetailsLoadingState.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.state
+
+import org.mjdev.tvapp.data.Movie
+
+sealed class DetailsLoadingState {
+
+    object Loading : DetailsLoadingState()
+
+    object NotFound : DetailsLoadingState()
+
+    class Ready(val movie: Movie) : DetailsLoadingState()
+
+}

+ 81 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/components/Details.kt

@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.components
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.background
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.MaterialTheme
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.ui.components.image.ImageAny
+import org.mjdev.tvapp.base.ui.components.text.TextAny
+import org.mjdev.tvapp.data.Movie
+
+@SuppressLint("ModifierParameter")
+@TvPreview
+@Composable
+fun Details(
+    movie: Movie? = null,
+    modifier: Modifier = Modifier,
+    textColor: Color = Color.White.copy(alpha = 0.8f)
+) {
+
+    Box(
+        modifier = modifier.fillMaxSize(),
+        contentAlignment = Alignment.BottomStart,
+    ) {
+
+        ImageAny(
+            src = movie?.cardImageUrl,
+            contentDescription = null,
+            contentScale = ContentScale.Crop,
+            modifier = Modifier.fillMaxSize()
+        )
+
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .background(Color.Black)
+                .padding(vertical = 32.dp, horizontal = 48.dp)
+        ) {
+
+            TextAny(
+                text = movie?.title,
+                style = MaterialTheme.typography.headlineLarge,
+                color = textColor
+            )
+
+            TextAny(
+                text = movie?.studio,
+                style = MaterialTheme.typography.headlineMedium,
+                color = textColor
+            )
+
+            TextAny(
+                text = movie?.title,
+                style = MaterialTheme.typography.headlineMedium,
+                color = textColor
+            )
+
+        }
+
+    }
+
+
+}

+ 34 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/components/Loading.kt

@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import org.mjdev.tvapp.base.annotations.TvPreview
+
+@TvPreview
+@Composable
+fun Loading(modifier: Modifier = Modifier) {
+
+    Box(
+        modifier = modifier.fillMaxSize(),
+        contentAlignment = Alignment.Center
+    ) {
+        Text(
+            text = "Loading...",
+            style = MaterialTheme.typography.headlineMedium
+        )
+    }
+
+}

+ 38 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/components/MediaPlayer.kt

@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.components
+
+import android.annotation.SuppressLint
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.interfaces.ItemWithVideoUri
+import org.mjdev.tvapp.base.media.MediaPlayerContainer
+
+@Suppress("UNUSED_ANONYMOUS_PARAMETER")
+@SuppressLint("ModifierParameter")
+@TvPreview
+@Composable
+fun MediaPlayer(
+    movie: ItemWithVideoUri? = null,
+    modifier: Modifier = Modifier
+) {
+
+    MediaPlayerContainer(
+        modifier = modifier,
+        uri = movie?.videoUri,
+        background = { state ->
+        },
+        mediaPlayerOverlay = { state ->
+        },
+        mediaPlayerControls = { state ->
+        }
+    )
+
+}

+ 96 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/components/MovieCard.kt

@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.components
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+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.unit.dp
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.CompactCard
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Glow
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.extensions.ModifierExt.touchable
+import org.mjdev.tvapp.base.ui.components.image.ImageAny
+import org.mjdev.tvapp.base.ui.components.text.TextAny
+import org.mjdev.tvapp.data.Movie
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@TvPreview
+@Composable
+@SuppressLint("ModifierParameter")
+fun MovieCard(
+    movie: Movie? = null,
+    modifier: Modifier = Modifier,
+    onFocus: () -> Unit = {},
+    onClick: (Movie?) -> Unit = {},
+) {
+
+    val focusState = remember { mutableStateOf<FocusState?>(null) }
+    val isFocused: () -> Boolean = {
+        ((focusState.value?.isFocused == true) || (focusState.value?.hasFocus == true))
+    }
+
+    CompactCard(
+        modifier = modifier
+            .widthIn(max = 320.dp)
+            .aspectRatio(16f / 9f)
+            .onFocusChanged { state ->
+                focusState.value = state
+            }
+            .touchable {
+                if (isFocused()) onClick(movie) else onFocus()
+            },
+        image = {
+            ImageAny(
+                src = movie?.cardImageUrl,
+                contentDescription = movie?.description,
+                contentScale = ContentScale.Crop,
+                placeholder = R.drawable.placeholder
+            )
+        },
+        title = {
+            TextAny(
+                modifier = Modifier.padding(4.dp),
+                text = movie?.title
+            )
+        },
+        subtitle = {
+            TextAny(
+                modifier = Modifier.padding(4.dp),
+                text = movie?.studio
+            )
+        },
+        description = {
+        },
+        onClick = {
+            onClick(movie)
+        },
+        glow = CardDefaults.glow(
+            glow = Glow.None,
+            focusedGlow = Glow(
+                elevationColor = Color.Green,
+                elevation = 10.dp
+            ),
+            pressedGlow = Glow.None
+        )
+    )
+
+}

+ 95 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/components/MyComposeView.kt

@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.components;
+
+import android.content.Context
+import android.graphics.PixelFormat
+import android.view.Gravity
+import android.view.View
+import android.view.WindowManager
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.AbstractComposeView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.ViewModelStoreOwner
+import androidx.lifecycle.setViewTreeLifecycleOwner
+import androidx.lifecycle.setViewTreeViewModelStoreOwner
+import androidx.savedstate.SavedStateRegistry
+import androidx.savedstate.SavedStateRegistryController
+import androidx.savedstate.SavedStateRegistryOwner
+import androidx.savedstate.setViewTreeSavedStateRegistryOwner
+
+class MyComposeView(val composeView: View) : AbstractComposeView(
+    composeView.context
+), LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {
+
+    private val savedStateRegistryController by lazy {
+        SavedStateRegistryController.create(this)
+    }
+
+    override val lifecycle: Lifecycle by lazy {
+        LifecycleRegistry(this)
+    }
+
+    override val viewModelStore: ViewModelStore by lazy {
+        ViewModelStore()
+    }
+
+    override val savedStateRegistry: SavedStateRegistry
+        get() = savedStateRegistryController.savedStateRegistry
+
+
+    private var viewShowing:Boolean = false
+
+    private fun handleLifecycleEvent(event: Lifecycle.Event) =
+        (lifecycle as LifecycleRegistry).handleLifecycleEvent(event)
+
+
+    private val windowManager by lazy {
+        composeView.context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+    }
+
+    private var params: WindowManager.LayoutParams = WindowManager.LayoutParams()
+        .apply {
+            gravity = Gravity.TOP
+            type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
+            token = composeView.applicationWindowToken
+            width = WindowManager.LayoutParams.WRAP_CONTENT
+            height = WindowManager.LayoutParams.WRAP_CONTENT
+            format = PixelFormat.TRANSLUCENT
+            flags = flags or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
+        }
+
+    init {
+        composeView.setViewTreeLifecycleOwner(this)
+        composeView.setViewTreeViewModelStoreOwner(this)
+        composeView.setViewTreeSavedStateRegistryOwner(this)
+    }
+
+    @Composable
+    override fun Content() {
+        // Some Compose Drawing Function
+    }
+
+    fun show() {
+        if (viewShowing) dismiss()
+        windowManager.addView(this, params)
+        viewShowing = true
+    }
+
+    fun dismiss() {
+        if (!viewShowing) return
+        disposeComposition()
+        windowManager.removeViewImmediate(this)
+        viewShowing = false
+    }
+
+}

+ 44 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/AboutScreen.kt

@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.screens
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavHostController
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.navigation.MenuItem
+import org.mjdev.tvapp.base.navigation.Screen
+
+class AboutScreen : Screen() {
+
+    override val titleResId = R.string.title_about
+
+    override val menuTitleResId: Int = R.string.title_about
+
+    override val menuIcon: ImageVector get() = Icons.Filled.AccountCircle
+
+    @TvPreview
+    @Composable
+    override fun Compose() = super.Compose()
+
+    @Composable
+    override fun Compose(
+        navController: NavHostController?,
+        backStackEntry: NavBackStackEntry?,
+        menuItems: List<MenuItem>,
+        args: Map<String, Any?>
+    ) {
+
+    }
+
+}

+ 84 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/DetailScreen.kt

@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.screens
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+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.NavBackStackEntry
+import androidx.navigation.NavHostController
+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.MenuItem
+import org.mjdev.tvapp.base.navigation.Screen
+import org.mjdev.tvapp.state.DetailsLoadingState
+import org.mjdev.tvapp.ui.components.Details
+import org.mjdev.tvapp.ui.components.Loading
+import org.mjdev.tvapp.viewmodel.DetailViewModel
+
+class DetailScreen : Screen() {
+
+    private val movieId = "movieId"
+
+    override val titleResId = R.string.title_movie_detail
+
+    override val args = listOf(
+        navArgument(movieId) {
+            defaultValue = -1
+            type = NavType.LongType
+        }
+    )
+
+    override val menuTitleResId: Int = -1
+
+    override val menuIcon: ImageVector get() = Icons.Filled.ArrowBack
+
+    @TvPreview
+    @Composable
+    override fun Compose() = super.Compose()
+
+    @Composable
+    override fun Compose(
+        navController: NavHostController?,
+        backStackEntry: NavBackStackEntry?,
+        menuItems: List<MenuItem>,
+        args: Map<String, Any?>
+    ) {
+
+        val viewModel = appViewModel<DetailViewModel>()
+        val movieId: Long? = remember { args[movieId] as Long? }
+        val movieState = remember { viewModel.detailsLoadingState(movieId) }.collectAsState()
+
+        fun postError(error: String) = viewModel.postError(error.asException())
+
+        when (movieState.value) {
+            is DetailsLoadingState.Ready ->
+                Details(movie = (movieState.value as DetailsLoadingState.Ready).movie)
+
+            is DetailsLoadingState.NotFound -> {
+                postError("Movie for id: $movieId not found.")
+                Loading()
+            }
+
+            else -> {
+                postError("Movie for id: $movieId not found.")
+                Loading()
+            }
+        }
+
+    }
+
+}

+ 172 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/MainScreen.kt

@@ -0,0 +1,172 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavHostController
+import androidx.tv.foundation.lazy.list.TvLazyRow
+import androidx.tv.foundation.lazy.list.items
+import androidx.tv.material3.Text
+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.MenuItem
+import org.mjdev.tvapp.base.navigation.Screen
+import org.mjdev.tvapp.base.network.NetworkStatus
+import org.mjdev.tvapp.base.ui.components.complex.ErrorMessage
+import org.mjdev.tvapp.base.ui.components.complex.ScreenView
+import org.mjdev.tvapp.base.ui.components.complex.ScrollableTvLazyRow
+import org.mjdev.tvapp.base.ui.components.complex.bigCarousel
+import org.mjdev.tvapp.base.ui.components.complex.header
+import org.mjdev.tvapp.base.ui.components.complex.tabs
+import org.mjdev.tvapp.ui.components.MovieCard
+import org.mjdev.tvapp.viewmodel.MainViewModel
+
+class MainScreen : Screen() {
+
+    override val titleResId = R.string.app_name
+
+    override val args = listOf<NamedNavArgument>()
+
+    override val menuTitleResId: Int = R.string.menu_item_home
+
+    override val menuIcon: ImageVector get() = Icons.Filled.Home
+
+    @TvPreview
+    @Composable
+    override fun Compose() = super.Compose()
+
+    @Composable
+    override fun Compose(
+        navController: NavHostController?,
+        backStackEntry: NavBackStackEntry?,
+        menuItems: List<MenuItem>,
+        args: Map<String, Any?>
+    ) {
+
+        val viewModel = appViewModel<MainViewModel>()
+
+        val categoryList = remember { viewModel.categoryList }.collectAsState()
+        val featuredMovieList = remember { viewModel.featuredMovieList }.collectAsState()
+        val messages = remember { viewModel.messages }.collectAsState()
+        val networkState = remember { viewModel.networkInfo.networkStatus }.collectAsState(null)
+
+        ScreenView(
+            navController = navController,
+            title = stringResource(titleResId),
+            menuItems = menuItems
+        ) { state ->
+
+            viewModel.handleError { error ->
+                state.error(error)
+            }
+
+            ScrollableTvLazyRow(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .background(Color.DarkGray),
+                verticalArrangement = Arrangement.spacedBy(32.dp),
+                contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
+            ) {
+
+                header(
+                    title = state.titleState.value,
+                    backgroundColor = Color.DarkGray,
+                    messagesCount = messages.value.size
+                )
+
+                if (networkState.value !is NetworkStatus.Connected) {
+                    item {
+                        ErrorMessage(
+                            error = stringResource(R.string.no_network).asException(),
+                            backgroundColor = Color.Black
+                        )
+                    }
+                }
+
+                tabs(
+                    items = categoryList.value.map { it.name }
+                )
+
+                if (featuredMovieList.value.isNotEmpty()) {
+                    bigCarousel(
+                        items = featuredMovieList.value
+                    )
+                }
+
+                items(categoryList.value) { category ->
+
+                    Text(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(0.dp, 16.dp),
+                        text = category.name,
+                        color = Color.White,
+                        fontWeight = FontWeight.Bold
+                    )
+
+                    TvLazyRow(
+                        horizontalArrangement = Arrangement.spacedBy(16.dp),
+                        modifier = Modifier.height(200.dp),
+                        contentPadding = PaddingValues(
+                            start = 16.dp,
+                            top = 16.dp,
+                            bottom = 16.dp,
+                            end = 16.dp
+                        )
+                    ) {
+
+                        items(category.movieList) { item ->
+
+                            MovieCard(
+                                movie = item,
+                                onClick = { movie ->
+                                    if (movie == null) {
+                                        viewModel.postError(Exception("No media found."))
+                                    } else if (movie.hasVideoUri) {
+                                        open<PlayerScreen>(navController, movie.id)
+                                    } else {
+                                        open<DetailScreen>(navController, movie.id)
+                                    }
+                                }
+                            )
+
+                        }
+
+                    }
+
+                }
+
+            }
+
+        }
+
+    }
+
+}

+ 90 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/PlayerScreen.kt

@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.screens
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavHostController
+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.MenuItem
+import org.mjdev.tvapp.base.navigation.Screen
+import org.mjdev.tvapp.state.DetailsLoadingState
+import org.mjdev.tvapp.ui.components.Loading
+import org.mjdev.tvapp.ui.components.MediaPlayer
+import org.mjdev.tvapp.viewmodel.DetailViewModel
+
+class PlayerScreen : Screen() {
+
+    private val movieId = "movieId"
+
+    override val titleResId = R.string.title_movie_player
+
+    override val args = listOf(
+        navArgument(movieId) {
+            defaultValue = -1
+            type = NavType.LongType
+        }
+    )
+
+    override val menuTitleResId: Int = -1
+
+    override val menuIcon: ImageVector get() = Icons.Filled.PlayArrow
+
+    @TvPreview
+    @Composable
+    override fun Compose() = super.Compose()
+
+    @Composable
+    override fun Compose(
+        navController: NavHostController?,
+        backStackEntry: NavBackStackEntry?,
+        menuItems: List<MenuItem>,
+        args: Map<String, Any?>
+    ) {
+
+        val viewModel = appViewModel<DetailViewModel>()
+        val movieId: Long? = remember { args[movieId] as Long? }
+        val movieState = remember {
+            viewModel?.detailsLoadingState(movieId)
+        }?.collectAsState()
+
+        fun postError(error: String) = viewModel?.postError(error.asException())
+
+        when (movieState?.value) {
+            is DetailsLoadingState.Ready -> MediaPlayer(
+                modifier = Modifier.fillMaxSize(),
+                movie = (movieState.value as DetailsLoadingState.Ready).movie
+            )
+
+            is DetailsLoadingState.NotFound -> {
+                postError("Movie for id: $movieId not found.")
+                Loading()
+            }
+
+            else -> {
+                postError("Movie for id: $movieId not found.")
+                Loading()
+            }
+        }
+
+    }
+
+}

+ 15 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/SplashScreen.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.ui.screens
+
+import org.mjdev.tvapp.base.navigation.Screen
+
+class SplashScreen : Screen() {
+
+}

+ 44 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/screens/SubscriptionScreen.kt

@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.screens
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavHostController
+import org.mjdev.tvapp.R
+import org.mjdev.tvapp.base.annotations.TvPreview
+import org.mjdev.tvapp.base.navigation.MenuItem
+import org.mjdev.tvapp.base.navigation.Screen
+
+class SubscriptionScreen : Screen() {
+
+    override val titleResId = R.string.title_subscription
+
+    override val menuTitleResId: Int = R.string.title_subscription
+
+    override val menuIcon: ImageVector get() = Icons.Filled.ArrowBack
+
+    @TvPreview
+    @Composable
+    override fun Compose() = super.Compose()
+
+    @Composable
+    override fun Compose(
+        navController: NavHostController?,
+        backStackEntry: NavBackStackEntry?,
+        menuItems: List<MenuItem>,
+        args: Map<String, Any?>
+    ) {
+
+    }
+
+}

+ 36 - 0
app/src/main/kotlin/org/mjdev/tvapp/ui/theme/Theme.kt

@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.Composable
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.darkColorScheme
+import androidx.tv.material3.lightColorScheme
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun TVAppTheme(
+    useDarkTheme: Boolean = isSystemInDarkTheme(),
+    content: @Composable () -> Unit,
+) {
+
+    val colorScheme = if (useDarkTheme) {
+        darkColorScheme()
+    } else {
+        lightColorScheme()
+    }
+
+    MaterialTheme(
+        colorScheme = colorScheme,
+        content = content
+    )
+
+}

+ 52 - 0
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/DetailViewModel.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.viewmodel
+
+import android.content.Context
+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.repository.MovieRepository
+import org.mjdev.tvapp.state.DetailsLoadingState
+import javax.inject.Inject
+
+@HiltViewModel
+class DetailViewModel @Inject constructor(
+    private val movieRepository: IRepository
+) : BaseViewModel() {
+
+    fun detailsLoadingState(
+        movieId: Long?
+    ): StateFlow<DetailsLoadingState> = flow {
+        val movie = movieRepository.findMovieById(id = movieId)
+        val state = if (movie == null) {
+            DetailsLoadingState.NotFound
+        } else {
+            DetailsLoadingState.Ready(movie = movie)
+        }
+        emit(state)
+    }.stateIn(
+        viewModelScope,
+        SharingStarted.WhileSubscribed(5000L),
+        DetailsLoadingState.Loading
+    )
+
+    companion object {
+
+        @Suppress("unused", "UNUSED_PARAMETER")
+        fun mock(context: Context) = DetailViewModel(MovieRepository(MovieAPI()))
+
+    }
+}

+ 58 - 0
app/src/main/kotlin/org/mjdev/tvapp/viewmodel/MainViewModel.kt

@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+package org.mjdev.tvapp.viewmodel
+
+import android.content.Context
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+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.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.repository.MovieRepository
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+    private val movieRepository: IRepository,
+    var networkInfo: NetworkConnectivityService
+) : BaseViewModel() {
+
+    val messages: StateFlow<List<Message>> = channelFlow<List<Message>> {
+        send(listOf())
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), listOf())
+
+    val featuredMovieList: StateFlow<List<Movie>> = flow {
+        emit(movieRepository.getFeaturedMovieList())
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), listOf())
+
+    val categoryList: StateFlow<List<Category>> = flow {
+        emit(movieRepository.getCategoryList())
+    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), listOf())
+
+    companion object {
+
+        @Suppress("unused")
+        fun mock(context: Context) = MainViewModel(
+            MovieRepository(MovieAPI()),
+            NetworkConnectivityServiceImpl(context),
+        )
+
+    }
+
+}

二進制
app/src/main/res/drawable/milanj.png


二進制
app/src/main/res/drawable/person.png


二進制
app/src/main/res/drawable/placeholder.png


二進制
app/src/main/res/mipmap-hdpi/ic_launcher.webp


二進制
app/src/main/res/mipmap-mdpi/ic_launcher.webp


二進制
app/src/main/res/mipmap-xhdpi/ic_launcher.webp


二進制
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp


二進制
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp


+ 15 - 0
app/src/main/res/values/colors.xml

@@ -0,0 +1,15 @@
+<!--
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+-->
+
+<resources>
+
+    <color name="black">#ff000000</color>
+
+</resources>

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

@@ -0,0 +1,24 @@
+<!--
+/*
+ * 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="error">error</string>
+    <string name="error_unknown">Unknown error.</string>
+    <string name="dismiss">dismiss</string>
+    <string name="menu_item_home">home</string>
+    <string name="title_movie_detail">movie detail</string>
+    <string name="title_about">about</string>
+    <string name="title_movie_player">movie player</string>
+    <string name="title_subscription">subscription</string>
+    <string name="no_network">Internet connection is missing.</string>
+
+</resources>

+ 22 - 0
app/src/main/res/values/themes.xml

@@ -0,0 +1,22 @@
+<!--
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+-->
+
+<resources>
+
+    <style name="Theme.TvApp" parent="">
+
+        <item name="android:windowNoTitle">true</item>
+        <item name="background">@color/black</item>
+        <item name="android:background">@color/black</item>
+        <item name="android:windowBackground">@color/black</item>
+
+    </style>
+
+</resources>

+ 14 - 0
build.gradle.kts

@@ -0,0 +1,14 @@
+/*
+ * Copyright (c) Milan Jurkulák 2023.
+ * Contact:
+ * e: mimoccc@gmail.com
+ * e: mj@mjdev.org
+ * w: https://mjdev.org
+ */
+
+plugins {
+    id("com.android.application") version "8.0.2" apply false
+    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
+}

二進制
buildSrc/.gradle/8.0/checksums/checksums.lock


二進制
buildSrc/.gradle/8.0/checksums/md5-checksums.bin


二進制
buildSrc/.gradle/8.0/checksums/sha1-checksums.bin


二進制
buildSrc/.gradle/8.0/dependencies-accessors/dependencies-accessors.lock


+ 0 - 0
buildSrc/.gradle/8.0/dependencies-accessors/gc.properties


二進制
buildSrc/.gradle/8.0/executionHistory/executionHistory.bin


Some files were not shown because too many files changed in this diff