├── .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 | 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 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | 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 | Screenshot_20251209_233857 5 | 6 | ### Who is watching 7 | Screenshot_20251209_233910 8 | 9 | ### Home hero item 10 | Screenshot_20251209_233923 11 | 12 | ### Hero item focused 13 | Screenshot_20251209_233937 14 | 15 | ### Home top pick 16 | Screenshot_20251209_233951 17 | 18 | ### Music 19 | Screenshot_20251209_234035 20 | 21 | ### Player screen 22 | Screenshot_20251209_234011 23 | 24 | ### Movie detail 25 | Screenshot_20251211_154352 26 | 27 | ### Movie detail view more 28 | Screenshot_20251211_154409 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 | --------------------------------------------------------------------------------