├── .idea
├── .name
├── .gitignore
├── compiler.xml
├── vcs.xml
├── misc.xml
├── jarRepositories.xml
└── gradle.xml
├── app
├── .gitignore
├── src
│ └── main
│ │ ├── res
│ │ ├── drawable
│ │ │ ├── boy.png
│ │ │ ├── boy3.png
│ │ │ ├── girl.png
│ │ │ ├── men.jpeg
│ │ │ ├── old.png
│ │ │ ├── song.png
│ │ │ ├── ic_app.png
│ │ │ ├── hero_item.jpg
│ │ │ ├── profile.jpg
│ │ │ ├── ic_next.xml
│ │ │ ├── ic_previous.xml
│ │ │ ├── ic_play.xml
│ │ │ ├── ic_menu.xml
│ │ │ ├── ic_repeat.xml
│ │ │ ├── ic_pause.xml
│ │ │ ├── ic_info.xml
│ │ │ ├── ic_star.xml
│ │ │ ├── ic_shuffle.xml
│ │ │ ├── ic_auto_awesome_motion.xml
│ │ │ ├── ic_cc.xml
│ │ │ ├── ic_search.xml
│ │ │ └── ic_settings.xml
│ │ └── values
│ │ │ ├── themes.xml
│ │ │ └── strings.xml
│ │ ├── java
│ │ └── com
│ │ │ └── techlads
│ │ │ └── composetv
│ │ │ ├── utils
│ │ │ ├── ImageUtils.kt
│ │ │ ├── EventPropagation.kt
│ │ │ └── extensions.kt
│ │ │ ├── features
│ │ │ ├── cast
│ │ │ │ ├── Person.kt
│ │ │ │ └── PersonCard.kt
│ │ │ ├── settings
│ │ │ │ ├── data
│ │ │ │ │ └── SettingsMenuModel.kt
│ │ │ │ ├── navigation
│ │ │ │ │ ├── SettingsScreens.kt
│ │ │ │ │ ├── NestedSettingsNavigation.kt
│ │ │ │ │ └── NestedHomeScreenNavigation.kt
│ │ │ │ ├── SettingsMenuData.kt
│ │ │ │ ├── SettingsMenuViewModel.kt
│ │ │ │ ├── SettingsMenuItem.kt
│ │ │ │ ├── SettingsMenu.kt
│ │ │ │ ├── SettingsScreen.kt
│ │ │ │ └── screens
│ │ │ │ │ ├── about
│ │ │ │ │ └── AboutMeScreen.kt
│ │ │ │ │ ├── Container.kt
│ │ │ │ │ └── profile
│ │ │ │ │ └── ProfileScreen.kt
│ │ │ ├── Movie.kt
│ │ │ ├── wiw
│ │ │ │ ├── data
│ │ │ │ │ └── Avatar.kt
│ │ │ │ └── WhoIsWatching.kt
│ │ │ ├── home
│ │ │ │ ├── hero
│ │ │ │ │ └── HeroItemState.kt
│ │ │ │ ├── carousel
│ │ │ │ │ ├── CardPayload.kt
│ │ │ │ │ ├── CarouselItemPayload.kt
│ │ │ │ │ ├── TestingTags.kt
│ │ │ │ │ ├── HomeCarouselState.kt
│ │ │ │ │ ├── HorizontalCarouselItem.kt
│ │ │ │ │ ├── CarouselItem.kt
│ │ │ │ │ └── HomeCarousel.kt
│ │ │ │ ├── HomeScreen.kt
│ │ │ │ ├── leftmenu
│ │ │ │ │ ├── model
│ │ │ │ │ │ └── MenuItem.kt
│ │ │ │ │ └── data
│ │ │ │ │ │ └── MenuData.kt
│ │ │ │ ├── navigation
│ │ │ │ │ ├── NestedScreens.kt
│ │ │ │ │ ├── NestedHomeNavigation.kt
│ │ │ │ │ └── NestedHomeScreenNavigation.kt
│ │ │ │ └── HomeScreenContent.kt
│ │ │ ├── keyboard
│ │ │ │ └── KeysGenerator.kt
│ │ │ ├── songs
│ │ │ │ └── data
│ │ │ │ │ └── SongsTagsData.kt
│ │ │ ├── player
│ │ │ │ ├── controls
│ │ │ │ │ ├── ControllerText.kt
│ │ │ │ │ ├── PlayerControlsState.kt
│ │ │ │ │ ├── PlayerControlsIcon.kt
│ │ │ │ │ └── PlayerControlsIndicator.kt
│ │ │ │ └── PlayerScreen.kt
│ │ │ ├── mp3
│ │ │ │ └── player
│ │ │ │ │ └── AudioPlayer.kt
│ │ │ ├── details
│ │ │ │ └── ArrowButton.kt
│ │ │ ├── favorites
│ │ │ │ └── FavoritesScreen.kt
│ │ │ ├── movies
│ │ │ │ └── MoviesScreen.kt
│ │ │ └── search
│ │ │ │ └── SearchScreen.kt
│ │ │ ├── MainApp.kt
│ │ │ ├── theme
│ │ │ ├── Shape.kt
│ │ │ ├── Type.kt
│ │ │ └── Color.kt
│ │ │ ├── navigation
│ │ │ └── Screens.kt
│ │ │ ├── ExitAppDelegate.kt
│ │ │ └── MainActivity.kt
│ │ └── AndroidManifest.xml
└── build.gradle.kts
├── libs
├── benchmark
│ ├── .gitignore
│ ├── src
│ │ └── main
│ │ │ ├── AndroidManifest.xml
│ │ │ └── java
│ │ │ └── com
│ │ │ └── techlads
│ │ │ └── composetv
│ │ │ └── benchmark
│ │ │ ├── TestingUtils.kt
│ │ │ ├── SkipLoginBenchMark.kt
│ │ │ ├── ScrollBenchMark.kt
│ │ │ └── StartupBenchmark.kt
│ └── build.gradle.kts
├── baselineprofile
│ ├── .gitignore
│ ├── src
│ │ └── main
│ │ │ ├── AndroidManifest.xml
│ │ │ └── java
│ │ │ └── com
│ │ │ └── techlads
│ │ │ └── composetv
│ │ │ └── baselineprofile
│ │ │ ├── BaselineProfileGenerator.kt
│ │ │ └── StartupBenchmarks.kt
│ └── build.gradle.kts
├── exoplayer
│ ├── src
│ │ └── main
│ │ │ ├── AndroidManifest.xml
│ │ │ └── java
│ │ │ └── com
│ │ │ └── techlads
│ │ │ └── exoplayer
│ │ │ ├── PlayerFactory.kt
│ │ │ ├── ExoPlayerStateListener.kt
│ │ │ └── ExoPlayerImpl.kt
│ └── build.gradle.kts
├── player
│ ├── src
│ │ └── main
│ │ │ ├── AndroidManifest.xml
│ │ │ └── java
│ │ │ └── com
│ │ │ └── techlads
│ │ │ └── player
│ │ │ └── domain
│ │ │ ├── state
│ │ │ ├── PlayerStateListener.kt
│ │ │ └── PlayerState.kt
│ │ │ └── TLPlayer.kt
│ └── build.gradle.kts
├── auth
│ ├── src
│ │ └── main
│ │ │ └── kotlin
│ │ │ └── com
│ │ │ └── techlads
│ │ │ └── auth
│ │ │ ├── data
│ │ │ └── User.kt
│ │ │ ├── AuthTokenProvider.kt
│ │ │ ├── AuthState.kt
│ │ │ └── UserSession.kt
│ └── build.gradle.kts
├── ui-components
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ └── java
│ │ └── com
│ │ └── techlads
│ │ └── uicomponents
│ │ └── widgets
│ │ ├── CardItemDefaults.kt
│ │ ├── ThumbnailImageCard.kt
│ │ ├── TvButton.kt
│ │ ├── FocusableItem.kt
│ │ └── BorderedFocusableItem.kt
├── network
│ ├── src
│ │ └── main
│ │ │ └── java
│ │ │ └── com
│ │ │ └── techlads
│ │ │ └── network
│ │ │ ├── ApiResult.kt
│ │ │ ├── AuthInterceptor.kt
│ │ │ └── di
│ │ │ └── NetworkModule.kt
│ └── build.gradle.kts
├── auth-imp
│ ├── build.gradle.kts
│ └── src
│ │ └── main
│ │ └── kotlin
│ │ └── com
│ │ └── techlads
│ │ └── auth
│ │ └── imp
│ │ └── di
│ │ └── AuthModule.kt
└── content
│ ├── src
│ └── main
│ │ └── java
│ │ └── com
│ │ └── techlads
│ │ └── content
│ │ ├── data
│ │ ├── CreditsResponse.kt
│ │ ├── MoviesResponse.kt
│ │ ├── MovieVideosResponse.kt
│ │ ├── LocalMoviesDataSource.kt
│ │ ├── RemoteMoviesDataSource.kt
│ │ ├── MovieResponse.kt
│ │ ├── FakeCastProvider.kt
│ │ └── MoviesRepository.kt
│ │ ├── di
│ │ └── RepositoryModule.kt
│ │ └── MoviesService.kt
│ └── build.gradle.kts
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── build-logic
│ ├── gradle.properties
│ ├── convention
│ ├── src
│ │ └── main
│ │ │ └── kotlin
│ │ │ └── com
│ │ │ └── techlads
│ │ │ └── gradle
│ │ │ └── plugins
│ │ │ ├── utils
│ │ │ ├── Versions.kt
│ │ │ ├── Kotlin.kt
│ │ │ ├── Android.kt
│ │ │ ├── VersionCatalog.kt
│ │ │ ├── AndroidCompose.kt
│ │ │ └── Dependencies.kt
│ │ │ ├── FeatureConventionPlugin.kt
│ │ │ ├── HiltConventionPlugin.kt
│ │ │ ├── LibraryConventionPlugin.kt
│ │ │ ├── AppConventionPlugin.kt
│ │ │ ├── ComposeConventionPlugin.kt
│ │ │ └── JvmConventionPlugin.kt
│ └── build.gradle.kts
│ └── settings.gradle.kts
├── CONTRIBUTING.md
├── .github
├── ISSUE_TEMPLATE
│ ├── custom.md
│ ├── feature_request.md
│ └── bug_report.md
├── dependabot.yml
├── workflows
│ ├── greetings.yml
│ └── android.yml
└── FUNDING.yml
├── features
├── login
│ ├── src
│ │ └── main
│ │ │ └── java
│ │ │ └── com
│ │ │ └── techlads
│ │ │ └── login
│ │ │ ├── withEmailPassword
│ │ │ ├── Image.kt
│ │ │ ├── CrossFadeState.kt
│ │ │ ├── ScreenHeading.kt
│ │ │ ├── LoginScreen.kt
│ │ │ ├── BackgroundState.kt
│ │ │ ├── BackgroundViewModel.kt
│ │ │ └── TvTextField.kt
│ │ │ ├── withToken
│ │ │ └── DeviceTokenAuthentication.kt
│ │ │ └── LandingScreen.kt
│ └── build.gradle.kts
└── config
│ ├── build.gradle.kts
│ └── src
│ └── main
│ └── java
│ └── com
│ └── techlads
│ └── config
│ ├── data
│ ├── ConfigRepository.kt
│ └── ConfigResponse.kt
│ ├── di
│ └── RepositoryModule.kt
│ └── ConfigApiService.kt
├── .gitignore
├── SECURITY.md
├── settings.gradle.kts
├── gradle.properties
├── README.md
└── gradlew.bat
/.idea/.name:
--------------------------------------------------------------------------------
1 | ComposeTV
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/libs/benchmark/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/libs/baselineprofile/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/libs/benchmark/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/libs/baselineprofile/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/libs/exoplayer/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/boy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UmairKhalid786/ComposeTv/HEAD/app/src/main/res/drawable/boy.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/boy3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UmairKhalid786/ComposeTv/HEAD/app/src/main/res/drawable/boy3.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/girl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UmairKhalid786/ComposeTv/HEAD/app/src/main/res/drawable/girl.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/men.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UmairKhalid786/ComposeTv/HEAD/app/src/main/res/drawable/men.jpeg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/old.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UmairKhalid786/ComposeTv/HEAD/app/src/main/res/drawable/old.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/song.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UmairKhalid786/ComposeTv/HEAD/app/src/main/res/drawable/song.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UmairKhalid786/ComposeTv/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UmairKhalid786/ComposeTv/HEAD/app/src/main/res/drawable/ic_app.png
--------------------------------------------------------------------------------
/app/src/main/res/drawable/hero_item.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UmairKhalid786/ComposeTv/HEAD/app/src/main/res/drawable/hero_item.jpg
--------------------------------------------------------------------------------
/app/src/main/res/drawable/profile.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UmairKhalid786/ComposeTv/HEAD/app/src/main/res/drawable/profile.jpg
--------------------------------------------------------------------------------
/libs/player/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | Want to contribute ? ✨
2 |
3 | - Fork the project
4 | - Fix code in your forked project
5 | - Create pull request targeting ComposeTv master branch
6 |
7 | THANK YOU ♥️
8 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/utils/ImageUtils.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.utils
2 |
3 | object ImageUtils {
4 | const val POSTER_BASE = "https://image.tmdb.org/t/p/w500"
5 | }
--------------------------------------------------------------------------------
/libs/auth/src/main/kotlin/com/techlads/auth/data/User.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.auth.data
2 |
3 | data class User(
4 | val id: String,
5 | val name: String?,
6 | val email: String?
7 | )
--------------------------------------------------------------------------------
/libs/player/src/main/java/com/techlads/player/domain/state/PlayerStateListener.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.player.domain.state
2 |
3 | interface PlayerStateListener {
4 | fun on(state: PlayerState)
5 | }
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/custom.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Custom issue template
3 | about: Describe this issue template's purpose here.
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/cast/Person.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.cast
2 |
3 | data class Person(
4 | val id: Int,
5 | val name: String,
6 | val imageUrl: String
7 | )
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/data/SettingsMenuModel.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings.data
2 |
3 | data class SettingsMenuModel(val text: String, val navigation: String)
4 |
--------------------------------------------------------------------------------
/features/login/src/main/java/com/techlads/login/withEmailPassword/Image.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.login.withEmailPassword
2 |
3 | data class Image(
4 | val url: String,
5 | val width: Int,
6 | val height: Int)
--------------------------------------------------------------------------------
/gradle/build-logic/gradle.properties:
--------------------------------------------------------------------------------
1 | # Gradle properties are not passed to included builds https://github.com/gradle/gradle/issues/2534
2 | org.gradle.parallel=true
3 | org.gradle.caching=true
4 | org.gradle.configureondemand=true
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/utils/EventPropagation.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.utils
2 |
3 | object EventPropagation {
4 | const val StopPropagation = true
5 | const val ContinuePropagation = false
6 | }
7 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/Movie.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features
2 |
3 | data class Movie(
4 | val imageUrl: String,
5 | val title: String,
6 | val metadata: String,
7 | val details: String,
8 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/wiw/data/Avatar.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.wiw.data
2 |
3 | import androidx.annotation.DrawableRes
4 |
5 | data class Avatar(val title: String, @DrawableRes val image: Int)
6 |
--------------------------------------------------------------------------------
/features/login/src/main/java/com/techlads/login/withEmailPassword/CrossFadeState.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.login.withEmailPassword
2 |
3 | data class CrossFadeState(
4 | val images: List,
5 | val durationMs: Float,
6 | val delaySec: Long
7 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/hero/HeroItemState.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.hero
2 |
3 | import com.techlads.composetv.features.Movie
4 |
5 | data class HeroItemState(
6 | val list: List = emptyList(),
7 | )
--------------------------------------------------------------------------------
/libs/player/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.techlads.android.library")
3 | }
4 |
5 | android {
6 | namespace = "com.techlads.player"
7 | }
8 |
9 | dependencies {
10 | implementation(libs.androidx.core.ktx)
11 | api(libs.timber)
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/carousel/CardPayload.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.carousel
2 |
3 | data class CardPayload(
4 | val id: String,
5 | val title: String,
6 | val image: String,
7 | val promo: String?,
8 | )
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/utils/Versions.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins.utils
2 |
3 | object Versions {
4 | const val COMPILE_SDK = 36
5 | const val MIN_SDK = 24 // Android 7.0
6 | const val TARGET_SDK = 34
7 | }
--------------------------------------------------------------------------------
/libs/auth/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.techlads.android.library")
3 | }
4 |
5 | android {
6 | namespace = "com.techlads.auth"
7 | }
8 |
9 | dependencies {
10 | implementation(libs.kotlinx.datetime)
11 | implementation(libs.kotlinx.coroutines.core)
12 | }
--------------------------------------------------------------------------------
/libs/auth/src/main/kotlin/com/techlads/auth/AuthTokenProvider.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.auth
2 |
3 | /** Narrow surface area for networking modules. */
4 | interface AuthTokenProvider {
5 | /** Null if logged out or token missing/expired. */
6 | suspend fun accessTokenOrNull(): String?
7 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/carousel/CarouselItemPayload.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.carousel
2 |
3 | data class CarouselItemPayload(
4 | val id: String,
5 | val title: String,
6 | val type: String,
7 | val items: List,
8 | )
--------------------------------------------------------------------------------
/libs/ui-components/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.techlads.android.library")
3 | id("com.techlads.android.compose")
4 | }
5 |
6 | android {
7 | namespace = "com.techlads.uicomponents"
8 | }
9 |
10 | dependencies {
11 | implementation(libs.bundles.compose.tv)
12 | }
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 | /.idea/
17 |
--------------------------------------------------------------------------------
/features/config/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.techlads.android.feature")
3 | id("com.techlads.android.hilt")
4 | alias(libs.plugins.kotlin.serialization)
5 | }
6 |
7 | android {
8 | namespace = "com.techlads.config"
9 | }
10 |
11 | dependencies {
12 | implementation(projects.libs.network)
13 | }
--------------------------------------------------------------------------------
/libs/benchmark/src/main/java/com/techlads/composetv/benchmark/TestingUtils.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.benchmark
2 |
3 | import androidx.benchmark.macro.MacrobenchmarkScope
4 | import androidx.test.uiautomator.By
5 |
6 |
7 | fun MacrobenchmarkScope.skip() {
8 | device.findObject(By.res("Skip")).click()
9 | }
10 |
--------------------------------------------------------------------------------
/features/config/src/main/java/com/techlads/config/data/ConfigRepository.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.config.data
2 |
3 | import com.techlads.config.ConfigApiService
4 | import javax.inject.Inject
5 |
6 | class ConfigRepository @Inject constructor(private val config: ConfigApiService) {
7 | suspend fun getConfig() = config.getConfig()
8 | }
--------------------------------------------------------------------------------
/libs/exoplayer/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.techlads.android.library")
3 | }
4 |
5 | android {
6 | namespace = "com.techlads.exoplayer"
7 | }
8 |
9 | dependencies {
10 | implementation(projects.libs.player)
11 |
12 | implementation(libs.androidx.core.ktx)
13 | implementation(libs.bundles.media3)
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/navigation/SettingsScreens.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings.navigation
2 |
3 | sealed class SettingsScreens(val title: String) {
4 | object Profile : SettingsScreens("profile")
5 | object AboutMe : SettingsScreens("about_me")
6 | object Logout : SettingsScreens("logout")
7 | }
8 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_next.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_previous.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/keyboard/KeysGenerator.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.keyboard
2 |
3 | object KeysGenerator {
4 | val alphabet = lazy { ('A'..'Z').toList() }
5 | val specialCharV1 = lazy { listOf("-", "'") }
6 | val alphabetLower = lazy { ('a'..'z').toList() }
7 | val numbers = lazy { ('0'..'9').toList() }
8 | }
9 |
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/utils/Kotlin.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins.utils
2 |
3 | import org.gradle.api.Project
4 | import org.gradle.kotlin.dsl.dependencies
5 |
6 | internal fun Project.configureKotlin() {
7 | dependencies {
8 | implementation(platform(library("kotlin-bom")))
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/wiw/WhoIsWatching.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.wiw
2 |
3 | import androidx.compose.runtime.Composable
4 | import com.techlads.composetv.features.wiw.data.Avatar
5 |
6 | @Composable
7 | fun WhoIsWatchingScreen(onProfileSelection: (avatar: Avatar) -> Unit) {
8 | WhoIsWatchingContent(onProfileSelection)
9 | }
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_play.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_menu.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/MainApp.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 | import timber.log.Timber
6 |
7 | @HiltAndroidApp
8 | class MainApp : Application() {
9 | override fun onCreate() {
10 | super.onCreate()
11 | Timber.plant(Timber.DebugTree())
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_repeat.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_pause.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/songs/data/SongsTagsData.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.songs.data
2 |
3 | import androidx.compose.ui.graphics.Color
4 | import java.util.Random
5 |
6 | object SongsTagsData {
7 | fun generateRandomColor(): Color {
8 | val rnd = Random()
9 | return Color(255, rnd.nextInt(256), rnd.nextInt(256), rnd.nextInt(256))
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_info.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/techlads/network/ApiResult.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.network
2 |
3 | sealed class ApiResult {
4 | data class Success(val data: T) : ApiResult()
5 | data class Error(val message: String) : ApiResult()
6 | }
7 |
8 | fun ApiResult.getOrElse() : T? {
9 | return when (this) {
10 | is ApiResult.Success -> this.data
11 | is ApiResult.Error -> null
12 | }
13 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_star.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/libs/auth-imp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.techlads.android.library")
3 | id("com.techlads.android.hilt")
4 | kotlin("plugin.serialization")
5 | }
6 |
7 | android {
8 | namespace = "com.techlads.authimp"
9 | }
10 |
11 | dependencies {
12 | api(projects.libs.auth)
13 | implementation(libs.androidx.datastore.preferences)
14 | implementation(libs.kotlinx.datetime)
15 | implementation(libs.kotlinx.coroutines.core)
16 | }
--------------------------------------------------------------------------------
/libs/content/src/main/java/com/techlads/content/data/CreditsResponse.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.content.data
2 |
3 | import kotlinx.serialization.Serializable
4 | import javax.inject.Named
5 |
6 | @Serializable
7 | data class CreditsResponse(
8 | val id: Int,
9 | val cast: List,
10 | )
11 |
12 | @Serializable
13 | data class Cast(
14 | val id: Int,
15 | val name: String,
16 | @Named("profile_path")
17 | val profilePath: String
18 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/HomeScreen.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home
2 |
3 | import androidx.compose.runtime.Composable
4 |
5 | @Composable
6 | fun HomeScreen(
7 | onItemFocus: (parent: String, id: String) -> Unit,
8 | onItemClick: (parent: String, id: String) -> Unit,
9 | onSongClick: () -> Unit,
10 | ) {
11 | HomeScreenContent(onItemClick = onItemClick, onItemFocus = onItemFocus, onSongClick)
12 | }
13 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/FeatureConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins
2 |
3 | import org.gradle.api.Plugin
4 | import org.gradle.api.Project
5 |
6 | class FeatureConventionPlugin : Plugin {
7 | override fun apply(target: Project) {
8 | with(target) {
9 | with(pluginManager) {
10 | apply("com.techlads.android.library")
11 | }
12 | }
13 | }
14 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/leftmenu/model/MenuItem.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.leftmenu.model
2 |
3 | import androidx.compose.ui.graphics.vector.ImageVector
4 | import com.techlads.composetv.features.home.leftmenu.data.MenuData.settingsItem
5 |
6 | data class MenuItem(
7 | val id: String,
8 | val text: String,
9 | val icon: ImageVector? = null,
10 | )
11 |
12 | fun MenuItem.isCircleIcon(): Boolean = id == settingsItem.id || id == "Search"
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/navigation/NestedScreens.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.navigation
2 |
3 | sealed class NestedScreens(val title: String) {
4 | object Home : NestedScreens("home")
5 | object Search : NestedScreens("search")
6 | object Movies : NestedScreens("movies")
7 | object Songs : NestedScreens("songs")
8 | object Favorites : NestedScreens("favourites")
9 | object Settings : NestedScreens("settings")
10 | }
11 |
--------------------------------------------------------------------------------
/libs/player/src/main/java/com/techlads/player/domain/state/PlayerState.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.player.domain.state
2 |
3 | sealed class PlayerState {
4 | object Playing : PlayerState()
5 | object Pause : PlayerState()
6 | object Stop : PlayerState()
7 | object Idle : PlayerState()
8 | object Buffering : PlayerState()
9 | object Complete : PlayerState()
10 |
11 | class SeekStart(position: Long) : PlayerState()
12 | class SeekEnd(position: Long) : PlayerState()
13 | }
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | We have code security standards being followed since 1.0.0
6 |
7 | | Version | Supported |
8 | | ------- | ------------------ |
9 | | > 1.x | :white_check_mark: |
10 |
11 | ## Reporting a Vulnerability
12 |
13 | How to report a vulnerability?
14 |
15 | Create an issue in the project and you can expect to get an update on a
16 | reported vulnerability, except if the vulnerability is accepted or
17 | declined, etc.
18 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/theme/Shape.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.theme
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.ui.unit.dp
5 | import androidx.tv.material3.Shapes
6 |
7 | val Shapes = Shapes(
8 | extraSmall = RoundedCornerShape(4.dp),
9 | small = RoundedCornerShape(8.dp),
10 | medium = RoundedCornerShape(12.dp),
11 | large = RoundedCornerShape(16.dp),
12 | extraLarge = RoundedCornerShape(24.dp),
13 | )
14 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_shuffle.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/libs/content/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.techlads.android.library")
3 | id("com.techlads.android.hilt")
4 | alias(libs.plugins.kotlin.serialization)
5 | }
6 |
7 | android {
8 | namespace = "com.techlads.content"
9 | }
10 |
11 | dependencies {
12 | api(projects.libs.network)
13 |
14 | implementation(libs.androidx.core.ktx)
15 | implementation(libs.androidx.appcompat)
16 | implementation(libs.material)
17 |
18 | implementation(libs.androidx.hilt.navigation.compose)
19 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/carousel/TestingTags.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.carousel
2 |
3 | const val SKIP_TAG = "Skip"
4 | const val HERO_ITEM_TAG = "hero_item"
5 | const val SECTIONS_LIST_TAG = "sections_list"
6 | const val SECTION_ITEM_TAG = "section_item_{parent}_{child}"
7 | const val PRODUCT_DETAIL_BANNER_TAG = "product_detail_banner"
8 |
9 | fun tagForItem(parent: Int, child: Int) = SECTION_ITEM_TAG.replace("{parent}", parent.toString()).replace("{child}", child.toString())
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/carousel/HomeCarouselState.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.carousel
2 |
3 | data class HomeCarouselState(
4 | val items: List,
5 | )
6 |
7 | fun HomeCarouselState.findIndexById(parentId: String, childId: String): Pair {
8 | val parentIndex = items.indexOfFirst { it.id == parentId }
9 | val childIndex = items[parentIndex].items.indexOfFirst { it.id == childId }
10 |
11 | return parentIndex to childIndex
12 | }
--------------------------------------------------------------------------------
/libs/auth/src/main/kotlin/com/techlads/auth/AuthState.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalTime::class)
2 |
3 | package com.techlads.auth
4 |
5 | import com.techlads.auth.data.User
6 | import kotlin.time.ExperimentalTime
7 | import kotlin.time.Instant
8 |
9 | sealed interface AuthState {
10 | data object LoggedOut : AuthState
11 | data class LoggedIn(
12 | val user: User,
13 | val accessToken: String,
14 | val refreshToken: String?,
15 | val expiresAt: Instant? // optional
16 | ) : AuthState
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_auto_awesome_motion.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/libs/content/src/main/java/com/techlads/content/data/MoviesResponse.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.content.data
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class MoviesResponse(
8 | val results: List
9 | )
10 |
11 | @Serializable
12 | data class MovieDto(
13 | val id: Int,
14 | val title: String,
15 | val overview: String,
16 | @SerialName("poster_path")
17 | val posterPath: String,
18 | @SerialName("backdrop_path")
19 | val backdropPath: String
20 | )
--------------------------------------------------------------------------------
/libs/network/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.techlads.android.library")
3 | id("com.techlads.android.hilt")
4 | }
5 |
6 | android {
7 | namespace = "com.techlads.network"
8 |
9 | defaultConfig {
10 | buildConfigField("String", "BASE_URL", "\"https://api.themoviedb.org/3\"")
11 | buildConfigField("String", "TMDB_API_KEY", "\"\"")
12 | }
13 |
14 | buildFeatures {
15 | buildConfig = true
16 | }
17 | }
18 |
19 | dependencies {
20 | implementation(projects.libs.auth)
21 | api(libs.bundles.ktor)
22 | }
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/utils/Android.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins.utils
2 |
3 | import com.android.build.api.dsl.CommonExtension
4 | import org.gradle.api.Project
5 |
6 | internal fun Project.configureAndroid(extension: CommonExtension<*, *, *, *, *, *>) {
7 | with(extension) {
8 | compileSdk = Versions.COMPILE_SDK
9 |
10 | defaultConfig {
11 | minSdk = Versions.MIN_SDK
12 |
13 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
14 | }
15 | }
16 | }
--------------------------------------------------------------------------------
/gradle/build-logic/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | dependencyResolutionManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | }
7 |
8 | versionCatalogs {
9 | create("libs") {
10 | from(files("../libs.versions.toml"))
11 | }
12 | }
13 | }
14 |
15 | plugins {
16 | id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
17 | }
18 |
19 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
20 |
21 | include(
22 | ":convention"
23 | )
24 |
25 | rootProject.name = "build-logic"
26 |
--------------------------------------------------------------------------------
/.github/workflows/greetings.yml:
--------------------------------------------------------------------------------
1 | name: Greetings
2 |
3 | on: [pull_request_target, issues]
4 |
5 | jobs:
6 | greeting:
7 | runs-on: ubuntu-latest
8 | permissions:
9 | issues: write
10 | pull-requests: write
11 | steps:
12 | - uses: actions/first-interaction@v1
13 | with:
14 | repo-token: ${{ secrets.GITHUB_TOKEN }}
15 | issue-message: "Thank you so much for opening this issue, one of the contributors will reply to you soon :)"
16 | pr-message: "Thank you so much for opening this PR, one of the contributor will review your PR within few days :)"
17 |
--------------------------------------------------------------------------------
/features/login/src/main/java/com/techlads/login/withEmailPassword/ScreenHeading.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.login.withEmailPassword
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.tooling.preview.Preview
6 | import androidx.tv.material3.Text
7 |
8 | @Composable
9 | fun ScreenHeading(heading: String) {
10 | Text(
11 | text = heading,
12 | style = MaterialTheme.typography.headlineLarge,
13 | )
14 | }
15 |
16 | @Preview
17 | @Composable
18 | fun ScreenHeadingPrev() {
19 | ScreenHeading("LOGIN")
20 | }
21 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/SettingsMenuData.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings
2 |
3 | import com.techlads.composetv.features.settings.data.SettingsMenuModel
4 | import com.techlads.composetv.features.settings.navigation.SettingsScreens
5 |
6 | object SettingsMenuData {
7 | val menu by lazy {
8 | listOf(
9 | SettingsMenuModel("Profile", SettingsScreens.Profile.title),
10 | SettingsMenuModel("About Me", SettingsScreens.AboutMe.title),
11 | SettingsMenuModel("Logout", SettingsScreens.Logout.title),
12 | )
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/utils/VersionCatalog.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins.utils
2 |
3 | import org.gradle.api.Project
4 | import org.gradle.api.artifacts.VersionCatalog
5 | import org.gradle.api.artifacts.VersionCatalogsExtension
6 | import org.gradle.kotlin.dsl.getByType
7 |
8 | internal val Project.libs: VersionCatalog
9 | get() = extensions.getByType().named("libs")
10 |
11 | internal fun Project.library(alias: String) = libs.findLibrary(alias).get()
12 |
13 | internal fun Project.plugin(alias: String) = libs.findPlugin(alias).get().get().pluginId
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/SettingsMenuViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.techlads.auth.UserSession
6 | import dagger.hilt.android.lifecycle.HiltViewModel
7 | import kotlinx.coroutines.launch
8 | import javax.inject.Inject
9 |
10 | @HiltViewModel
11 | class SettingsMenuViewModel @Inject constructor(
12 | private val userSession: UserSession
13 | ) : ViewModel() {
14 |
15 | fun logout() {
16 | viewModelScope.launch {
17 | userSession.logout()
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/libs/content/src/main/java/com/techlads/content/data/MovieVideosResponse.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.content.data
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class MovieVideosResponse(
8 | val id: Int, val results: List
9 | )
10 |
11 | @Serializable
12 | data class VideoDto(
13 | val name: String,
14 | val size: Int,
15 | val site: String,
16 | val type: String,
17 | val key: String,
18 | val official: Boolean,
19 | @SerialName("published_at") val publishedAt: String,
20 | @SerialName("iso_639_1") val iso639: String,
21 | @SerialName("iso_3166_1") val iso3166: String,
22 | )
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Compose TV
3 | Compose TV Icon
4 | Watch Trailer
5 | \"Batman ventures into Gotham City\'s underworld when a sadistic killer leaves behind a trail of cryptic clues. \"
6 | Batman
7 | 2022
8 | 2h 56mm
9 | Play
10 | Cast and Crew
11 | User Ratings
12 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/libs/auth-imp/src/main/kotlin/com/techlads/auth/imp/di/AuthModule.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.auth.imp.di
2 |
3 | import com.techlads.auth.AuthTokenProvider
4 | import com.techlads.auth.UserSession
5 | import com.techlads.auth.imp.DefaultUserSession
6 | import dagger.Binds
7 | import dagger.Module
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.components.SingletonComponent
10 | import javax.inject.Singleton
11 |
12 | @Module
13 | @InstallIn(SingletonComponent::class)
14 | abstract class AuthModule {
15 | @Binds @Singleton
16 | abstract fun bindUserSession(impl: DefaultUserSession): UserSession
17 | @Binds @Singleton
18 | abstract fun bindTokenProvider(impl: DefaultUserSession): AuthTokenProvider
19 | }
--------------------------------------------------------------------------------
/libs/player/src/main/java/com/techlads/player/domain/TLPlayer.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.player.domain
2 |
3 | import android.view.View
4 | import com.techlads.player.domain.state.PlayerStateListener
5 |
6 | interface TLPlayer {
7 | fun play()
8 | fun pause()
9 | fun stop()
10 |
11 | fun seekTo(positionMs: Long)
12 | fun seekForward()
13 | fun seekBack()
14 |
15 | fun prepare(uri: String, playWhenReady: Boolean)
16 | fun release()
17 | fun getView(): View
18 |
19 | val currentPosition: Long
20 | val duration: Long
21 | val isPlaying: Boolean
22 |
23 | fun setPlaybackEvent(callback: PlayerStateListener)
24 | fun removePlaybackEvent(callback: PlayerStateListener)
25 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_cc.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/player/controls/ControllerText.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.player.controls
2 |
3 | import androidx.compose.foundation.layout.padding
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.dp
8 | import androidx.tv.material3.MaterialTheme
9 | import androidx.tv.material3.Text
10 |
11 | @Composable
12 | fun ControllerText(text: String) {
13 | Text(
14 | modifier = Modifier.padding(horizontal = 12.dp),
15 | text = text,
16 | color = MaterialTheme.colorScheme.onSurface,
17 | fontWeight = FontWeight.SemiBold,
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/mp3/player/AudioPlayer.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.mp3.player
2 |
3 | import androidx.activity.compose.BackHandler
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.tooling.preview.Devices
8 | import androidx.compose.ui.tooling.preview.Preview
9 |
10 |
11 | @Composable
12 | fun AudioPlayerScreen(onBackPressed: () -> Unit) {
13 | BackHandler(onBack = onBackPressed)
14 | AudioPlayerScreenContent(modifier = Modifier.fillMaxSize())
15 | }
16 |
17 | @Preview(device = Devices.TV_1080p)
18 | @Composable
19 | private fun AudioPlayerScreenPrev() {
20 | AudioPlayerScreen {}
21 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/navigation/Screens.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.navigation
2 |
3 | import com.techlads.composetv.navigation.Screens.ProductDetail.Args.ID
4 |
5 | sealed class Screens(val route: String) {
6 | object Login : Screens("login")
7 | object LoginToken : Screens("login_token")
8 | object Home : Screens("home_screen")
9 | object Player : Screens("player_screen")
10 | object ProductDetail : Screens("product_detail/{${Args.ID}}") {
11 | object Args {
12 | const val ID = "id"
13 | }
14 | fun createRoute(id: Int) = route.replace("{$ID}", id.toString(), false)
15 | }
16 | object WhoIsWatching : Screens("who_is_watching")
17 | object Mp3Player : Screens("mp3_player")
18 | }
19 |
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/utils/AndroidCompose.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins.utils
2 |
3 | import com.android.build.api.dsl.CommonExtension
4 | import org.gradle.api.Project
5 | import org.gradle.kotlin.dsl.configure
6 | import org.gradle.kotlin.dsl.dependencies
7 |
8 | fun Project.configureAndroidCompose() {
9 | android {
10 | buildFeatures {
11 | compose = true
12 | }
13 | }
14 |
15 | dependencies {
16 | implementation(platform(library("compose-bom")))
17 | androidTestImplementation(platform(library("compose-bom")))
18 | }
19 | }
20 |
21 | private fun Project.android(action: CommonExtension<*, *, *, *, *, *>.() -> Unit) = extensions.configure(CommonExtension::class, action)
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/navigation/NestedSettingsNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings.navigation
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.tooling.preview.Preview
6 | import androidx.navigation.NavHostController
7 | import com.google.accompanist.navigation.animation.rememberAnimatedNavController
8 |
9 | @Composable
10 | fun NestedHomeNavigation(navController: NavHostController) {
11 | NestedSettingsScreenNavigation(navController)
12 | }
13 |
14 | @OptIn(ExperimentalAnimationApi::class)
15 | @Preview
16 | @Composable
17 | fun NestedHomeNavigationPrev() {
18 | NestedHomeNavigation(rememberAnimatedNavController())
19 | }
20 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: UmairAliKhalid
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/techlads/network/AuthInterceptor.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.network
2 |
3 | import com.techlads.auth.AuthTokenProvider
4 | import kotlinx.coroutines.runBlocking
5 | import okhttp3.Interceptor
6 | import okhttp3.Response
7 | import javax.inject.Inject
8 |
9 | internal class AuthInterceptor @Inject constructor(
10 | private val tokens: AuthTokenProvider
11 | ) : Interceptor {
12 | override fun intercept(chain: Interceptor.Chain): Response {
13 | val original = chain.request()
14 | val token = runBlocking { tokens.accessTokenOrNull() }
15 | val req = if (token != null) {
16 | original.newBuilder()
17 | .header("Authorization", "Bearer $token")
18 | .build()
19 | } else original
20 | return chain.proceed(req)
21 | }
22 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/ExitAppDelegate.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv
2 |
3 | import android.window.OnBackInvokedDispatcher
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.addCallback
6 | import androidx.core.os.BuildCompat
7 |
8 | fun ComponentActivity.registerOnBackPress(onBackPress: () -> Unit) {
9 | if (BuildCompat.isAtLeastT()) {
10 | onBackInvokedDispatcher.registerOnBackInvokedCallback(
11 | OnBackInvokedDispatcher.PRIORITY_DEFAULT,
12 | ) {
13 | // Back is pressed... Finishing the activity
14 | onBackPress()
15 | }
16 | } else {
17 | onBackPressedDispatcher.addCallback(this /* lifecycle owner */) {
18 | // Back is pressed... Finishing the activity
19 | onBackPress()
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/features/config/src/main/java/com/techlads/config/di/RepositoryModule.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.config.di
2 |
3 | import com.techlads.config.ConfigApiService
4 | import com.techlads.config.ConfigApiServiceImpl
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 | import io.ktor.client.HttpClient
10 | import javax.inject.Named
11 | import javax.inject.Singleton
12 |
13 | @Module
14 | @InstallIn(SingletonComponent::class)
15 | object ConfigModule {
16 |
17 | @Provides
18 | @Singleton
19 | fun provideConfigApiService(
20 | client: HttpClient,
21 | @Named("TMDBBaseUrl") baseUrl: String,
22 | @Named("TMDBApiKey") apiKey: String
23 | ): ConfigApiService {
24 | return ConfigApiServiceImpl(client, baseUrl, apiKey)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/features/login/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.techlads.android.feature")
3 | id("com.techlads.android.compose")
4 | id("com.techlads.android.hilt")
5 | }
6 |
7 | android {
8 | namespace = "com.techlads.login"
9 | }
10 |
11 | dependencies {
12 | implementation(projects.libs.content)
13 | implementation(projects.libs.uiComponents)
14 |
15 | implementation(libs.androidx.core.ktx)
16 | implementation(libs.androidx.appcompat)
17 | implementation(libs.material)
18 |
19 | implementation(libs.qrcode)
20 |
21 | implementation(libs.coil.core)
22 | implementation(libs.coil.compose)
23 |
24 | implementation(platform(libs.compose.bom))
25 | implementation(libs.bundles.androidx.compose.bom)
26 | implementation(libs.bundles.compose.tv)
27 |
28 | implementation(libs.androidx.hilt.navigation.compose)
29 | }
--------------------------------------------------------------------------------
/features/config/src/main/java/com/techlads/config/data/ConfigResponse.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.config.data
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class ConfigResponse(
8 | @SerialName("change_keys") val keys: List,
9 | @SerialName("images") val images: Images,
10 | )
11 |
12 | @Serializable
13 | data class Images(
14 | @SerialName("base_url") val baseUrl: String,
15 | @SerialName("secure_base_url") val secureBaseUrl: String,
16 | @SerialName("backdrop_sizes") val backDropSizes: List,
17 | @SerialName("logo_sizes") val logoSizes: List,
18 | @SerialName("poster_sizes") val posterSizes: List,
19 | @SerialName("profile_sizes") val profileSizes: List,
20 | @SerialName("still_sizes") val stillSizes: List,
21 | )
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
2 | pluginManagement {
3 | includeBuild("gradle/build-logic")
4 | repositories {
5 | google()
6 | mavenCentral()
7 | gradlePluginPortal()
8 | }
9 | }
10 |
11 | dependencyResolutionManagement {
12 | repositories {
13 | google()
14 | mavenCentral()
15 | maven { url = uri("https://jitpack.io") }
16 | }
17 | }
18 |
19 | include(
20 | // Apps
21 | ":app",
22 |
23 | // Libs
24 | ":libs:auth",
25 | ":libs:benchmark",
26 | ":libs:baselineprofile",
27 | ":libs:content",
28 | ":libs:exoplayer",
29 | ":libs:network",
30 | ":libs:player",
31 | ":libs:ui-components",
32 |
33 | // Features
34 | ":features:config",
35 | ":features:login",
36 | )
37 |
38 | rootProject.name = "ComposeTV"
39 | include(":libs:auth-imp")
40 |
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/HiltConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins
2 |
3 | import com.techlads.gradle.plugins.utils.implementation
4 | import com.techlads.gradle.plugins.utils.kapt
5 | import com.techlads.gradle.plugins.utils.library
6 | import com.techlads.gradle.plugins.utils.plugin
7 | import org.gradle.api.Plugin
8 | import org.gradle.api.Project
9 | import org.gradle.kotlin.dsl.dependencies
10 |
11 | class HiltConventionPlugin : Plugin {
12 | override fun apply(target: Project) {
13 | with(target) {
14 | with(pluginManager) {
15 | apply(plugin("kotlin-kapt"))
16 | apply(plugin("hilt"))
17 | }
18 |
19 | dependencies {
20 | implementation(library("hilt-android"))
21 | kapt(library("hilt-compiler"))
22 | }
23 | }
24 | }
25 | }
--------------------------------------------------------------------------------
/libs/content/src/main/java/com/techlads/content/data/LocalMoviesDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.content.data
2 |
3 | import com.techlads.content.MoviesService
4 | import javax.inject.Inject
5 | import javax.inject.Named
6 |
7 | data class LocalMoviesDataSource @Inject constructor(
8 | @Named("FakeMoviesService") private val apiService: MoviesService
9 | ) {
10 | suspend fun fetchPopularMovies() = apiService.getMovies("popular")
11 | suspend fun fetchTopRatedMovies() = apiService.getMovies("top_rated")
12 | suspend fun fetchNowPlayingMovies() = apiService.getMovies("now_playing")
13 | suspend fun fetchUpcomingMovies() = apiService.getMovies("upcoming")
14 | suspend fun fetchMovieDetail(movieId: Int) = apiService.getMovieDetail(movieId)
15 | suspend fun fetchMovieVideos(movieId: Int) = apiService.getMovieVideos(movieId)
16 | suspend fun fetchMovieCredit(movieId: Int) = apiService.getMovieCredits(movieId)
17 | }
18 |
--------------------------------------------------------------------------------
/features/login/src/main/java/com/techlads/login/withEmailPassword/LoginScreen.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.login.withEmailPassword
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.tooling.preview.Devices
8 | import androidx.compose.ui.tooling.preview.Preview
9 | import androidx.tv.material3.MaterialTheme
10 |
11 | @Composable
12 | fun LoginScreen(
13 | modifier: Modifier = Modifier,
14 | goToHomeScreen: () -> Unit,
15 | ) {
16 | Box(modifier = modifier.fillMaxSize()) {
17 | LoginPageContent { _, _ ->
18 | goToHomeScreen()
19 | }
20 | }
21 | }
22 |
23 | @Preview(device = Devices.TV_1080p)
24 | @Composable
25 | fun LoginScreenPrev() {
26 | MaterialTheme {
27 | LoginScreen(Modifier.fillMaxSize()) {
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/libs/content/src/main/java/com/techlads/content/data/RemoteMoviesDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.content.data
2 |
3 | import com.techlads.content.MoviesService
4 | import javax.inject.Inject
5 | import javax.inject.Named
6 |
7 | data class RemoteMoviesDataSource @Inject constructor(
8 | @Named("FakeMoviesService")
9 | private val apiService: MoviesService) {
10 | suspend fun fetchPopularMovies() = apiService.getMovies("popular")
11 | suspend fun fetchTopRatedMovies() = apiService.getMovies("top_rated")
12 | suspend fun fetchNowPlayingMovies() = apiService.getMovies("now_playing")
13 | suspend fun fetchUpcomingMovies() = apiService.getMovies("upcoming")
14 | suspend fun fetchMovieDetail(movieId: Int) = apiService.getMovieDetail(movieId)
15 | suspend fun fetchMovieCredits(movieId: Int) = apiService.getMovieCredits(movieId)
16 | suspend fun fetchMovieVideos(movieId: Int) = apiService.getMovieVideos(movieId)
17 | }
18 |
19 |
--------------------------------------------------------------------------------
/libs/baselineprofile/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.androidTest)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.androidx.baselineprofile)
5 | }
6 |
7 | android {
8 | namespace = "com.techlads.composetv.baselineprofile"
9 | compileSdk = 34
10 |
11 | defaultConfig {
12 | minSdk = 28
13 | targetSdk = 34
14 |
15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
16 | }
17 |
18 | targetProjectPath = ":app"
19 | }
20 |
21 | // This is the configuration block for the Baseline Profile plugin.
22 | // You can specify to run the generators on a managed devices or connected devices.
23 | baselineProfile {
24 | useConnectedDevices = true
25 | }
26 |
27 | dependencies {
28 | implementation(libs.androidx.test.ext.junit)
29 | implementation(libs.androidx.test.espresso.core)
30 | implementation(libs.uiautomator)
31 | implementation(libs.benchmark.macro.junit4)
32 | }
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/libs/auth/src/main/kotlin/com/techlads/auth/UserSession.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalTime::class)
2 |
3 | package com.techlads.auth
4 |
5 | import com.techlads.auth.data.User
6 | import kotlinx.coroutines.flow.Flow
7 | import kotlinx.coroutines.flow.StateFlow
8 | import kotlin.time.ExperimentalTime
9 | import kotlin.time.Instant
10 |
11 |
12 | interface UserSession {
13 | /** Current auth state, hot and replaying last known state. */
14 | val authState: StateFlow
15 |
16 | /** Derived convenience flow. */
17 | val isLoggedIn: Flow
18 |
19 | suspend fun setLoggedIn(
20 | user: User,
21 | accessToken: String,
22 | refreshToken: String? = null,
23 | expiresAt: Instant? = null
24 | )
25 |
26 | suspend fun logout()
27 |
28 | /** Replace token after refresh without touching user. */
29 | suspend fun updateTokens(
30 | accessToken: String,
31 | refreshToken: String? = null,
32 | expiresAt: Instant? = null
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/libs/content/src/main/java/com/techlads/content/data/MovieResponse.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.content.data
2 |
3 | import kotlinx.serialization.SerialName
4 | import kotlinx.serialization.Serializable
5 |
6 | @Serializable
7 | data class MovieResponse(
8 | val id: Int,
9 | val title: String,
10 | val adult: Boolean,
11 | val budget: Int,
12 | val overview: String,
13 | val genre: List,
14 | @SerialName("poster_path") val posterPath: String,
15 | @SerialName("release_date") val releaseDate: String,
16 | @SerialName("backdrop_path") val backdropPath: String,
17 | @SerialName("original_title") val originalTitle: String,
18 | @SerialName("spoken_languages") val spokenLanguages: List,
19 | )
20 |
21 | @Serializable
22 | data class GenreDto(
23 | val id: Int,
24 | val name: String,
25 | )
26 |
27 | @Serializable
28 | data class LanguageDto(
29 | @SerialName("english_name") val englishName: String,
30 | @SerialName("iso_639_1") val iso: String,
31 | val name: String,
32 | )
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/SettingsMenuItem.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings
2 |
3 | import androidx.compose.foundation.layout.padding
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 | import androidx.compose.ui.tooling.preview.Preview
7 | import androidx.compose.ui.unit.dp
8 | import androidx.tv.material3.Text
9 | import com.techlads.composetv.features.settings.data.SettingsMenuModel
10 | import com.techlads.uicomponents.widgets.FocusableItem
11 |
12 | @Composable
13 | fun SettingsMenuItem(item: SettingsMenuModel, onMenuSelected: (SettingsMenuModel) -> Unit) {
14 | FocusableItem(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
15 | onClick = { onMenuSelected(item) }) {
16 | Text(
17 | text = item.text, modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
18 | )
19 | }
20 | }
21 |
22 | @Preview
23 | @Composable
24 | fun SettingsMenuItemPrev() {
25 | SettingsMenuItem(SettingsMenuModel("Menu", "")) {}
26 | }
27 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/navigation/NestedHomeNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.navigation
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.tooling.preview.Preview
6 | import androidx.navigation.NavHostController
7 | import com.google.accompanist.navigation.animation.rememberAnimatedNavController
8 |
9 | @Composable
10 | fun NestedHomeNavigation(
11 | navController: NavHostController,
12 | onItemClick: (parent: String, child: String) -> Unit,
13 | onItemFocus: (parent: String, child: String) -> Unit,
14 | onSongClick: () -> Unit
15 | ) {
16 | NestedHomeScreenNavigation(
17 | navController, onItemClick, onItemFocus, onSongClick
18 | )
19 | }
20 |
21 | @OptIn(ExperimentalAnimationApi::class)
22 | @Preview
23 | @Composable
24 | private fun NestedHomeNavigationPrev() {
25 | NestedHomeNavigation(
26 | rememberAnimatedNavController(),
27 | { _, _ -> },
28 | { _, _ -> }) {}
29 | }
30 |
--------------------------------------------------------------------------------
/features/config/src/main/java/com/techlads/config/ConfigApiService.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.config
2 |
3 | import com.techlads.config.data.ConfigResponse
4 | import com.techlads.network.ApiResult
5 | import com.techlads.network.di.safeGet
6 | import io.ktor.client.HttpClient
7 | import io.ktor.client.request.header
8 | import io.ktor.client.request.parameter
9 | import io.ktor.client.request.url
10 | import javax.inject.Inject
11 | import javax.inject.Named
12 |
13 | interface ConfigApiService {
14 | suspend fun getConfig(): ApiResult
15 | }
16 |
17 | class ConfigApiServiceImpl @Inject constructor(
18 | private val client: HttpClient,
19 | @Named("TMDBBaseUrl") private val baseUrl: String,
20 | @Named("TMDBApiKey") private val apiKey: String
21 | ) : ConfigApiService {
22 |
23 | override suspend fun getConfig(): ApiResult = client.safeGet {
24 | url("$baseUrl/configuration")
25 | header("Content-Type", "application/json")
26 | parameter("api_key", apiKey)
27 | parameter("language", "en")
28 | }
29 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_search.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
9 |
12 |
13 |
15 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/features/login/src/main/java/com/techlads/login/withToken/DeviceTokenAuthentication.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.login.withToken
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Alignment
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.tooling.preview.Preview
10 |
11 | @Composable
12 | fun DeviceTokenAuthenticationScreen(
13 | modifier: Modifier = Modifier,
14 | onSkip: () -> Unit,
15 | onLogin: () -> Unit,
16 | ) {
17 | Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
18 | DeviceTokenAuthenticationContent(token = "OTF2", "www.google.com", skip = onSkip) {
19 | onLogin()
20 | }
21 | }
22 | }
23 |
24 |
25 | @Preview
26 | @Composable
27 | private fun DeviceTokenAuthenticationScreenPreview() {
28 | MaterialTheme {
29 | DeviceTokenAuthenticationScreen(Modifier.fillMaxSize(), onSkip = {}) {
30 |
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/libs/content/src/main/java/com/techlads/content/di/RepositoryModule.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.content.di
2 |
3 | import com.techlads.content.FakeMoviesService
4 | import com.techlads.content.MoviesService
5 | import com.techlads.content.TmdbApiServiceImpl
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.components.SingletonComponent
10 | import io.ktor.client.HttpClient
11 | import javax.inject.Named
12 | import javax.inject.Singleton
13 |
14 | @Module
15 | @InstallIn(SingletonComponent::class)
16 | object RepositoryModule {
17 |
18 | @Provides
19 | @Singleton
20 | @Named("TmdbApiService")
21 | fun provideTmdbApiService(
22 | client: HttpClient,
23 | @Named("TMDBBaseUrl") baseUrl: String,
24 | @Named("TMDBApiKey") apiKey: String
25 | ): MoviesService {
26 | return TmdbApiServiceImpl(client, baseUrl, apiKey)
27 | }
28 |
29 | @Provides
30 | @Singleton
31 | @Named("FakeMoviesService")
32 | fun provideFakeApiService(): MoviesService {
33 | return FakeMoviesService()
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/LibraryConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins
2 |
3 | import com.android.build.gradle.LibraryExtension
4 | import com.techlads.gradle.plugins.utils.Versions
5 | import com.techlads.gradle.plugins.utils.configureAndroid
6 | import com.techlads.gradle.plugins.utils.configureKotlin
7 | import com.techlads.gradle.plugins.utils.plugin
8 | import org.gradle.api.Plugin
9 | import org.gradle.api.Project
10 | import org.gradle.kotlin.dsl.configure
11 |
12 | class LibraryConventionPlugin : Plugin {
13 | override fun apply(target: Project) {
14 | with(target) {
15 | with(pluginManager) {
16 | apply(plugin("android-library"))
17 | apply(plugin("kotlin-android"))
18 | apply("com.techlads.android.jvm")
19 | }
20 |
21 | extensions.configure {
22 | configureAndroid(this)
23 |
24 | defaultConfig.targetSdk = Versions.TARGET_SDK
25 | }
26 |
27 | configureKotlin()
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/AppConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins
2 |
3 | import com.android.build.api.dsl.ApplicationExtension
4 | import com.techlads.gradle.plugins.utils.Versions
5 | import com.techlads.gradle.plugins.utils.configureAndroid
6 | import com.techlads.gradle.plugins.utils.configureKotlin
7 | import com.techlads.gradle.plugins.utils.plugin
8 | import org.gradle.api.Plugin
9 | import org.gradle.api.Project
10 | import org.gradle.kotlin.dsl.configure
11 |
12 | class AppConventionPlugin : Plugin {
13 | override fun apply(target: Project) {
14 | with(target) {
15 | with(pluginManager) {
16 | apply(plugin("android-application"))
17 | apply(plugin("kotlin-android"))
18 | apply("com.techlads.android.jvm")
19 | }
20 |
21 | extensions.configure {
22 | configureAndroid(this)
23 |
24 | defaultConfig.targetSdk = Versions.TARGET_SDK
25 | }
26 |
27 | configureKotlin()
28 | }
29 | }
30 | }
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/ComposeConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins
2 |
3 | import com.techlads.gradle.plugins.utils.configureAndroidCompose
4 | import com.techlads.gradle.plugins.utils.debugImplementation
5 | import com.techlads.gradle.plugins.utils.implementation
6 | import com.techlads.gradle.plugins.utils.library
7 | import com.techlads.gradle.plugins.utils.plugin
8 | import org.gradle.api.Plugin
9 | import org.gradle.api.Project
10 | import org.gradle.kotlin.dsl.dependencies
11 |
12 | class ComposeConventionPlugin : Plugin {
13 | override fun apply(target: Project) =
14 | with(target) {
15 | with(pluginManager) {
16 | apply(plugin("compose-compiler"))
17 | }
18 |
19 | pluginManager.withPlugin("com.android.base") {
20 | configureAndroidCompose()
21 | }
22 |
23 | dependencies {
24 | debugImplementation(library("androidx-compose-ui-tooling"))
25 | implementation(library("androidx-compose-ui-tooling-preview"))
26 | }
27 | }
28 | }
--------------------------------------------------------------------------------
/libs/ui-components/src/main/java/com/techlads/uicomponents/widgets/CardItemDefaults.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.uicomponents.widgets
2 |
3 | import androidx.compose.foundation.BorderStroke
4 | import androidx.compose.foundation.shape.RoundedCornerShape
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.ReadOnlyComposable
7 | import androidx.compose.ui.graphics.Color
8 | import androidx.compose.ui.unit.Dp
9 | import androidx.compose.ui.unit.dp
10 | import androidx.tv.material3.Border
11 | import androidx.tv.material3.ClickableSurfaceDefaults
12 |
13 | object CardItemDefaults {
14 | @ReadOnlyComposable
15 | @Composable
16 | fun border(borderRadius: Dp, color: Color) = ClickableSurfaceDefaults.border(
17 | focusedBorder = Border(
18 | BorderStroke(
19 | width = 2.dp, color = color
20 | ), shape = RoundedCornerShape(borderRadius)
21 | )
22 | )
23 |
24 | @ReadOnlyComposable
25 | @Composable
26 | fun shape(borderRadius: Dp) = ClickableSurfaceDefaults.shape(
27 | shape = RoundedCornerShape(borderRadius), focusedShape = RoundedCornerShape(borderRadius)
28 | )
29 | }
--------------------------------------------------------------------------------
/libs/exoplayer/src/main/java/com/techlads/exoplayer/PlayerFactory.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.exoplayer
2 |
3 | import android.content.Context
4 | import android.view.ViewGroup.LayoutParams.MATCH_PARENT
5 | import android.widget.FrameLayout
6 | import androidx.media3.common.util.UnstableApi
7 | import androidx.media3.exoplayer.ExoPlayer
8 | import androidx.media3.ui.AspectRatioFrameLayout
9 | import androidx.media3.ui.PlayerView
10 | import com.techlads.player.domain.TLPlayer
11 | import java.lang.ref.WeakReference
12 |
13 | @UnstableApi
14 | object PlayerFactory {
15 |
16 | fun create(
17 | context: Context
18 | ): TLPlayer {
19 | val exoPlayer = ExoPlayer.Builder(context).build()
20 | return ExoPlayerImpl(
21 | WeakReference(context),
22 | exoPlayer
23 | ) {
24 | PlayerView(context).apply {
25 | hideController()
26 | player = exoPlayer
27 | useController = false
28 | resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
29 | layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
30 | }
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/libs/benchmark/src/main/java/com/techlads/composetv/benchmark/SkipLoginBenchMark.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.benchmark
2 |
3 | import androidx.benchmark.macro.FrameTimingMetric
4 | import androidx.benchmark.macro.MacrobenchmarkScope
5 | import androidx.benchmark.macro.StartupMode
6 | import androidx.benchmark.macro.junit4.MacrobenchmarkRule
7 | import androidx.test.ext.junit.runners.AndroidJUnit4
8 | import androidx.test.uiautomator.By
9 | import androidx.test.uiautomator.Until
10 | import org.junit.Rule
11 | import org.junit.Test
12 | import org.junit.runner.RunWith
13 |
14 | @RunWith(AndroidJUnit4::class)
15 | class HomeLaunchBenchmark {
16 | @get:Rule
17 | val benchmarkRule = MacrobenchmarkRule()
18 |
19 | @Test
20 | fun startup() = benchmarkRule.measureRepeated(
21 | packageName = "com.techlads.composetv",
22 | metrics = listOf(FrameTimingMetric()),
23 | iterations = 1,
24 | startupMode = StartupMode.COLD
25 | ) {
26 | pressHome()
27 | startActivityAndWait()
28 | login()
29 | }
30 | }
31 |
32 | fun MacrobenchmarkScope.login() {
33 | device.wait(Until.hasObject(By.res("Skip")), 1000)
34 |
35 | assert(device.hasObject(By.res("Skip")))
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/libs/benchmark/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.androidTest)
3 | alias(libs.plugins.kotlin.android)
4 | }
5 |
6 | android {
7 | namespace = "com.techlads.composetv.benchmark"
8 | compileSdk = 34
9 |
10 | defaultConfig {
11 | minSdk = 24
12 | targetSdk = 34
13 |
14 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
15 | }
16 |
17 | buildTypes {
18 | // This benchmark buildType is used for benchmarking, and should function like your
19 | // release build (for example, with minification on). It"s signed with a debug key
20 | // for easy local/CI testing.
21 | create("benchmark") {
22 | isDebuggable = true
23 | signingConfig = getByName("debug").signingConfig
24 | matchingFallbacks += listOf("release")
25 | }
26 | }
27 |
28 | targetProjectPath = ":app"
29 | experimentalProperties["android.experimental.self-instrumenting"] = true
30 | }
31 |
32 | dependencies {
33 | implementation(libs.androidx.test.ext.junit)
34 | implementation(libs.androidx.test.espresso.core)
35 | implementation(libs.uiautomator)
36 | implementation(libs.benchmark.macro.junit4)
37 | }
38 |
39 | androidComponents {
40 | beforeVariants(selector().all()) {
41 | it.enable = it.buildType == "benchmark"
42 | }
43 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 | jvm.version=17
--------------------------------------------------------------------------------
/gradle/build-logic/convention/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | `kotlin-dsl`
3 | }
4 |
5 | dependencies {
6 |
7 | compileOnly(libs.android.gradlePlugin)
8 | compileOnly(libs.kotlin.gradlePlugin)
9 |
10 | testImplementation(libs.junit)
11 | }
12 |
13 |
14 | gradlePlugin {
15 | plugins {
16 | register("androidCompose") {
17 | id = "com.techlads.android.compose"
18 | implementationClass = "com.techlads.gradle.plugins.ComposeConventionPlugin"
19 | }
20 | register("androidApplication") {
21 | id = "com.techlads.android.application"
22 | implementationClass = "com.techlads.gradle.plugins.AppConventionPlugin"
23 | }
24 | register("androidLibrary") {
25 | id = "com.techlads.android.library"
26 | implementationClass = "com.techlads.gradle.plugins.LibraryConventionPlugin"
27 | }
28 | register("androidFeature") {
29 | id = "com.techlads.android.feature"
30 | implementationClass = "com.techlads.gradle.plugins.FeatureConventionPlugin"
31 | }
32 | register("androidHilt") {
33 | id = "com.techlads.android.hilt"
34 | implementationClass = "com.techlads.gradle.plugins.HiltConventionPlugin"
35 | }
36 | register("androidJvm") {
37 | id = "com.techlads.android.jvm"
38 | implementationClass = "com.techlads.gradle.plugins.JvmConventionPlugin"
39 | }
40 | }
41 | }
--------------------------------------------------------------------------------
/.github/workflows/android.yml:
--------------------------------------------------------------------------------
1 | name: Android CI/CD
2 |
3 | # Trigger the workflow on push or pull request to the main branch
4 | on:
5 | push:
6 | branches:
7 | - master
8 | pull_request:
9 | branches:
10 | - master
11 |
12 | jobs:
13 | build:
14 | name: Build APK
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 | # Step 1: Check out the repository
19 | - name: Checkout repository
20 | uses: actions/checkout@v4
21 |
22 | # Step 2: Set up JDK 11 (required for Gradle)
23 | - name: Set up JDK 17
24 | uses: actions/setup-java@v4
25 | with:
26 | distribution: 'zulu'
27 | java-version: '17'
28 |
29 | # Step 3: Cache Gradle dependencies to speed up the build process
30 | - name: Cache Gradle dependencies
31 | uses: actions/cache@v4
32 | with:
33 | path: |
34 | ~/.gradle/caches
35 | ~/.gradle/wrapper
36 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
37 | restore-keys: |
38 | ${{ runner.os }}-gradle
39 |
40 | # Step 4: Build the APK using Gradle
41 | - name: Build with Gradle
42 | run: ./gradlew :app:assembleDebug
43 |
44 | # Step 5: Upload the generated APK as an artifact
45 | - name: Upload APK
46 | uses: actions/upload-artifact@v4
47 | with:
48 | name: debug-apk
49 | path: app/build/outputs/apk/debug/app-debug.apk
50 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
10 |
13 |
14 |
21 |
22 |
25 |
26 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/libs/exoplayer/src/main/java/com/techlads/exoplayer/ExoPlayerStateListener.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.exoplayer
2 |
3 | import androidx.media3.common.Player
4 | import androidx.media3.common.Player.STATE_BUFFERING
5 | import androidx.media3.common.Player.STATE_ENDED
6 | import androidx.media3.common.Player.STATE_READY
7 | import androidx.media3.exoplayer.ExoPlayer
8 | import com.techlads.player.domain.state.PlayerState
9 | import com.techlads.player.domain.state.PlayerStateListener
10 | import timber.log.Timber
11 |
12 | internal class ExoPlayerStateListener(
13 | private val stateListener: PlayerStateListener,
14 | val player: ExoPlayer
15 | ) : Player.Listener {
16 |
17 | override fun onIsPlayingChanged(isPlaying: Boolean) {
18 | super.onIsPlayingChanged(isPlaying)
19 | stateListener.on(getStateWhen(isPlaying))
20 | }
21 |
22 | override fun onPlaybackStateChanged(playbackState: Int) {
23 | super.onPlaybackStateChanged(playbackState)
24 | val state = when (playbackState) {
25 | STATE_BUFFERING -> PlayerState.Buffering
26 | STATE_READY -> {
27 | val isPlaying = player.playWhenReady
28 | getStateWhen(isPlaying)
29 | }
30 |
31 | STATE_ENDED -> PlayerState.Complete
32 | else -> PlayerState.Idle
33 | }
34 |
35 | Timber.d("PlaybackState $state")
36 | stateListener.on(state)
37 | }
38 |
39 | private fun getStateWhen(playing: Boolean) = if (playing)
40 | PlayerState.Playing
41 | else
42 | PlayerState.Pause
43 | }
--------------------------------------------------------------------------------
/.idea/jarRepositories.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/navigation/NestedHomeScreenNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings.navigation
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.runtime.Composable
5 | import androidx.navigation.NavHostController
6 | import com.google.accompanist.navigation.animation.AnimatedNavHost
7 | import com.google.accompanist.navigation.animation.composable
8 | import com.techlads.composetv.features.settings.screens.about.AboutScreen
9 | import com.techlads.composetv.features.settings.screens.profile.ProfileScreen
10 | import com.techlads.composetv.navigation.tabEnterTransition
11 | import com.techlads.composetv.navigation.tabExitTransition
12 |
13 | @OptIn(ExperimentalAnimationApi::class)
14 | @Composable
15 | fun NestedSettingsScreenNavigation(navController: NavHostController) {
16 | AnimatedNavHost(
17 | navController = navController,
18 | startDestination = SettingsScreens.Profile.title,
19 | ) {
20 | // e.g will add auth routes here if when we will extend project
21 | composable(
22 | SettingsScreens.Profile.title,
23 | enterTransition = { tabEnterTransition() },
24 | exitTransition = { tabExitTransition() },
25 | ) {
26 | ProfileScreen()
27 | }
28 | composable(
29 | SettingsScreens.AboutMe.title,
30 | enterTransition = { tabEnterTransition() },
31 | exitTransition = { tabExitTransition() },
32 | ) {
33 | AboutScreen()
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/SettingsMenu.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings
2 |
3 | import androidx.compose.foundation.layout.width
4 | import androidx.compose.foundation.lazy.LazyColumn
5 | import androidx.compose.foundation.lazy.items
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.remember
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.tooling.preview.Preview
10 | import androidx.compose.ui.unit.dp
11 | import androidx.hilt.navigation.compose.hiltViewModel
12 | import com.techlads.composetv.features.settings.data.SettingsMenuModel
13 | import com.techlads.composetv.features.settings.navigation.SettingsScreens
14 |
15 | @Composable
16 | fun SettingsMenu(
17 | modifier: Modifier = Modifier,
18 | viewModel: SettingsMenuViewModel = hiltViewModel(),
19 | onMenuSelected: (SettingsMenuModel) -> Unit
20 | ) {
21 | val settingsMenu = remember {
22 | SettingsMenuData.menu
23 | }
24 |
25 | LazyColumn(modifier = modifier.width(200.dp)) {
26 | items(settingsMenu) { item ->
27 | SettingsMenuItem(item) {
28 | when (item.navigation) {
29 | SettingsScreens.Logout.title -> {
30 | viewModel.logout()
31 | }
32 | else -> {
33 | onMenuSelected(item)
34 | }
35 | }
36 | }
37 | }
38 | }
39 | }
40 |
41 | @Preview
42 | @Composable
43 | fun SettingsMenuPrev() {
44 | SettingsMenu {}
45 | }
46 |
--------------------------------------------------------------------------------
/libs/ui-components/src/main/java/com/techlads/uicomponents/widgets/ThumbnailImageCard.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.uicomponents.widgets
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.BoxScope
6 | import androidx.compose.foundation.layout.aspectRatio
7 | import androidx.compose.foundation.layout.width
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.tooling.preview.Preview
12 | import androidx.compose.ui.unit.dp
13 | import androidx.tv.material3.MaterialTheme
14 | import androidx.tv.material3.Text
15 |
16 | @Composable
17 | fun ThumbnailImageCard(
18 | modifier: Modifier = Modifier,
19 | content: @Composable (BoxScope.() -> Unit),
20 | ) {
21 | Box(
22 | modifier = modifier
23 | .background(
24 | color = MaterialTheme.colorScheme.surface,
25 | shape = MaterialTheme.shapes.small,
26 | ).aspectRatio(0.6f),
27 | contentAlignment = Alignment.Center,
28 | ) {
29 | content()
30 | }
31 | }
32 |
33 | @Preview
34 | @Composable
35 | fun ThumbnailImageCardPreview() {
36 | MaterialTheme {
37 | ThumbnailImageCard(
38 | Modifier
39 | .width(150.dp)
40 | .background(
41 | color = MaterialTheme.colorScheme.onSurface,
42 | shape = MaterialTheme.shapes.small,
43 | ),
44 | ) {
45 | Text(text = "1x1")
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/utils/Dependencies.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins.utils
2 |
3 | import org.gradle.kotlin.dsl.DependencyHandlerScope
4 |
5 | /**
6 | * Adds a dependency to the specified configuration.
7 | *
8 | * @param configurationName The name of the configuration (e.g., "implementation").
9 | * @param dependencyNotation The dependency notation.
10 | */
11 | fun DependencyHandlerScope.addDependency(configurationName: String, dependencyNotation: Any) =
12 | dependencies.add(configurationName, dependencyNotation)
13 |
14 | /**
15 | * Adds a dependency to the 'androidTestImplementation' configuration.
16 | */
17 | fun DependencyHandlerScope.androidTestImplementation(dependencyNotation: Any) =
18 | addDependency("androidTestImplementation", dependencyNotation)
19 |
20 | /**
21 | * Adds a dependency to the 'api' configuration.
22 | */
23 | fun DependencyHandlerScope.api(dependencyNotation: Any) =
24 | addDependency("api", dependencyNotation)
25 |
26 | /**
27 | * Adds a dependency to the 'debugImplementation' configuration.
28 | */
29 | fun DependencyHandlerScope.debugImplementation(dependencyNotation: Any) =
30 | addDependency("debugImplementation", dependencyNotation)
31 |
32 | /**
33 | * Adds a dependency to the 'implementation' configuration.
34 | */
35 | fun DependencyHandlerScope.implementation(dependencyNotation: Any) =
36 | addDependency("implementation", dependencyNotation)
37 |
38 | /**
39 | * Adds a dependency to the 'kapt' configuration.
40 | */
41 | fun DependencyHandlerScope.kapt(dependencyNotation: Any) =
42 | addDependency("kapt", dependencyNotation)
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ComposeTv
2 |
3 | ### Login
4 |
5 |
6 | ### Who is watching
7 |
8 |
9 | ### Home hero item
10 |
11 |
12 | ### Hero item focused
13 |
14 |
15 | ### Home top pick
16 |
17 |
18 | ### Music
19 |
20 |
21 | ### Player screen
22 |
23 |
24 | ### Movie detail
25 |
26 |
27 | ### Movie detail view more
28 |
29 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/details/ArrowButton.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.details
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.padding
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.draw.clip
12 | import androidx.compose.ui.graphics.vector.ImageVector
13 | import androidx.compose.ui.tooling.preview.Preview
14 | import androidx.compose.ui.unit.dp
15 | import androidx.tv.material3.Icon
16 | import androidx.tv.material3.MaterialTheme
17 | import androidx.tv.material3.Text
18 |
19 | @Composable
20 | fun ArrowDownButton(modifier: Modifier = Modifier,
21 | text: String,
22 | icon: ImageVector,
23 | onClick: () -> Unit) {
24 | Column(modifier = Modifier
25 | .clickable {
26 | onClick()
27 | }
28 | .clip(
29 | MaterialTheme.shapes.medium
30 | )
31 | .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
32 | Text(text, modifier = modifier, style = MaterialTheme.typography.titleMedium)
33 | Icon(icon, contentDescription = text)
34 | }
35 | }
36 |
37 | @Preview
38 | @Composable
39 | private fun ArrowDownButtonPreview() {
40 | ArrowDownButton(
41 | text = "Arrow Down",
42 | icon = Icons.Default.KeyboardDoubleArrowDown,
43 | onClick = { }
44 | )
45 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/SettingsScreen.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.fillMaxHeight
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.tooling.preview.Preview
12 | import androidx.compose.ui.unit.dp
13 | import androidx.navigation.NavHostController
14 | import androidx.tv.material3.MaterialTheme
15 | import com.google.accompanist.navigation.animation.rememberAnimatedNavController
16 | import com.techlads.composetv.features.settings.navigation.NestedSettingsScreenNavigation
17 |
18 | @OptIn(ExperimentalAnimationApi::class)
19 | @Composable
20 | fun SettingsScreen() {
21 | val navController = rememberAnimatedNavController()
22 |
23 | Row(
24 | Modifier
25 | .fillMaxSize(),
26 | ) {
27 | SettingsMenu(
28 | modifier = Modifier
29 | .fillMaxHeight()
30 | .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.2f))
31 | .padding(vertical = 32.dp, horizontal = 16.dp),
32 | ) {
33 | navController.navigate(it.navigation)
34 | }
35 | SettingsNavigation(navController)
36 | }
37 | }
38 |
39 | @Composable
40 | fun SettingsNavigation(navController: NavHostController) {
41 | NestedSettingsScreenNavigation(navController = navController)
42 | }
43 |
44 | @Preview
45 | @Composable
46 | fun SettingsScreenPrev() {
47 | SettingsScreen()
48 | }
49 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/leftmenu/data/MenuData.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.leftmenu.data
2 |
3 | import com.techlads.composetv.features.home.leftmenu.model.MenuItem
4 | import com.techlads.composetv.features.home.navigation.NestedScreens.Favorites
5 | import com.techlads.composetv.features.home.navigation.NestedScreens.Home
6 | import com.techlads.composetv.features.home.navigation.NestedScreens.Movies
7 | import com.techlads.composetv.features.home.navigation.NestedScreens.Search
8 | import com.techlads.composetv.features.home.navigation.NestedScreens.Settings
9 | import com.techlads.composetv.features.home.navigation.NestedScreens.Songs
10 | import compose.icons.LineAwesomeIcons
11 | import compose.icons.lineawesomeicons.CogSolid
12 | import compose.icons.lineawesomeicons.HeartSolid
13 | import compose.icons.lineawesomeicons.HomeSolid
14 | import compose.icons.lineawesomeicons.MusicSolid
15 | import compose.icons.lineawesomeicons.SearchSolid
16 | import compose.icons.lineawesomeicons.UserCircle
17 | import compose.icons.lineawesomeicons.VideoSolid
18 |
19 | object MenuData {
20 | val menuItems = listOf(
21 | MenuItem(Home.title, "Home", LineAwesomeIcons.HomeSolid),
22 | MenuItem(Search.title, "Search", LineAwesomeIcons.SearchSolid),
23 | MenuItem(Movies.title, "Movies", LineAwesomeIcons.VideoSolid),
24 | MenuItem(Songs.title, "Songs", LineAwesomeIcons.MusicSolid),
25 | MenuItem(Favorites.title, "Favorites", LineAwesomeIcons.HeartSolid),
26 | )
27 |
28 | val settingsItem = MenuItem(
29 | Settings.title,
30 | "Settings",
31 | LineAwesomeIcons.CogSolid,
32 | )
33 |
34 | val profile = MenuItem(
35 | Home.title,
36 | "My Profile",
37 | LineAwesomeIcons.UserCircle,
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/theme/Type.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2022 The Android Open Source Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * https://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 | package com.techlads.composetv.theme
17 |
18 | import androidx.compose.ui.text.TextStyle
19 | import androidx.compose.ui.text.font.FontFamily
20 | import androidx.compose.ui.text.font.FontWeight
21 | import androidx.compose.ui.unit.sp
22 | import androidx.tv.material3.Typography
23 |
24 | // Set of Material typography styles to start with
25 | val Typography = Typography(
26 | bodyLarge = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Normal,
29 | fontSize = 16.sp,
30 | lineHeight = 24.sp,
31 | letterSpacing = 0.5.sp,
32 | ),
33 | /* Other default text styles to override
34 | titleLarge = TextStyle(
35 | fontFamily = FontFamily.Default,
36 | fontWeight = FontWeight.Normal,
37 | fontSize = 22.sp,
38 | lineHeight = 28.sp,
39 | letterSpacing = 0.sp
40 | ),
41 | labelSmall = TextStyle(
42 | fontFamily = FontFamily.Default,
43 | fontWeight = FontWeight.Medium,
44 | fontSize = 11.sp,
45 | lineHeight = 16.sp,
46 | letterSpacing = 0.5.sp
47 | )
48 | */
49 | )
50 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/screens/about/AboutMeScreen.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings.screens.about
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.Modifier
5 | import androidx.compose.ui.tooling.preview.Preview
6 | import androidx.tv.material3.LocalContentColor
7 | import androidx.tv.material3.MaterialTheme
8 | import androidx.tv.material3.Text
9 | import com.techlads.composetv.features.settings.data.SettingsMenuModel
10 | import com.techlads.composetv.features.settings.screens.PreferencesContainer
11 |
12 | @Composable
13 | fun AboutScreen() {
14 | PreferencesContainer(preference = SettingsMenuModel("About", "about_me")) {
15 | Text(
16 | modifier = Modifier,
17 | text = "Hello! I'm Umair Khalid,\nA software developer with a passion for open source development. I have a strong background in programming languages and technologies, and I'm constantly learning and improving my skills. I believe in the power of open source development to create innovative solutions and make a positive impact on society. That's why I have contributed to several open source projects, which you can find on my GitHub profile at https://github.com/UmairKhalid786. I'm also active on LinkedIn, where you can learn more about my experience, education, and interests in software development: https://www.linkedin.com/in/umairkhalid786/. I'm always looking for new opportunities to collaborate with other developers and create meaningful solutions, so feel free to connect with me on LinkedIn or check out my projects on GitHub.",
18 | color = LocalContentColor.current.copy(alpha = 0.4f),
19 | style = MaterialTheme.typography.bodyMedium,
20 | )
21 | }
22 | }
23 |
24 | @Preview
25 | @Composable
26 | private fun AboutScreenPrev() {
27 | AboutScreen()
28 | }
29 |
--------------------------------------------------------------------------------
/libs/content/src/main/java/com/techlads/content/data/FakeCastProvider.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.content.data
2 |
3 | object FakeCastProvider {
4 | val cast = listOf(
5 | Cast(
6 | id = 1,
7 | name = "Liam Westwood",
8 | profilePath = "https://picsum.photos/seed/actor1/400/600"
9 | ),
10 | Cast(
11 | id = 2,
12 | name = "Aria Stonefield",
13 | profilePath = "https://picsum.photos/seed/actor2/400/600"
14 | ),
15 | Cast(
16 | id = 3,
17 | name = "Damon Hale",
18 | profilePath = "https://picsum.photos/seed/actor3/400/600"
19 | ),
20 | Cast(
21 | id = 4,
22 | name = "Selena Marlowe",
23 | profilePath = "https://picsum.photos/seed/actor4/400/600"
24 | ),
25 | Cast(
26 | id = 5,
27 | name = "Ethan Blackwell",
28 | profilePath = "https://picsum.photos/seed/actor5/400/600"
29 | ),
30 | Cast(
31 | id = 6,
32 | name = "Nora Valente",
33 | profilePath = "https://picsum.photos/seed/actor6/400/600"
34 | ),
35 | Cast(
36 | id = 7,
37 | name = "Jasper Thorn",
38 | profilePath = "https://picsum.photos/seed/actor7/400/600"
39 | ),
40 | Cast(
41 | id = 8,
42 | name = "Riley Ashford",
43 | profilePath = "https://picsum.photos/seed/actor8/400/600"
44 | ),
45 | Cast(
46 | id = 9,
47 | name = "Mila Rowan",
48 | profilePath = "https://picsum.photos/seed/actor9/400/600"
49 | ),
50 | Cast(
51 | id = 10,
52 | name = "Caleb Drayton",
53 | profilePath = "https://picsum.photos/seed/actor10/400/600"
54 | )
55 | )
56 | }
--------------------------------------------------------------------------------
/features/login/src/main/java/com/techlads/login/withEmailPassword/BackgroundState.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.login.withEmailPassword
2 |
3 | import android.graphics.drawable.BitmapDrawable
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.Immutable
6 | import androidx.compose.runtime.Stable
7 | import androidx.compose.runtime.mutableStateOf
8 | import androidx.compose.runtime.remember
9 | import androidx.compose.ui.graphics.ImageBitmap
10 | import androidx.compose.ui.graphics.asImageBitmap
11 | import androidx.compose.ui.platform.LocalContext
12 | import coil.ImageLoader
13 | import coil.request.ImageRequest
14 | import coil.request.ImageResult
15 | import kotlinx.coroutines.Deferred
16 |
17 | @Stable
18 | @Immutable
19 | class BackgroundState(
20 | private val coilImageLoader: ImageLoader,
21 | private val coilBuilder: ImageRequest.Builder,
22 | ) {
23 | val drawable by lazy { mutableStateOf(null) }
24 | private var job: Deferred? = null
25 |
26 | fun load(url: String, onSuccess: () -> Unit = {}, onError: () -> Unit = {}) {
27 | job?.cancel()
28 |
29 | val request = coilBuilder.data(url).target(onSuccess = { result ->
30 | drawable.value = (result as? BitmapDrawable)?.bitmap?.asImageBitmap()
31 | onSuccess()
32 | }, onError = {
33 | onError()
34 | }).build()
35 |
36 | job = coilImageLoader.enqueue(request).job
37 | }
38 | }
39 |
40 |
41 | @Composable
42 | fun backgroundImageState(): BackgroundState {
43 | val context = LocalContext.current
44 | val imageLoader = remember { ImageLoader(context) }
45 | val builder = remember { ImageRequest.Builder(context) }
46 |
47 | return remember(imageLoader, builder) {
48 | BackgroundState(
49 | imageLoader,
50 | builder,
51 | )
52 | }
53 | }
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.techlads.android.application")
3 | id("com.techlads.android.compose")
4 | id("com.techlads.android.hilt")
5 | alias(libs.plugins.androidx.baselineprofile)
6 | alias(libs.plugins.kotlin.serialization)
7 | }
8 |
9 | android {
10 | defaultConfig {
11 | namespace = "com.techlads.composetv"
12 | versionCode = 1
13 | versionName = "1.0"
14 | }
15 |
16 | buildTypes {
17 | create("benchmark") {
18 | initWith(buildTypes.getByName("release"))
19 | signingConfig = signingConfigs.getByName("debug")
20 | matchingFallbacks += listOf("release")
21 | isDebuggable = false
22 | }
23 | }
24 | }
25 |
26 | dependencies {
27 | implementation(projects.libs.authImp)
28 | implementation(projects.libs.player)
29 | implementation(projects.libs.content)
30 | implementation(projects.libs.network)
31 | implementation(projects.libs.exoplayer)
32 | implementation(projects.libs.uiComponents)
33 |
34 | implementation(projects.features.login)
35 | implementation(projects.features.config)
36 |
37 | implementation(libs.androidx.core.ktx)
38 | implementation(libs.androidx.activity.compose)
39 |
40 | implementation(platform(libs.compose.bom))
41 | implementation(libs.bundles.compose.tv)
42 | implementation(libs.bundles.compose.accompanist)
43 | implementation(libs.compose.material.iconsExtended)
44 |
45 | implementation(libs.kotlin.serialization)
46 | implementation(libs.androidx.hilt.navigation.compose)
47 |
48 | implementation(libs.profileinstaller)
49 | "baselineProfile"(projects.libs.baselineprofile)
50 |
51 | implementation(libs.coil.core)
52 | implementation(libs.coil.compose)
53 |
54 | implementation(libs.androidx.lifecycle.viewModelCompose)
55 | implementation(libs.line.awesome.icons)
56 | }
57 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/favorites/FavoritesScreen.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalComposeUiApi::class)
2 |
3 | package com.techlads.composetv.features.favorites
4 |
5 | import androidx.compose.foundation.layout.PaddingValues
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.lazy.grid.GridCells
8 | import androidx.compose.foundation.lazy.grid.GridItemSpan
9 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.ExperimentalComposeUiApi
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.focus.focusRestorer
14 | import androidx.compose.ui.tooling.preview.Preview
15 | import androidx.compose.ui.unit.dp
16 | import androidx.tv.material3.MaterialTheme
17 | import androidx.tv.material3.Text
18 | import com.techlads.composetv.features.home.carousel.VerticalCarouselItem
19 |
20 | @Composable
21 | fun FavoritesScreen() {
22 | FavoritesGrid(Modifier)
23 | }
24 |
25 | @Composable
26 | fun FavoritesGrid(modifier: Modifier) {
27 | LazyVerticalGrid(
28 | modifier = modifier.focusRestorer(),
29 | columns = GridCells.Fixed(5),
30 | contentPadding = PaddingValues(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 48.dp),
31 | ) {
32 | item(span = {
33 | GridItemSpan(5)
34 | }) {
35 | GridHeader()
36 | }
37 | items(30) {
38 | VerticalCarouselItem(parent = "0", child = "0") { _, _ ->
39 | }
40 | }
41 | }
42 | }
43 |
44 | @Composable
45 | fun GridHeader() {
46 | Text(
47 | text = "Favorites",
48 | style = MaterialTheme.typography.titleLarge,
49 | modifier = Modifier.padding(bottom = 24.dp, start = 8.dp),
50 | )
51 | }
52 |
53 | @Preview
54 | @Composable
55 | fun FavoritesScreenPrev() {
56 | FavoritesScreen()
57 | }
58 |
--------------------------------------------------------------------------------
/libs/ui-components/src/main/java/com/techlads/uicomponents/widgets/TvButton.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.uicomponents.widgets
2 |
3 | import androidx.compose.foundation.interaction.MutableInteractionSource
4 | import androidx.compose.foundation.layout.PaddingValues
5 | import androidx.compose.foundation.layout.RowScope
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.remember
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.unit.Dp
10 | import androidx.compose.ui.unit.dp
11 | import androidx.tv.material3.Button
12 | import androidx.tv.material3.ButtonBorder
13 | import androidx.tv.material3.ButtonColors
14 | import androidx.tv.material3.ButtonDefaults
15 | import androidx.tv.material3.ButtonGlow
16 | import androidx.tv.material3.ButtonScale
17 | import androidx.tv.material3.ButtonShape
18 |
19 | @Composable
20 | fun TvButton(
21 | onClick: () -> Unit,
22 | modifier: Modifier = Modifier,
23 | enabled: Boolean = true,
24 | scale: ButtonScale = ButtonDefaults.scale(),
25 | glow: ButtonGlow = ButtonDefaults.glow(),
26 | shape: ButtonShape = ButtonDefaults.shape(),
27 | colors: ButtonColors = ButtonDefaults.colors(),
28 | tonalElevation: Dp = 0.dp,
29 | border: ButtonBorder = ButtonDefaults.border(),
30 | contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
31 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
32 | content: @Composable RowScope.() -> Unit,
33 | ) {
34 | Button(
35 | onClick = onClick,
36 | modifier = modifier,
37 | enabled = enabled,
38 | scale = scale,
39 | glow = glow,
40 | shape = shape,
41 | colors = colors,
42 | tonalElevation = tonalElevation,
43 | border = border,
44 | contentPadding = contentPadding,
45 | interactionSource = interactionSource,
46 | content = content,
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/cast/PersonCard.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.cast
2 |
3 | import androidx.compose.foundation.BorderStroke
4 | import androidx.compose.foundation.layout.aspectRatio
5 | import androidx.compose.foundation.layout.width
6 | import androidx.compose.foundation.shape.CircleShape
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.ui.Modifier
9 | import androidx.compose.ui.layout.ContentScale
10 | import androidx.compose.ui.tooling.preview.PreviewScreenSizes
11 | import androidx.compose.ui.unit.dp
12 | import androidx.tv.material3.Border
13 | import androidx.tv.material3.Card
14 | import androidx.tv.material3.CardDefaults
15 | import androidx.tv.material3.MaterialTheme
16 | import coil.compose.AsyncImage
17 | import com.techlads.composetv.theme.ComposeTvTheme
18 |
19 |
20 | // create a simple card to show person's image and name
21 |
22 | @Composable
23 | fun PersonCard(
24 | person: String,
25 | modifier: Modifier = Modifier
26 | ) {
27 | Card(
28 | modifier = modifier,
29 | border = CardDefaults.border(
30 | focusedBorder = Border(
31 | border = BorderStroke(width = 2.dp, color = MaterialTheme.colorScheme.onSurface),
32 | shape = CircleShape
33 | )
34 | ),
35 | shape = CardDefaults.shape(CircleShape),
36 | onClick = { /* No-op */ },
37 | ) {
38 | AsyncImage(
39 | model = person,
40 | contentDescription = null,
41 | contentScale = ContentScale.Crop,
42 | modifier = Modifier.aspectRatio(1f)
43 | )
44 | }
45 | }
46 |
47 | @PreviewScreenSizes
48 | @Composable
49 | private fun PersonCardPreview() {
50 | ComposeTvTheme {
51 | PersonCard(
52 | person = "https://via.placeholder.com/150",
53 | modifier = Modifier
54 | .width(150.dp)
55 | )
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/movies/MoviesScreen.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalComposeUiApi::class)
2 |
3 | package com.techlads.composetv.features.movies
4 |
5 | import androidx.compose.foundation.layout.PaddingValues
6 | import androidx.compose.foundation.layout.padding
7 | import androidx.compose.foundation.lazy.grid.GridCells
8 | import androidx.compose.foundation.lazy.grid.GridItemSpan
9 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.ui.ExperimentalComposeUiApi
12 | import androidx.compose.ui.Modifier
13 | import androidx.compose.ui.focus.focusRestorer
14 | import androidx.compose.ui.tooling.preview.Preview
15 | import androidx.compose.ui.unit.dp
16 | import androidx.tv.material3.MaterialTheme
17 | import androidx.tv.material3.Text
18 | import com.techlads.composetv.features.home.carousel.VerticalCarouselItem
19 |
20 | @Composable
21 | fun MoviesScreen(onItemFocus: (parent: String, child: String) -> Unit) {
22 | MoviesGrid(Modifier, onItemFocus)
23 | }
24 |
25 | @Composable
26 | fun MoviesGrid(modifier: Modifier, onItemFocus: (parent: String, child: String) -> Unit) {
27 | LazyVerticalGrid(
28 | modifier = modifier.focusRestorer(),
29 | columns = GridCells.Fixed(5),
30 | contentPadding = PaddingValues(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 48.dp),
31 | ) {
32 | item(span = {
33 | GridItemSpan(5)
34 | }) {
35 | GridHeader()
36 | }
37 | items(30) {
38 | VerticalCarouselItem(parent = "0", child = "0", onItemFocus)
39 | }
40 | }
41 | }
42 |
43 | @Composable
44 | fun GridHeader() {
45 | Text(
46 | text = "Movies",
47 | style = MaterialTheme.typography.titleLarge,
48 | modifier = Modifier.padding(bottom = 24.dp, start = 8.dp),
49 | )
50 | }
51 |
52 | @Preview(device = "id:tv_1080p")
53 | @Composable
54 | fun MoviesScreenPrev() {
55 | MoviesScreen { _, _ -> }
56 | }
57 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/player/controls/PlayerControlsState.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.player.controls
2 |
3 | import androidx.annotation.IntRange
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.getValue
6 | import androidx.compose.runtime.mutableStateOf
7 | import androidx.compose.runtime.remember
8 | import androidx.compose.runtime.setValue
9 | import kotlinx.coroutines.CoroutineScope
10 | import kotlinx.coroutines.delay
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.collectLatest
13 | import kotlinx.coroutines.launch
14 |
15 | class PlayerControlsState internal constructor(
16 | @IntRange(from = 0)
17 | val hideSeconds: Int,
18 | coroutineScope: CoroutineScope,
19 | ) {
20 | var isDisplayed by mutableStateOf(false)
21 | private val countDownTimer = MutableStateFlow(value = hideSeconds)
22 |
23 | init {
24 | coroutineScope.launch {
25 | countDownTimer.collectLatest { time ->
26 | if (time > 0) {
27 | isDisplayed = true
28 | delay(1000)
29 | countDownTimer.emit(countDownTimer.value - 1)
30 | } else {
31 | isDisplayed = false
32 | }
33 | }
34 | }
35 | }
36 |
37 | suspend fun showControls(seconds: Int = hideSeconds) {
38 | countDownTimer.emit(seconds)
39 | }
40 | }
41 |
42 | /**
43 | * Create and remember a [PlayerControlsState] instance. Useful when trying to control the state of
44 | * the [PlayerControls]-related composable.
45 | * @return A remembered instance of [PlayerControlsState].
46 | * @param hideSeconds How many seconds should the controls be visible before being hidden.
47 | * */
48 | @Composable
49 | fun rememberVideoPlayerState(
50 | @IntRange(from = 0) hideSeconds: Int = 2,
51 | coroutineScope: CoroutineScope,
52 | ) =
53 | remember { PlayerControlsState(hideSeconds = hideSeconds, coroutineScope = coroutineScope) }
54 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/HomeScreenContent.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.LaunchedEffect
6 | import androidx.compose.runtime.mutableStateOf
7 | import androidx.compose.runtime.remember
8 | import androidx.compose.ui.tooling.preview.Preview
9 | import com.google.accompanist.navigation.animation.rememberAnimatedNavController
10 | import com.techlads.composetv.features.home.leftmenu.data.MenuData
11 | import com.techlads.composetv.features.home.navigation.NestedHomeNavigation
12 | import com.techlads.composetv.features.home.navigation.topbar.HomeTopBar
13 | import com.techlads.composetv.theme.ComposeTvTheme
14 |
15 | @OptIn(ExperimentalAnimationApi::class)
16 | @Composable
17 | fun HomeScreenContent(
18 | onItemClick: (parent: String, id: String) -> Unit,
19 | onItemFocus: (parent: String, id: String) -> Unit,
20 | onSongClick: () -> Unit,
21 | ) {
22 | val navController = rememberAnimatedNavController()
23 |
24 | val selectedId = remember {
25 | mutableStateOf(MenuData.menuItems.first().id)
26 | }
27 |
28 | LaunchedEffect(key1 = Unit) {
29 | navController.addOnDestinationChangedListener { _, destination, _ ->
30 | selectedId.value = destination.route ?: return@addOnDestinationChangedListener
31 | }
32 | }
33 |
34 | HomeTopBar(content = {
35 | NestedHomeNavigation(
36 | navController, onItemClick = { parent, child ->
37 | onItemClick(parent, child)
38 | }, onItemFocus = { parent, child ->
39 | onItemFocus(parent, child)
40 | }, onSongClick
41 | )
42 | }, selectedId = selectedId.value) {
43 | navController.navigate(it.id)
44 | }
45 | }
46 |
47 | @Preview
48 | @Composable
49 | fun HomeScreenContentPrev() {
50 | ComposeTvTheme {
51 | HomeScreenContent(onItemFocus = { _, _ -> }, onItemClick = { _, _ -> }) {}
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/features/login/src/main/java/com/techlads/login/withEmailPassword/BackgroundViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.login.withEmailPassword
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import com.techlads.content.data.MoviesRepository
6 | import com.techlads.network.getOrElse
7 | import dagger.hilt.android.lifecycle.HiltViewModel
8 | import kotlinx.coroutines.flow.MutableStateFlow
9 | import kotlinx.coroutines.flow.StateFlow
10 | import kotlinx.coroutines.flow.asStateFlow
11 | import kotlinx.coroutines.flow.update
12 | import kotlinx.coroutines.launch
13 | import javax.inject.Inject
14 |
15 | @HiltViewModel
16 | class BackgroundViewModel @Inject constructor(
17 | private val movies: MoviesRepository
18 | ) : ViewModel() {
19 |
20 | private val _crossFadeState = MutableStateFlow(null)
21 | val crossFadeState: StateFlow = _crossFadeState.asStateFlow()
22 |
23 | init {
24 | viewModelScope.launch {
25 | playDefault()
26 | _crossFadeState.value = CrossFadeState(
27 | images = listOf(),
28 | durationMs = 500f,
29 | delaySec = 5L
30 | )
31 | }
32 | }
33 |
34 | fun changeBackground(parent: String, child: String) {
35 | // You can implement logic to change background based on navigation here
36 | }
37 |
38 | fun play(url: String) {
39 | // You can implement logic to change background based on navigation here
40 | _crossFadeState.update {
41 | it?.copy(images = listOf(url))
42 | }
43 | }
44 |
45 | fun play(urls: List) {
46 | // You can implement logic to change background based on navigation here
47 | _crossFadeState.update {
48 | it?.copy(images = urls)
49 | }
50 | }
51 |
52 | fun playDefault() {
53 | viewModelScope.launch {
54 | val popularMovies = movies.getPopularMovies().getOrElse()?.results?.map { it.backdropPath } ?: emptyList()
55 | play(popularMovies)
56 | }
57 | }
58 | }
--------------------------------------------------------------------------------
/libs/benchmark/src/main/java/com/techlads/composetv/benchmark/ScrollBenchMark.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.benchmark
2 |
3 | import androidx.benchmark.macro.CompilationMode
4 | import androidx.benchmark.macro.FrameTimingMetric
5 | import androidx.benchmark.macro.MacrobenchmarkScope
6 | import androidx.benchmark.macro.StartupMode
7 | import androidx.benchmark.macro.junit4.MacrobenchmarkRule
8 | import androidx.test.ext.junit.runners.AndroidJUnit4
9 | import androidx.test.uiautomator.By
10 | import androidx.test.uiautomator.Direction
11 | import androidx.test.uiautomator.Until
12 | import org.junit.Rule
13 | import org.junit.Test
14 | import org.junit.runner.RunWith
15 |
16 | @RunWith(AndroidJUnit4::class)
17 | class ComposeScrollPerformanceBenchmark {
18 | @get:Rule
19 | val benchmarkRule = MacrobenchmarkRule()
20 |
21 | @Test
22 | fun composeScrollPerformanc() = benchmarkRule.measureRepeated(
23 | packageName = "com.techlads.composetv",
24 | metrics = listOf(FrameTimingMetric()),
25 | // Try switching to different compilation modes to see the effect
26 | // it has on frame timing metrics.
27 | compilationMode = CompilationMode.None(),
28 | startupMode = StartupMode.WARM, // restarts activity each iteration
29 | iterations = 5,
30 | setupBlock = {
31 | pressHome()
32 | startActivityAndWait()
33 |
34 | device.waitForIdle(1000)
35 | device.wait(Until.hasObject(By.res("Skip")), 10_000)
36 | device.findObject(By.res("Skip")).click()
37 |
38 | }
39 | ) {
40 | scrollThroughHomeSections()
41 | }
42 |
43 | private fun MacrobenchmarkScope.scrollThroughHomeSections() {
44 |
45 | device.waitForIdle(1000)
46 |
47 | device.wait(Until.hasObject(By.res("sections_list")), 10_000)
48 |
49 | val recycler = device.findObject(By.res("sections_list"))
50 | // Set gesture margin to avoid triggering gesture navigation
51 | // with input events from automation.
52 | recycler.setGestureMargin(device.displayWidth / 5)
53 |
54 | // Scroll down several times
55 | repeat(3) { recycler.scroll(Direction.DOWN, 1f) }
56 | }
57 | }
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
45 |
46 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/screens/Container.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings.screens
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.height
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.layout.wrapContentWidth
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.tooling.preview.Preview
16 | import androidx.compose.ui.unit.dp
17 | import androidx.tv.material3.LocalContentColor
18 | import androidx.tv.material3.MaterialTheme
19 | import androidx.tv.material3.Text
20 | import com.techlads.composetv.features.settings.data.SettingsMenuModel
21 |
22 | @Composable
23 | fun PreferencesContainer(
24 | modifier: Modifier = Modifier,
25 | preference: SettingsMenuModel,
26 | content: @Composable () -> Unit,
27 | ) {
28 | Box(
29 | modifier
30 | .fillMaxSize()
31 | .padding(64.dp),
32 | contentAlignment = Alignment.CenterStart,
33 | ) {
34 | Column(modifier = Modifier.fillMaxSize()) {
35 | ContentHeading(title = preference.text)
36 | Spacer(modifier = Modifier.padding(8.dp))
37 | Spacer(
38 | modifier = Modifier.height(1.dp).fillMaxWidth()
39 | .background(color = LocalContentColor.current.copy(alpha = 0.1f)),
40 | )
41 | Spacer(modifier = Modifier.padding(8.dp))
42 | content()
43 | }
44 | }
45 | }
46 |
47 | @Composable
48 | fun ContentHeading(title: String) {
49 | Text(
50 | text = title,
51 | style = MaterialTheme.typography.headlineLarge,
52 | modifier = Modifier
53 | .wrapContentWidth(),
54 | )
55 | }
56 |
57 | @Preview
58 | @Composable
59 | fun ContentHeadingPrev() {
60 | ContentHeading(SettingsMenuModel("Profile", "").text)
61 | }
62 |
--------------------------------------------------------------------------------
/libs/ui-components/src/main/java/com/techlads/uicomponents/widgets/FocusableItem.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.uicomponents.widgets
2 |
3 | import androidx.compose.foundation.layout.BoxScope
4 | import androidx.compose.foundation.layout.fillMaxWidth
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.tooling.preview.Preview
8 | import androidx.compose.ui.unit.dp
9 | import androidx.tv.material3.ClickableSurfaceColors
10 | import androidx.tv.material3.ClickableSurfaceDefaults
11 | import androidx.tv.material3.ClickableSurfaceGlow
12 | import androidx.tv.material3.ClickableSurfaceScale
13 | import androidx.tv.material3.ClickableSurfaceShape
14 | import androidx.tv.material3.Glow
15 | import androidx.tv.material3.MaterialTheme
16 | import androidx.tv.material3.ShapeDefaults
17 | import androidx.tv.material3.Surface
18 | import androidx.tv.material3.Text
19 |
20 | @Composable
21 | fun FocusableItem(
22 | onClick: () -> Unit,
23 | modifier: Modifier = Modifier,
24 | shape: ClickableSurfaceShape = ClickableSurfaceDefaults.shape(
25 | shape = ShapeDefaults.Small,
26 | focusedShape = ShapeDefaults.Small
27 | ),
28 | color: ClickableSurfaceColors = ClickableSurfaceDefaults.colors(
29 | containerColor = MaterialTheme.colorScheme.onSurface,
30 | focusedContainerColor = MaterialTheme.colorScheme.surface,
31 | contentColor = MaterialTheme.colorScheme.surface,
32 | focusedContentColor = MaterialTheme.colorScheme.onSurface
33 | ),
34 | glow: ClickableSurfaceGlow = ClickableSurfaceDefaults.glow(
35 | focusedGlow = Glow(
36 | elevation = 5.dp,
37 | elevationColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
38 | )
39 | ),
40 | scale: ClickableSurfaceScale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f),
41 | content: @Composable (BoxScope.() -> Unit)
42 | ) {
43 | Surface(
44 | onClick = { onClick() },
45 | scale = scale,
46 | colors = color,
47 | glow = glow,
48 | shape = shape,
49 | modifier = modifier
50 | .fillMaxWidth(),
51 | ) {
52 | content()
53 | }
54 | }
55 |
56 | @Preview
57 | @Composable
58 | fun FocusableItemPrev() {
59 | FocusableItem(onClick = {}) {
60 | Text(text = "Preview Text")
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/libs/content/src/main/java/com/techlads/content/data/MoviesRepository.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.content.data
2 |
3 | import com.techlads.network.ApiResult
4 | import javax.inject.Inject
5 |
6 | class MoviesRepository @Inject constructor(
7 | val localMoviesDataSource: LocalMoviesDataSource,
8 | val remoteMoviesDataSource: RemoteMoviesDataSource,
9 | ) {
10 | suspend fun getPopularMovies() = remoteMoviesDataSource.fetchPopularMovies().apply {
11 | // Simulate caching to local data source
12 | if (this is ApiResult.Error) {
13 | localMoviesDataSource.fetchPopularMovies()
14 | }
15 | }
16 |
17 | suspend fun getTopRatedMovies() = remoteMoviesDataSource.fetchTopRatedMovies().apply {
18 | // Simulate caching to local data source
19 | if (this is ApiResult.Error) {
20 | localMoviesDataSource.fetchTopRatedMovies()
21 | }
22 | }
23 |
24 | suspend fun getNowPlaying() = remoteMoviesDataSource.fetchNowPlayingMovies().apply {
25 | // Simulate caching to local data source
26 | if (this is ApiResult.Error) {
27 | localMoviesDataSource.fetchNowPlayingMovies()
28 | }
29 | }
30 |
31 | suspend fun getUpcoming() = remoteMoviesDataSource.fetchUpcomingMovies().apply {
32 | // Simulate caching to local data source
33 | if (this is ApiResult.Error) {
34 | localMoviesDataSource.fetchUpcomingMovies()
35 | }
36 | }
37 |
38 | suspend fun getMovieDetail(movieId: Int) =
39 | remoteMoviesDataSource.fetchMovieDetail(movieId).apply {
40 | // Simulate caching to local data source
41 | if (this is ApiResult.Error) {
42 | localMoviesDataSource.fetchMovieDetail(movieId)
43 | }
44 | }
45 |
46 | suspend fun getMovieCredit(movieId: Int) =
47 | remoteMoviesDataSource.fetchMovieCredits(movieId).apply {
48 | // Simulate caching to local data source
49 | if (this is ApiResult.Error) {
50 | localMoviesDataSource.fetchMovieCredit(movieId)
51 | }
52 | }
53 |
54 | suspend fun getMovieVideos(movieId: Int) =
55 | remoteMoviesDataSource.fetchMovieVideos(movieId).apply {
56 | // Simulate caching to local data source
57 | if (this is ApiResult.Error) {
58 | localMoviesDataSource.fetchMovieVideos(movieId)
59 | }
60 | }
61 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/player/controls/PlayerControlsIcon.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.player.controls
2 |
3 | import androidx.annotation.DrawableRes
4 | import androidx.compose.foundation.interaction.MutableInteractionSource
5 | import androidx.compose.foundation.interaction.collectIsFocusedAsState
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.size
9 | import androidx.compose.foundation.shape.CircleShape
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.LaunchedEffect
12 | import androidx.compose.runtime.getValue
13 | import androidx.compose.runtime.remember
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.res.painterResource
16 | import androidx.compose.ui.unit.dp
17 | import androidx.tv.material3.ClickableSurfaceDefaults
18 | import androidx.tv.material3.Icon
19 | import androidx.tv.material3.LocalContentColor
20 | import androidx.tv.material3.MaterialTheme
21 | import androidx.tv.material3.Surface
22 |
23 | @Composable
24 | fun VideoPlayerControlsIcon(
25 | modifier: Modifier = Modifier,
26 | state: PlayerControlsState,
27 | isPlaying: Boolean,
28 | @DrawableRes icon: Int,
29 | contentDescription: String? = null,
30 | onClick: () -> Unit = {},
31 | ) {
32 | val interactionSource = remember { MutableInteractionSource() }
33 | val isFocused by interactionSource.collectIsFocusedAsState()
34 |
35 | LaunchedEffect(isFocused && isPlaying) {
36 | if (isFocused && isPlaying) {
37 | state.showControls()
38 | }
39 | }
40 |
41 | Surface(
42 | modifier = modifier.size(40.dp),
43 | onClick = onClick,
44 | shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
45 | colors = ClickableSurfaceDefaults.colors(
46 | containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
47 | ),
48 | scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
49 | interactionSource = interactionSource,
50 | ) {
51 | Icon(
52 | modifier = Modifier
53 | .fillMaxSize()
54 | .padding(8.dp),
55 | painter = painterResource(id = icon),
56 | contentDescription = contentDescription,
57 | tint = LocalContentColor.current,
58 | )
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/libs/baselineprofile/src/main/java/com/techlads/composetv/baselineprofile/BaselineProfileGenerator.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.baselineprofile
2 |
3 | import androidx.benchmark.macro.junit4.BaselineProfileRule
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 | import androidx.test.filters.LargeTest
6 | import org.junit.Rule
7 | import org.junit.Test
8 | import org.junit.runner.RunWith
9 |
10 | /**
11 | * This test class generates a basic startup baseline profile for the target package.
12 | *
13 | * We recommend you start with this but add important user flows to the profile to improve their performance.
14 | * Refer to the [baseline profile documentation](https://d.android.com/topic/performance/baselineprofiles)
15 | * for more information.
16 | *
17 | * You can run the generator with the Generate Baseline Profile run configuration,
18 | * or directly with `generateBaselineProfile` Gradle task:
19 | * ```
20 | * ./gradlew :app:generateReleaseBaselineProfile -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile
21 | * ```
22 | * The run configuration runs the Gradle task and applies filtering to run only the generators.
23 | *
24 | * Check [documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args)
25 | * for more information about available instrumentation arguments.
26 | *
27 | * After you run the generator, you can verify the improvements running the [StartupBenchmarks] benchmark.
28 | **/
29 | @RunWith(AndroidJUnit4::class)
30 | @LargeTest
31 | class BaselineProfileGenerator {
32 |
33 | @get:Rule
34 | val rule = BaselineProfileRule()
35 |
36 | @Test
37 | fun generate() {
38 | rule.collect("com.example.application") {
39 | // This block defines the app's critical user journey. Here we are interested in
40 | // optimizing for app startup. But you can also navigate and scroll
41 | // through your most important UI.
42 |
43 | // Start default activity for your app
44 | pressHome()
45 | startActivityAndWait()
46 |
47 | // TODO Write more interactions to optimize advanced journeys of your app.
48 | // For example:
49 | // 1. Wait until the content is asynchronously loaded
50 | // 2. Scroll the feed content
51 | // 3. Navigate to detail screen
52 |
53 | // Check UiAutomator documentation for more information how to interact with the app.
54 | // https://d.android.com/training/testing/other-components/ui-automator
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/carousel/HorizontalCarouselItem.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalComposeUiApi::class)
2 |
3 | package com.techlads.composetv.features.home.carousel
4 |
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.PaddingValues
7 | import androidx.compose.foundation.layout.height
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.lazy.LazyRow
10 | import androidx.compose.foundation.lazy.items
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.ExperimentalComposeUiApi
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.focus.focusRestorer
15 | import androidx.compose.ui.tooling.preview.Preview
16 | import androidx.compose.ui.unit.dp
17 | import androidx.tv.material3.Text
18 |
19 | @Composable
20 | fun HorizontalCarouselItem(
21 | parent: CarouselItemPayload,
22 | onItemFocus: (parentId: String, childId: String) -> Unit,
23 | onItemClick: (parentId: String, childId: String) -> Unit,
24 | ) {
25 | Column(
26 | Modifier
27 | .height(150.dp)
28 | .padding(top = 16.dp)
29 | ) {
30 | Text(text = parent.title, modifier = Modifier.padding(horizontal = 52.dp, vertical = 4.dp))
31 | PositionFocusedItemInLazyLayout(
32 | parentFraction = 0.1f,
33 | childFraction = 0.1f,
34 | ) {
35 | LazyRow(
36 | modifier = Modifier.focusRestorer(),
37 | contentPadding = PaddingValues(
38 | start = 42.dp,
39 | top = 8.dp,
40 | bottom = 8.dp,
41 | end = 100.dp,
42 | ),
43 | ) {
44 | items(parent.items) { child ->
45 | CarouselItem(
46 | modifier = Modifier,
47 | cardPayload = child,
48 | onItemClick = { onItemClick(parent.id, child.id) },
49 | onItemFocus = { onItemFocus(parent.id, child.id) },
50 | )
51 | }
52 | }
53 | }
54 | }
55 | }
56 |
57 | @Preview
58 | @Composable
59 | fun HorizontalCarouselItemPrev() {
60 | HorizontalCarouselItem(
61 | parent = CarouselItemPayload(
62 | id = "1",
63 | title = "Title",
64 | type = "Type",
65 | items = List(10) {
66 | CardPayload(
67 | id = "abc$it", title = "Card $it", image = "empty", promo = ""
68 | )
69 | },
70 | ),
71 | onItemFocus = { _, _ -> },
72 | onItemClick = { _, _ -> },
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/features/login/src/main/java/com/techlads/login/LandingScreen.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.login
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.height
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.material3.TextField
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.getValue
13 | import androidx.compose.runtime.mutableStateOf
14 | import androidx.compose.runtime.remember
15 | import androidx.compose.runtime.setValue
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.graphics.Color
19 | import androidx.compose.ui.tooling.preview.Preview
20 | import androidx.compose.ui.unit.dp
21 | import androidx.tv.material3.Button
22 | import androidx.tv.material3.MaterialTheme
23 | import androidx.tv.material3.OutlinedButton
24 | import androidx.tv.material3.Text
25 |
26 |
27 | @Composable
28 | fun LandingScreen(
29 | onApiKeyEntered: (String) -> Unit, onGuestMode: () -> Unit
30 | ) {
31 |
32 | var inputKey by remember { mutableStateOf("") }
33 | val apiKeyUrl = "https://developer.themoviedb.org/reference/getting-started"
34 |
35 | Column(
36 | modifier = Modifier
37 | .fillMaxSize()
38 | .padding(32.dp),
39 | verticalArrangement = Arrangement.Center,
40 | horizontalAlignment = Alignment.CenterHorizontally
41 | ) {
42 | Text("Welcome! To access all features, please provide your TMDB API key.")
43 | Spacer(Modifier.height(8.dp))
44 | Text("Don't have one? Visit:")
45 | Text(
46 | text = apiKeyUrl, color = Color.Cyan, modifier = Modifier.clickable {
47 | // On TV, show this URL or provide a QR code for convenience
48 | })
49 | Spacer(Modifier.height(8.dp))
50 | Text("You can keep your key in the app or enter it each time.")
51 | Spacer(Modifier.height(16.dp))
52 | TextField(
53 | value = inputKey,
54 | onValueChange = { inputKey = it },
55 | label = { Text("TMDB API Key") })
56 | Spacer(Modifier.height(16.dp))
57 | Button(
58 | onClick = { onApiKeyEntered(inputKey) }, enabled = inputKey.isNotBlank()
59 | ) {
60 | Text("Continue")
61 | }
62 | Spacer(Modifier.height(8.dp))
63 | OutlinedButton(onClick = onGuestMode) {
64 | Text("Enter Guest Mode")
65 | }
66 | }
67 | }
68 |
69 | @Preview
70 | @Composable
71 | private fun LandingScreenPreview() {
72 | MaterialTheme {
73 | LandingScreen(onApiKeyEntered = {}, onGuestMode = {})
74 | }
75 | }
--------------------------------------------------------------------------------
/libs/exoplayer/src/main/java/com/techlads/exoplayer/ExoPlayerImpl.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.exoplayer
2 |
3 | import android.content.Context
4 | import android.view.View
5 | import androidx.media3.common.C
6 | import androidx.media3.common.MediaItem
7 | import androidx.media3.common.Player
8 | import androidx.media3.common.util.UnstableApi
9 | import androidx.media3.datasource.DefaultDataSource.Factory
10 | import androidx.media3.exoplayer.ExoPlayer
11 | import androidx.media3.exoplayer.source.ProgressiveMediaSource
12 | import com.techlads.player.domain.TLPlayer
13 | import com.techlads.player.domain.state.PlayerStateListener
14 | import java.lang.ref.WeakReference
15 |
16 | @UnstableApi internal class ExoPlayerImpl(
17 | private val context: WeakReference,
18 | private val player: ExoPlayer,
19 | private val providePlayerView: () -> View
20 | ) : TLPlayer {
21 |
22 | private var listener: Player.Listener? = null
23 |
24 | override fun play() {
25 | player.play()
26 | }
27 |
28 | override fun pause() {
29 | player.pause()
30 | }
31 |
32 | override fun stop() {
33 | player.stop()
34 | }
35 |
36 | override fun seekTo(positionMs: Long) {
37 | player.seekTo(positionMs)
38 | }
39 |
40 | override fun seekForward() {
41 | player.seekForward()
42 | }
43 |
44 | override fun seekBack() {
45 | player.seekBack()
46 | }
47 |
48 | override fun prepare(uri: String, playWhenReady: Boolean) {
49 | val context = context.get() ?: return
50 | player.apply {
51 | this.playWhenReady = playWhenReady
52 | videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
53 | repeatMode = Player.REPEAT_MODE_OFF
54 | setMediaSource(prepareMediaSource(context, uri))
55 | prepare()
56 | }
57 | }
58 |
59 | override fun release() {
60 | player.release()
61 | }
62 |
63 | override fun getView(): View = providePlayerView()
64 |
65 | override val currentPosition: Long
66 | get() = player.currentPosition
67 |
68 | override val duration: Long
69 | get() = player.duration
70 |
71 | override val isPlaying: Boolean
72 | get() = player.isPlaying
73 |
74 | override fun setPlaybackEvent(callback: PlayerStateListener) {
75 | listener = ExoPlayerStateListener(callback, player).apply {
76 | player.addListener(this)
77 | }
78 | }
79 |
80 | override fun removePlaybackEvent(callback: PlayerStateListener) {
81 | listener?.let {
82 | player.removeListener(it)
83 | }
84 | listener = null
85 | }
86 |
87 | private fun prepareMediaSource(context: Context, uri: String) =
88 | ProgressiveMediaSource
89 | .Factory(Factory(context, Factory(context)))
90 | .createMediaSource(MediaItem.fromUri(uri))
91 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/search/SearchScreen.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.search
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.PaddingValues
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.height
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.layout.width
12 | import androidx.compose.foundation.lazy.grid.GridCells
13 | import androidx.compose.foundation.lazy.grid.GridItemSpan
14 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.tooling.preview.Devices
18 | import androidx.compose.ui.tooling.preview.Preview
19 | import androidx.compose.ui.unit.dp
20 | import androidx.tv.material3.MaterialTheme
21 | import androidx.tv.material3.Text
22 | import com.techlads.composetv.features.home.carousel.VerticalCarouselItem
23 | import com.techlads.composetv.features.keyboard.MiniKeyboard
24 | import com.techlads.composetv.theme.ComposeTvTheme
25 |
26 | @Composable
27 | fun SearchScreen() {
28 | Row(modifier = Modifier.fillMaxSize()) {
29 | Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 24.dp)) {
30 | SearchView()
31 | MiniKeyboard(modifier = Modifier.width(300.dp))
32 | }
33 | ContentGrid()
34 | }
35 | }
36 |
37 | @Composable
38 | fun ContentGrid(modifier: Modifier = Modifier) {
39 | LazyVerticalGrid(
40 | modifier = modifier,
41 | columns = GridCells.Fixed(3),
42 | contentPadding = PaddingValues(start = 12.dp, top = 24.dp, end = 12.dp, bottom = 48.dp),
43 | ) {
44 | item(span = {
45 | GridItemSpan(3)
46 | }) {
47 | GridHeader()
48 | }
49 | items(30) {
50 | VerticalCarouselItem(parent = "0", child = "0") { a, b -> }
51 | }
52 | }
53 | }
54 |
55 | @Composable
56 | fun GridHeader() {
57 | Text(
58 | text = "Search Results",
59 | style = MaterialTheme.typography.titleLarge,
60 | modifier = Modifier.padding(bottom = 24.dp, start = 8.dp),
61 | )
62 | }
63 |
64 | @Composable
65 | fun SearchView() {
66 | Column {
67 | Text(
68 | text = "Start typing to search",
69 | style = MaterialTheme.typography.titleLarge,
70 | modifier = Modifier.padding(all = 12.dp),
71 | )
72 | Spacer(modifier = Modifier.height(1.dp).background(MaterialTheme.colorScheme.onSurface))
73 | }
74 | }
75 |
76 | @Preview(device = Devices.TV_1080p)
77 | @Composable
78 | fun SearchScreenPrev() {
79 | ComposeTvTheme {
80 | SearchScreen()
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/navigation/NestedHomeScreenNavigation.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.navigation
2 |
3 | import androidx.compose.animation.ExperimentalAnimationApi
4 | import androidx.compose.runtime.Composable
5 | import androidx.hilt.navigation.compose.hiltViewModel
6 | import androidx.navigation.NavHostController
7 | import com.google.accompanist.navigation.animation.AnimatedNavHost
8 | import com.google.accompanist.navigation.animation.composable
9 | import com.techlads.composetv.features.favorites.FavoritesScreen
10 | import com.techlads.composetv.features.home.HomeNestedScreen
11 | import com.techlads.composetv.features.movies.MoviesScreen
12 | import com.techlads.composetv.features.search.SearchScreen
13 | import com.techlads.composetv.features.settings.SettingsScreen
14 | import com.techlads.composetv.features.songs.SongsScreen
15 | import com.techlads.composetv.navigation.tabEnterTransition
16 | import com.techlads.composetv.navigation.tabExitTransition
17 |
18 | @OptIn(ExperimentalAnimationApi::class)
19 | @Composable
20 | fun NestedHomeScreenNavigation(
21 | navController: NavHostController,
22 | onItemClick: (parent: String, child: String) -> Unit,
23 | onItemFocus: (parent: String, child: String) -> Unit,
24 | onSongClick: () -> Unit
25 | ) {
26 | AnimatedNavHost(navController = navController, startDestination = NestedScreens.Home.title) {
27 | // e.g will add auth routes here if when we will extend project
28 | composable(
29 | NestedScreens.Home.title,
30 | enterTransition = { tabEnterTransition() },
31 | exitTransition = { tabExitTransition() }) {
32 | HomeNestedScreen(onItemFocus = onItemFocus, onItemClick = onItemClick, homeViewModel = hiltViewModel())
33 | }
34 |
35 | composable(
36 | NestedScreens.Search.title,
37 | enterTransition = { tabEnterTransition() },
38 | exitTransition = { tabExitTransition() }) {
39 | SearchScreen()
40 | }
41 |
42 | composable(
43 | NestedScreens.Movies.title,
44 | enterTransition = { tabEnterTransition() },
45 | exitTransition = { tabExitTransition() }) {
46 | MoviesScreen(onItemClick)
47 | }
48 |
49 | composable(
50 | NestedScreens.Songs.title,
51 | enterTransition = { tabEnterTransition() },
52 | exitTransition = { tabExitTransition() }) {
53 | SongsScreen(onSongClick)
54 | }
55 |
56 | composable(
57 | NestedScreens.Favorites.title,
58 | enterTransition = { tabEnterTransition() },
59 | exitTransition = { tabExitTransition() }) {
60 | FavoritesScreen()
61 | }
62 |
63 | composable(
64 | NestedScreens.Settings.title,
65 | enterTransition = { tabEnterTransition() },
66 | exitTransition = { tabExitTransition() }) {
67 | SettingsScreen()
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/carousel/CarouselItem.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.home.carousel
2 |
3 | import android.util.Log
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.aspectRatio
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.ui.Alignment
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.focus.onFocusChanged
12 | import androidx.compose.ui.layout.ContentScale
13 | import androidx.compose.ui.platform.testTag
14 | import androidx.compose.ui.text.style.TextAlign
15 | import androidx.compose.ui.tooling.preview.Preview
16 | import androidx.compose.ui.unit.dp
17 | import androidx.tv.material3.Text
18 | import coil.compose.AsyncImage
19 | import com.techlads.composetv.theme.ComposeTvTheme
20 | import com.techlads.uicomponents.widgets.BorderedFocusableItem
21 |
22 | @Composable
23 | fun CarouselItem(
24 | cardPayload: CardPayload,
25 | modifier: Modifier = Modifier,
26 | onItemFocus: () -> Unit,
27 | onItemClick: () -> Unit,
28 | ) {
29 | BorderedFocusableItem(
30 | onClick = { onItemClick() },
31 | borderRadius = 12.dp,
32 | modifier = modifier
33 | .testTag(cardPayload.id)
34 | .padding(horizontal = 8.dp)
35 | .aspectRatio(1.8f)
36 | .onFocusChanged {
37 | if (it.isFocused) {
38 | onItemFocus()
39 | }
40 | },
41 | ) {
42 | Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
43 | AsyncImage(
44 | model = cardPayload.image,
45 | modifier = Modifier.fillMaxSize(),
46 | contentDescription = null,
47 | contentScale = ContentScale.Crop
48 | ).apply {
49 | Log.e("CarouselItem", "CarouselItem: Loading Image ${cardPayload.image}" )
50 | }
51 | }
52 | }
53 | }
54 |
55 | @Composable
56 | fun VerticalCarouselItem(
57 | parent: String,
58 | child: String,
59 | onItemFocus: (parent: String, child: String) -> Unit
60 | ) {
61 | BorderedFocusableItem(
62 | onClick = {
63 | onItemFocus(parent, child)
64 | },
65 | modifier = Modifier
66 | .padding(8.dp)
67 | .aspectRatio(0.6f),
68 | ) {
69 | Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
70 | Text(text = "Item $parent x $child", textAlign = TextAlign.Center)
71 | }
72 | }
73 | }
74 |
75 | @Preview
76 | @Composable
77 | fun CarouselItemPrev() {
78 | ComposeTvTheme {
79 | CarouselItem(CardPayload("1", "Item 1", "empty", ""), onItemFocus = {}, onItemClick = {})
80 | }
81 | }
82 |
83 | @Preview
84 | @Composable
85 | fun VerticalCarouselItemPrev() {
86 | ComposeTvTheme {
87 | VerticalCarouselItem("1", "1") { _, _ ->
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/gradle/build-logic/convention/src/main/kotlin/com/techlads/gradle/plugins/JvmConventionPlugin.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.gradle.plugins
2 |
3 | import com.android.build.api.dsl.CommonExtension
4 | import org.gradle.api.JavaVersion
5 | import org.gradle.api.Plugin
6 | import org.gradle.api.Project
7 | import org.gradle.api.plugins.JavaPluginExtension
8 | import org.gradle.jvm.toolchain.JavaLanguageVersion
9 | import org.gradle.kotlin.dsl.withType
10 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
11 | import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension
12 | import org.jetbrains.kotlin.gradle.dsl.KotlinJvmCompilerOptions
13 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask
14 | import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
15 |
16 | class JvmConventionPlugin : Plugin {
17 | override fun apply(target: Project) = with(target) {
18 |
19 | // ---- resolve JVM version lazily & safely
20 | val jvmVersionProvider = providers.gradleProperty("jvm.version")
21 | .map { it.toInt() }
22 | .map { it.takeIf { it in setOf(8, 11, 17, 21) } ?: 17 }
23 | .orElse(17)
24 |
25 | val javaVersionProvider = jvmVersionProvider.map(JavaVersion::toVersion)
26 | val jvmTargetProvider = jvmVersionProvider.map { it.toJvmTarget() }
27 |
28 | // ---- Java toolchain for Java modules
29 | pluginManager.withPlugin("java") {
30 | extensions.configure(JavaPluginExtension::class.java) {
31 | toolchain.languageVersion.set(JavaLanguageVersion.of(jvmVersionProvider.get()))
32 | }
33 | }
34 |
35 | // ---- Kotlin toolchain for any Kotlin plugin (android/jvm/mpp)
36 | // (Extension exists only when a Kotlin plugin is applied)
37 | extensions.findByType(KotlinBaseExtension::class.java)?.apply {
38 | jvmToolchain(jvmVersionProvider.get())
39 | }
40 |
41 | // ---- Kotlin compilerOptions.jvmTarget for all Kotlin compilations
42 | // (works for Kotlin/JVM tasks in Android & pure JVM modules)
43 | tasks.withType(KotlinJvmCompile::class.java).configureEach {
44 | compilerOptions {
45 | // Bind to provider so it’s config-cache friendly
46 | jvmTarget.set(jvmTargetProvider.get())
47 | }
48 | }
49 |
50 | tasks.withType>().configureEach {
51 | compilerOptions {
52 | jvmTarget.set(jvmTargetProvider.map { it.ordinal.toJvmTarget() })
53 | }
54 | }
55 |
56 | // ---- Android (app/library/custom) modules in one place
57 | // CommonExtension covers application & library (and your custom plugins that apply them)
58 | extensions.findByType(CommonExtension::class.java)?.apply {
59 | compileOptions {
60 | sourceCompatibility = javaVersionProvider.get()
61 | targetCompatibility = javaVersionProvider.get()
62 | }
63 | }
64 |
65 | Unit
66 | }
67 | }
68 |
69 | // ---- helpers
70 | private fun Int.toJvmTarget(): JvmTarget = when (this) {
71 | 8 -> JvmTarget.JVM_1_8
72 | 11 -> JvmTarget.JVM_11
73 | 17 -> JvmTarget.JVM_17
74 | 21 -> JvmTarget.JVM_21
75 | else -> JvmTarget.JVM_17
76 | }
--------------------------------------------------------------------------------
/libs/baselineprofile/src/main/java/com/techlads/composetv/baselineprofile/StartupBenchmarks.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.baselineprofile
2 |
3 | import androidx.benchmark.macro.BaselineProfileMode
4 | import androidx.benchmark.macro.CompilationMode
5 | import androidx.benchmark.macro.StartupMode
6 | import androidx.benchmark.macro.StartupTimingMetric
7 | import androidx.benchmark.macro.junit4.MacrobenchmarkRule
8 | import androidx.test.ext.junit.runners.AndroidJUnit4
9 | import androidx.test.filters.LargeTest
10 | import org.junit.Rule
11 | import org.junit.Test
12 | import org.junit.runner.RunWith
13 |
14 | /**
15 | * This test class benchmarks the speed of app startup.
16 | * Run this benchmark to verify how effective a Baseline Profile is.
17 | * It does this by comparing [CompilationMode.None], which represents the app with no Baseline
18 | * Profiles optimizations, and [CompilationMode.Partial], which uses Baseline Profiles.
19 | *
20 | * Run this benchmark to see startup measurements and captured system traces for verifying
21 | * the effectiveness of your Baseline Profiles. You can run it directly from Android
22 | * Studio as an instrumentation test, or run all benchmarks with this Gradle task:
23 | * ```
24 | * ./gradlew :baselineprofile:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=Macrobenchmark
25 | * ```
26 | *
27 | * You should run the benchmarks on a physical device, not an Android emulator, because the
28 | * emulator doesn't represent real world performance and shares system resources with its host.
29 | *
30 | * For more information, see the [Macrobenchmark documentation](https://d.android.com/macrobenchmark#create-macrobenchmark)
31 | * and the [instrumentation arguments documentation](https://d.android.com/topic/performance/benchmarking/macrobenchmark-instrumentation-args).
32 | **/
33 | @RunWith(AndroidJUnit4::class)
34 | @LargeTest
35 | class StartupBenchmarks {
36 |
37 | @get:Rule
38 | val rule = MacrobenchmarkRule()
39 |
40 | @Test
41 | fun startupCompilationNone() =
42 | benchmark(CompilationMode.None())
43 |
44 | @Test
45 | fun startupCompilationBaselineProfiles() =
46 | benchmark(CompilationMode.Partial(BaselineProfileMode.Require))
47 |
48 | private fun benchmark(compilationMode: CompilationMode) {
49 | rule.measureRepeated(
50 | packageName = "com.example.application",
51 | metrics = listOf(StartupTimingMetric()),
52 | compilationMode = compilationMode,
53 | startupMode = StartupMode.COLD,
54 | iterations = 10,
55 | setupBlock = {
56 | pressHome()
57 | },
58 | measureBlock = {
59 | startActivityAndWait()
60 |
61 | // TODO Add interactions to wait for when your app is fully drawn.
62 | // The app is fully drawn when Activity.reportFullyDrawn is called.
63 | // For Jetpack Compose, you can use ReportDrawn, ReportDrawnWhen and ReportDrawnAfter
64 | // from the AndroidX Activity library.
65 |
66 | // Check the UiAutomator documentation for more information on how to
67 | // interact with the app.
68 | // https://d.android.com/training/testing/other-components/ui-automator
69 | }
70 | )
71 | }
72 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/theme/Color.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.ui.graphics.Color
2 |
3 | val md_theme_light_primary = Color(0xFF166D33)
4 | val md_theme_light_onPrimary = Color(0xFFFFFFFF)
5 | val md_theme_light_primaryContainer = Color(0xFFA1F6AC)
6 | val md_theme_light_onPrimaryContainer = Color(0xFF002109)
7 | val md_theme_light_secondary = Color(0xFF396A1E)
8 | val md_theme_light_onSecondary = Color(0xFFFFFFFF)
9 | val md_theme_light_secondaryContainer = Color(0xFFB9F295)
10 | val md_theme_light_onSecondaryContainer = Color(0xFF082100)
11 | val md_theme_light_tertiary = Color(0xFF39656D)
12 | val md_theme_light_onTertiary = Color(0xFFFFFFFF)
13 | val md_theme_light_tertiaryContainer = Color(0xFFBDEAF3)
14 | val md_theme_light_onTertiaryContainer = Color(0xFF001F24)
15 | val md_theme_light_error = Color(0xFFBA1A1A)
16 | val md_theme_light_errorContainer = Color(0xFFFFDAD6)
17 | val md_theme_light_onError = Color(0xFFFFFFFF)
18 | val md_theme_light_onErrorContainer = Color(0xFF410002)
19 | val md_theme_light_background = Color(0xFFFCFDF7)
20 | val md_theme_light_onBackground = Color(0xFF1A1C19)
21 | val md_theme_light_surface = Color(0xFFFCFDF7)
22 | val md_theme_light_onSurface = Color(0xFF1A1C19)
23 | val md_theme_light_surfaceVariant = Color(0xFFDDE5DA)
24 | val md_theme_light_onSurfaceVariant = Color(0xFF414941)
25 | val md_theme_light_outline = Color(0xFF727970)
26 | val md_theme_light_inverseOnSurface = Color(0xFFF0F1EC)
27 | val md_theme_light_inverseSurface = Color(0xFF2E312E)
28 | val md_theme_light_inversePrimary = Color(0xFF86D992)
29 | val md_theme_light_shadow = Color(0xFF000000)
30 | val md_theme_light_surfaceTint = Color(0xFF166D33)
31 | val md_theme_light_outlineVariant = Color(0xFFC1C9BE)
32 | val md_theme_light_scrim = Color(0xFF000000)
33 |
34 | val md_theme_dark_primary = Color(0xFF86D992)
35 | val md_theme_dark_onPrimary = Color(0xFF003915)
36 | val md_theme_dark_primaryContainer = Color(0xFF005321)
37 | val md_theme_dark_onPrimaryContainer = Color(0xFFA1F6AC)
38 | val md_theme_dark_secondary = Color(0xFF9ED67C)
39 | val md_theme_dark_onSecondary = Color(0xFF133800)
40 | val md_theme_dark_secondaryContainer = Color(0xFF225105)
41 | val md_theme_dark_onSecondaryContainer = Color(0xFFB9F295)
42 | val md_theme_dark_tertiary = Color(0xFFA1CED7)
43 | val md_theme_dark_onTertiary = Color(0xFF00363D)
44 | val md_theme_dark_tertiaryContainer = Color(0xFF1F4D55)
45 | val md_theme_dark_onTertiaryContainer = Color(0xFFBDEAF3)
46 | val md_theme_dark_error = Color(0xFFFFB4AB)
47 | val md_theme_dark_errorContainer = Color(0xFF93000A)
48 | val md_theme_dark_onError = Color(0xFF690005)
49 | val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
50 | val md_theme_dark_background = Color(0xFF1A1C19)
51 | val md_theme_dark_onBackground = Color(0xFFE2E3DD)
52 | val md_theme_dark_surface = Color(0xFF1A1C19)
53 | val md_theme_dark_onSurface = Color(0xFFE2E3DD)
54 | val md_theme_dark_surfaceVariant = Color(0xFF414941)
55 | val md_theme_dark_onSurfaceVariant = Color(0xFFC1C9BE)
56 | val md_theme_dark_outline = Color(0xFF8B9389)
57 | val md_theme_dark_inverseOnSurface = Color(0xFF1A1C19)
58 | val md_theme_dark_inverseSurface = Color(0xFFE2E3DD)
59 | val md_theme_dark_inversePrimary = Color(0xFF166D33)
60 | val md_theme_dark_shadow = Color(0xFF000000)
61 | val md_theme_dark_surfaceTint = Color(0xFF86D992)
62 | val md_theme_dark_outlineVariant = Color(0xFF414941)
63 | val md_theme_dark_scrim = Color(0xFF000000)
64 |
65 | val seed = Color(0xFF006028)
66 |
--------------------------------------------------------------------------------
/libs/network/src/main/java/com/techlads/network/di/NetworkModule.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.network.di
2 |
3 | import com.techlads.network.ApiResult
4 | import com.techlads.network.BuildConfig
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.components.SingletonComponent
9 | import io.ktor.client.HttpClient
10 | import io.ktor.client.call.body
11 | import io.ktor.client.engine.okhttp.OkHttp
12 | import io.ktor.client.plugins.ClientRequestException
13 | import io.ktor.client.plugins.HttpRequestTimeoutException
14 | import io.ktor.client.plugins.HttpResponseValidator
15 | import io.ktor.client.plugins.HttpTimeout
16 | import io.ktor.client.plugins.ResponseException
17 | import io.ktor.client.plugins.ServerResponseException
18 | import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
19 | import io.ktor.client.request.HttpRequestBuilder
20 | import io.ktor.client.request.get
21 | import io.ktor.serialization.kotlinx.json.json
22 | import kotlinx.serialization.json.Json
23 | import javax.inject.Named
24 | import javax.inject.Singleton
25 |
26 | @Module
27 | @InstallIn(SingletonComponent::class)
28 | object NetworkModule {
29 |
30 | @Singleton
31 | @Provides
32 | fun provideHttpClient(): HttpClient {
33 | return HttpClient(OkHttp) {
34 | install(ContentNegotiation) {
35 | json(
36 | Json {
37 | prettyPrint = true
38 | isLenient = true
39 | ignoreUnknownKeys = true
40 | }
41 | )
42 | }
43 |
44 | HttpResponseValidator {
45 | validateResponse { response ->
46 | val statusCode = response.status.value
47 | if (statusCode >= 400) {
48 | throw ResponseException(response, "HTTP error with status code $statusCode")
49 | }
50 | }
51 |
52 | handleResponseExceptionWithRequest { cause, _ ->
53 | when (cause) {
54 | is ClientRequestException -> {
55 | // Handle 4xx errors
56 | println("Client Error: ${cause.response.status}")
57 | }
58 | is ServerResponseException -> {
59 | // Handle 5xx errors
60 | println("Server Error: ${cause.response.status}")
61 | }
62 | is HttpRequestTimeoutException -> {
63 | println("Request timed out")
64 | }
65 | else -> println("Unknown error: ${cause.localizedMessage}")
66 | }
67 | }
68 | }
69 | install(HttpTimeout) {
70 | requestTimeoutMillis = 15000
71 | connectTimeoutMillis = 15000
72 | socketTimeoutMillis = 15000
73 | }
74 | }
75 | }
76 |
77 | @Provides
78 | @Named("TMDBBaseUrl")
79 | fun provideBaseUrl(): String = BuildConfig.BASE_URL
80 |
81 | @Provides
82 | @Named("TMDBApiKey")
83 | fun provideApiKey(): String = BuildConfig.TMDB_API_KEY
84 | }
85 |
86 |
87 | suspend inline fun HttpClient.safeGet(block: HttpRequestBuilder.() -> Unit): ApiResult =
88 | try {
89 | ApiResult.Success(get(HttpRequestBuilder().apply(block)).body())
90 | } catch (e: Throwable) {
91 | ApiResult.Error(e.message ?: "Something went wrong")
92 | }
93 |
--------------------------------------------------------------------------------
/libs/benchmark/src/main/java/com/techlads/composetv/benchmark/StartupBenchmark.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.benchmark
2 |
3 | import androidx.benchmark.macro.BaselineProfileMode
4 | import androidx.benchmark.macro.CompilationMode
5 | import androidx.benchmark.macro.StartupMode
6 | import androidx.benchmark.macro.StartupTimingMetric
7 | import androidx.benchmark.macro.junit4.MacrobenchmarkRule
8 | import androidx.test.filters.SdkSuppress
9 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
10 | import org.junit.Rule
11 | import org.junit.Test
12 | import org.junit.runner.RunWith
13 |
14 | /**
15 | * This is an example startup benchmark.
16 | *
17 | * It navigates to the device's home screen, and launches the default activity.
18 | *
19 | * Before running this benchmark:
20 | * 1) switch your app's active build variant in the Studio (affects Studio runs only)
21 | * 2) add `` to your app's manifest, within the `` tag
22 | *
23 | * Run this benchmark from Studio to see startup measurements, and captured system traces
24 | * for investigating your app's performance.
25 | */
26 |
27 | const val DEFAULT_ITERATIONS = 5
28 | const val TARGET_PACKAGE = "com.techlads.composetv"
29 |
30 |
31 | /**
32 | * Run this benchmark from Studio to see startup measurements, and captured system traces
33 | * for investigating your app's performance from a cold state.
34 | */
35 | @RunWith(AndroidJUnit4ClassRunner::class)
36 | class ColdStartupBenchmark : AbstractStartupBenchmark(StartupMode.COLD)
37 |
38 | /**
39 | * Run this benchmark from Studio to see startup measurements, and captured system traces
40 | * for investigating your app's performance from a warm state.
41 | */
42 | @RunWith(AndroidJUnit4ClassRunner::class)
43 | class WarmStartupBenchmark : AbstractStartupBenchmark(StartupMode.WARM)
44 |
45 | /**
46 | * Run this benchmark from Studio to see startup measurements, and captured system traces
47 | * for investigating your app's performance from a hot state.
48 | */
49 | // Hot startups tend to be flaky in some test environments. It's disabled here for your convenience.
50 | //@RunWith(AndroidJUnit4ClassRunner::class)
51 | class HotStartupBenchmark : AbstractStartupBenchmark(StartupMode.HOT)
52 |
53 | /**
54 | * Base class for benchmarks with different startup modes.
55 | * Enables app startups from various states of baseline profile or [CompilationMode]s.
56 | */
57 | abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
58 | @get:Rule
59 | val benchmarkRule = MacrobenchmarkRule()
60 |
61 | @Test
62 | @SdkSuppress(minSdkVersion = 24)
63 | fun startupNoCompilation() = startup(CompilationMode.None())
64 |
65 | @Test
66 | @SdkSuppress(minSdkVersion = 24)
67 | fun startupPartialCompilation() = startup(
68 | CompilationMode.Partial(
69 | baselineProfileMode = BaselineProfileMode.Disable,
70 | warmupIterations = 3
71 | )
72 | )
73 |
74 | @Test
75 | @SdkSuppress(minSdkVersion = 24)
76 | fun startupPartialWithBaselineProfiles() =
77 | startup(CompilationMode.Partial(baselineProfileMode = BaselineProfileMode.Require))
78 |
79 | @Test
80 | fun startupFullCompilation() = startup(CompilationMode.Full())
81 |
82 | private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(
83 | packageName = TARGET_PACKAGE,
84 | metrics = listOf(StartupTimingMetric()),
85 | compilationMode = compilationMode,
86 | iterations = DEFAULT_ITERATIONS,
87 | startupMode = startupMode,
88 | setupBlock = {
89 | pressHome()
90 | }
91 | ) {
92 | startActivityAndWait()
93 | }
94 | }
--------------------------------------------------------------------------------
/features/login/src/main/java/com/techlads/login/withEmailPassword/TvTextField.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.login.withEmailPassword
2 |
3 | import androidx.compose.foundation.interaction.MutableInteractionSource
4 | import androidx.compose.foundation.interaction.collectIsFocusedAsState
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.text.BasicTextField
9 | import androidx.compose.foundation.text.KeyboardOptions
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.getValue
12 | import androidx.compose.runtime.remember
13 | import androidx.compose.runtime.rememberUpdatedState
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.draw.shadow
17 | import androidx.compose.ui.graphics.graphicsLayer
18 | import androidx.compose.ui.text.input.KeyboardType
19 | import androidx.compose.ui.text.input.VisualTransformation
20 | import androidx.compose.ui.tooling.preview.Preview
21 | import androidx.compose.ui.unit.dp
22 | import androidx.tv.material3.LocalContentColor
23 | import androidx.tv.material3.MaterialTheme
24 | import androidx.tv.material3.Surface
25 | import androidx.tv.material3.SurfaceDefaults
26 | import androidx.tv.material3.Text
27 |
28 | @Composable
29 | fun TvTextField(
30 | value: String,
31 | placeholder: String,
32 | mutableInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
33 | visualTransformation: VisualTransformation = VisualTransformation.None,
34 | keyboardType: KeyboardType = KeyboardType.Text,
35 | modifier: Modifier = Modifier,
36 | onValueChange: (String) -> Unit,
37 | ) {
38 | val isFocused by mutableInteractionSource.collectIsFocusedAsState()
39 |
40 | val container by rememberUpdatedState(if (isFocused) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.surface)
41 | val textColor by rememberUpdatedState(if (isFocused) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.onSurface)
42 |
43 | Surface(
44 | modifier = modifier.shadow(2.dp, shape = MaterialTheme.shapes.medium),
45 | colors = SurfaceDefaults.colors(
46 | containerColor = container,
47 | contentColor = textColor
48 | ), shape = MaterialTheme.shapes.medium
49 | ) {
50 |
51 | BasicTextField(
52 | value = value,
53 | decorationBox = {
54 | Box(
55 | modifier = Modifier
56 | .fillMaxWidth()
57 | .padding(16.dp),
58 | contentAlignment = Alignment.CenterStart
59 | ) {
60 | if (value.isEmpty()) {
61 | Text(
62 | text = placeholder,
63 | modifier = modifier.graphicsLayer { alpha = 0.6f },
64 | style = MaterialTheme.typography.bodyMedium
65 | )
66 | }
67 | it() // This is where the actual BasicTextField will be placed
68 | }
69 | },
70 | onValueChange = onValueChange,
71 | interactionSource = mutableInteractionSource,
72 | visualTransformation = visualTransformation,
73 | keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
74 | textStyle = MaterialTheme.typography.bodyMedium.copy(
75 | color = LocalContentColor.current
76 | )
77 | )
78 | }
79 | }
80 |
81 | @Preview
82 | @Composable
83 | fun TvTextFieldPrev() {
84 | TvTextField("Test", "Enter Test") {}
85 | }
86 |
--------------------------------------------------------------------------------
/libs/ui-components/src/main/java/com/techlads/uicomponents/widgets/BorderedFocusableItem.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.uicomponents.widgets
2 |
3 | import androidx.compose.animation.core.Animatable
4 | import androidx.compose.animation.core.FastOutSlowInEasing
5 | import androidx.compose.animation.core.RepeatMode
6 | import androidx.compose.animation.core.infiniteRepeatable
7 | import androidx.compose.animation.core.tween
8 | import androidx.compose.foundation.interaction.MutableInteractionSource
9 | import androidx.compose.foundation.interaction.collectIsFocusedAsState
10 | import androidx.compose.foundation.layout.BoxScope
11 | import androidx.compose.foundation.layout.fillMaxWidth
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.DisposableEffect
14 | import androidx.compose.runtime.LaunchedEffect
15 | import androidx.compose.runtime.getValue
16 | import androidx.compose.runtime.remember
17 | import androidx.compose.runtime.rememberCoroutineScope
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.graphics.Color
20 | import androidx.compose.ui.tooling.preview.Preview
21 | import androidx.compose.ui.unit.Dp
22 | import androidx.compose.ui.unit.dp
23 | import androidx.tv.material3.ClickableSurfaceBorder
24 | import androidx.tv.material3.ClickableSurfaceDefaults
25 | import androidx.tv.material3.ClickableSurfaceScale
26 | import androidx.tv.material3.ClickableSurfaceShape
27 | import androidx.tv.material3.MaterialTheme
28 | import androidx.tv.material3.Surface
29 | import androidx.tv.material3.Text
30 | import kotlinx.coroutines.delay
31 | import kotlinx.coroutines.launch
32 | import kotlin.time.Duration.Companion.seconds
33 |
34 | @Composable
35 | fun BorderedFocusableItem(
36 | modifier: Modifier = Modifier,
37 | borderRadius: Dp = 12.dp,
38 | scale: ClickableSurfaceScale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
39 | border: ClickableSurfaceBorder? = null,
40 | shape: ClickableSurfaceShape = CardItemDefaults.shape(borderRadius = borderRadius),
41 | color : Color = MaterialTheme.colorScheme.surface,
42 | onClick: () -> Unit,
43 | interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
44 | content: @Composable (BoxScope.() -> Unit)
45 | ) {
46 | val isFocused by interactionSource.collectIsFocusedAsState()
47 |
48 | val animatedFloat = remember { Animatable(1f) }
49 | val scope = rememberCoroutineScope()
50 |
51 | LaunchedEffect(isFocused) {
52 | if (isFocused) {
53 | delay(1.seconds)
54 | animatedFloat.animateTo(
55 | targetValue = 0f, animationSpec = infiniteRepeatable(
56 | animation = tween(700, easing = FastOutSlowInEasing),
57 | repeatMode = RepeatMode.Reverse
58 | )
59 | )
60 | } else {
61 | animatedFloat.stop()
62 | }
63 | }
64 |
65 | DisposableEffect(Unit) {
66 | onDispose {
67 | scope.launch {
68 | animatedFloat.stop()
69 | }
70 | }
71 | }
72 |
73 | Surface(
74 | onClick = { onClick() },
75 | scale = scale,
76 | border = border ?: CardItemDefaults.border(borderRadius, MaterialTheme.colorScheme.inverseSurface.copy(alpha = animatedFloat.value)),
77 | shape = shape,
78 | colors = ClickableSurfaceDefaults.colors(containerColor = color, focusedContainerColor = color),
79 | modifier = modifier.fillMaxWidth(),
80 | interactionSource = interactionSource
81 | ) {
82 | content()
83 | }
84 | }
85 |
86 | @Preview
87 | @Composable
88 | private fun BorderedFocusableItemPrev() {
89 | BorderedFocusableItem(onClick = {}) {
90 | Text(text = "Preview Text")
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/utils/extensions.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.utils
2 |
3 | import android.view.KeyEvent
4 | import android.view.KeyEvent.KEYCODE_DPAD_CENTER
5 | import android.view.KeyEvent.KEYCODE_DPAD_DOWN
6 | import android.view.KeyEvent.KEYCODE_DPAD_LEFT
7 | import android.view.KeyEvent.KEYCODE_DPAD_RIGHT
8 | import android.view.KeyEvent.KEYCODE_DPAD_UP
9 | import android.view.KeyEvent.KEYCODE_ENTER
10 | import android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
11 | import android.view.KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN
12 | import android.view.KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT
13 | import android.view.KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT
14 | import android.view.KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.draw.drawWithContent
17 | import androidx.compose.ui.graphics.BlendMode
18 | import androidx.compose.ui.graphics.Brush
19 | import androidx.compose.ui.graphics.CompositingStrategy
20 | import androidx.compose.ui.graphics.graphicsLayer
21 | import androidx.compose.ui.input.key.onPreviewKeyEvent
22 | import kotlinx.coroutines.flow.MutableStateFlow
23 | import kotlinx.coroutines.flow.StateFlow
24 |
25 | fun StateFlow.toMutable() = this as MutableStateFlow
26 |
27 | private val DPadEventsKeyCodes = listOf(
28 | KEYCODE_DPAD_LEFT,
29 | KEYCODE_SYSTEM_NAVIGATION_LEFT,
30 | KEYCODE_DPAD_RIGHT,
31 | KEYCODE_SYSTEM_NAVIGATION_RIGHT,
32 | KEYCODE_DPAD_CENTER,
33 | KEYCODE_ENTER,
34 | KEYCODE_NUMPAD_ENTER,
35 | KEYCODE_DPAD_UP,
36 | KEYCODE_SYSTEM_NAVIGATION_UP,
37 | KEYCODE_DPAD_DOWN,
38 | KEYCODE_SYSTEM_NAVIGATION_DOWN
39 | )
40 |
41 | fun Modifier.handleDPadKeyEvents(
42 | onUp: (() -> Unit)? = null,
43 | onDown: (() -> Unit)? = null,
44 | onLeft: (() -> Unit)? = null,
45 | onRight: (() -> Unit)? = null,
46 | onEnter: (() -> Unit)? = null,
47 | ) = onPreviewKeyEvent {
48 | fun onActionUp(block: () -> Unit) {
49 | if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) block()
50 | }
51 |
52 | if (!DPadEventsKeyCodes.contains(it.nativeKeyEvent.keyCode)) return@onPreviewKeyEvent false
53 |
54 | when (it.nativeKeyEvent.keyCode) {
55 | KEYCODE_ENTER,
56 | KEYCODE_DPAD_CENTER,
57 | KEYCODE_NUMPAD_ENTER,
58 | -> {
59 | onEnter?.apply {
60 | onActionUp(::invoke)
61 | return@onPreviewKeyEvent true
62 | }
63 | }
64 |
65 | KEYCODE_DPAD_LEFT,
66 | KEYCODE_SYSTEM_NAVIGATION_LEFT,
67 | -> {
68 | onLeft?.apply {
69 | onActionUp(::invoke)
70 | return@onPreviewKeyEvent true
71 | }
72 | }
73 |
74 | KEYCODE_DPAD_UP,
75 | KEYCODE_SYSTEM_NAVIGATION_UP,
76 | -> {
77 | onUp?.apply {
78 | onActionUp(::invoke)
79 | return@onPreviewKeyEvent false
80 | }
81 | }
82 |
83 | KEYCODE_DPAD_DOWN,
84 | KEYCODE_SYSTEM_NAVIGATION_DOWN,
85 | -> {
86 | onDown?.apply {
87 | onActionUp(::invoke)
88 | return@onPreviewKeyEvent false
89 | }
90 | }
91 |
92 | KEYCODE_DPAD_RIGHT,
93 | KEYCODE_SYSTEM_NAVIGATION_RIGHT,
94 | -> {
95 | onRight?.apply {
96 | onActionUp(::invoke)
97 | return@onPreviewKeyEvent true
98 | }
99 | }
100 | }
101 | false
102 | }
103 |
104 | fun Modifier.fadingEdge(brush: Brush) = this
105 | .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
106 | .drawWithContent {
107 | drawContent()
108 | drawRect(brush = brush, blendMode = BlendMode.DstIn)
109 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/settings/screens/profile/ProfileScreen.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.settings.screens.profile
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.border
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.size
10 | import androidx.compose.foundation.layout.width
11 | import androidx.compose.foundation.shape.CircleShape
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.draw.clip
16 | import androidx.compose.ui.draw.shadow
17 | import androidx.compose.ui.res.painterResource
18 | import androidx.compose.ui.text.style.TextAlign
19 | import androidx.compose.ui.unit.dp
20 | import androidx.tv.material3.LocalContentColor
21 | import androidx.tv.material3.MaterialTheme
22 | import androidx.tv.material3.Text
23 | import com.techlads.composetv.R
24 | import com.techlads.composetv.features.settings.data.SettingsMenuModel
25 | import com.techlads.composetv.features.settings.screens.PreferencesContainer
26 | import com.techlads.uicomponents.widgets.TvButton
27 |
28 | @Composable
29 | fun ProfileScreen() {
30 | PreferencesContainer(preference = SettingsMenuModel("Profile", "profile")) {
31 | ProfilesContent()
32 | }
33 | }
34 |
35 | @Composable
36 | fun ProfilesContent() {
37 | Column {
38 | Row(verticalAlignment = Alignment.CenterVertically) {
39 | ProfilePicture()
40 | Spacer(modifier = Modifier.size(20.dp))
41 | UserDetails()
42 | }
43 | Spacer(modifier = Modifier.size(5.dp))
44 | Row {
45 | Spacer(modifier = Modifier.size(120.dp))
46 | TvButton(onClick = {}, modifier = Modifier.width(120.dp)) {
47 | Text(
48 | text = "Save",
49 | modifier = Modifier.fillMaxWidth(),
50 | textAlign = TextAlign.Center,
51 | )
52 | }
53 | Spacer(modifier = Modifier.size(16.dp))
54 | TvButton(onClick = {}, modifier = Modifier.width(120.dp)) {
55 | Text(
56 | text = "Cancel",
57 | modifier = Modifier.fillMaxWidth(),
58 | textAlign = TextAlign.Center,
59 | )
60 | }
61 | }
62 | }
63 | }
64 |
65 | @Composable
66 | fun ProfilePicture() {
67 | Image(
68 | modifier = Modifier
69 | .size(100.dp)
70 | .clip(CircleShape)
71 | .shadow(elevation = 12.dp, shape = CircleShape, clip = true)
72 | .border(2.dp, LocalContentColor.current, CircleShape),
73 | painter = painterResource(id = R.drawable.profile),
74 | contentDescription = "User profile",
75 | )
76 | }
77 |
78 | @Composable
79 | fun UserDetails() {
80 | Column {
81 | Text(text = "Umair Khalid", style = MaterialTheme.typography.headlineSmall)
82 | Spacer(modifier = Modifier.size(8.dp))
83 | Text(
84 | text = "Android Developer",
85 | style = MaterialTheme.typography.labelSmall,
86 | color = LocalContentColor.current.copy(alpha = 0.4f),
87 | )
88 | Text(
89 | text = "Github: https://github.com/UmairKhalid786",
90 | style = MaterialTheme.typography.labelSmall,
91 | color = LocalContentColor.current.copy(alpha = 0.4f),
92 | )
93 | }
94 | }
95 |
96 | @androidx.compose.ui.tooling.preview.Preview
97 | @Composable
98 | fun ProfileScreenPrev() {
99 | ProfileScreen()
100 | }
101 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.viewModels
7 | import androidx.compose.animation.ExperimentalAnimationApi
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.runtime.getValue
11 | import androidx.compose.runtime.mutableStateOf
12 | import androidx.compose.runtime.remember
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.draw.drawWithContent
15 | import androidx.compose.ui.graphics.Brush
16 | import androidx.core.view.WindowCompat
17 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
18 | import androidx.navigation.NavHostController
19 | import androidx.tv.material3.MaterialTheme
20 | import com.google.accompanist.navigation.animation.rememberAnimatedNavController
21 | import com.techlads.composetv.features.home.HomeViewModel
22 | import com.techlads.composetv.navigation.AppNavigation
23 | import com.techlads.composetv.theme.ComposeTvTheme
24 | import com.techlads.login.withEmailPassword.BackgroundViewModel
25 | import com.techlads.login.withEmailPassword.CrossFadeBackground
26 | import dagger.hilt.android.AndroidEntryPoint
27 |
28 | @AndroidEntryPoint
29 | class MainActivity : ComponentActivity() {
30 | @OptIn(ExperimentalAnimationApi::class)
31 | override fun onCreate(savedInstanceState: Bundle?) {
32 | super.onCreate(savedInstanceState)
33 |
34 | WindowCompat.setDecorFitsSystemWindows(window, false)
35 |
36 | val backgroundViewModel by viewModels()
37 |
38 | setContent {
39 | ComposeTvTheme {
40 | val state by backgroundViewModel.crossFadeState.collectAsStateWithLifecycle()
41 | val background = MaterialTheme.colorScheme.surface
42 |
43 | state?.let {
44 | CrossFadeBackground(
45 | state = it, modifier = Modifier
46 | .fillMaxSize()
47 | .drawWithContent {
48 | drawContent()
49 | drawRect(
50 | Brush.radialGradient(
51 | listOf(
52 | background.copy(0.8f),
53 | background.copy(0.7f),
54 | background.copy(0.6f),
55 | )
56 | ), size = size
57 | )
58 | })
59 | }
60 |
61 |
62 | val displayDialog = remember {
63 | mutableStateOf(false)
64 | }
65 | val homeViewModel: HomeViewModel by viewModels()
66 | App(
67 | navController = rememberAnimatedNavController(),
68 | homeViewModel = homeViewModel,
69 | backgroundViewModel = backgroundViewModel
70 | )
71 |
72 | registerOnBackPress {
73 | displayDialog.value = true
74 | }
75 |
76 | if (displayDialog.value) {
77 | CustomDialog(openDialogCustom = displayDialog) {
78 | finish()
79 | }
80 | }
81 | }
82 | }
83 | }
84 |
85 | @Composable
86 | fun App(
87 | navController: NavHostController,
88 | homeViewModel: HomeViewModel,
89 | backgroundViewModel: BackgroundViewModel
90 | ) {
91 | AppNavigation(
92 | navController, homeViewModel = homeViewModel, backgroundViewModel = backgroundViewModel
93 | )
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/player/PlayerScreen.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.player
2 |
3 | import android.annotation.SuppressLint
4 | import androidx.activity.compose.BackHandler
5 | import androidx.compose.foundation.focusable
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.DisposableEffect
10 | import androidx.compose.runtime.LaunchedEffect
11 | import androidx.compose.runtime.getValue
12 | import androidx.compose.runtime.mutableLongStateOf
13 | import androidx.compose.runtime.remember
14 | import androidx.compose.runtime.rememberCoroutineScope
15 | import androidx.compose.runtime.setValue
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.platform.LocalContext
19 | import androidx.compose.ui.tooling.preview.Preview
20 | import androidx.compose.ui.viewinterop.AndroidView
21 | import com.techlads.composetv.features.player.controls.PlayerControls
22 | import com.techlads.composetv.features.player.controls.rememberVideoPlayerState
23 | import com.techlads.composetv.utils.handleDPadKeyEvents
24 | import com.techlads.exoplayer.PlayerFactory
25 | import kotlinx.coroutines.delay
26 | import kotlinx.coroutines.launch
27 |
28 | @Composable
29 | fun PlayerScreen(mediaUrl: String, onBackPressed: () -> Unit) {
30 | PlayerScreenContent(Modifier, mediaUrl, onBackPressed)
31 | }
32 |
33 | @SuppressLint("UnsafeOptInUsageError")
34 | @Composable
35 | fun PlayerScreenContent(modifier: Modifier, mediaUrl: String, onBackPressed: () -> Unit) {
36 | val context = LocalContext.current
37 |
38 | val player = remember {
39 | PlayerFactory.create(context)
40 | }
41 |
42 | val coroutineScope = rememberCoroutineScope()
43 | var contentCurrentPosition: Long by remember { mutableLongStateOf(0L) }
44 | val videoPlayerState = rememberVideoPlayerState(hideSeconds = 4, coroutineScope)
45 |
46 | BackHandler(onBack = onBackPressed)
47 |
48 | LaunchedEffect(Unit) {
49 | player.prepare(mediaUrl, true)
50 | }
51 |
52 | LaunchedEffect(Unit) {
53 | while (true) {
54 | delay(300)
55 | contentCurrentPosition = player.currentPosition
56 | }
57 | }
58 |
59 | Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
60 | AndroidView(
61 | modifier = Modifier
62 | .handleDPadKeyEvents(
63 | onEnter = {
64 | if (!videoPlayerState.isDisplayed) {
65 | coroutineScope.launch {
66 | videoPlayerState.showControls()
67 | }
68 | }
69 | },
70 | )
71 | .focusable(),
72 | factory = {
73 | player.getView()
74 | },
75 | )
76 | DisposableEffect(Unit) {
77 | onDispose { player.release() }
78 | }
79 | PlayerControls(
80 | modifier = Modifier.align(Alignment.BottomCenter),
81 | isPlaying = player.isPlaying,
82 | onPlayPauseToggle = { shouldPlay ->
83 | if (shouldPlay) {
84 | player.play()
85 | } else {
86 | player.pause()
87 | }
88 | },
89 | contentProgressInMillis = contentCurrentPosition,
90 | contentDurationInMillis = player.duration,
91 | state = videoPlayerState,
92 | onSeek = { seekProgress ->
93 | player.seekTo(player.duration.times(seekProgress).toLong())
94 | },
95 | )
96 | }
97 | }
98 |
99 | @Preview
100 | @Composable
101 | private fun PlayerScreenPreview() {
102 | PlayerScreen("http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4") {
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/libs/content/src/main/java/com/techlads/content/MoviesService.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.content
2 |
3 | import com.techlads.content.data.CreditsResponse
4 | import com.techlads.content.data.FakeCastProvider
5 | import com.techlads.content.data.FakeMoviesDataProvider
6 | import com.techlads.content.data.MovieResponse
7 | import com.techlads.content.data.MovieVideosResponse
8 | import com.techlads.content.data.MoviesResponse
9 | import com.techlads.network.ApiResult
10 | import com.techlads.network.di.safeGet
11 | import io.ktor.client.HttpClient
12 | import io.ktor.client.request.header
13 | import io.ktor.client.request.parameter
14 | import io.ktor.client.request.url
15 | import javax.inject.Inject
16 | import javax.inject.Named
17 |
18 | interface MoviesService {
19 | suspend fun getMovies(category: String): ApiResult
20 | suspend fun getMovieDetail(movieId: Int): ApiResult
21 | suspend fun getMovieCredits(movieId: Int): ApiResult
22 | suspend fun getMovieVideos(movieId: Int): ApiResult
23 | suspend fun getTrending(category: String): ApiResult
24 | }
25 |
26 | class TmdbApiServiceImpl @Inject constructor(
27 | private val client: HttpClient,
28 | @Named("TMDBBaseUrl") private val baseUrl: String,
29 | @Named("TMDBApiKey") private val apiKey: String
30 | ) : MoviesService {
31 |
32 | override suspend fun getMovies(category: String): ApiResult = client.safeGet {
33 | url("$baseUrl/movie/$category")
34 | header("Content-Type", "application/json")
35 | parameter("api_key", apiKey)
36 | parameter("page", 1)
37 | parameter("language", "en")
38 | }
39 |
40 | override suspend fun getMovieDetail(movieId: Int): ApiResult = client.safeGet {
41 | url("$baseUrl/movie/$movieId")
42 | header("Content-Type", "application/json")
43 | parameter("api_key", apiKey)
44 | }
45 |
46 | override suspend fun getMovieCredits(movieId: Int): ApiResult =
47 | client.safeGet {
48 | url("$baseUrl/movie/$movieId/credits")
49 | header("Content-Type", "application/json")
50 | parameter("api_key", apiKey)
51 | }
52 |
53 | override suspend fun getMovieVideos(movieId: Int): ApiResult =
54 | client.safeGet {
55 | url("$baseUrl/movie/${movieId}/videos")
56 | header("Content-Type", "application/json")
57 | parameter("api_key", apiKey)
58 | }
59 |
60 | override suspend fun getTrending(category: String): ApiResult = client.safeGet {
61 | url("$baseUrl/trending/$category/day")
62 | header("Content-Type", "application/json")
63 | parameter("api_key", apiKey)
64 | parameter("page", 1)
65 | parameter("language", "en")
66 | }
67 | }
68 |
69 | class FakeMoviesService @Inject constructor() : MoviesService {
70 | override suspend fun getMovies(category: String): ApiResult {
71 | return ApiResult.Success(MoviesResponse(FakeMoviesDataProvider.movies))
72 | }
73 |
74 | override suspend fun getMovieDetail(movieId: Int): ApiResult {
75 | val movie =
76 | FakeMoviesDataProvider.movieDetails.find { it.id == movieId } ?: return ApiResult.Error(
77 | "Movie not found"
78 | )
79 | return ApiResult.Success(movie)
80 | }
81 |
82 | override suspend fun getMovieCredits(movieId: Int): ApiResult {
83 | val credits = FakeCastProvider.cast
84 | return ApiResult.Success(CreditsResponse(id = movieId, cast = credits))
85 | }
86 |
87 | override suspend fun getMovieVideos(movieId: Int): ApiResult {
88 | val videos = FakeMoviesDataProvider.movieVideos.find { it.id == movieId }
89 | ?: return ApiResult.Error("No videos found for this movie")
90 | return ApiResult.Success(videos)
91 | }
92 |
93 | override suspend fun getTrending(category: String): ApiResult {
94 | return ApiResult.Success(MoviesResponse(FakeMoviesDataProvider.movies.take(5)))
95 | }
96 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/player/controls/PlayerControlsIndicator.kt:
--------------------------------------------------------------------------------
1 | package com.techlads.composetv.features.player.controls
2 |
3 | import androidx.compose.animation.core.animateDpAsState
4 | import androidx.compose.foundation.Canvas
5 | import androidx.compose.foundation.focusable
6 | import androidx.compose.foundation.interaction.MutableInteractionSource
7 | import androidx.compose.foundation.interaction.collectIsFocusedAsState
8 | import androidx.compose.foundation.layout.RowScope
9 | import androidx.compose.foundation.layout.height
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.LaunchedEffect
13 | import androidx.compose.runtime.getValue
14 | import androidx.compose.runtime.mutableStateOf
15 | import androidx.compose.runtime.remember
16 | import androidx.compose.runtime.rememberUpdatedState
17 | import androidx.compose.runtime.setValue
18 | import androidx.compose.ui.ExperimentalComposeUiApi
19 | import androidx.compose.ui.Modifier
20 | import androidx.compose.ui.focus.FocusDirection
21 | import androidx.compose.ui.geometry.Offset
22 | import androidx.compose.ui.graphics.StrokeCap
23 | import androidx.compose.ui.platform.LocalFocusManager
24 | import androidx.compose.ui.unit.dp
25 | import androidx.tv.material3.ExperimentalTvMaterial3Api
26 | import androidx.tv.material3.MaterialTheme
27 | import com.techlads.composetv.utils.handleDPadKeyEvents
28 |
29 | @OptIn(ExperimentalComposeUiApi::class, ExperimentalTvMaterial3Api::class)
30 | @Composable
31 | fun RowScope.VideoPlayerControllerIndicator(
32 | modifier: Modifier = Modifier,
33 | progress: Float,
34 | onSeek: (seekProgress: Float) -> Unit,
35 | state: PlayerControlsState,
36 | ) {
37 | val interactionSource = remember { MutableInteractionSource() }
38 | var isSelected by remember { mutableStateOf(false) }
39 | val isFocused by interactionSource.collectIsFocusedAsState()
40 | val color by rememberUpdatedState(
41 | newValue = if (isSelected) {
42 | MaterialTheme.colorScheme.primary
43 | } else {
44 | MaterialTheme.colorScheme.onSurface
45 | },
46 | )
47 | val animatedIndicatorHeight by animateDpAsState(
48 | targetValue = 4.dp.times((if (isFocused) 2.5f else 1f)),
49 | )
50 | var seekProgress by remember { mutableStateOf(0f) }
51 | val focusManager = LocalFocusManager.current
52 |
53 | LaunchedEffect(isSelected) {
54 | if (isSelected) {
55 | state.showControls(seconds = Int.MAX_VALUE)
56 | } else {
57 | state.showControls()
58 | }
59 | }
60 |
61 | Canvas(
62 | modifier = modifier
63 | .weight(1f)
64 | .height(animatedIndicatorHeight)
65 | .padding(horizontal = 4.dp)
66 | .handleDPadKeyEvents(
67 | onEnter = {
68 | if (isSelected) {
69 | onSeek(seekProgress)
70 | focusManager.moveFocus(FocusDirection.Exit)
71 | } else {
72 | seekProgress = progress
73 | }
74 | isSelected = !isSelected
75 | },
76 | onLeft = {
77 | if (isSelected) {
78 | seekProgress -= 0.1f
79 | } else {
80 | focusManager.moveFocus(FocusDirection.Left)
81 | }
82 | },
83 | onRight = {
84 | if (isSelected) {
85 | seekProgress += 0.1f
86 | } else {
87 | focusManager.moveFocus(FocusDirection.Right)
88 | }
89 | },
90 | )
91 | .focusable(interactionSource = interactionSource),
92 | onDraw = {
93 | val yOffset = size.height.div(2)
94 | drawLine(
95 | color = color.copy(alpha = 0.24f),
96 | start = Offset(x = 0f, y = yOffset),
97 | end = Offset(x = size.width, y = yOffset),
98 | strokeWidth = size.height,
99 | cap = StrokeCap.Round,
100 | )
101 | drawLine(
102 | color = color,
103 | start = Offset(x = 0f, y = yOffset),
104 | end = Offset(
105 | x = size.width.times(if (isSelected) seekProgress else progress),
106 | y = yOffset,
107 | ),
108 | strokeWidth = size.height,
109 | cap = StrokeCap.Round,
110 | )
111 | },
112 | )
113 | }
114 |
--------------------------------------------------------------------------------
/app/src/main/java/com/techlads/composetv/features/home/carousel/HomeCarousel.kt:
--------------------------------------------------------------------------------
1 | @file:OptIn(ExperimentalFoundationApi::class)
2 |
3 | package com.techlads.composetv.features.home.carousel
4 |
5 | import androidx.compose.foundation.ExperimentalFoundationApi
6 | import androidx.compose.foundation.gestures.BringIntoViewSpec
7 | import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.PaddingValues
10 | import androidx.compose.foundation.lazy.LazyColumn
11 | import androidx.compose.foundation.lazy.items
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.CompositionLocalProvider
14 | import androidx.compose.runtime.getValue
15 | import androidx.compose.runtime.mutableStateOf
16 | import androidx.compose.runtime.remember
17 | import androidx.compose.runtime.rememberUpdatedState
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.graphics.Brush
20 | import androidx.compose.ui.graphics.Color
21 | import androidx.compose.ui.platform.testTag
22 | import androidx.compose.ui.tooling.preview.Preview
23 | import androidx.compose.ui.unit.dp
24 | import com.techlads.composetv.utils.fadingEdge
25 |
26 | @Composable
27 | fun HomeCarousel(
28 | homeState: HomeCarouselState,
29 | modifier: Modifier,
30 | onItemFocus: (parentId: String, childId: String) -> Unit,
31 | onItemClick: (parentId: String, childId: String) -> Unit,
32 | ) {
33 | val topFade by rememberUpdatedState(
34 | Brush.verticalGradient(
35 | 0f to Color.Transparent,
36 | 0.3f to Color.Black
37 | )
38 | )
39 | val enableFadeEdge = remember { mutableStateOf(false) }
40 |
41 | PositionFocusedItemInLazyLayout(
42 | parentFraction = 0.25f,
43 | childFraction = 0.1f,
44 | ) {
45 | LazyColumn(
46 | modifier
47 | .testTag(SECTIONS_LIST_TAG)
48 | .then(
49 | if (enableFadeEdge.value) {
50 | Modifier.fadingEdge(topFade)
51 | } else {
52 | Modifier
53 | }
54 | ),
55 | contentPadding = PaddingValues(bottom = 100.dp)
56 | ) {
57 | items(homeState.items) {
58 | HorizontalCarouselItem(it, onItemFocus = { p, c ->
59 | onItemFocus(p, c)
60 | enableFadeEdge.value = it.items.firstOrNull { it.id == p } != null
61 | }, onItemClick = onItemClick)
62 | }
63 | }
64 | }
65 | }
66 |
67 | @Preview
68 | @Composable
69 | fun HomeCarouselPrev() {
70 | Column {
71 | HomeCarousel(homeState = HomeCarouselState(items = (1..20).map { it ->
72 | CarouselItemPayload(id = it.toString(), title = "Item $it", type = "empty", items = (1..10).map {
73 | CardPayload(id = it.toString(), title = "Card $it", image = "empty", promo = "")
74 | })
75 | }), modifier = Modifier, onItemFocus = { _, _ -> }) { _, _ -> }
76 | }
77 | }
78 |
79 |
80 | @Composable
81 | fun PositionFocusedItemInLazyLayout(
82 | parentFraction: Float = 0.3f,
83 | childFraction: Float = 0f,
84 | content: @Composable () -> Unit,
85 | ) {
86 | // a bring into view spec that pivots around the center of the scrollable container
87 | val bringIntoViewSpec = remember(parentFraction, childFraction) {
88 | object : BringIntoViewSpec {
89 | override fun calculateScrollDistance(
90 | // initial position of item requesting focus
91 | offset: Float,
92 | // size of item requesting focus
93 | size: Float,
94 | // size of the lazy container
95 | containerSize: Float,
96 | ): Float {
97 | val childSmallerThanParent = size <= containerSize
98 | val initialTargetForLeadingEdge =
99 | parentFraction * containerSize - (childFraction * size)
100 | val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge
101 |
102 | val targetForLeadingEdge =
103 | if (childSmallerThanParent && spaceAvailableToShowItem < size) {
104 | containerSize - size
105 | } else {
106 | initialTargetForLeadingEdge
107 | }
108 |
109 | return offset - targetForLeadingEdge
110 | }
111 | }
112 | }
113 |
114 | // LocalBringIntoViewSpec will apply to all scrollables in the hierarchy.
115 | CompositionLocalProvider(
116 | LocalBringIntoViewSpec provides bringIntoViewSpec,
117 | content = content,
118 | )
119 | }
120 |
--------------------------------------------------------------------------------