diff --git a/tv/build.gradle.kts b/tv/build.gradle.kts index bce37fe94..f0e64c896 100644 --- a/tv/build.gradle.kts +++ b/tv/build.gradle.kts @@ -5,14 +5,15 @@ plugins { android { namespace = "com.example.tv" - compileSdk = 37 + compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "com.example.tv" - minSdk = 28 - targetSdk = 37 + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1 versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -25,14 +26,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlin { - jvmToolchain(21) - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) - } + jvmToolchain(17) } buildFeatures { diff --git a/tv/src/main/java/com/example/tv/playback/AdjustDisplaySettings.kt b/tv/src/main/java/com/example/tv/playback/AdjustDisplaySettings.kt new file mode 100644 index 000000000..bfcaac360 --- /dev/null +++ b/tv/src/main/java/com/example/tv/playback/AdjustDisplaySettings.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.tv.playback + +import android.content.Context +import android.media.MediaCodec +import android.media.MediaFormat +import android.media.quality.MediaQualityManager +import android.media.quality.PictureProfile +import android.os.Build +import android.os.Bundle +import androidx.annotation.RequiresApi + +// [START android_tv_playback_adjust_display_settings_profile_constants] +const val NAME_STANDARD: String = "standard" +const val NAME_VIVID: String = "vivid" +const val NAME_SPORTS: String = "sports" +const val NAME_GAME: String = "game" +const val NAME_MOVIE: String = "movie" +const val NAME_ENERGY_SAVING: String = "energy_saving" +const val NAME_USER: String = "user" +// [END android_tv_playback_adjust_display_settings_profile_constants] + +@RequiresApi(36) +class AdjustDisplaySettings { + + fun queryAvailableProfiles(context: Context, mediaCodec: MediaCodec) { +// [START android_tv_playback_adjust_display_settings_available_profiles] + if (Build.VERSION.SDK_INT >= 36) { + val mediaQualityManager: MediaQualityManager = + context.getSystemService(MediaQualityManager::class.java) + val profiles = mediaQualityManager.getAvailablePictureProfiles(null) + for (profile in profiles) { + // If we have a system sports profile, apply it to our media codec + if (profile.profileType == PictureProfile.TYPE_SYSTEM && + profile.name == NAME_SPORTS + ) { + val bundle = Bundle().apply { + putParcelable(MediaFormat.KEY_PICTURE_PROFILE_INSTANCE, profile) + } + mediaCodec.setParameters(bundle) + } + } + } +// [END android_tv_playback_adjust_display_settings_available_profiles] + } + + fun getProfileByName(mediaQualityManager: MediaQualityManager) { +// [START android_tv_playback_adjust_display_settings_get_profile] + if (Build.VERSION.SDK_INT >= 36) { + val profile = mediaQualityManager.getPictureProfile( + PictureProfile.TYPE_SYSTEM, NAME_SPORTS, null + ) + } +// [END android_tv_playback_adjust_display_settings_get_profile] + } +} diff --git a/tv/src/main/java/com/example/tv/playback/AudioCapabilities.kt b/tv/src/main/java/com/example/tv/playback/AudioCapabilities.kt new file mode 100644 index 000000000..34eba0768 --- /dev/null +++ b/tv/src/main/java/com/example/tv/playback/AudioCapabilities.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.tv.playback + +import android.media.AudioAttributes +import android.media.AudioDeviceInfo +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioProfile +import android.media.AudioTrack +import android.os.Build +import android.os.Handler +import androidx.annotation.RequiresApi + +abstract class AudioPlayer { + abstract val audioTrack: AudioTrack + fun addAudioTrackWriteErrorListener(listener: (Int) -> Unit) {} +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +class AudioCapabilities( + private val audioManager: AudioManager, + private val audioPlayer: AudioPlayer, + private val handler: Handler +) { + + fun checkDirectPlaybackSupport() { +// [START android_tv_playback_audio_capabilities_direct_playback_support] + val format = AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_E_AC3) + .setChannelMask(AudioFormat.CHANNEL_OUT_5POINT1) + .setSampleRate(48000) + .build() + val attributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + + if (AudioManager.getDirectPlaybackSupport(format, attributes) != + AudioManager.DIRECT_PLAYBACK_NOT_SUPPORTED + ) { + // The format and attributes are supported for direct playback + // on the currently active routed audio path + } else { + // The format and attributes are NOT supported for direct playback + // on the currently active routed audio path + } +// [END android_tv_playback_audio_capabilities_direct_playback_support] + } + +// [START android_tv_playback_audio_capabilities_find_best_format] + private fun findBestAudioFormat(audioAttributes: AudioAttributes): AudioFormat { + val preferredFormats = listOf( + AudioFormat.ENCODING_E_AC3, + AudioFormat.ENCODING_AC3, + AudioFormat.ENCODING_PCM_16BIT, + AudioFormat.ENCODING_DEFAULT + ) + val audioProfiles = audioManager.getDirectProfilesForAttributes(audioAttributes) + val bestAudioProfile = preferredFormats.firstNotNullOf { format -> + audioProfiles.firstOrNull { it.format == format } + } + val sampleRate = findBestSampleRate(bestAudioProfile) + val channelMask = findBestChannelMask(bestAudioProfile) + return AudioFormat.Builder() + .setEncoding(bestAudioProfile.format) + .setSampleRate(sampleRate) + .setChannelMask(channelMask) + .build() + } +// [END android_tv_playback_audio_capabilities_find_best_format] + + private fun findBestSampleRate(audioProfile: AudioProfile): Int { + return audioProfile.sampleRates.maxOrNull() ?: 48000 + } + + private fun findBestChannelMask(audioProfile: AudioProfile): Int { + return audioProfile.channelMasks.firstOrNull() ?: AudioFormat.CHANNEL_OUT_STEREO + } + + private fun findDefaultAudioDeviceInfo(): AudioDeviceInfo? = null + private fun restartAudioTrack(audioDeviceInfo: AudioDeviceInfo?) {} + private fun needsAudioFormatChange(audioDeviceInfo: AudioDeviceInfo): Boolean = false + + fun registerRoutingListener() { +// [START android_tv_playback_audio_capabilities_routing_listener] + // audioPlayer is a wrapper around an AudioTrack + // which calls a callback for an AudioTrack write error + audioPlayer.addAudioTrackWriteErrorListener { + // error code can be checked here, + // in case of write error try to recreate the audio track + restartAudioTrack(findDefaultAudioDeviceInfo()) + } + + audioPlayer.audioTrack.addOnRoutingChangedListener({ audioRouting -> + audioRouting?.routedDevice?.let { audioDeviceInfo -> + // use the updated audio routed device to determine + // what audio format should be used + if (needsAudioFormatChange(audioDeviceInfo)) { + restartAudioTrack(audioDeviceInfo) + } + } + }, handler) +// [END android_tv_playback_audio_capabilities_routing_listener] + } +} diff --git a/tv/src/main/java/com/example/tv/ui/TvMoviesActivity.kt b/tv/src/main/java/com/example/tv/ui/TvMoviesActivity.kt index 669860fc5..d906afc79 100644 --- a/tv/src/main/java/com/example/tv/ui/TvMoviesActivity.kt +++ b/tv/src/main/java/com/example/tv/ui/TvMoviesActivity.kt @@ -19,7 +19,6 @@ package com.example.tv.ui import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,8 +26,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -37,6 +36,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.items import androidx.tv.material3.Carousel import androidx.tv.material3.ExperimentalTvMaterial3Api import coil.compose.AsyncImage @@ -62,7 +63,8 @@ class TvMoviesActivity : ComponentActivity() { ) val SECTIONS = listOf( Section( - "Favorites", listOf( + "Favorites", + listOf( Movie( "The Shawshank Redemption", "Two imprisoned men bond over a number of years", @@ -96,7 +98,8 @@ class TvMoviesActivity : ComponentActivity() { ) ), Section( - "Comedy", listOf( + "Comedy", + listOf( Movie( "Role Models", "Two salesmen have to undergo community service to mentor two boys", @@ -126,22 +129,43 @@ class TvMoviesActivity : ComponentActivity() { ) } + @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Scaffold(modifier = Modifier.fillMaxSize()) { padding -> Column { FeaturedCarousel(FEATURED) - CatalogBrowser(SECTIONS, Modifier.padding(padding)) + CatalogBrowserWithCarousel.CatalogBrowser( + FEATURED, + SECTIONS, + Modifier.padding(padding) + ) } } } } } +private object CatalogBrowserStub { +// [START android_compose_tv_catalog_browser_stub] +@Composable +fun CatalogBrowser( + featuredContentList: List, + sectionList: List
, + modifier: Modifier = Modifier, + onItemSelected: (Movie) -> Unit = {}, +) { + // ToDo: add implementation +} +// [END android_compose_tv_catalog_browser_stub] +} + +private object CatalogBrowserLazyColumn { // [START android_compose_tv_catalog_browser] @Composable fun CatalogBrowser( + featuredContentList: List, sectionList: List
, modifier: Modifier = Modifier, onItemSelected: (Movie) -> Unit = {}, @@ -150,12 +174,14 @@ fun CatalogBrowser( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - items(sectionList) { section -> + items(sectionList.size) { index -> + val section = sectionList[index] SectionRow(section, onItemSelected = onItemSelected) } } } // [END android_compose_tv_catalog_browser] +} // [START android_compose_tv_catalog_browser_section] @Composable @@ -187,7 +213,8 @@ fun MovieCatalog( modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - items(movies) { movie -> + items(movies.size) { index -> + val movie = movies[index] MovieCard( movie = movie, onClick = { onClick(movie) } @@ -198,15 +225,17 @@ fun MovieCatalog( // [END android_compose_tv_lazyrow] // [START android_compose_tv_movie_card] +@OptIn(ExperimentalMaterial3Api::class) @Composable fun MovieCard( movie: Movie, modifier: Modifier = Modifier, - onClick: (Movie) -> Unit = {}, + onClick: () -> Unit = {} ) { Card( modifier = modifier, - onClick = { onClick(movie) }) { + onClick = onClick + ) { AsyncImage( model = movie.thumbnailUrl, contentDescription = movie.title, @@ -237,15 +266,35 @@ fun FeaturedCarousel( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) - Text( - text = content.title, - style = MaterialTheme.typography.headlineMedium.copy(color = androidx.compose.ui.graphics.Color.White), - modifier = Modifier - .padding(16.dp) - .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f)) - .padding(8.dp) - ) + Text(text = content.title) } } } // [END android_compose_tv_featured_carousel] + +private object CatalogBrowserWithCarousel { +// [START android_compose_tv_catalog_browser_with_carousel] +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun CatalogBrowser( + featuredContentList: List, + sectionList: List
, + modifier: Modifier = Modifier, + onItemSelected: (Movie) -> Unit = {}, +) { + TvLazyColumn( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + FeaturedCarousel(featuredContentList) + } + + items(sectionList.size) { index -> + val section = sectionList[index] + SectionRow(section, onItemSelected = onItemSelected) + } + } +} +// [END android_compose_tv_catalog_browser_with_carousel] +}