commit b0af9375923c326a6de796545ccad762cb3be8cf Author: Tinsae Date: Mon Mar 23 01:14:54 2026 +0100 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22bb44c --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +*.iml +*.iws +.idea/ +/out/ +!/.idea/assetWizardSettings.xml +!/.idea/navEditor.xml + +.gradle/ +/build/ +/*/build/ + +local.properties +secrets.properties + +*.apk +*.ap_ +*.aab +*.dex + +*.jks +*.keystore + +.externalNativeBuild/ +.cxx/ + +.DS_Store +Thumbs.db + +*.log +captures/ + +*.hprof diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..e6fc70a --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,79 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) +} + +android { + namespace = "net.tinsae.tv" + compileSdk = 36 + + defaultConfig { + applicationId = "net.tinsae.tv" + minSdk = 23 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } + lint { + disable.add("NullSafeMutableLiveData") + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.tv.foundation) + implementation(libs.androidx.tv.material) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.material3) + + // Media3 + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.hls) + implementation(libs.androidx.media3.session) + + // Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.graphics.shapes) + ksp(libs.androidx.room.compiler) + + implementation(libs.retrofit.core) + implementation(libs.retrofit.scalars) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.coil.compose) + + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/release/baselineProfiles/0/app-release.dm b/app/release/baselineProfiles/0/app-release.dm new file mode 100644 index 0000000..1c31e40 Binary files /dev/null and b/app/release/baselineProfiles/0/app-release.dm differ diff --git a/app/release/baselineProfiles/1/app-release.dm b/app/release/baselineProfiles/1/app-release.dm new file mode 100644 index 0000000..df08986 Binary files /dev/null and b/app/release/baselineProfiles/1/app-release.dm differ diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json new file mode 100644 index 0000000..45037d5 --- /dev/null +++ b/app/release/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "net.tinsae.tv", + "variantName": "release", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0", + "outputFile": "app-release.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-release.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-release.dm" + ] + } + ], + "minSdkVersionForDexing": 23 +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..713b1cd --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/tv/MainActivity.kt b/app/src/main/java/net/tinsae/tv/MainActivity.kt new file mode 100644 index 0000000..158562c --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/MainActivity.kt @@ -0,0 +1,28 @@ +package net.tinsae.tv + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatDelegate +import androidx.tv.material3.ExperimentalTvMaterial3Api +import net.tinsae.tv.ui.screens.MainScreen +import net.tinsae.tv.ui.theme.TVTheme + +class MainActivity : ComponentActivity() { + @OptIn(ExperimentalTvMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Ensure the app starts in dark mode + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + + // Ensure the window background is transparent to allow SurfaceView (Video) + // to be visible through the UI layers. + window.setBackgroundDrawableResource(android.R.color.transparent) + + setContent { + TVTheme { + MainScreen() + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/data/AppDatabase.kt b/app/src/main/java/net/tinsae/tv/data/AppDatabase.kt new file mode 100644 index 0000000..f279725 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/data/AppDatabase.kt @@ -0,0 +1,28 @@ +package net.tinsae.tv.data + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase + +@Database(entities = [ChannelEntity::class], version = 1, exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + abstract fun channelDao(): ChannelDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "tv_database" + ).build() + INSTANCE = instance + instance + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/data/ChannelDao.kt b/app/src/main/java/net/tinsae/tv/data/ChannelDao.kt new file mode 100644 index 0000000..a49e913 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/data/ChannelDao.kt @@ -0,0 +1,25 @@ +package net.tinsae.tv.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChannelDao { + @Query("SELECT * FROM channels WHERE isFavorite = 1") + fun getFavorites(): Flow> + + @Query("SELECT url FROM channels WHERE isFavorite = 1") + suspend fun getFavoriteUrls(): List + + @Query("SELECT * FROM channels WHERE lastPlayedTimestamp > 0 ORDER BY lastPlayedTimestamp DESC LIMIT 20") + fun getLastPlayed(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertChannel(channel: ChannelEntity) + + @Update + suspend fun updateChannel(channel: ChannelEntity) + + @Query("SELECT * FROM channels WHERE url = :url") + suspend fun getChannelByUrl(url: String): ChannelEntity? +} diff --git a/app/src/main/java/net/tinsae/tv/data/ChannelEntity.kt b/app/src/main/java/net/tinsae/tv/data/ChannelEntity.kt new file mode 100644 index 0000000..30e5271 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/data/ChannelEntity.kt @@ -0,0 +1,15 @@ +package net.tinsae.tv.data + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "channels") +data class ChannelEntity( + @PrimaryKey val url: String, + val name: String, + val logoUrl: String?, + val group: String?, + val id: String?, + val isFavorite: Boolean = false, + val lastPlayedTimestamp: Long = 0 +) diff --git a/app/src/main/java/net/tinsae/tv/data/mapper/ChannelMapper.kt b/app/src/main/java/net/tinsae/tv/data/mapper/ChannelMapper.kt new file mode 100644 index 0000000..902e216 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/data/mapper/ChannelMapper.kt @@ -0,0 +1,21 @@ +package net.tinsae.tv.data.mapper + +import net.tinsae.tv.data.ChannelEntity +import net.tinsae.tv.model.Channel + +fun Channel.toEntity() = ChannelEntity( + url = url, + name = name, + logoUrl = logoUrl, + group = group, + id = id +) + +fun ChannelEntity.toChannel(forceFavorite: Boolean = false) = Channel( + name = name, + url = url, + logoUrl = logoUrl, + group = group, + id = id, + isFavorite = forceFavorite || isFavorite +) diff --git a/app/src/main/java/net/tinsae/tv/model/Category.kt b/app/src/main/java/net/tinsae/tv/model/Category.kt new file mode 100644 index 0000000..9e941ce --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/model/Category.kt @@ -0,0 +1,11 @@ +package net.tinsae.tv.model + +data class Category( + val name: String, + val url: String, + val type: CategoryType +) + +enum class CategoryType { + COUNTRY, LANGUAGE, GENRE, FAVORITES, LAST_PLAYED, CHANNEL_LIST +} diff --git a/app/src/main/java/net/tinsae/tv/model/Channel.kt b/app/src/main/java/net/tinsae/tv/model/Channel.kt new file mode 100644 index 0000000..ad6e139 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/model/Channel.kt @@ -0,0 +1,10 @@ +package net.tinsae.tv.model + +data class Channel( + val name: String, + val url: String, + val logoUrl: String? = null, + val group: String? = null, + val id: String? = null, + val isFavorite: Boolean = false +) diff --git a/app/src/main/java/net/tinsae/tv/network/M3uService.kt b/app/src/main/java/net/tinsae/tv/network/M3uService.kt new file mode 100644 index 0000000..863127e --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/network/M3uService.kt @@ -0,0 +1,26 @@ +package net.tinsae.tv.network + +import retrofit2.Retrofit +import retrofit2.converter.scalars.ScalarsConverterFactory +import retrofit2.http.GET +import retrofit2.http.Url + +interface M3uService { + @GET + suspend fun fetchM3u(@Url url: String): String + + companion object { + private var instance: M3uService? = null + + fun getInstance(): M3uService { + if (instance == null) { + val retrofit = Retrofit.Builder() + .baseUrl("https://example.com/") // Base URL is required but overridden by @Url + .addConverterFactory(ScalarsConverterFactory.create()) + .build() + instance = retrofit.create(M3uService::class.java) + } + return instance!! + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/parser/M3uParser.kt b/app/src/main/java/net/tinsae/tv/parser/M3uParser.kt new file mode 100644 index 0000000..771c8b3 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/parser/M3uParser.kt @@ -0,0 +1,70 @@ +package net.tinsae.tv.parser + +import net.tinsae.tv.model.Channel + +object M3uParser { + fun parse(content: String): List { + val channels = mutableListOf() + val lines = content.lines() + + var currentName = "" + var currentLogo: String? = null + var currentGroup: String? = null + var currentId: String? = null + + for (line in lines) { + val trimmedLine = line.trim() + if (trimmedLine.startsWith("#EXTINF:", ignoreCase = true)) { + // Reset metadata for a new entry + currentName = "" + currentLogo = null + currentGroup = null + currentId = null + + val metadata = trimmedLine.substring(8) // After "#EXTINF:" + + // M3U standard: #EXTINF:duration attributes,name + // We look for the last comma to separate attributes from name + val lastCommaIndex = metadata.lastIndexOf(',') + if (lastCommaIndex != -1) { + currentName = metadata.substring(lastCommaIndex + 1).trim() + val attributesPart = metadata.substring(0, lastCommaIndex) + + currentId = extractAttribute(attributesPart, "tvg-id") + currentLogo = extractAttribute(attributesPart, "tvg-logo") + currentGroup = extractAttribute(attributesPart, "group-title") + } else { + // Fallback for malformed or simple #EXTINF lines + // Try to skip duration and take the rest as name + val parts = metadata.split(Regex("\\s+"), limit = 2) + if (parts.size >= 2) { + currentName = parts[1].trim() + } else { + currentName = metadata.trim() + } + } + } else if (trimmedLine.isNotEmpty() && !trimmedLine.startsWith("#")) { + // This line is the URL + if (currentName.isNotEmpty()) { + channels.add( + Channel( + name = currentName, + url = trimmedLine, + logoUrl = currentLogo, + group = currentGroup, + id = currentId + ) + ) + // Reset name after adding to avoid duplicates if multiple URLs follow + currentName = "" + } + } + } + return channels + } + + private fun extractAttribute(source: String, attributeName: String): String? { + val pattern = """$attributeName="([^"]+)"""".toRegex() + return pattern.find(source)?.groupValues?.get(1) + } +} diff --git a/app/src/main/java/net/tinsae/tv/repo/ChannelRepository.kt b/app/src/main/java/net/tinsae/tv/repo/ChannelRepository.kt new file mode 100644 index 0000000..0438db8 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/repo/ChannelRepository.kt @@ -0,0 +1,86 @@ +package net.tinsae.tv.repo + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import net.tinsae.tv.data.ChannelDao +import net.tinsae.tv.data.mapper.toChannel +import net.tinsae.tv.data.mapper.toEntity +import net.tinsae.tv.model.Category +import net.tinsae.tv.model.CategoryType +import net.tinsae.tv.model.Channel +import net.tinsae.tv.network.M3uService +import net.tinsae.tv.parser.M3uParser +import org.json.JSONArray + +class ChannelRepository(private val channelDao: ChannelDao) { + + fun getFavorites(): Flow> = channelDao.getFavorites().map { entities -> + entities.map { it.toChannel(true) } + } + + fun getLastPlayed(): Flow> = channelDao.getLastPlayed().map { entities -> + entities.map { it.toChannel() } + } + + suspend fun saveChannel(channel: Channel, isFavorite: Boolean? = null, lastPlayed: Boolean = false) { + val existing = channelDao.getChannelByUrl(channel.url) + val finalIsFavorite = isFavorite ?: existing?.isFavorite ?: false + val finalLastPlayed = if (lastPlayed) System.currentTimeMillis() else existing?.lastPlayedTimestamp ?: 0L + + val entity = channel.toEntity().copy( + isFavorite = finalIsFavorite, + lastPlayedTimestamp = finalLastPlayed + ) + channelDao.insertChannel(entity) + } + + suspend fun fetchSubCategories(type: CategoryType): List { + val apiUrl = when (type) { + CategoryType.COUNTRY -> "https://iptv-org.github.io/api/countries.json" + CategoryType.LANGUAGE -> "https://iptv-org.github.io/api/languages.json" + CategoryType.GENRE -> "https://iptv-org.github.io/api/categories.json" + else -> return emptyList() + } + + val jsonResponse = M3uService.getInstance().fetchM3u(apiUrl) + val jsonArray = JSONArray(jsonResponse) + val subCategories = mutableListOf() + + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + val name = obj.getString("name") + + val m3uUrl = when (type) { + CategoryType.COUNTRY -> { + val code = obj.getString("code").lowercase() + "https://iptv-org.github.io/iptv/countries/$code.m3u" + } + CategoryType.LANGUAGE -> { + val code = obj.getString("code").lowercase() + "https://iptv-org.github.io/iptv/languages/$code.m3u" + } + CategoryType.GENRE -> { + val id = obj.getString("id") + "https://iptv-org.github.io/iptv/categories/$id.m3u" + } + else -> "" + } + + subCategories.add(Category(name, m3uUrl, CategoryType.CHANNEL_LIST)) + } + + return subCategories.sortedBy { it.name } + } + + suspend fun fetchChannels(category: Category): List { + val m3uContent = M3uService.getInstance().fetchM3u(category.url) + val channels = M3uParser.parse(m3uContent) + + // Batch lookup of all favorite URLs to avoid N+1 queries + val favoriteUrls = channelDao.getFavoriteUrls().toSet() + + return channels.map { channel -> + channel.copy(isFavorite = favoriteUrls.contains(channel.url)) + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryCard.kt b/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryCard.kt new file mode 100644 index 0000000..b917488 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryCard.kt @@ -0,0 +1,42 @@ +package net.tinsae.tv.ui.components.category + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.* +import net.tinsae.tv.model.Category + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun CategoryCard( + category: Category, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + onClick = onClick, + modifier = modifier + .padding(8.dp) + .aspectRatio(30f / 10f) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = category.name, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + maxLines = 2 + ) + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryGrid.kt b/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryGrid.kt new file mode 100644 index 0000000..4726128 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/category/CategoryGrid.kt @@ -0,0 +1,74 @@ +package net.tinsae.tv.ui.components.category + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import net.tinsae.tv.model.Category +import net.tinsae.tv.ui.components.common.SearchBar + +@Composable +fun CategoryGrid( + categories: List, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onCategoryClick: (Category) -> Unit, + modifier: Modifier = Modifier, + initialSelectedCategory: Category? = null +) { + val gridState = rememberLazyGridState() + + // Filter categories based on search query + val filteredCategories = remember(categories, searchQuery) { + if (searchQuery.isBlank()) { + categories + } else { + categories.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + } + + val focusRequesters = remember(filteredCategories) { + List(filteredCategories.size) { FocusRequester() } + } + + LaunchedEffect(filteredCategories, initialSelectedCategory) { + val index = filteredCategories.indexOfFirst { it.url == initialSelectedCategory?.url } + if (index != -1) { + gridState.scrollToItem(index) + focusRequesters[index].requestFocus() + } + } + + Column(modifier = modifier.fillMaxSize()) { + SearchBar( + query = searchQuery, + onQueryChange = onSearchQueryChange, + placeholder = "Search categories..." + ) + + LazyVerticalGrid( + state = gridState, + columns = GridCells.Fixed(4), + contentPadding = PaddingValues(32.dp), + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed(filteredCategories) { index, category -> + CategoryCard( + category = category, + onClick = { onCategoryClick(category) }, + modifier = Modifier.focusRequester(focusRequesters[index]) + ) + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelCard.kt b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelCard.kt new file mode 100644 index 0000000..d7fbad5 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelCard.kt @@ -0,0 +1,85 @@ +package net.tinsae.tv.ui.components.channel + +import android.view.KeyEvent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import androidx.tv.material3.CompactCard +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import net.tinsae.tv.model.Channel +import net.tinsae.tv.util.Misc.makeName + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ChannelCard( + channel: Channel, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + CompactCard( + onClick = onClick, + onLongClick = onLongClick, + image = { + Box { + AsyncImage( + model = channel.logoUrl, + contentDescription = channel.name, + modifier = Modifier.aspectRatio(16f / 9f) + ) + if (channel.isFavorite) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = "Favorite", + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + tint = Color.Red + ) + } + } + }, + title = { + Text( + text = channel.name.makeName(), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + modifier = Modifier.padding(5.dp) + ) + }, + modifier = modifier + .padding(8.dp) + .onKeyEvent { keyEvent -> + if (keyEvent.type == KeyEventType.KeyDown) { + when (keyEvent.key.nativeKeyCode) { + KeyEvent.KEYCODE_PROG_RED, + KeyEvent.KEYCODE_PROG_GREEN, + KeyEvent.KEYCODE_PROG_YELLOW, + KeyEvent.KEYCODE_PROG_BLUE -> { + onLongClick?.invoke() + true + } + else -> false + } + } else { + false + } + } + ) +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelGrid.kt b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelGrid.kt new file mode 100644 index 0000000..aedf3d2 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelGrid.kt @@ -0,0 +1,79 @@ +package net.tinsae.tv.ui.components.channel + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import net.tinsae.tv.model.Channel +import net.tinsae.tv.ui.components.common.SearchBar + +@Composable +fun ChannelGrid( + channels: List, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onChannelClick: (Channel) -> Unit, + onToggleFavorite: (Channel) -> Unit, + modifier: Modifier = Modifier, + initialSelectedChannel: Channel? = null +) { + val gridState = rememberLazyGridState() + + // Filter channels based on search query + val filteredChannels = remember(channels, searchQuery) { + if (searchQuery.isBlank()) { + channels + } else { + channels.filter { it.name.contains(searchQuery, ignoreCase = true) } + } + } + + val focusRequesters = remember(filteredChannels) { + List(filteredChannels.size) { FocusRequester() } + } + + LaunchedEffect(filteredChannels, initialSelectedChannel) { + val index = filteredChannels.indexOfFirst { it.url == initialSelectedChannel?.url } + if (index != -1) { + gridState.scrollToItem(index) + focusRequesters[index].requestFocus() + } else if (filteredChannels.isNotEmpty()) { + // Only request focus if we aren't typing in the search bar + // Actually, we usually want to keep focus on the search bar while typing + } + } + + Column(modifier = modifier.fillMaxSize()) { + SearchBar( + query = searchQuery, + onQueryChange = onSearchQueryChange, + placeholder = "Search channels..." + ) + + LazyVerticalGrid( + state = gridState, + columns = GridCells.Fixed(5), + contentPadding = PaddingValues(32.dp), + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed(filteredChannels) { index, channel -> + ChannelCard( + channel = channel, + onClick = { onChannelClick(channel) }, + onLongClick = { onToggleFavorite(channel) }, + modifier = Modifier.focusRequester(focusRequesters[index]) + ) + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelRow.kt b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelRow.kt new file mode 100644 index 0000000..b0457b1 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/channel/ChannelRow.kt @@ -0,0 +1,99 @@ +package net.tinsae.tv.ui.components.channel + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import net.tinsae.tv.model.Channel + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ChannelRow( + title: String, + channels: List, + onChannelClick: (Channel) -> Unit, + onToggleFavorite: (Channel) -> Unit, + modifier: Modifier = Modifier, + firstItemFocusRequester: FocusRequester? = null, + initialSelectedChannel: Channel? = null +) { + val listState = rememberLazyListState() + val focusRequesters = remember(channels) { + List(channels.size) { FocusRequester() } + } + + LaunchedEffect(channels, initialSelectedChannel) { + val index = channels.indexOfFirst { it.url == initialSelectedChannel?.url } + if (index != -1) { + listState.scrollToItem(index) + focusRequesters[index].requestFocus() + } else if (channels.isNotEmpty() && firstItemFocusRequester != null) { + // This is for the video player overlay case + firstItemFocusRequester.requestFocus() + } + } + + Column(modifier = modifier.padding(vertical = 16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 32.dp, bottom = 8.dp) + ) + + if (channels.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(150.dp) + .padding(horizontal = 32.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = "No channels here yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } else { + LazyRow( + state = listState, + contentPadding = PaddingValues(horizontal = 24.dp) + ) { + itemsIndexed(channels, key = { _, channel -> channel.url }) { index, channel -> + val itemFocusRequester = if (index == 0 && firstItemFocusRequester != null) { + firstItemFocusRequester + } else { + focusRequesters[index] + } + + ChannelCard( + channel = channel, + onClick = { onChannelClick(channel) }, + onLongClick = { onToggleFavorite(channel) }, + modifier = Modifier + .width(200.dp) + .focusRequester(itemFocusRequester) + ) + } + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/common/DigitalClock.kt b/app/src/main/java/net/tinsae/tv/ui/components/common/DigitalClock.kt new file mode 100644 index 0000000..acbf204 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/common/DigitalClock.kt @@ -0,0 +1,38 @@ +package net.tinsae.tv.ui.components.common + +import android.graphics.Color +import android.widget.TextClock +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import net.tinsae.tv.ui.theme.black + +@Composable +fun DigitalClock() { + val shape = RoundedTrapezoidShape(15.dp, 7.dp) + + Box(modifier = Modifier.fillMaxWidth().padding(16.dp, 0.dp), contentAlignment = Alignment.TopCenter){ + AndroidView( + factory = { context -> + TextClock(context).apply { + format12Hour = "hh:mm a" + textSize= 14f + setTextColor(Color.WHITE) + setPadding(5,0,5,0) + } + }, + modifier = Modifier + .padding(0.dp) + .background( + color = black, + shape = shape + ) + ) + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/common/SearchBar.kt b/app/src/main/java/net/tinsae/tv/ui/components/common/SearchBar.kt new file mode 100644 index 0000000..30ef7fb --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/common/SearchBar.kt @@ -0,0 +1,47 @@ +package net.tinsae.tv.ui.components.common + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme + +@Composable +fun SearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "Search..." +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 16.dp), + placeholder = { Text(text = placeholder, color = Color.Gray) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search Icon", + tint = MaterialTheme.colorScheme.onSurface + ) + }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = Color.Gray, + cursorColor = MaterialTheme.colorScheme.primary, + focusedTextColor = Color.White, + unfocusedTextColor = Color.White + ) + ) +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/common/Shapes.kt b/app/src/main/java/net/tinsae/tv/ui/components/common/Shapes.kt new file mode 100644 index 0000000..97c1f49 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/common/Shapes.kt @@ -0,0 +1,58 @@ +package net.tinsae.tv.ui.components.common + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection + +class TrapezoidShape(private val angle: Float) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val path = Path().apply { + moveTo(angle, 0f) + lineTo(size.width - angle, 0f) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + } + return Outline.Generic(path) + } +} + +class RoundedTrapezoidShape( + private val topOffset: Dp, + private val cornerRadius: Dp +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val offset = with(density) { topOffset.toPx() } + val radius = with(density) { cornerRadius.toPx() } + + val path = Path().apply { + // Start at top-left (no radius) + moveTo(0f - offset, 0f) + // Line to top-right (no radius) + lineTo(size.width + offset, 0f) + + // Line towards bottom-right corner, then curve + lineTo(size.width + radius * 0.5f, size.height - radius * 0.5f) + quadraticTo(size.width, size.height, size.width - radius, size.height ) + + // Line towards bottom-left corner, then curve + lineTo( radius, size.height) + quadraticTo(0f, size.height, -radius * 0.5f, size.height - radius * 0.5f) + + close() + } + return Outline.Generic(path) + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/player/ChannelOverlay.kt b/app/src/main/java/net/tinsae/tv/ui/components/player/ChannelOverlay.kt new file mode 100644 index 0000000..bf50546 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/player/ChannelOverlay.kt @@ -0,0 +1,49 @@ +package net.tinsae.tv.ui.components.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import net.tinsae.tv.model.Channel +import net.tinsae.tv.ui.components.channel.ChannelRow +import net.tinsae.tv.ui.theme.translucent + +@Composable +fun ChannelOverlay( + visible: Boolean, + channels: List, + onChannelChange: (Channel) -> Unit, + onToggleFavorite: (Channel) -> Unit, + firstItemFocusRequester: FocusRequester, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + modifier = modifier + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(translucent) + .padding(vertical = 6.dp) + ) { + ChannelRow( + title = "Channels", + channels = channels, + onChannelClick = onChannelChange, + onToggleFavorite = onToggleFavorite, + firstItemFocusRequester = firstItemFocusRequester + ) + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/player/EventHandler.kt b/app/src/main/java/net/tinsae/tv/ui/components/player/EventHandler.kt new file mode 100644 index 0000000..09b9eea --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/player/EventHandler.kt @@ -0,0 +1,33 @@ +package net.tinsae.tv.ui.components.player + + +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.type +import android.view.KeyEvent as NativeKeyEvent + +fun eventHandler( keyEvent: KeyEvent, + onToggleOverlay: (showOverlay: Boolean) -> Unit, + playerFocusRequester: FocusRequester +): Boolean +{ + + if(keyEvent.type == KeyEventType.KeyDown){ + when(keyEvent.nativeKeyEvent.keyCode){ + // up show overlay + NativeKeyEvent.KEYCODE_DPAD_UP -> { + onToggleOverlay(true) + return true + } + + // down hide overlay + NativeKeyEvent.KEYCODE_DPAD_DOWN -> { + onToggleOverlay(false) + playerFocusRequester.requestFocus() + return true + } + } + } + return false +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/tv/ui/components/player/PlaybackErrorMessage.kt b/app/src/main/java/net/tinsae/tv/ui/components/player/PlaybackErrorMessage.kt new file mode 100644 index 0000000..573de1e --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/player/PlaybackErrorMessage.kt @@ -0,0 +1,30 @@ +package net.tinsae.tv.ui.components.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text + +@Composable +fun PlaybackErrorMessage(message: String) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + Text( + text = message, + color = Color.White, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(32.dp) + ) + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/components/player/PlayerContainer.kt b/app/src/main/java/net/tinsae/tv/ui/components/player/PlayerContainer.kt new file mode 100644 index 0000000..87334f5 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/player/PlayerContainer.kt @@ -0,0 +1,62 @@ +package net.tinsae.tv.ui.components.player + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.annotation.OptIn +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import net.tinsae.tv.ui.theme.cyan + + +@Composable +fun PlayerContainer(isLoading: Boolean, exoPlayer: ExoPlayer, playerFocusRequester: FocusRequester){ + AndroidView( + factory = { ctx -> + playerView(ctx, exoPlayer) + }, + modifier = Modifier + .fillMaxSize() + .focusRequester(playerFocusRequester) + .focusable() + ) + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = cyan + ) + } + } +} + + +@OptIn(UnstableApi::class) +fun playerView(ctx: Context, exoPlayer: ExoPlayer): View { + return PlayerView(ctx).apply { + player = exoPlayer + useController = false + // Keep the screen awake while the video player is visible + keepScreenOn = true + + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/tv/ui/components/player/VideoPlayer.kt b/app/src/main/java/net/tinsae/tv/ui/components/player/VideoPlayer.kt new file mode 100644 index 0000000..e8acb82 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/components/player/VideoPlayer.kt @@ -0,0 +1,135 @@ +package net.tinsae.tv.ui.components.player + +import androidx.activity.compose.BackHandler +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import kotlinx.coroutines.delay +import net.tinsae.tv.model.Channel + +@OptIn(UnstableApi::class) +@Composable +fun VideoPlayer( + url: String, + channels: List, + onChannelChange: (Channel) -> Unit, + onBack: () -> Unit, + onToggleFavorite: (Channel) -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var showChannelList by remember { mutableStateOf(false) } + val playerFocusRequester = remember { FocusRequester() } + val firstItemFocusRequester = remember { FocusRequester() } + + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(url)) + prepare() + playWhenReady = true + + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + isLoading = playbackState == Player.STATE_BUFFERING || + playbackState == Player.STATE_IDLE + if (playbackState == Player.STATE_READY) { + errorMessage = null + } + } + + override fun onPlayerError(error: PlaybackException) { + isLoading = false + errorMessage = "Playback Error: ${error.localizedMessage}" + } + }) + } + } + + // MediaSession + DisposableEffect(exoPlayer) { + val mediaSession = MediaSession.Builder(context, exoPlayer).build() + onDispose { + mediaSession.release() + exoPlayer.release() + } + } + + LaunchedEffect(Unit) { + playerFocusRequester.requestFocus() + } + + // Update media item if URL changes + LaunchedEffect(url) { + errorMessage = null + isLoading = true + exoPlayer.setMediaItem(MediaItem.fromUri(url)) + exoPlayer.prepare() + exoPlayer.playWhenReady = true + showChannelList = false + playerFocusRequester.requestFocus() + } + + // Focus to list when it appears + LaunchedEffect(showChannelList) { + if (showChannelList) { + delay(200) + firstItemFocusRequester.requestFocus() + } + } + + BackHandler(onBack = { + if (showChannelList) { + showChannelList = false + playerFocusRequester.requestFocus() + } else { + onBack() + } + }) + + Box( + modifier = modifier + .fillMaxSize() + .onKeyEvent { event -> + eventHandler( + keyEvent = event, + onToggleOverlay = { showChannelList = it }, + playerFocusRequester = playerFocusRequester + ) + } + ) { + PlayerContainer(isLoading, exoPlayer, playerFocusRequester) + + errorMessage?.let { msg -> + PlaybackErrorMessage(message = msg) + } + + ChannelOverlay( + visible = showChannelList, + channels = channels, + onChannelChange = onChannelChange, + onToggleFavorite = onToggleFavorite, + firstItemFocusRequester = firstItemFocusRequester, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/screens/HomeScreen.kt b/app/src/main/java/net/tinsae/tv/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..5665233 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/screens/HomeScreen.kt @@ -0,0 +1,94 @@ +package net.tinsae.tv.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import net.tinsae.tv.model.Category +import net.tinsae.tv.model.Channel +import net.tinsae.tv.ui.components.category.CategoryCard +import net.tinsae.tv.ui.components.channel.ChannelRow + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun HomeScreen( + favorites: List, + lastPlayed: List, + filterCategories: List, + onChannelClick: (Channel, List) -> Unit, + onFilterClick: (Category) -> Unit, + onToggleFavorite: (Channel) -> Unit, + initialSelectedChannel: Channel? = null, + initialSelectedCategory: Category? = null +) { + val filterFocusRequesters = remember { List(filterCategories.size) { FocusRequester() } } + + LaunchedEffect(Unit) { + if (initialSelectedCategory != null) { + val index = filterCategories.indexOfFirst { it.name == initialSelectedCategory.name } + if (index != -1) { + filterFocusRequesters[index].requestFocus() + } + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 32.dp) + ) { + item { + ChannelRow( + title = "Favorites", + channels = favorites, + onChannelClick = { onChannelClick(it, favorites) }, + onToggleFavorite = onToggleFavorite, + initialSelectedChannel = initialSelectedChannel + ) + } + item { + ChannelRow( + title = "Recently Played", + channels = lastPlayed, + onChannelClick = { onChannelClick(it, lastPlayed) }, + onToggleFavorite = onToggleFavorite, + initialSelectedChannel = initialSelectedChannel + ) + } + item { + Column(modifier = Modifier.padding(vertical = 16.dp)) { + Text( + text = "Filter", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(start = 32.dp, bottom = 8.dp) + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 34.dp) + ) { + itemsIndexed(filterCategories) { index, category -> + CategoryCard( + category = category, + onClick = { onFilterClick(category) }, + modifier = Modifier + .width(280.dp) + .focusRequester(filterFocusRequesters[index]) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/screens/MainScreen.kt b/app/src/main/java/net/tinsae/tv/ui/screens/MainScreen.kt new file mode 100644 index 0000000..74e3d70 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/screens/MainScreen.kt @@ -0,0 +1,152 @@ +package net.tinsae.tv.ui.screens + +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import net.tinsae.tv.model.Category +import net.tinsae.tv.model.Channel +import net.tinsae.tv.ui.components.category.CategoryGrid +import net.tinsae.tv.ui.components.channel.ChannelGrid +import net.tinsae.tv.ui.components.common.DigitalClock +import net.tinsae.tv.ui.components.player.VideoPlayer +import net.tinsae.tv.ui.viewmodel.ChannelUiState +import net.tinsae.tv.ui.viewmodel.ChannelViewModel + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun MainScreen(viewModel: ChannelViewModel = viewModel()) { + val uiState by viewModel.uiState.collectAsState() + val favorites by viewModel.favorites.collectAsState() + val lastPlayed by viewModel.lastPlayed.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + + var playingChannel by remember { mutableStateOf(null) } + var lastSelectedChannel by remember { mutableStateOf(null) } + var currentChannelList by remember { mutableStateOf>(emptyList()) } + var lastSelectedCategory by remember { mutableStateOf(null) } + + // When video is playing, background must be transparent + // so the SurfaceView hardware layer behind the window can be seen. + Box( + modifier = Modifier + .fillMaxSize() + .background(if (playingChannel != null) Color.Transparent else MaterialTheme.colorScheme.background) + ) { + if (playingChannel != null) { + VideoPlayer( + url = playingChannel!!.url, + channels = currentChannelList, + onChannelChange = { + playingChannel = it + lastSelectedChannel = it + }, + onBack = { playingChannel = null }, + onToggleFavorite = { viewModel.toggleFavorite(it) } + ) + LaunchedEffect(playingChannel) { + viewModel.onChannelPlayed(playingChannel!!) + } + } else { + BackHandler(enabled = uiState !is ChannelUiState.Home) { + viewModel.goBack() + } + + Surface(modifier = Modifier.fillMaxSize()) { + when (val state = uiState) { + is ChannelUiState.Home -> { + HomeScreen( + favorites = favorites, + lastPlayed = lastPlayed, + filterCategories = viewModel.filterCategories, + onChannelClick = { channel, list -> + currentChannelList = list + lastSelectedChannel = channel + playingChannel = channel + Log.d("PLAYING", "url ${channel.url}") + }, + onFilterClick = { category -> + lastSelectedCategory = category + viewModel.fetchSubCategories(category) + }, + onToggleFavorite = { viewModel.toggleFavorite(it) }, + initialSelectedChannel = lastSelectedChannel, + initialSelectedCategory = lastSelectedCategory + ) + } + is ChannelUiState.Categories -> { + Column { + Text( + text = state.title, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(32.dp) + ) + CategoryGrid( + categories = state.categories, + searchQuery = searchQuery, + onSearchQueryChange = { viewModel.onSearchQueryChange(it) }, + onCategoryClick = { category -> + lastSelectedCategory = category + viewModel.fetchChannels(category) + }, + initialSelectedCategory = lastSelectedCategory + ) + } + } + is ChannelUiState.Success -> { + Column { + Text( + text = state.title, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(32.dp) + ) + ChannelGrid( + channels = state.channels, + searchQuery = searchQuery, + onSearchQueryChange = { viewModel.onSearchQueryChange(it) }, + onChannelClick = { + currentChannelList = state.channels + lastSelectedChannel = it + playingChannel = it + }, + onToggleFavorite = { viewModel.toggleFavorite(it) }, + initialSelectedChannel = lastSelectedChannel + ) + } + } + is ChannelUiState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is ChannelUiState.Error -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = "Error: ${state.message}") + } + } + } + } + } + DigitalClock() + } +} diff --git a/app/src/main/java/net/tinsae/tv/ui/theme/Color.kt b/app/src/main/java/net/tinsae/tv/ui/theme/Color.kt new file mode 100644 index 0000000..9a57514 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/theme/Color.kt @@ -0,0 +1,19 @@ +package net.tinsae.tv.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +val cyan = Color(0xFF03A9F4) +val gray = Color(0xFF424242) +val black = Color(0xFF000000) + +val clear = Color(0x00000000) + +val translucent = Color(0x60000000) \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/tv/ui/theme/Theme.kt b/app/src/main/java/net/tinsae/tv/ui/theme/Theme.kt new file mode 100644 index 0000000..12f8939 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/theme/Theme.kt @@ -0,0 +1,23 @@ +package net.tinsae.tv.ui.theme + +import androidx.compose.runtime.Composable +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.darkColorScheme + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun TVTheme( + content: @Composable () -> Unit, +) { + val colorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 + ) + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/net/tinsae/tv/ui/theme/Type.kt b/app/src/main/java/net/tinsae/tv/ui/theme/Type.kt new file mode 100644 index 0000000..4990ebb --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/theme/Type.kt @@ -0,0 +1,36 @@ +package net.tinsae.tv.ui.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Typography + +// Set of Material typography styles to start with +@OptIn(ExperimentalTvMaterial3Api::class) +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/net/tinsae/tv/ui/viewmodel/ChannelViewModel.kt b/app/src/main/java/net/tinsae/tv/ui/viewmodel/ChannelViewModel.kt new file mode 100644 index 0000000..857e708 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/ui/viewmodel/ChannelViewModel.kt @@ -0,0 +1,118 @@ +package net.tinsae.tv.ui.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import net.tinsae.tv.data.AppDatabase +import net.tinsae.tv.model.Category +import net.tinsae.tv.model.CategoryType +import net.tinsae.tv.model.Channel +import net.tinsae.tv.repo.ChannelRepository + +sealed class ChannelUiState { + data object Loading : ChannelUiState() + data object Home : ChannelUiState() + data class Categories(val categories: List, val title: String) : ChannelUiState() + data class Success(val channels: List, val title: String) : ChannelUiState() + data class Error(val message: String) : ChannelUiState() +} + +class ChannelViewModel(application: Application) : AndroidViewModel(application) { + private val repository = ChannelRepository(AppDatabase.getDatabase(application).channelDao()) + + private val _uiState = MutableStateFlow(ChannelUiState.Home) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val navigationStack = mutableListOf() + + val favorites: StateFlow> = repository.getFavorites() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val lastPlayed: StateFlow> = repository.getLastPlayed() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val filterCategories = listOf( + Category("By Country", "", CategoryType.COUNTRY), + Category("By Language", "", CategoryType.LANGUAGE), + Category("By Category", "", CategoryType.GENRE) + ) + + fun onSearchQueryChange(query: String) { + _searchQuery.value = query + } + + fun showHome() { + navigationStack.clear() + _searchQuery.value = "" + _uiState.value = ChannelUiState.Home + } + + fun goBack(): Boolean { + _searchQuery.value = "" + return if (navigationStack.isNotEmpty()) { + _uiState.value = navigationStack.removeAt(navigationStack.size - 1) + true + } else { + false + } + } + + fun fetchSubCategories(category: Category) { + viewModelScope.launch { + val previousState = _uiState.value + _uiState.value = ChannelUiState.Loading + _searchQuery.value = "" // Clear search when navigating + try { + val subCategories = repository.fetchSubCategories(category.type) + navigationStack.add(previousState) + _uiState.value = ChannelUiState.Categories(subCategories, category.name) + } catch (e: Exception) { + _uiState.value = ChannelUiState.Error(e.message ?: "Unknown error occurred") + } + } + } + + fun fetchChannels(category: Category) { + viewModelScope.launch { + val previousState = _uiState.value + _uiState.value = ChannelUiState.Loading + _searchQuery.value = "" // Clear search when navigating + try { + val channels = repository.fetchChannels(category) + if (channels.isEmpty()) { + throw Exception("No channels found for ${category.name}") + } + navigationStack.add(previousState) + _uiState.value = ChannelUiState.Success(channels, category.name) + } catch (e: Exception) { + _uiState.value = ChannelUiState.Error(e.message ?: "Unknown error occurred") + } + } + } + + fun onChannelPlayed(channel: Channel) { + viewModelScope.launch { + repository.saveChannel(channel, lastPlayed = true) + } + } + + fun toggleFavorite(channel: Channel) { + viewModelScope.launch { + val isFavorite = !channel.isFavorite + repository.saveChannel(channel, isFavorite = isFavorite) + + val currentState = _uiState.value + if (currentState is ChannelUiState.Success) { + val updatedList = currentState.channels.map { + if (it.url == channel.url) it.copy(isFavorite = isFavorite) else it + } + _uiState.value = currentState.copy(channels = updatedList) + } + } + } +} diff --git a/app/src/main/java/net/tinsae/tv/util/Misc.kt b/app/src/main/java/net/tinsae/tv/util/Misc.kt new file mode 100644 index 0000000..f8c6d65 --- /dev/null +++ b/app/src/main/java/net/tinsae/tv/util/Misc.kt @@ -0,0 +1,20 @@ +package net.tinsae.tv.util + +object Misc { + + fun String.makeName(): String { + // there are some strings that start wit a . + val name = if (this.startsWith(".", ignoreCase = true)) { + this.replace(".", "") + } else { + this + } + + + if (name.contains("(")) { + return name.substringBefore("(").trim() + } + + return name.trim() + } +} diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6bf70c4 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + TV + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4497fe0 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +