Initial commit: HorrorTV Android TV App for Chromecast 4K
Features: - Horror-themed categories (Scream, Halloween, Conjuring, etc.) - ExoPlayer native video playback from streamimdb.me - D-pad navigation with visible focus indicators (8dp border + shadow) - Custom app icon (Scream mask) - VideoExtractor for HTML parsing and URL resolution - FocusRequester crash fixes - HLS streaming support Built with: - Kotlin + Jetpack Compose - ExoPlayer 1.4.0 - Hilt DI - Coil image loading - OMDb API integration Tested on Chromecast 4K with working video playback.
152
app/build.gradle.kts
Normal file
@@ -0,0 +1,152 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("kotlin-kapt")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.horrortv.app"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.horrortv.app"
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
buildConfigField("String", "APP_NAME", "\"HorrorTV\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file("release.keystore")
|
||||
storePassword = System.getenv("HORRORTV_KEYSTORE_PASSWORD") ?: "placeholder"
|
||||
keyAlias = System.getenv("HORRORTV_KEY_ALIAS") ?: "release"
|
||||
keyPassword = System.getenv("HORRORTV_KEY_PASSWORD") ?: "placeholder"
|
||||
}
|
||||
getByName("debug") {
|
||||
storeFile = file("debug.keystore")
|
||||
storePassword = "android"
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
release {
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "environment"
|
||||
productFlavors {
|
||||
create("dev") {
|
||||
dimension = "environment"
|
||||
applicationIdSuffix = ".dev"
|
||||
versionNameSuffix = "-dev"
|
||||
buildConfigField("boolean", "DEBUG_MODE", "true")
|
||||
}
|
||||
create("prod") {
|
||||
dimension = "environment"
|
||||
buildConfigField("boolean", "DEBUG_MODE", "false")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
freeCompilerArgs += listOf(
|
||||
"-Xopt-in=kotlin.RequiresOptIn",
|
||||
"-Xjvm-default=all"
|
||||
)
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
excludes += "/META-INF/DEPENDENCIES"
|
||||
excludes += "/META-INF/LICENSE"
|
||||
excludes += "/META-INF/LICENSE.txt"
|
||||
excludes += "/META-INF/NOTICE"
|
||||
excludes += "/META-INF/NOTICE.txt"
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = false
|
||||
checkReleaseBuilds = true
|
||||
disable += "MissingTranslation"
|
||||
disable += "ExtraTranslation"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
|
||||
implementation(platform("androidx.compose:compose-bom:2024.10.01"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.foundation:foundation")
|
||||
implementation("androidx.activity:activity-compose:1.8.2")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||
implementation("androidx.leanback:leanback:1.0.0")
|
||||
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
implementation("com.google.dagger:hilt-android:2.50")
|
||||
kapt("com.google.dagger:hilt-android-compiler:2.50")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
|
||||
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
implementation("androidx.media3:media3-exoplayer:1.4.0")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.4.0")
|
||||
implementation("androidx.media3:media3-ui:1.4.0")
|
||||
implementation("androidx.media3:media3-common:1.4.0")
|
||||
implementation("org.jsoup:jsoup:1.17.2")
|
||||
|
||||
debugImplementation(platform("androidx.compose:compose-bom:2024.10.01"))
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
BIN
app/debug.keystore
Normal file
72
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
-keep public class * extends android.app.Activity
|
||||
-keep public class * extends android.app.Service
|
||||
-keep public class * extends android.content.BroadcastReceiver
|
||||
-keep public class * extends androidx.lifecycle.ViewModel
|
||||
|
||||
-keep class com.horrortv.app.data.remote.omdb.dto.** { *; }
|
||||
-keep class com.horrortv.app.domain.model.** { *; }
|
||||
|
||||
-keep class retrofit2.** { *; }
|
||||
-keepclassmembernames class * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep interface okhttp3.** { *; }
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
|
||||
-keep class com.google.gson.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@com.google.gson.annotations.SerializedName <fields>;
|
||||
}
|
||||
|
||||
-keep class dagger.** { *; }
|
||||
-keep class * extends dagger.** { *; }
|
||||
-keep class * implements dagger.** { *; }
|
||||
-keep class * extends javax.inject.** { *; }
|
||||
-keep class * implements javax.inject.** { *; }
|
||||
-dontwarn dagger.**
|
||||
|
||||
-keep class io.coil.** { *; }
|
||||
-keep class coil.** { *; }
|
||||
|
||||
-keepclassmembers class kotlinx.coroutines.** {
|
||||
volatile <fields>;
|
||||
}
|
||||
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
|
||||
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
|
||||
-keepclassmembernames class kotlinx.coroutines.** {
|
||||
@kotlinx.coroutines.InternalCoroutinesApi <methods>;
|
||||
}
|
||||
|
||||
-keep class kotlin.** { *; }
|
||||
-keep class * implements kotlin.** { *; }
|
||||
|
||||
-assumenosideeffects class android.util.Log {
|
||||
public static boolean isLoggable(...);
|
||||
public static int v(...);
|
||||
public static int d(...);
|
||||
public static int i(...);
|
||||
}
|
||||
|
||||
-optimizationpasses 5
|
||||
-dontusemixedcaseclassnames
|
||||
-dontskipnonpubliclibraryclasses
|
||||
-verbose
|
||||
|
||||
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
|
||||
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keepattributes RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations
|
||||
-keepattributes RuntimeVisibleParameterAnnotations,RuntimeInvisibleParameterAnnotations
|
||||
-keepattributes AnnotationDefault
|
||||
|
||||
-renamesourcefileattribute SourceFile
|
||||
-keepattributes EnclosingMethod
|
||||
|
||||
-dontwarn javax.annotation.**
|
||||
-dontwarn kotlin.Unit
|
||||
-dontwarn retrofit2.Platform$Java8
|
||||
-dontwarn kotlin.jvm.internal.Reflection
|
||||
BIN
app/release.keystore
Normal file
51
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
|
||||
<uses-feature android:name="android.hardware.type.tv" android:required="true"/>
|
||||
<uses-feature android:name="android.software.leanback" android:required="true"/>
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
|
||||
|
||||
<application
|
||||
android:name=".HorrorTvApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:banner="@drawable/ic_banner"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:theme="@style/Theme.HorrorTV">
|
||||
|
||||
<activity
|
||||
android:name=".presentation.MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:screenOrientation="landscape">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="horrortv"/>
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="horrortv.app"
|
||||
android:pathPrefix="/movie"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
18
app/src/main/java/com/horrortv/app/HorrorTvApp.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.horrortv.app
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class HorrorTvApp : Application(), ImageLoaderFactory {
|
||||
|
||||
@Inject
|
||||
lateinit var imageLoader: ImageLoader
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return imageLoader
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.horrortv.app.data.remote.omdb
|
||||
|
||||
import com.horrortv.app.data.remote.omdb.dto.OmdbMovieDetailDto
|
||||
import com.horrortv.app.data.remote.omdb.dto.OmdbSearchResponse
|
||||
import com.horrortv.app.util.Constants
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface OmdbApiService {
|
||||
@GET("/")
|
||||
suspend fun searchByCategory(
|
||||
@Query("s") query: String,
|
||||
@Query("type") type: String = "movie",
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("apikey") apiKey: String = Constants.OMDB_API_KEY
|
||||
): OmdbSearchResponse
|
||||
|
||||
@GET("/")
|
||||
suspend fun getMovieDetail(
|
||||
@Query("i") imdbId: String,
|
||||
@Query("plot") plot: String = "full",
|
||||
@Query("apikey") apiKey: String = Constants.OMDB_API_KEY
|
||||
): OmdbMovieDetailDto
|
||||
|
||||
@GET("/")
|
||||
suspend fun searchMovies(
|
||||
@Query("s") query: String,
|
||||
@Query("type") type: String = "movie",
|
||||
@Query("page") page: Int = 1,
|
||||
@Query("apikey") apiKey: String = Constants.OMDB_API_KEY
|
||||
): OmdbSearchResponse
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.horrortv.app.data.remote.omdb.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class OmdbMovieDetailDto(
|
||||
@SerializedName("Title") val title: String,
|
||||
@SerializedName("Year") val year: String,
|
||||
@SerializedName("Rated") val rated: String?,
|
||||
@SerializedName("Released") val released: String?,
|
||||
@SerializedName("Runtime") val runtime: String?,
|
||||
@SerializedName("Genre") val genre: String?,
|
||||
@SerializedName("Director") val director: String?,
|
||||
@SerializedName("Writer") val writer: String?,
|
||||
@SerializedName("Actors") val actors: String?,
|
||||
@SerializedName("Plot") val plot: String?,
|
||||
@SerializedName("Language") val language: String?,
|
||||
@SerializedName("Country") val country: String?,
|
||||
@SerializedName("Awards") val awards: String?,
|
||||
@SerializedName("Poster") val poster: String?,
|
||||
@SerializedName("Metascore") val metascore: String?,
|
||||
@SerializedName("imdbRating") val imdbRating: String?,
|
||||
@SerializedName("imdbVotes") val imdbVotes: String?,
|
||||
@SerializedName("imdbID") val imdbId: String,
|
||||
@SerializedName("Type") val type: String,
|
||||
@SerializedName("Response") val response: String,
|
||||
@SerializedName("Error") val error: String? = null
|
||||
) {
|
||||
val isSuccess: Boolean get() = response == "True"
|
||||
val hasValidPoster: Boolean get() = poster != null && poster != "N/A"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.horrortv.app.data.remote.omdb.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class OmdbSearchResponse(
|
||||
@SerializedName("Search") val search: List<OmdbMovieSearchDto>?,
|
||||
@SerializedName("totalResults") val totalResults: String?,
|
||||
@SerializedName("Response") val response: String,
|
||||
@SerializedName("Error") val error: String?
|
||||
) {
|
||||
val isSuccess: Boolean get() = response == "True"
|
||||
}
|
||||
|
||||
data class OmdbMovieSearchDto(
|
||||
@SerializedName("Title") val title: String,
|
||||
@SerializedName("Year") val year: String,
|
||||
@SerializedName("imdbID") val imdbId: String,
|
||||
@SerializedName("Type") val type: String,
|
||||
@SerializedName("Poster") val poster: String?
|
||||
) {
|
||||
val hasValidPoster: Boolean get() = poster != null && poster != "N/A"
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package com.horrortv.app.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.horrortv.app.data.remote.omdb.OmdbApiService
|
||||
import com.horrortv.app.domain.model.AppError
|
||||
import com.horrortv.app.domain.model.Movie
|
||||
import com.horrortv.app.domain.model.MovieCategory
|
||||
import com.horrortv.app.domain.repository.MovieRepository
|
||||
import com.horrortv.app.domain.repository.Result
|
||||
import com.horrortv.app.util.ApiException
|
||||
import com.horrortv.app.util.Constants
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MovieRepositoryImpl @Inject constructor(
|
||||
private val apiService: OmdbApiService
|
||||
) : MovieRepository {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MovieRepositoryImpl"
|
||||
}
|
||||
|
||||
private data class CacheEntry<T>(
|
||||
val data: T,
|
||||
val timestamp: Long,
|
||||
val ttlMs: Long
|
||||
)
|
||||
|
||||
private val categoryCache = LruCache<String, CacheEntry<List<Movie>>>(20)
|
||||
private val searchCache = LruCache<String, CacheEntry<List<Movie>>>(50)
|
||||
private val detailCache = LruCache<String, CacheEntry<Movie>>(100)
|
||||
|
||||
private fun categoryTtlMs(): Long = Constants.Cache.CATEGORY_CACHE_HOURS * 60 * 60 * 1000L
|
||||
private fun searchTtlMs(): Long = Constants.Cache.SEARCH_CACHE_HOURS * 60 * 60 * 1000L
|
||||
private fun detailTtlMs(): Long = Constants.Cache.DETAIL_CACHE_HOURS * 60 * 60 * 1000L
|
||||
|
||||
private fun buildCategoryKey(category: String): String = "cat:${category.lowercase()}"
|
||||
private fun buildSearchKey(query: String): String = "search:${query.trim().lowercase()}"
|
||||
private fun buildDetailKey(imdbId: String): String = "detail:${imdbId.lowercase()}"
|
||||
|
||||
private fun <T> getFromCache(cache: LruCache<String, CacheEntry<T>>, key: String): T? {
|
||||
val entry = cache.get(key) ?: return null
|
||||
if (System.currentTimeMillis() - entry.timestamp > entry.ttlMs) {
|
||||
cache.remove(key)
|
||||
return null
|
||||
}
|
||||
return entry.data
|
||||
}
|
||||
|
||||
private fun <T> putInCache(cache: LruCache<String, CacheEntry<T>>, key: String, data: T, ttlMs: Long) {
|
||||
cache.put(key, CacheEntry(data, System.currentTimeMillis(), ttlMs))
|
||||
}
|
||||
|
||||
override suspend fun getFeaturedCategories(): List<MovieCategory> = withContext(Dispatchers.Default) {
|
||||
Log.d(TAG, "Fetching featured categories with limited concurrency")
|
||||
|
||||
coroutineScope {
|
||||
Constants.HORROR_CATEGORIES
|
||||
.chunked(3)
|
||||
.map { batch ->
|
||||
batch.map { category ->
|
||||
async {
|
||||
val cacheKey = buildCategoryKey(category)
|
||||
val cached = getFromCache(categoryCache, cacheKey)
|
||||
if (cached != null) {
|
||||
Log.d(TAG, "Cache HIT for category: $category")
|
||||
MovieCategory(category, cached)
|
||||
} else {
|
||||
try {
|
||||
Log.d(TAG, "Fetching category: $category")
|
||||
val response = apiService.searchByCategory(category, page = 1)
|
||||
if (response.response == "False") {
|
||||
Log.w(TAG, "API error for category: $category - ${response.error}")
|
||||
MovieCategory(category, emptyList())
|
||||
} else {
|
||||
val movies = response.search?.filter { it.hasValidPoster }?.map { dto ->
|
||||
Movie(
|
||||
imdbId = dto.imdbId,
|
||||
title = dto.title,
|
||||
year = dto.year,
|
||||
posterUrl = dto.poster ?: ""
|
||||
)
|
||||
} ?: emptyList()
|
||||
putInCache(categoryCache, cacheKey, movies, categoryTtlMs())
|
||||
MovieCategory(category, movies)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error fetching category: $category", e)
|
||||
MovieCategory(category, emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun searchMovies(query: String): Result<List<Movie>> {
|
||||
val cacheKey = buildSearchKey(query)
|
||||
|
||||
val cached = getFromCache(searchCache, cacheKey)
|
||||
if (cached != null) {
|
||||
Log.d(TAG, "Cache HIT for search: $query")
|
||||
return Result.Success(cached)
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.Default) {
|
||||
try {
|
||||
Log.d(TAG, "Searching for: $query")
|
||||
val response = apiService.searchMovies(query, page = 1)
|
||||
|
||||
if (response.response == "False") {
|
||||
val errorMsg = response.error ?: "Unknown error"
|
||||
Result.Error(AppError.ApiError(
|
||||
userMessage = "Error: $errorMsg",
|
||||
debugMessage = errorMsg,
|
||||
code = 400
|
||||
))
|
||||
} else {
|
||||
val movies = response.search?.filter { it.hasValidPoster }?.map { dto ->
|
||||
Movie(
|
||||
imdbId = dto.imdbId,
|
||||
title = dto.title,
|
||||
year = dto.year,
|
||||
posterUrl = dto.poster ?: ""
|
||||
)
|
||||
} ?: emptyList()
|
||||
putInCache(searchCache, cacheKey, movies, searchTtlMs())
|
||||
Result.Success(movies)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error searching: $query", e)
|
||||
val appError = if (e is ApiException) e.toAppError() else AppError.UnknownError(
|
||||
userMessage = "Error inesperado. Intenta de nuevo.",
|
||||
debugMessage = "Unhandled exception: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
Result.Error(appError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMovieById(imdbId: String): Result<Movie?> {
|
||||
if (imdbId.isEmpty()) return Result.Success(null)
|
||||
|
||||
val cacheKey = buildDetailKey(imdbId)
|
||||
val cached = getFromCache(detailCache, cacheKey)
|
||||
if (cached != null) {
|
||||
Log.d(TAG, "Cache HIT for movie: $imdbId")
|
||||
return Result.Success(cached)
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.Default) {
|
||||
try {
|
||||
Log.d(TAG, "Fetching movie detail: $imdbId")
|
||||
val detail = apiService.getMovieDetail(imdbId)
|
||||
|
||||
if (detail.response != "True") {
|
||||
val errorMsg = detail.error ?: "Movie not found"
|
||||
Result.Error(AppError.ApiError(
|
||||
userMessage = "Error: $errorMsg",
|
||||
debugMessage = errorMsg,
|
||||
code = 404
|
||||
))
|
||||
} else {
|
||||
val movie = Movie(
|
||||
imdbId = detail.imdbId,
|
||||
title = detail.title,
|
||||
year = detail.year,
|
||||
posterUrl = detail.poster ?: "",
|
||||
rating = detail.imdbRating ?: "N/A",
|
||||
genre = detail.genre ?: "",
|
||||
plot = detail.plot ?: "",
|
||||
runtime = detail.runtime ?: "",
|
||||
director = detail.director ?: "",
|
||||
actors = detail.actors ?: ""
|
||||
)
|
||||
putInCache(detailCache, cacheKey, movie, detailTtlMs())
|
||||
Result.Success(movie)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error fetching movie: $imdbId", e)
|
||||
val appError = if (e is ApiException) e.toAppError() else AppError.UnknownError(
|
||||
userMessage = "Error inesperado. Intenta de nuevo.",
|
||||
debugMessage = "Unhandled exception: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
Result.Error(appError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAllCaches() {
|
||||
categoryCache.evictAll()
|
||||
searchCache.evictAll()
|
||||
detailCache.evictAll()
|
||||
Log.i(TAG, "All caches cleared")
|
||||
}
|
||||
}
|
||||
45
app/src/main/java/com/horrortv/app/di/CoilModule.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
package com.horrortv.app.di
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import coil.request.CachePolicy
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object CoilModule {
|
||||
|
||||
private const val MEMORY_CACHE_PERCENT = 0.15
|
||||
private const val DISK_CACHE_SIZE_MB = 150L
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideImageLoader(@ApplicationContext context: Context): ImageLoader {
|
||||
return ImageLoader.Builder(context)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(context)
|
||||
.maxSizePercent(MEMORY_CACHE_PERCENT)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(context.cacheDir.resolve("image_cache"))
|
||||
.maxSizeBytes(DISK_CACHE_SIZE_MB * 1024 * 1024)
|
||||
.build()
|
||||
}
|
||||
.bitmapConfig(Bitmap.Config.RGB_565)
|
||||
.respectCacheHeaders(false)
|
||||
.diskCachePolicy(CachePolicy.ENABLED)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.networkCachePolicy(CachePolicy.ENABLED)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
54
app/src/main/java/com/horrortv/app/di/NetworkModule.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package com.horrortv.app.di
|
||||
|
||||
import android.content.Context
|
||||
import com.horrortv.app.util.Constants
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient {
|
||||
val cacheDir = context.cacheDir.resolve("http_cache")
|
||||
val cache = Cache(cacheDir, Constants.Network.CACHE_SIZE_BYTES)
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.cache(cache)
|
||||
.connectTimeout(Constants.Network.CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(Constants.Network.READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(Constants.Network.WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(Constants.OMDB_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOmdbApiService(retrofit: Retrofit): com.horrortv.app.data.remote.omdb.OmdbApiService {
|
||||
return retrofit.create(com.horrortv.app.data.remote.omdb.OmdbApiService::class.java)
|
||||
}
|
||||
}
|
||||
20
app/src/main/java/com/horrortv/app/di/RepositoryModule.kt
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.horrortv.app.di
|
||||
|
||||
import com.horrortv.app.data.repository.MovieRepositoryImpl
|
||||
import com.horrortv.app.domain.repository.MovieRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindMovieRepository(
|
||||
impl: MovieRepositoryImpl
|
||||
): MovieRepository
|
||||
}
|
||||
54
app/src/main/java/com/horrortv/app/domain/model/AppError.kt
Normal file
@@ -0,0 +1,54 @@
|
||||
package com.horrortv.app.domain.model
|
||||
|
||||
sealed class AppError {
|
||||
abstract val userMessage: String
|
||||
abstract val debugMessage: String
|
||||
abstract val cause: Throwable?
|
||||
abstract val isRetryable: Boolean
|
||||
|
||||
data class NetworkError(
|
||||
override val userMessage: String = "Error de conexión. Verifica tu internet.",
|
||||
override val debugMessage: String = "Network error",
|
||||
override val cause: Throwable? = null
|
||||
) : AppError() {
|
||||
override val isRetryable: Boolean = true
|
||||
}
|
||||
|
||||
data class ApiError(
|
||||
override val userMessage: String = "Error del servicio.",
|
||||
override val debugMessage: String = "API error",
|
||||
val code: Int = 0,
|
||||
override val cause: Throwable? = null
|
||||
) : AppError() {
|
||||
override val isRetryable: Boolean = code in retryableCodes
|
||||
}
|
||||
|
||||
data class CacheError(
|
||||
override val userMessage: String = "Error de datos.",
|
||||
override val debugMessage: String = "Cache error",
|
||||
override val cause: Throwable? = null
|
||||
) : AppError() {
|
||||
override val isRetryable: Boolean = true
|
||||
}
|
||||
|
||||
data class ValidationError(
|
||||
override val userMessage: String = "Datos inválidos.",
|
||||
override val debugMessage: String = "Validation error",
|
||||
val field: String = "",
|
||||
override val cause: Throwable? = null
|
||||
) : AppError() {
|
||||
override val isRetryable: Boolean = false
|
||||
}
|
||||
|
||||
data class UnknownError(
|
||||
override val userMessage: String = "Error inesperado. Intenta de nuevo.",
|
||||
override val debugMessage: String = "Unknown error",
|
||||
override val cause: Throwable? = null
|
||||
) : AppError() {
|
||||
override val isRetryable: Boolean = cause is java.io.IOException
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val retryableCodes = setOf(429, 500, 502, 503, 504)
|
||||
}
|
||||
}
|
||||
14
app/src/main/java/com/horrortv/app/domain/model/Movie.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.horrortv.app.domain.model
|
||||
|
||||
data class Movie(
|
||||
val imdbId: String,
|
||||
val title: String,
|
||||
val year: String,
|
||||
val posterUrl: String,
|
||||
val rating: String = "N/A",
|
||||
val genre: String = "",
|
||||
val plot: String = "",
|
||||
val runtime: String = "",
|
||||
val director: String = "",
|
||||
val actors: String = ""
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.horrortv.app.domain.model
|
||||
|
||||
data class MovieCategory(
|
||||
val name: String,
|
||||
val movies: List<Movie>
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.horrortv.app.domain.model
|
||||
|
||||
enum class VideoType {
|
||||
HLS,
|
||||
MP4,
|
||||
DASH,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
data class SubtitleTrack(
|
||||
val url: String,
|
||||
val language: String,
|
||||
val label: String,
|
||||
val isDefault: Boolean = false
|
||||
)
|
||||
|
||||
data class VideoSource(
|
||||
val videoUrl: String,
|
||||
val videoType: VideoType,
|
||||
val subtitleTracks: List<SubtitleTrack> = emptyList(),
|
||||
val posterUrl: String? = null,
|
||||
val title: String = ""
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.horrortv.app.domain.repository
|
||||
|
||||
import com.horrortv.app.domain.model.Movie
|
||||
import com.horrortv.app.domain.model.MovieCategory
|
||||
|
||||
sealed class Result<out T> {
|
||||
data class Success<T>(val data: T) : Result<T>()
|
||||
data class Error(val error: com.horrortv.app.domain.model.AppError) : Result<Nothing>()
|
||||
}
|
||||
|
||||
interface MovieRepository {
|
||||
suspend fun getFeaturedCategories(): List<MovieCategory>
|
||||
suspend fun searchMovies(query: String): Result<List<Movie>>
|
||||
suspend fun getMovieById(imdbId: String): Result<Movie?>
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.horrortv.app.domain.usecase
|
||||
|
||||
import android.util.Log
|
||||
import com.horrortv.app.domain.model.VideoSource
|
||||
import com.horrortv.app.domain.model.VideoType
|
||||
import com.horrortv.app.domain.model.SubtitleTrack
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jsoup.Jsoup
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class VideoExtractor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VideoExtractor"
|
||||
private const val PLAYIMDB_BASE = "https://playimdb.com/title/"
|
||||
private const val STREAMIMDB_BASE = "https://streamimdb.me/embed/"
|
||||
private const val TIMEOUT_MS = 15000
|
||||
}
|
||||
|
||||
suspend fun extractVideoSource(imdbId: String): Result<VideoSource> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Log.d(TAG, "Extracting video for: $imdbId")
|
||||
|
||||
val embedUrl = "$STREAMIMDB_BASE$imdbId"
|
||||
Log.d(TAG, "Fetching embed: $embedUrl")
|
||||
|
||||
val html = fetchHtml(embedUrl)
|
||||
Log.d(TAG, "HTML length: ${html.length}")
|
||||
Log.d(TAG, "HTML snippet (first 2000 chars): ${html.take(2000)}")
|
||||
|
||||
var doc = Jsoup.parse(html)
|
||||
|
||||
val iframeUrl = doc.selectFirst("iframe")?.attr("src")
|
||||
if (iframeUrl != null && iframeUrl.isNotEmpty()) {
|
||||
val fullIframeUrl = if (iframeUrl.startsWith("//")) {
|
||||
"https:$iframeUrl"
|
||||
} else {
|
||||
iframeUrl
|
||||
}
|
||||
Log.d(TAG, "Found iframe redirect: $fullIframeUrl")
|
||||
try {
|
||||
val iframeHtml = fetchHtml(fullIframeUrl)
|
||||
Log.d(TAG, "Iframe HTML length: ${iframeHtml.length}")
|
||||
Log.d(TAG, "IFRAME FULL HTML START =====")
|
||||
Log.d(TAG, iframeHtml)
|
||||
Log.d(TAG, "IFRAME FULL HTML END =====")
|
||||
|
||||
val prorcpPattern = Regex("['\"]?src['\"]?\\s*:\\s*['\"]([^'\"]+/prorcp/[^'\"]+)['\"]")
|
||||
val prorcpMatch = prorcpPattern.find(iframeHtml)
|
||||
if (prorcpMatch != null) {
|
||||
val innerUrl = prorcpMatch.groupValues[1]
|
||||
val fullInnerUrl = if (innerUrl.startsWith("/")) {
|
||||
"https://cloudnestra.com$innerUrl"
|
||||
} else if (innerUrl.startsWith("//")) {
|
||||
"https:$innerUrl"
|
||||
} else {
|
||||
innerUrl
|
||||
}
|
||||
Log.d(TAG, "Found prorcp URL: $fullInnerUrl")
|
||||
try {
|
||||
val innerHtml = fetchHtml(fullInnerUrl)
|
||||
Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}")
|
||||
Log.d(TAG, "Prorcp FULL HTML: $innerHtml")
|
||||
doc = Jsoup.parse(innerHtml)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed prorcp fetch: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "No prorcp pattern found, searching for /prorcp/ directly")
|
||||
val directPattern = Regex("/prorcp/[A-Za-z0-9+/=]+")
|
||||
val directMatch = directPattern.find(iframeHtml)
|
||||
if (directMatch != null) {
|
||||
val innerUrl = directMatch.value
|
||||
val fullInnerUrl = "https://cloudnestra.com$innerUrl"
|
||||
Log.d(TAG, "Found prorcp direct: $fullInnerUrl")
|
||||
try {
|
||||
val innerHtml = fetchHtml(fullInnerUrl)
|
||||
Log.d(TAG, "Prorcp direct HTML length: ${innerHtml.length}")
|
||||
Log.d(TAG, "Prorcp direct FULL HTML: $innerHtml")
|
||||
doc = Jsoup.parse(innerHtml)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed prorcp direct fetch: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch iframe: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val videoUrl = extractVideoUrl(doc)
|
||||
if (videoUrl == null) {
|
||||
Log.w(TAG, "No video URL found in HTML")
|
||||
return@withContext Result.failure(VideoExtractionException("No video URL found"))
|
||||
}
|
||||
|
||||
Log.d(TAG, "Video URL found: $videoUrl")
|
||||
|
||||
val subtitles = extractSubtitles(doc)
|
||||
Log.d(TAG, "Subtitles found: ${subtitles.size}")
|
||||
|
||||
val knownDomains = listOf(
|
||||
"neonhorizonworkshops.com",
|
||||
"wanderlynest.com",
|
||||
"orchidpixelgardens.com",
|
||||
"cloudnestra.com"
|
||||
)
|
||||
|
||||
val finalVideoUrl = if (videoUrl.contains("{v")) {
|
||||
val basePattern = Regex("https://tmstr3\\.\\{v\\d+\\}/(.+)")
|
||||
val pathMatch = basePattern.find(videoUrl)
|
||||
if (pathMatch != null) {
|
||||
val path = pathMatch.groupValues[1]
|
||||
val testDomain = knownDomains.first()
|
||||
val constructedUrl = "https://tmstr1.$testDomain/$path"
|
||||
Log.d(TAG, "Constructed URL from known domain: $constructedUrl")
|
||||
constructedUrl
|
||||
} else {
|
||||
videoUrl.replace(Regex("\\{v\\d+\\}"), knownDomains.first())
|
||||
}
|
||||
} else {
|
||||
videoUrl
|
||||
}
|
||||
|
||||
Log.d(TAG, "Final video URL: $finalVideoUrl")
|
||||
|
||||
val videoType = determineVideoType(finalVideoUrl)
|
||||
Log.d(TAG, "Video type: $videoType")
|
||||
|
||||
Result.success(
|
||||
VideoSource(
|
||||
videoUrl = finalVideoUrl,
|
||||
videoType = videoType,
|
||||
subtitleTracks = subtitles,
|
||||
title = imdbId
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error extracting video", e)
|
||||
Result.failure(VideoExtractionException(e.message ?: "Unknown error", e))
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchHtml(url: String): String {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = TIMEOUT_MS
|
||||
readTimeout = TIMEOUT_MS
|
||||
setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
setRequestProperty("Accept-Language", "en-US,en;q=0.5")
|
||||
setRequestProperty("Referer", PLAYIMDB_BASE)
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
Log.d(TAG, "Response code: $responseCode")
|
||||
|
||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw VideoExtractionException("HTTP error: $responseCode")
|
||||
}
|
||||
|
||||
return connection.inputStream.bufferedReader().use { it.readText() }
|
||||
}
|
||||
|
||||
private fun extractVideoUrl(doc: org.jsoup.nodes.Document): String? {
|
||||
val videoElement = doc.selectFirst("video")
|
||||
if (videoElement != null) {
|
||||
val src = videoElement.attr("src")
|
||||
if (src.isNotEmpty()) {
|
||||
Log.d(TAG, "Found video[src]: $src")
|
||||
return src
|
||||
}
|
||||
|
||||
val sourceElements = videoElement.select("source")
|
||||
for (source in sourceElements) {
|
||||
val srcAttr = source.attr("src")
|
||||
if (srcAttr.isNotEmpty() && isValidVideoUrl(srcAttr)) {
|
||||
Log.d(TAG, "Found source[src]: $srcAttr")
|
||||
return srcAttr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scripts = doc.select("script")
|
||||
for (script in scripts) {
|
||||
val scriptContent = script.html()
|
||||
val urls = extractUrlsFromScript(scriptContent)
|
||||
for (url in urls) {
|
||||
if (isValidVideoUrl(url)) {
|
||||
Log.d(TAG, "Found URL in script: $url")
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val allElements = doc.select("*[src], *[href], *[data-src], *[data-url]")
|
||||
for (elem in allElements) {
|
||||
val possibleUrl = elem.attr("src")
|
||||
.ifEmpty { elem.attr("href") }
|
||||
.ifEmpty { elem.attr("data-src") }
|
||||
.ifEmpty { elem.attr("data-url") }
|
||||
|
||||
if (possibleUrl.isNotEmpty() && isValidVideoUrl(possibleUrl)) {
|
||||
Log.d(TAG, "Found URL in element: $possibleUrl")
|
||||
return possibleUrl
|
||||
}
|
||||
}
|
||||
|
||||
val htmlText = doc.html()
|
||||
val m3u8Pattern = Regex("https?://[^\"'<>\\s]+\\.m3u8[^\"'<>\\s]*")
|
||||
val m3u8Match = m3u8Pattern.find(htmlText)
|
||||
if (m3u8Match != null) {
|
||||
Log.d(TAG, "Found .m3u8 via regex: ${m3u8Match.value}")
|
||||
return m3u8Match.value
|
||||
}
|
||||
|
||||
val mp4Pattern = Regex("https?://[^\"'<>\\s]+\\.mp4[^\"'<>\\s]*")
|
||||
val mp4Match = mp4Pattern.find(htmlText)
|
||||
if (mp4Match != null) {
|
||||
Log.d(TAG, "Found .mp4 via regex: ${mp4Match.value}")
|
||||
return mp4Match.value
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun extractUrlsFromScript(scriptContent: String): List<String> {
|
||||
val urlPattern = Regex("https?://[^\"'<>\\s]+")
|
||||
return urlPattern.findAll(scriptContent)
|
||||
.map { it.value }
|
||||
.filter { isValidVideoUrl(it) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun isValidVideoUrl(url: String): Boolean {
|
||||
return url.contains(".m3u8") ||
|
||||
url.contains(".mp4") ||
|
||||
url.contains(".mkv") ||
|
||||
url.contains("stream") ||
|
||||
url.contains("video") ||
|
||||
url.contains("player")
|
||||
}
|
||||
|
||||
private fun determineVideoType(url: String): VideoType {
|
||||
return when {
|
||||
url.contains(".m3u8") -> VideoType.HLS
|
||||
url.contains(".mp4") -> VideoType.MP4
|
||||
url.contains(".mpd") -> VideoType.DASH
|
||||
else -> VideoType.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractSubtitles(doc: org.jsoup.nodes.Document): List<SubtitleTrack> {
|
||||
val tracks = mutableListOf<SubtitleTrack>()
|
||||
|
||||
val trackElements = doc.select("track")
|
||||
for (track in trackElements) {
|
||||
val src = track.attr("src")
|
||||
val srclang = track.attr("srclang")
|
||||
val label = track.attr("label").ifEmpty { srclang }
|
||||
val isDefault = track.attr("default") == "default"
|
||||
|
||||
if (src.isNotEmpty()) {
|
||||
tracks.add(
|
||||
SubtitleTrack(
|
||||
url = src,
|
||||
language = srclang,
|
||||
label = label,
|
||||
isDefault = isDefault
|
||||
)
|
||||
)
|
||||
Log.d(TAG, "Found subtitle: $srclang - $src")
|
||||
}
|
||||
}
|
||||
|
||||
return tracks
|
||||
}
|
||||
}
|
||||
|
||||
class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
123
app/src/main/java/com/horrortv/app/presentation/AppNavigation.kt
Normal file
@@ -0,0 +1,123 @@
|
||||
package com.horrortv.app.presentation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.navDeepLink
|
||||
import com.horrortv.app.presentation.detail.DetailScreen
|
||||
import com.horrortv.app.presentation.detail.DetailViewModel
|
||||
import com.horrortv.app.presentation.home.HomeScreen
|
||||
import com.horrortv.app.presentation.home.HomeViewModel
|
||||
import com.horrortv.app.presentation.player.PlayerScreen
|
||||
import com.horrortv.app.presentation.search.SearchScreen
|
||||
import com.horrortv.app.presentation.search.SearchViewModel
|
||||
|
||||
object Routes {
|
||||
const val HOME = "home"
|
||||
const val SEARCH = "search"
|
||||
const val DETAIL = "detail/{imdbId}"
|
||||
const val PLAYER = "player/{imdbId}/{title}"
|
||||
|
||||
object Args {
|
||||
const val IMDB_ID = "imdbId"
|
||||
const val TITLE = "title"
|
||||
}
|
||||
|
||||
object DeepLinks {
|
||||
const val SCHEME = "horrortv"
|
||||
const val HOST = "horrortv.app"
|
||||
const val PATH_MOVIE = "/movie"
|
||||
const val PATH_PLAY = "/play"
|
||||
}
|
||||
|
||||
fun detail(imdbId: String): String = "detail/$imdbId"
|
||||
fun player(imdbId: String, title: String): String = "player/$imdbId/${encodeTitle(title)}"
|
||||
|
||||
private fun encodeTitle(title: String): String = Uri.encode(title)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
deepLinkUri: Uri? = null,
|
||||
modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Routes.HOME,
|
||||
modifier = modifier
|
||||
) {
|
||||
composable(Routes.HOME) {
|
||||
val viewModel: HomeViewModel = hiltViewModel()
|
||||
HomeScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToSearch = { navController.navigate(Routes.SEARCH) },
|
||||
onNavigateToDetail = { imdbId -> navController.navigate(Routes.detail(imdbId)) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.SEARCH) {
|
||||
val viewModel: SearchViewModel = hiltViewModel()
|
||||
SearchScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToDetail = { imdbId -> navController.navigate(Routes.detail(imdbId)) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.DETAIL,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.Args.IMDB_ID) { type = NavType.StringType }
|
||||
),
|
||||
deepLinks = listOf(
|
||||
navDeepLink {
|
||||
uriPattern = "${Routes.DeepLinks.SCHEME}://movie/{${Routes.Args.IMDB_ID}}"
|
||||
},
|
||||
navDeepLink {
|
||||
uriPattern = "https://${Routes.DeepLinks.HOST}${Routes.DeepLinks.PATH_MOVIE}/{${Routes.Args.IMDB_ID}}"
|
||||
}
|
||||
)
|
||||
) {
|
||||
val viewModel: DetailViewModel = hiltViewModel()
|
||||
val imdbId = it.arguments?.getString(Routes.Args.IMDB_ID) ?: ""
|
||||
|
||||
DetailScreen(
|
||||
imdbId = imdbId,
|
||||
viewModel = viewModel,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToPlayer = { id, title -> navController.navigate(Routes.player(id, title)) }
|
||||
)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = Routes.PLAYER,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.Args.IMDB_ID) { type = NavType.StringType },
|
||||
navArgument(Routes.Args.TITLE) { type = NavType.StringType }
|
||||
),
|
||||
deepLinks = listOf(
|
||||
navDeepLink {
|
||||
uriPattern = "${Routes.DeepLinks.SCHEME}://play/{${Routes.Args.IMDB_ID}}/{${Routes.Args.TITLE}}"
|
||||
},
|
||||
navDeepLink {
|
||||
uriPattern = "https://${Routes.DeepLinks.HOST}${Routes.DeepLinks.PATH_PLAY}/{${Routes.Args.IMDB_ID}}/{${Routes.Args.TITLE}}"
|
||||
}
|
||||
)
|
||||
) {
|
||||
val imdbId = it.arguments?.getString(Routes.Args.IMDB_ID) ?: ""
|
||||
val title = Uri.decode(it.arguments?.getString(Routes.Args.TITLE) ?: "Horror TV")
|
||||
|
||||
PlayerScreen(
|
||||
imdbId = imdbId,
|
||||
title = title,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.horrortv.app.presentation
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.horrortv.app.presentation.theme.HorrorTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val w = window
|
||||
WindowCompat.setDecorFitsSystemWindows(w, false)
|
||||
val controller = WindowInsetsControllerCompat(w, w.decorView)
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
|
||||
w.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
)
|
||||
|
||||
val deepLinkUri = intent?.data
|
||||
|
||||
setContent {
|
||||
HorrorTheme {
|
||||
val navController = rememberNavController()
|
||||
AppNavigation(
|
||||
navController = navController,
|
||||
deepLinkUri = deepLinkUri,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.horrortv.app.presentation.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.horrortv.app.presentation.theme.HorrorColors
|
||||
import com.horrortv.app.presentation.theme.HorrorTypography
|
||||
|
||||
@Composable
|
||||
fun LoadingIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
size: Int = 48
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = HorrorColors.HorrorRed,
|
||||
modifier = Modifier.size(size.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorState(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(text = message, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorAccent)
|
||||
TextButton(onClick = onRetry) {
|
||||
Text("Reintentar", color = HorrorColors.HorrorRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyState(
|
||||
message: String = "No hay contenido disponible",
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = message, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorLightGray)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.horrortv.app.presentation.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import android.graphics.Bitmap
|
||||
import com.horrortv.app.R
|
||||
import com.horrortv.app.presentation.theme.HorrorColors
|
||||
import com.horrortv.app.util.PosterSize
|
||||
import com.horrortv.app.util.PosterUtils
|
||||
|
||||
@Composable
|
||||
fun PosterImage(
|
||||
url: String?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Int = PosterSize.CARD,
|
||||
contentScale: ContentScale = ContentScale.Crop,
|
||||
shape: Shape? = null
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val optimizedUrl = remember(url, size) {
|
||||
PosterUtils.optimizePosterUrl(url, size)
|
||||
}
|
||||
|
||||
val request = remember(optimizedUrl) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(optimizedUrl)
|
||||
.memoryCacheKey(optimizedUrl)
|
||||
.diskCacheKey(optimizedUrl)
|
||||
.placeholder(R.drawable.poster_placeholder)
|
||||
.error(R.drawable.poster_error)
|
||||
.size(Size(size, size))
|
||||
.bitmapConfig(Bitmap.Config.RGB_565)
|
||||
.build()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(if (shape != null) Modifier.clip(shape) else Modifier)
|
||||
.background(HorrorColors.HorrorGray),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = contentScale,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackgroundPosterImage(
|
||||
url: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
PosterImage(
|
||||
url = url,
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
size = PosterSize.BACKGROUND,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.horrortv.app.presentation.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.horrortv.app.domain.model.AppError
|
||||
import com.horrortv.app.presentation.theme.HorrorColors
|
||||
import com.horrortv.app.presentation.theme.HorrorTypography
|
||||
|
||||
@Composable
|
||||
fun TvErrorDisplay(
|
||||
error: AppError,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isRetryFocused by remember { mutableStateOf(false) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
val iconRes = when (error) {
|
||||
is AppError.NetworkError -> android.R.drawable.ic_menu_close_clear_cancel
|
||||
is AppError.ApiError -> android.R.drawable.ic_dialog_alert
|
||||
is AppError.CacheError -> android.R.drawable.ic_menu_delete
|
||||
is AppError.ValidationError -> android.R.drawable.ic_menu_edit
|
||||
is AppError.UnknownError -> android.R.drawable.ic_menu_help
|
||||
}
|
||||
|
||||
val titleText = when (error) {
|
||||
is AppError.NetworkError -> "ERROR DE CONEXIÓN"
|
||||
is AppError.ApiError -> "ERROR DEL SERVICIO"
|
||||
is AppError.CacheError -> "ERROR DE DATOS"
|
||||
is AppError.ValidationError -> "DATOS INVÁLIDOS"
|
||||
is AppError.UnknownError -> "ERROR INESPERADO"
|
||||
}
|
||||
|
||||
val retryButtonColor = if (isRetryFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
||||
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
modifier = Modifier.padding(48.dp).focusGroup()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = iconRes),
|
||||
contentDescription = "Error",
|
||||
tint = HorrorColors.HorrorRed,
|
||||
modifier = Modifier.size(80.dp)
|
||||
)
|
||||
|
||||
Text(text = titleText, style = HorrorTypography.CategoryTitle, color = HorrorColors.HorrorAccent, textAlign = TextAlign.Center)
|
||||
Text(text = error.userMessage, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorWhite, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(0.6f))
|
||||
|
||||
if (error.isRetryable) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(retryButtonColor)
|
||||
.border(if (isRetryFocused) 3.dp else 1.dp, if (isRetryFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(8.dp))
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { isRetryFocused = it.isFocused }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onRetry),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "REINTENTAR", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TvSnackbarError(
|
||||
error: AppError,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isDismissFocused by remember { mutableStateOf(false) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
val dismissColor = if (isDismissFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(HorrorColors.HorrorGray.copy(alpha = 0.9f), RoundedCornerShape(8.dp))
|
||||
.padding(16.dp)
|
||||
.focusGroup()
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = android.R.drawable.ic_menu_close_clear_cancel),
|
||||
contentDescription = "Error",
|
||||
tint = HorrorColors.HorrorRed,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Text(text = error.userMessage, style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite, modifier = Modifier.weight(1f))
|
||||
if (error.isRetryable) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(dismissColor)
|
||||
.border(if (isDismissFocused) 2.dp else 1.dp, if (isDismissFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(4.dp))
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { isDismissFocused = it.isFocused }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onDismiss),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "OK", style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.horrortv.app.presentation.detail
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
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.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.horrortv.app.domain.model.Movie
|
||||
import com.horrortv.app.presentation.common.TvErrorDisplay
|
||||
import com.horrortv.app.presentation.theme.HorrorColors
|
||||
import com.horrortv.app.presentation.theme.HorrorTypography
|
||||
|
||||
private val PosterShape = RoundedCornerShape(12.dp)
|
||||
private val PlayButtonShape = RoundedCornerShape(12.dp)
|
||||
private val PlayButtonWidth = 400.dp
|
||||
private val PlayButtonHeight = 70.dp
|
||||
|
||||
@Composable
|
||||
fun DetailScreen(
|
||||
imdbId: String,
|
||||
viewModel: DetailViewModel,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToPlayer: (String, String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BackHandler(enabled = true) { onNavigateBack() }
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val movie = uiState.movie
|
||||
|
||||
LaunchedEffect(imdbId) { viewModel.loadMovie(imdbId) }
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().background(HorrorColors.HorrorBlack)
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading -> DetailLoadingIndicator()
|
||||
uiState.error != null -> {
|
||||
val error = uiState.error
|
||||
if (error != null) {
|
||||
TvErrorDisplay(error = error, onRetry = { viewModel.retry() })
|
||||
}
|
||||
}
|
||||
movie != null -> MovieDetailContent(
|
||||
movie = movie,
|
||||
onPlayClick = { onNavigateToPlayer(movie.imdbId, movie.title) },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailLoadingIndicator() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = HorrorColors.HorrorRed, modifier = Modifier.padding(48.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MovieDetailContent(
|
||||
movie: Movie,
|
||||
onPlayClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isPlayFocused by remember { mutableStateOf(false) }
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(500)
|
||||
playFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
||||
val scale = if (isPlayFocused) 1.08f else 1.0f
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AsyncImage(
|
||||
model = movie.posterUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize().alpha(0.15f)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(48.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = movie.posterUrl,
|
||||
contentDescription = movie.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.width(350.dp)
|
||||
.height(500.dp)
|
||||
.clip(PosterShape)
|
||||
.border(2.dp, HorrorColors.HorrorLightGray, PosterShape)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).focusGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${movie.title} (${movie.year})",
|
||||
style = HorrorTypography.DetailTitle,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "IMDb: ⭐ ${movie.rating}", style = HorrorTypography.DetailRating)
|
||||
Text(text = movie.runtime, style = HorrorTypography.DetailInfo)
|
||||
Text(
|
||||
text = movie.genre,
|
||||
style = HorrorTypography.DetailGenre,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(text = "SINOPSIS", style = HorrorTypography.MovieTitle, color = HorrorColors.HorrorAccent)
|
||||
Text(text = movie.plot, style = HorrorTypography.DetailPlot, maxLines = 8, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (movie.director.isNotEmpty()) {
|
||||
Text(text = "Director: ${movie.director}", style = HorrorTypography.DetailInfo, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
if (movie.actors.isNotEmpty()) {
|
||||
Text(text = "Actores: ${movie.actors}", style = HorrorTypography.DetailInfo, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(PlayButtonWidth)
|
||||
.height(PlayButtonHeight)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
.clip(PlayButtonShape)
|
||||
.background(playButtonColor)
|
||||
.border(if (isPlayFocused) 4.dp else 2.dp, if (isPlayFocused) Color.White else HorrorColors.HorrorLightGray, PlayButtonShape)
|
||||
.focusRequester(playFocusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { isPlayFocused = it.isFocused }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onPlayClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "▶ VER PELÍCULA", style = HorrorTypography.PlayButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.horrortv.app.presentation.detail
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.horrortv.app.domain.model.AppError
|
||||
import com.horrortv.app.domain.model.Movie
|
||||
import com.horrortv.app.domain.repository.MovieRepository
|
||||
import com.horrortv.app.domain.repository.Result
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DetailViewModel @Inject constructor(
|
||||
private val repository: MovieRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private companion object {
|
||||
const val TAG = "DetailViewModel"
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(DetailUiState())
|
||||
val uiState: StateFlow<DetailUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
private var lastImdbId: String = ""
|
||||
|
||||
fun loadMovie(imdbId: String) {
|
||||
if (imdbId.isEmpty()) {
|
||||
Log.w(TAG, "Empty IMDB ID provided")
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = AppError.ValidationError(
|
||||
userMessage = "ID de película inválido.",
|
||||
debugMessage = "Empty IMDB ID provided",
|
||||
field = "imdbId"
|
||||
)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lastImdbId = imdbId
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
Log.d(TAG, "Loading movie: $imdbId")
|
||||
_uiState.update { it.copy(isLoading = true, error = null, movie = null) }
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
repository.getMovieById(imdbId)
|
||||
}
|
||||
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
val movie = result.data
|
||||
if (movie != null) {
|
||||
Log.d(TAG, "Loaded movie: ${movie.title}")
|
||||
_uiState.update { it.copy(movie = movie, isLoading = false, error = null) }
|
||||
} else {
|
||||
Log.w(TAG, "Movie not found: $imdbId")
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = AppError.ValidationError(
|
||||
userMessage = "No se encontró la película.",
|
||||
debugMessage = "Movie not found",
|
||||
field = "imdbId"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Result.Error -> {
|
||||
Log.e(TAG, "Error loading movie: ${result.error.debugMessage}")
|
||||
_uiState.update { it.copy(isLoading = false, error = result.error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
if (lastImdbId.isNotEmpty()) {
|
||||
loadMovie(lastImdbId)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
}
|
||||
|
||||
data class DetailUiState(
|
||||
val movie: Movie? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: AppError? = null
|
||||
)
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.horrortv.app.presentation.home
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.horrortv.app.domain.model.Movie
|
||||
import com.horrortv.app.domain.model.MovieCategory
|
||||
import com.horrortv.app.presentation.common.TvErrorDisplay
|
||||
import com.horrortv.app.presentation.theme.HorrorColors
|
||||
import com.horrortv.app.presentation.theme.HorrorTypography
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
viewModel: HomeViewModel,
|
||||
onNavigateToSearch: () -> Unit,
|
||||
onNavigateToDetail: (String) -> Unit,
|
||||
onExit: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
showExitDialog = true
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val firstCardFocusRequester = remember { FocusRequester() }
|
||||
|
||||
var hasRequestedInitialFocus by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(uiState.categories.isNotEmpty()) {
|
||||
if (uiState.categories.isNotEmpty() && uiState.categories.first().movies.isNotEmpty() && !hasRequestedInitialFocus) {
|
||||
kotlinx.coroutines.delay(800)
|
||||
try {
|
||||
firstCardFocusRequester.requestFocus()
|
||||
hasRequestedInitialFocus = true
|
||||
} catch (e: IllegalStateException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onMovieClick = remember(onNavigateToDetail) {
|
||||
{ movie: Movie -> onNavigateToDetail(movie.imdbId) }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().background(HorrorColors.HorrorBlack)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
HomeHeader(onSearchClick = onNavigateToSearch)
|
||||
|
||||
when {
|
||||
uiState.isLoading && uiState.categories.isEmpty() -> CategoryPlaceholderList()
|
||||
uiState.error != null -> {
|
||||
val error = uiState.error
|
||||
if (error != null) {
|
||||
TvErrorDisplay(error = error, onRetry = { viewModel.retry() })
|
||||
}
|
||||
}
|
||||
uiState.categories.isEmpty() -> EmptyState()
|
||||
else -> CategoriesList(
|
||||
categories = uiState.categories,
|
||||
onMovieClick = onMovieClick,
|
||||
firstCardFocusRequester = firstCardFocusRequester
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showExitDialog) {
|
||||
ExitConfirmationDialog(
|
||||
onConfirm = { showExitDialog = false; onExit() },
|
||||
onDismiss = { showExitDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryPlaceholderList() {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
repeat(3) { CategoryPlaceholderRow() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "No hay películas disponibles", style = HorrorTypography.DetailPlot)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoriesList(
|
||||
categories: List<MovieCategory>,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
firstCardFocusRequester: FocusRequester
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = categories,
|
||||
key = { _, category -> category.name }
|
||||
) { index, category ->
|
||||
HorrorRow(
|
||||
category = category,
|
||||
onMovieClick = onMovieClick,
|
||||
isFirstRow = index == 0,
|
||||
focusRequester = if (index == 0) firstCardFocusRequester else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeHeader(onSearchClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
var isSearchFocused by remember { mutableStateOf(false) }
|
||||
val searchFocusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "HORROR TV", style = HorrorTypography.DetailTitle, color = HorrorColors.HorrorRed)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isSearchFocused) HorrorColors.HorrorGray else Color.Transparent)
|
||||
.border(if (isSearchFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp))
|
||||
.focusRequester(searchFocusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { isSearchFocused = it.isFocused }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onSearchClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = android.R.drawable.ic_menu_search),
|
||||
contentDescription = "Buscar",
|
||||
tint = HorrorColors.HorrorWhite,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CategoryPlaceholderRow(modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
Box(
|
||||
modifier = Modifier.width(200.dp).height(20.dp)
|
||||
.padding(horizontal = 48.dp).clip(RoundedCornerShape(4.dp))
|
||||
.background(HorrorColors.HorrorGray)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
repeat(6) {
|
||||
Box(
|
||||
modifier = Modifier.width(180.dp).height(270.dp)
|
||||
.clip(RoundedCornerShape(8.dp)).background(HorrorColors.HorrorGray)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExitConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
var isConfirmFocused by remember { mutableStateOf(false) }
|
||||
val confirmFocusRequester = remember { FocusRequester() }
|
||||
val confirmInteractionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(HorrorColors.HorrorBlack.copy(alpha = 0.85f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Text(text = "¿Salir de Horror TV?", style = HorrorTypography.DetailTitle, color = HorrorColors.HorrorWhite)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
|
||||
TextButton(onClick = onDismiss) { Text("No", color = HorrorColors.HorrorWhite) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(200.dp).height(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isConfirmFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed)
|
||||
.border(if (isConfirmFocused) 3.dp else 1.dp, if (isConfirmFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(8.dp))
|
||||
.focusRequester(confirmFocusRequester)
|
||||
.focusable(interactionSource = confirmInteractionSource)
|
||||
.onFocusChanged { isConfirmFocused = it.isFocused }
|
||||
.clickable(interactionSource = confirmInteractionSource, indication = null, onClick = onConfirm),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Sí, salir", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextButton(onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(100.dp).height(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isFocused) HorrorColors.HorrorGray else Color.Transparent)
|
||||
.border(if (isFocused) 2.dp else 0.dp, HorrorColors.HorrorWhite, RoundedCornerShape(8.dp))
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.horrortv.app.presentation.home
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.horrortv.app.domain.model.AppError
|
||||
import com.horrortv.app.domain.model.MovieCategory
|
||||
import com.horrortv.app.domain.repository.MovieRepository
|
||||
import com.horrortv.app.util.ApiException
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class HomeViewModel @Inject constructor(
|
||||
private val repository: MovieRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private companion object {
|
||||
const val TAG = "HomeViewModel"
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(HomeUiState())
|
||||
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var loadJob: Job? = null
|
||||
|
||||
init {
|
||||
loadFeaturedCategories()
|
||||
}
|
||||
|
||||
fun loadFeaturedCategories() {
|
||||
loadJob?.cancel()
|
||||
loadJob = viewModelScope.launch {
|
||||
Log.d(TAG, "Loading featured categories")
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
|
||||
try {
|
||||
val categories = repository.getFeaturedCategories()
|
||||
Log.d(TAG, "Loaded ${categories.size} categories")
|
||||
_uiState.update { it.copy(categories = categories, isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading categories", e)
|
||||
val appError = if (e is ApiException) {
|
||||
e.toAppError()
|
||||
} else {
|
||||
AppError.UnknownError(
|
||||
userMessage = "Error inesperado. Intenta de nuevo.",
|
||||
debugMessage = "Unhandled exception: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = false, error = appError) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
loadFeaturedCategories()
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
}
|
||||
|
||||
data class HomeUiState(
|
||||
val categories: List<MovieCategory> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: AppError? = null
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.horrortv.app.presentation.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.Text
|
||||
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.unit.dp
|
||||
import com.horrortv.app.domain.model.Movie
|
||||
import com.horrortv.app.domain.model.MovieCategory
|
||||
import com.horrortv.app.presentation.theme.HorrorTypography
|
||||
|
||||
@Composable
|
||||
fun HorrorRow(
|
||||
category: MovieCategory,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
isFirstRow: Boolean = false,
|
||||
focusRequester: FocusRequester? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val categoryTitle = remember(category.name) { "${category.name.uppercase()} MOVIES" }
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = categoryTitle,
|
||||
style = HorrorTypography.CategoryTitle,
|
||||
modifier = Modifier.padding(horizontal = 48.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = category.movies,
|
||||
key = { _, movie -> movie.imdbId }
|
||||
) { index, movie ->
|
||||
MoviePosterCard(
|
||||
movie = movie,
|
||||
onClick = { onMovieClick(movie) },
|
||||
isFirstCard = isFirstRow && index == 0,
|
||||
firstCardFocusRequester = focusRequester
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.horrortv.app.presentation.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.horrortv.app.domain.model.Movie
|
||||
import com.horrortv.app.presentation.common.PosterImage
|
||||
import com.horrortv.app.presentation.theme.HorrorColors
|
||||
|
||||
private val CardShape = RoundedCornerShape(8.dp)
|
||||
private val CardWidth = 180.dp
|
||||
private val CardHeight = 270.dp
|
||||
|
||||
@Composable
|
||||
fun MoviePosterCard(
|
||||
movie: Movie,
|
||||
onClick: () -> Unit,
|
||||
isFirstCard: Boolean = false,
|
||||
firstCardFocusRequester: FocusRequester? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val localFocusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val activeFocusRequester = if (isFirstCard && firstCardFocusRequester != null) {
|
||||
firstCardFocusRequester
|
||||
} else {
|
||||
localFocusRequester
|
||||
}
|
||||
|
||||
val scale = if (isFocused) 1.08f else 1.0f
|
||||
val borderW = if (isFocused) 8.dp else 1.dp
|
||||
val borderC = if (isFocused) Color.Red else HorrorColors.HorrorLightGray
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(CardWidth)
|
||||
.height(CardHeight)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
shadowElevation = if (isFocused) 24.dp.toPx() else 0.dp.toPx()
|
||||
ambientShadowColor = if (isFocused) Color.Red else Color.Transparent
|
||||
spotShadowColor = if (isFocused) Color.Red else Color.Transparent
|
||||
}
|
||||
.clip(CardShape)
|
||||
.background(HorrorColors.HorrorGray)
|
||||
.border(borderW, borderC, CardShape)
|
||||
.focusRequester(activeFocusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
|
||||
) {
|
||||
PosterImage(
|
||||
url = movie.posterUrl,
|
||||
contentDescription = movie.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
shape = CardShape
|
||||
)
|
||||
|
||||
if (isFocused) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Red.copy(alpha = 0.2f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package com.horrortv.app.presentation.player
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.horrortv.app.domain.model.VideoSource
|
||||
import com.horrortv.app.domain.usecase.VideoExtractor
|
||||
import com.horrortv.app.presentation.theme.HorrorColors
|
||||
import com.horrortv.app.presentation.theme.HorrorTypography
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import android.util.Log
|
||||
|
||||
private const val TAG = "PlayerScreen"
|
||||
private val ButtonShape = RoundedCornerShape(12.dp)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
imdbId: String,
|
||||
title: String = "Horror TV",
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var videoSource by remember { mutableStateOf<VideoSource?>(null) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var hasError by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
var isPlaying by remember { mutableStateOf(false) }
|
||||
var currentTimeMs by remember { mutableLongStateOf(0L) }
|
||||
var durationMs by remember { mutableLongStateOf(0L) }
|
||||
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
val backFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val exoPlayer = remember { ExoPlayer.Builder(context).build() }
|
||||
|
||||
LaunchedEffect(imdbId) {
|
||||
scope.launch {
|
||||
try {
|
||||
isLoading = true
|
||||
hasError = false
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
VideoExtractor().extractVideoSource(imdbId)
|
||||
}
|
||||
|
||||
result.fold(
|
||||
onSuccess = { source ->
|
||||
videoSource = source
|
||||
val mediaItem = MediaItem.fromUri(source.videoUrl)
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
isLoading = false
|
||||
},
|
||||
onFailure = { error ->
|
||||
hasError = true
|
||||
errorMessage = "Error: ${error.message}"
|
||||
isLoading = false
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
hasError = true
|
||||
errorMessage = "Error: ${e.message}"
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(exoPlayer) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
isPlaying = playing
|
||||
}
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY -> {
|
||||
isLoading = false
|
||||
durationMs = exoPlayer.duration
|
||||
}
|
||||
Player.STATE_BUFFERING -> isLoading = true
|
||||
Player.STATE_ENDED -> isPlaying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
exoPlayer.addListener(listener)
|
||||
onDispose {
|
||||
exoPlayer.removeListener(listener)
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showControls, isPlaying) {
|
||||
if (showControls && isPlaying) {
|
||||
delay(4000)
|
||||
showControls = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isPlaying) {
|
||||
while (isPlaying) {
|
||||
currentTimeMs = exoPlayer.currentPosition
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(enabled = true) { onNavigateBack() }
|
||||
|
||||
LaunchedEffect(!isLoading, showControls) {
|
||||
if (!isLoading && showControls) {
|
||||
delay(300)
|
||||
try {
|
||||
playFocusRequester.requestFocus()
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.d(TAG, "FocusRequester not ready yet, will retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().background(HorrorColors.HorrorBlack)
|
||||
) {
|
||||
if (hasError) {
|
||||
PlayerErrorOverlay(
|
||||
errorMessage = errorMessage,
|
||||
onRetry = {
|
||||
hasError = false
|
||||
isLoading = true
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
VideoExtractor().extractVideoSource(imdbId)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { source ->
|
||||
videoSource = source
|
||||
val mediaItem = MediaItem.fromUri(source.videoUrl)
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
isLoading = false
|
||||
},
|
||||
onFailure = { error ->
|
||||
hasError = true
|
||||
errorMessage = "Error: ${error.message}"
|
||||
isLoading = false
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
onBack = onNavigateBack
|
||||
)
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
useController = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "Cargando...", style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite)
|
||||
}
|
||||
}
|
||||
|
||||
if (showControls && !isLoading) {
|
||||
PlayerControlsOverlay(
|
||||
isPlaying = isPlaying,
|
||||
currentTime = currentTimeMs,
|
||||
duration = durationMs,
|
||||
onPlayPause = {
|
||||
if (isPlaying) exoPlayer.pause() else exoPlayer.play()
|
||||
showControls = true
|
||||
},
|
||||
onSeekForward = { exoPlayer.seekTo(exoPlayer.currentPosition + 10000) },
|
||||
onSeekBackward = { exoPlayer.seekTo(exoPlayer.currentPosition - 10000) },
|
||||
onBack = onNavigateBack,
|
||||
playFocusRequester = playFocusRequester,
|
||||
backFocusRequester = backFocusRequester
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerControlsOverlay(
|
||||
isPlaying: Boolean,
|
||||
currentTime: Long,
|
||||
duration: Long,
|
||||
onPlayPause: () -> Unit,
|
||||
onSeekForward: () -> Unit,
|
||||
onSeekBackward: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
backFocusRequester: FocusRequester
|
||||
) {
|
||||
var isPlayFocused by remember { mutableStateOf(false) }
|
||||
var isBackFocused by remember { mutableStateOf(false) }
|
||||
var isForwardFocused by remember { mutableStateOf(false) }
|
||||
var isBackwardFocused by remember { mutableStateOf(false) }
|
||||
|
||||
val playInteraction = remember { MutableInteractionSource() }
|
||||
val backInteraction = remember { MutableInteractionSource() }
|
||||
val forwardInteraction = remember { MutableInteractionSource() }
|
||||
val backwardInteraction = remember { MutableInteractionSource() }
|
||||
|
||||
val currentSec = (currentTime / 1000).toInt()
|
||||
val durationSec = (duration / 1000).toInt()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
PlayerButton(
|
||||
iconRes = android.R.drawable.ic_menu_close_clear_cancel,
|
||||
label = "BACK",
|
||||
isFocused = isBackFocused,
|
||||
onClick = onBack,
|
||||
focusRequester = backFocusRequester,
|
||||
interactionSource = backInteraction,
|
||||
onFocusChange = { isBackFocused = it }
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(bottom = 48.dp, start = 48.dp, end = 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = formatTime(currentSec), style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite)
|
||||
Text(text = formatTime(durationSec), style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
PlayerButton(
|
||||
iconRes = android.R.drawable.ic_media_previous,
|
||||
label = "-10s",
|
||||
isFocused = isBackwardFocused,
|
||||
onClick = onSeekBackward,
|
||||
focusRequester = FocusRequester(),
|
||||
interactionSource = backwardInteraction,
|
||||
onFocusChange = { isBackwardFocused = it }
|
||||
)
|
||||
|
||||
PlayerButton(
|
||||
iconRes = if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
|
||||
label = if (isPlaying) "PAUSE" else "PLAY",
|
||||
isFocused = isPlayFocused,
|
||||
onClick = onPlayPause,
|
||||
focusRequester = playFocusRequester,
|
||||
interactionSource = playInteraction,
|
||||
onFocusChange = { isPlayFocused = it }
|
||||
)
|
||||
|
||||
PlayerButton(
|
||||
iconRes = android.R.drawable.ic_media_next,
|
||||
label = "+10s",
|
||||
isFocused = isForwardFocused,
|
||||
onClick = onSeekForward,
|
||||
focusRequester = FocusRequester(),
|
||||
interactionSource = forwardInteraction,
|
||||
onFocusChange = { isForwardFocused = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerButton(
|
||||
iconRes: Int,
|
||||
label: String,
|
||||
isFocused: Boolean,
|
||||
onClick: () -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
interactionSource: MutableInteractionSource,
|
||||
onFocusChange: (Boolean) -> Unit
|
||||
) {
|
||||
val scale = if (isFocused) 1.1f else 1.0f
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(ButtonShape)
|
||||
.background(if (isFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorGray)
|
||||
.border(if (isFocused) 4.dp else 1.dp, if (isFocused) Color.White else HorrorColors.HorrorLightGray, ButtonShape)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { onFocusChange(it.isFocused) }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = iconRes),
|
||||
contentDescription = label,
|
||||
tint = HorrorColors.HorrorWhite,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = HorrorTypography.MovieYear,
|
||||
color = if (isFocused) HorrorColors.HorrorWhite else HorrorColors.HorrorLightGray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerErrorOverlay(
|
||||
errorMessage: String,
|
||||
onRetry: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var isRetryFocused by remember { mutableStateOf(false) }
|
||||
var isBackFocused by remember { mutableStateOf(true) }
|
||||
|
||||
val retryFocusRequester = remember { FocusRequester() }
|
||||
val backFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) { backFocusRequester.requestFocus() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(HorrorColors.HorrorBlack),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = android.R.drawable.ic_menu_close_clear_cancel),
|
||||
contentDescription = "Error",
|
||||
tint = HorrorColors.HorrorRed,
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = HorrorTypography.DetailPlot,
|
||||
color = HorrorColors.HorrorWhite,
|
||||
modifier = Modifier.padding(horizontal = 48.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
|
||||
PlayerActionButton(
|
||||
label = "VOLVER",
|
||||
isFocused = isBackFocused,
|
||||
onClick = onBack,
|
||||
focusRequester = backFocusRequester,
|
||||
onFocusChange = { isBackFocused = it }
|
||||
)
|
||||
|
||||
PlayerActionButton(
|
||||
label = "REINTENTAR",
|
||||
isFocused = isRetryFocused,
|
||||
onClick = onRetry,
|
||||
focusRequester = retryFocusRequester,
|
||||
onFocusChange = { isRetryFocused = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerActionButton(
|
||||
label: String,
|
||||
isFocused: Boolean,
|
||||
onClick: () -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
onFocusChange: (Boolean) -> Unit
|
||||
) {
|
||||
val scale = if (isFocused) 1.08f else 1.0f
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(200.dp)
|
||||
.height(64.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed)
|
||||
.border(if (isFocused) 4.dp else 1.dp, if (isFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(12.dp))
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { onFocusChange(it.isFocused) }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = label, style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTime(seconds: Int): String {
|
||||
val mins = seconds / 60
|
||||
val secs = seconds % 60
|
||||
return "%02d:%02d".format(mins, secs)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package com.horrortv.app.presentation.search
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.horrortv.app.domain.model.AppError
|
||||
import com.horrortv.app.domain.model.Movie
|
||||
import com.horrortv.app.presentation.common.PosterImage
|
||||
import com.horrortv.app.util.PosterSize
|
||||
import com.horrortv.app.presentation.common.TvErrorDisplay
|
||||
import com.horrortv.app.presentation.theme.HorrorColors
|
||||
import com.horrortv.app.presentation.theme.HorrorTypography
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val CardShape = RoundedCornerShape(8.dp)
|
||||
private val SearchFieldShape = RoundedCornerShape(8.dp)
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
viewModel: SearchViewModel,
|
||||
onNavigateBack: () -> Unit = {},
|
||||
onNavigateToDetail: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
val searchFieldFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) { searchFieldFocusRequester.requestFocus() }
|
||||
|
||||
val movies = uiState.movies
|
||||
val isLoading = uiState.isLoading
|
||||
val error = uiState.error
|
||||
val directMovie = uiState.directMovie
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().background(HorrorColors.HorrorBlack)) {
|
||||
SearchHeader(
|
||||
query = searchQuery,
|
||||
onQueryChange = { newQuery ->
|
||||
searchQuery = newQuery
|
||||
viewModel.updateQuery(newQuery)
|
||||
},
|
||||
onBackClick = onNavigateBack,
|
||||
focusRequester = searchFieldFocusRequester
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
isLoading -> SearchLoadingIndicator()
|
||||
error != null -> TvErrorDisplay(error = error, onRetry = { viewModel.retry() })
|
||||
directMovie != null -> DirectMovieResult(
|
||||
movie = directMovie,
|
||||
onPlayClick = { onNavigateToDetail(directMovie.imdbId) }
|
||||
)
|
||||
searchQuery.isEmpty() -> SearchInitialState()
|
||||
movies.isEmpty() -> SearchNoResultsState()
|
||||
else -> SearchResultsGrid(
|
||||
movies = movies,
|
||||
onMovieClick = { movie -> onNavigateToDetail(movie.imdbId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchLoadingIndicator() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = HorrorColors.HorrorRed, modifier = Modifier.size(48.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchInitialState() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Icon(painter = painterResource(id = android.R.drawable.ic_menu_search), contentDescription = null, tint = HorrorColors.HorrorLightGray, modifier = Modifier.size(64.dp))
|
||||
Text(text = "Busca películas por nombre o IMDB ID", style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorLightGray)
|
||||
Text(text = "Ejemplo: \"Scream\" o \"tt11245972\"", style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorGray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchNoResultsState() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = "No se encontraron resultados", style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorLightGray)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultsGrid(
|
||||
movies: List<Movie>,
|
||||
onMovieClick: (Movie) -> Unit
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(5),
|
||||
contentPadding = PaddingValues(48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||
modifier = Modifier.focusGroup()
|
||||
) {
|
||||
items(items = movies, key = { it.imdbId }) { movie ->
|
||||
SearchResultCard(movie = movie, onClick = { onMovieClick(movie) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchHeader(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isTextFieldFocused by remember { mutableStateOf(false) }
|
||||
var isBackFocused by remember { mutableStateOf(false) }
|
||||
val backFocusRequester = remember { FocusRequester() }
|
||||
val textFieldInteractionSource = remember { MutableInteractionSource() }
|
||||
val backInteractionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val borderColor = if (isTextFieldFocused) HorrorColors.HorrorRed else HorrorColors.HorrorLightGray
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isBackFocused) HorrorColors.HorrorGray else Color.Transparent)
|
||||
.border(if (isBackFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp))
|
||||
.focusRequester(backFocusRequester)
|
||||
.focusable(interactionSource = backInteractionSource)
|
||||
.onFocusChanged { isBackFocused = it.isFocused }
|
||||
.clickable(interactionSource = backInteractionSource, indication = null, onClick = onBackClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(painter = painterResource(id = android.R.drawable.ic_menu_close_clear_cancel), contentDescription = "Cerrar", tint = HorrorColors.HorrorWhite, modifier = Modifier.size(32.dp))
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(56.dp)
|
||||
.clip(SearchFieldShape)
|
||||
.background(HorrorColors.HorrorGray)
|
||||
.border(2.dp, borderColor, SearchFieldShape)
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
if (query.isEmpty() && !isTextFieldFocused) {
|
||||
Text(text = "Buscar por nombre o IMDB ID", style = HorrorTypography.SearchHint)
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
textStyle = HorrorTypography.DetailPlot.copy(color = HorrorColors.HorrorWhite),
|
||||
cursorBrush = SolidColor(HorrorColors.HorrorRed),
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(interactionSource = textFieldInteractionSource)
|
||||
.onFocusChanged { isTextFieldFocused = it.isFocused }
|
||||
)
|
||||
}
|
||||
|
||||
Icon(painter = painterResource(id = android.R.drawable.ic_menu_search), contentDescription = "Buscar", tint = HorrorColors.HorrorWhite, modifier = Modifier.size(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SearchResultCard(
|
||||
movie: Movie,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val borderW = if (isFocused) 6.dp else 1.dp
|
||||
val borderC = if (isFocused) HorrorColors.HorrorRed else HorrorColors.HorrorLightGray
|
||||
val scale = if (isFocused) 1.08f else 1.0f
|
||||
|
||||
Column(modifier = modifier.width(180.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(180.dp)
|
||||
.height(270.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.bringIntoViewRequester(bringIntoViewRequester)
|
||||
.clip(CardShape)
|
||||
.background(HorrorColors.HorrorGray)
|
||||
.border(borderW, borderC, CardShape)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) {
|
||||
coroutineScope.launch { bringIntoViewRequester.bringIntoView() }
|
||||
}
|
||||
}
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
|
||||
) {
|
||||
PosterImage(
|
||||
url = movie.posterUrl,
|
||||
contentDescription = movie.title,
|
||||
size = PosterSize.CARD,
|
||||
contentScale = ContentScale.Crop,
|
||||
shape = CardShape,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
if (isFocused) {
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Red.copy(alpha = 0.2f)))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = movie.title, style = HorrorTypography.MovieTitle, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 4.dp))
|
||||
Text(text = movie.year, style = HorrorTypography.MovieYear, modifier = Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DirectMovieResult(
|
||||
movie: Movie,
|
||||
onPlayClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isPlayFocused by remember { mutableStateOf(false) }
|
||||
val playFocusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
LaunchedEffect(Unit) { playFocusRequester.requestFocus() }
|
||||
|
||||
val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(48.dp).focusGroup(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(32.dp)
|
||||
) {
|
||||
Text(text = "RESULTADO ENCONTRADO", style = HorrorTypography.CategoryTitle, color = HorrorColors.HorrorAccent)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp), modifier = Modifier.fillMaxWidth(0.7f)) {
|
||||
PosterImage(
|
||||
url = movie.posterUrl,
|
||||
contentDescription = movie.title,
|
||||
size = PosterSize.DETAIL,
|
||||
contentScale = ContentScale.Crop,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.width(300.dp).height(450.dp).border(2.dp, HorrorColors.HorrorRed, RoundedCornerShape(12.dp))
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.weight(1f)) {
|
||||
Text(text = "${movie.title} (${movie.year})", style = HorrorTypography.DetailTitle)
|
||||
Text(text = "IMDb: ⭐ ${movie.rating}", style = HorrorTypography.DetailRating)
|
||||
Text(text = movie.genre, style = HorrorTypography.DetailGenre)
|
||||
Text(text = movie.plot, style = HorrorTypography.DetailPlot, maxLines = 6, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(350.dp)
|
||||
.height(60.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(playButtonColor)
|
||||
.border(if (isPlayFocused) 4.dp else 2.dp, if (isPlayFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(12.dp))
|
||||
.focusRequester(playFocusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { isPlayFocused = it.isFocused }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onPlayClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "▶ VER PELÍCULA", style = HorrorTypography.PlayButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.horrortv.app.presentation.search
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.horrortv.app.domain.model.AppError
|
||||
import com.horrortv.app.domain.model.Movie
|
||||
import com.horrortv.app.domain.repository.MovieRepository
|
||||
import com.horrortv.app.domain.repository.Result
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@HiltViewModel
|
||||
class SearchViewModel @Inject constructor(
|
||||
private val repository: MovieRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SearchViewModel"
|
||||
const val DEBOUNCE_DELAY_MS = 300L
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(SearchUiState())
|
||||
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
private var searchJob: Job? = null
|
||||
|
||||
init {
|
||||
_searchQuery
|
||||
.debounce(DEBOUNCE_DELAY_MS)
|
||||
.onEach { query ->
|
||||
if (query.isNotEmpty()) {
|
||||
performSearch(query)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun updateQuery(query: String) {
|
||||
val trimmedQuery = query.trim()
|
||||
_searchQuery.value = trimmedQuery
|
||||
|
||||
if (trimmedQuery.isEmpty()) {
|
||||
searchJob?.cancel()
|
||||
_uiState.update { it.copy(movies = emptyList(), directMovie = null, error = null, isLoading = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSearch(query: String) {
|
||||
if (query.isEmpty()) return
|
||||
|
||||
searchJob?.cancel()
|
||||
searchJob = viewModelScope.launch {
|
||||
Log.d(TAG, "Searching for: $query")
|
||||
_uiState.update { it.copy(isLoading = true, error = null, directMovie = null) }
|
||||
|
||||
if (query.isValidImdbId()) {
|
||||
searchById(query)
|
||||
} else {
|
||||
searchByName(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun searchById(imdbId: String) {
|
||||
val result = repository.getMovieById(imdbId)
|
||||
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
val movie = result.data
|
||||
if (movie != null) {
|
||||
Log.d(TAG, "Found movie: ${movie.title}")
|
||||
_uiState.update { it.copy(directMovie = movie, movies = emptyList(), isLoading = false) }
|
||||
} else {
|
||||
Log.w(TAG, "Movie not found: $imdbId")
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
error = AppError.ValidationError(
|
||||
userMessage = "No se encontró la película con ID: $imdbId",
|
||||
debugMessage = "Movie not found for IMDB ID",
|
||||
field = "imdbId"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is Result.Error -> {
|
||||
Log.e(TAG, "Error searching by ID: ${result.error.debugMessage}")
|
||||
_uiState.update { it.copy(isLoading = false, error = result.error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun searchByName(query: String) {
|
||||
val result = repository.searchMovies(query)
|
||||
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
val movies = result.data
|
||||
Log.d(TAG, "Found ${movies.size} movies")
|
||||
_uiState.update { it.copy(movies = movies, directMovie = null, isLoading = false) }
|
||||
}
|
||||
is Result.Error -> {
|
||||
Log.e(TAG, "Error searching: ${result.error.debugMessage}")
|
||||
_uiState.update { it.copy(isLoading = false, error = result.error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
val currentQuery = _searchQuery.value
|
||||
if (currentQuery.isNotEmpty()) {
|
||||
performSearch(currentQuery)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearResults() {
|
||||
searchJob?.cancel()
|
||||
_searchQuery.value = ""
|
||||
_uiState.update { SearchUiState() }
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(error = null) }
|
||||
}
|
||||
}
|
||||
|
||||
data class SearchUiState(
|
||||
val movies: List<Movie> = emptyList(),
|
||||
val directMovie: Movie? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val error: AppError? = null
|
||||
)
|
||||
|
||||
fun String.isValidImdbId(): Boolean {
|
||||
return com.horrortv.app.util.Constants.IMDB_ID_PATTERN.matches(this)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.horrortv.app.presentation.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
object HorrorColors {
|
||||
val HorrorBlack = Color(0xFF121212)
|
||||
val HorrorRed = Color(0xFFCC0000) // Brightened for TV contrast (was 0xFF8B0000)
|
||||
val HorrorGray = Color(0xFF1E1E1E)
|
||||
val HorrorDarkGray = Color(0xFF1A1A1A) // Lightened from 0xFF0A0A0A
|
||||
val HorrorLightGray = Color(0xFF2A2A2A)
|
||||
val HorrorWhite = Color(0xFFE0E0E0)
|
||||
val HorrorAccent = Color(0xFFFF1744)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.horrortv.app.presentation.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
|
||||
private val HorrorColorScheme = darkColorScheme(
|
||||
primary = HorrorColors.HorrorRed,
|
||||
secondary = HorrorColors.HorrorAccent,
|
||||
tertiary = HorrorColors.HorrorGray,
|
||||
background = HorrorColors.HorrorBlack,
|
||||
surface = HorrorColors.HorrorDarkGray,
|
||||
onPrimary = HorrorColors.HorrorWhite,
|
||||
onSecondary = HorrorColors.HorrorWhite,
|
||||
onTertiary = HorrorColors.HorrorWhite,
|
||||
onBackground = HorrorColors.HorrorWhite,
|
||||
onSurface = HorrorColors.HorrorWhite
|
||||
)
|
||||
|
||||
private val HorrorTypographyConfig = androidx.compose.material3.Typography(
|
||||
displayLarge = HorrorTypography.DetailTitle,
|
||||
displayMedium = HorrorTypography.CategoryTitle,
|
||||
displaySmall = HorrorTypography.MovieTitle,
|
||||
headlineLarge = HorrorTypography.CategoryTitle,
|
||||
headlineMedium = HorrorTypography.MovieTitle,
|
||||
headlineSmall = HorrorTypography.MovieYear,
|
||||
titleLarge = HorrorTypography.MovieTitle,
|
||||
titleMedium = HorrorTypography.DetailInfo,
|
||||
titleSmall = HorrorTypography.MovieYear,
|
||||
bodyLarge = HorrorTypography.DetailPlot,
|
||||
bodyMedium = HorrorTypography.DetailInfo,
|
||||
bodySmall = HorrorTypography.SearchHint,
|
||||
labelLarge = HorrorTypography.PlayButton,
|
||||
labelMedium = HorrorTypography.DetailInfo,
|
||||
labelSmall = HorrorTypography.MovieYear
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HorrorTheme(
|
||||
darkTheme: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = HorrorColorScheme,
|
||||
typography = HorrorTypographyConfig,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.horrortv.app.presentation.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
|
||||
|
||||
object HorrorTypography {
|
||||
val MovieTitle = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
)
|
||||
|
||||
val MovieYear = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp
|
||||
)
|
||||
|
||||
val CategoryTitle = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 42.sp
|
||||
)
|
||||
|
||||
val DetailTitle = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 38.sp,
|
||||
lineHeight = 50.sp
|
||||
)
|
||||
|
||||
val DetailRating = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
)
|
||||
|
||||
val DetailInfo = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp
|
||||
)
|
||||
|
||||
val DetailGenre = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp
|
||||
)
|
||||
|
||||
val DetailPlot = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 26.sp,
|
||||
lineHeight = 36.sp
|
||||
)
|
||||
|
||||
val PlayButton = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
)
|
||||
|
||||
val SearchHint = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp
|
||||
)
|
||||
}
|
||||
99
app/src/main/java/com/horrortv/app/util/ApiException.kt
Normal file
@@ -0,0 +1,99 @@
|
||||
package com.horrortv.app.util
|
||||
|
||||
import android.util.Log
|
||||
import com.horrortv.app.domain.model.AppError
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
|
||||
sealed class ApiException : Exception() {
|
||||
|
||||
abstract fun toAppError(): AppError
|
||||
|
||||
data class NetworkUnavailable(
|
||||
override val message: String = "No hay conexión a internet",
|
||||
override val cause: Throwable? = null
|
||||
) : ApiException() {
|
||||
override fun toAppError(): AppError = AppError.NetworkError(
|
||||
userMessage = message,
|
||||
debugMessage = "Network unavailable: ${cause?.message}",
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
data class Timeout(
|
||||
override val message: String = "Tiempo de espera agotado",
|
||||
override val cause: Throwable? = null
|
||||
) : ApiException() {
|
||||
override fun toAppError(): AppError = AppError.NetworkError(
|
||||
userMessage = message,
|
||||
debugMessage = "Request timeout",
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
data class ServerError(
|
||||
val code: Int,
|
||||
override val message: String = "Error del servidor",
|
||||
override val cause: Throwable? = null
|
||||
) : ApiException() {
|
||||
override fun toAppError(): AppError = AppError.ApiError(
|
||||
userMessage = message,
|
||||
debugMessage = "Server error: $code",
|
||||
code = code,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
data class RateLimited(
|
||||
override val message: String = "Demasiadas solicitudes. Intenta más tarde.",
|
||||
override val cause: Throwable? = null
|
||||
) : ApiException() {
|
||||
override fun toAppError(): AppError = AppError.ApiError(
|
||||
userMessage = message,
|
||||
debugMessage = "Rate limited (429)",
|
||||
code = 429,
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
data class Unknown(
|
||||
override val message: String = "Error inesperado",
|
||||
override val cause: Throwable? = null
|
||||
) : ApiException() {
|
||||
override fun toAppError(): AppError = AppError.UnknownError(
|
||||
userMessage = message,
|
||||
debugMessage = cause?.message ?: "Unknown error",
|
||||
cause = cause
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val retryableCodes = setOf(429, 500, 502, 503, 504)
|
||||
|
||||
fun isRetryable(error: ApiException): Boolean {
|
||||
return error is Timeout ||
|
||||
error is RateLimited ||
|
||||
(error is ServerError && error.code in retryableCodes)
|
||||
}
|
||||
|
||||
fun fromThrowable(throwable: Throwable): ApiException {
|
||||
return when (throwable) {
|
||||
is ApiException -> throwable
|
||||
is SocketTimeoutException -> Timeout(cause = throwable)
|
||||
is ConnectException -> NetworkUnavailable(cause = throwable)
|
||||
is UnknownHostException -> NetworkUnavailable(cause = throwable)
|
||||
is IOException -> NetworkUnavailable(cause = throwable)
|
||||
is HttpException -> {
|
||||
val code = throwable.code()
|
||||
if (code == 429) RateLimited(cause = throwable)
|
||||
else if (code in retryableCodes) ServerError(code, cause = throwable)
|
||||
else ServerError(code, cause = throwable)
|
||||
}
|
||||
else -> Unknown(cause = throwable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/src/main/java/com/horrortv/app/util/Constants.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.horrortv.app.util
|
||||
|
||||
object Constants {
|
||||
const val OMDB_API_KEY = "5854c81e"
|
||||
const val OMDB_BASE_URL = "https://www.omdbapi.com/"
|
||||
|
||||
val HORROR_CATEGORIES = listOf(
|
||||
"Scream",
|
||||
"Halloween",
|
||||
"Conjuring",
|
||||
"Exorcist",
|
||||
"Nightmare",
|
||||
"Insidious",
|
||||
"Terrifier",
|
||||
"Hereditary",
|
||||
"It",
|
||||
"Poltergeist",
|
||||
"Saw",
|
||||
"Paranormal"
|
||||
)
|
||||
|
||||
val IMDB_ID_PATTERN = Regex("^tt\\d{7,8}$")
|
||||
|
||||
object Network {
|
||||
const val CONNECT_TIMEOUT_SECONDS = 15L
|
||||
const val READ_TIMEOUT_SECONDS = 30L
|
||||
const val WRITE_TIMEOUT_SECONDS = 30L
|
||||
const val CACHE_SIZE_BYTES = 10 * 1024 * 1024L
|
||||
const val MAX_RETRIES = 2
|
||||
const val RETRY_DELAY_MS = 1000L
|
||||
}
|
||||
|
||||
object Cache {
|
||||
const val CATEGORY_CACHE_HOURS = 12L
|
||||
const val SEARCH_CACHE_HOURS = 4L
|
||||
const val DETAIL_CACHE_HOURS = 24L
|
||||
const val CATEGORY_CACHE_SIZE = 20
|
||||
const val SEARCH_CACHE_SIZE = 50
|
||||
const val DETAIL_CACHE_SIZE = 100
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/com/horrortv/app/util/ErrorLogger.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.horrortv.app.util
|
||||
|
||||
import android.util.Log
|
||||
import com.horrortv.app.domain.model.AppError
|
||||
|
||||
object ErrorLogger {
|
||||
private const val TAG_PREFIX = "HorrorTV"
|
||||
|
||||
fun logError(
|
||||
component: String,
|
||||
operation: String,
|
||||
error: AppError,
|
||||
additionalContext: Map<String, Any?> = emptyMap()
|
||||
) {
|
||||
val fullTag = "$TAG_PREFIX-$component"
|
||||
|
||||
val contextStr = if (additionalContext.isNotEmpty()) {
|
||||
additionalContext.entries.joinToString(", ") { "${it.key}=${it.value}" }
|
||||
} else ""
|
||||
|
||||
val message = buildString {
|
||||
append("[$operation] ")
|
||||
append(error.debugMessage)
|
||||
if (contextStr.isNotEmpty()) {
|
||||
append(" | Context: $contextStr")
|
||||
}
|
||||
append(" | Retryable: ${error.isRetryable}")
|
||||
}
|
||||
|
||||
when (error) {
|
||||
is AppError.NetworkError -> {
|
||||
Log.w(fullTag, message)
|
||||
error.cause?.let { Log.w(fullTag, "Cause: ${it.message}", it) }
|
||||
}
|
||||
is AppError.ApiError -> {
|
||||
val priority = if (error.code >= 500) Log.ERROR else Log.WARN
|
||||
Log.println(priority, fullTag, "$message | Status: ${error.code}")
|
||||
}
|
||||
is AppError.CacheError -> Log.e(fullTag, message)
|
||||
is AppError.ValidationError -> Log.w(fullTag, message)
|
||||
is AppError.UnknownError -> {
|
||||
Log.e(fullTag, message)
|
||||
error.cause?.let { Log.e(fullTag, "Cause: ${it.message}", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun logInfo(component: String, operation: String, message: String) {
|
||||
Log.i("$TAG_PREFIX-$component", "[$operation] $message")
|
||||
}
|
||||
|
||||
fun logDebug(component: String, operation: String, message: String) {
|
||||
Log.d("$TAG_PREFIX-$component", "[$operation] $message")
|
||||
}
|
||||
|
||||
fun logWarning(component: String, operation: String, message: String) {
|
||||
Log.w("$TAG_PREFIX-$component", "[$operation] $message")
|
||||
}
|
||||
}
|
||||
75
app/src/main/java/com/horrortv/app/util/NetworkStatus.kt
Normal file
@@ -0,0 +1,75 @@
|
||||
package com.horrortv.app.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class NetworkStatus @Inject constructor(
|
||||
private val context: Context
|
||||
) {
|
||||
|
||||
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
fun isNetworkAvailable(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
||||
}
|
||||
|
||||
fun isMeteredNetwork(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
fun isWifi(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
}
|
||||
|
||||
fun observeNetworkStatus(): Flow<NetworkState> = callbackFlow {
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(NetworkState.Available())
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(NetworkState.Unavailable)
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||
val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
val wifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
trySend(NetworkState.Available(metered = metered, wifi = wifi))
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
|
||||
trySend(if (isNetworkAvailable()) NetworkState.Available() else NetworkState.Unavailable)
|
||||
|
||||
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
sealed class NetworkState {
|
||||
object Unavailable : NetworkState()
|
||||
data class Available(val metered: Boolean = false, val wifi: Boolean = false) : NetworkState()
|
||||
}
|
||||
}
|
||||
30
app/src/main/java/com/horrortv/app/util/PosterUtils.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.horrortv.app.util
|
||||
|
||||
object PosterSize {
|
||||
const val CARD = 600
|
||||
const val DETAIL = 800
|
||||
const val BACKGROUND = 2160
|
||||
const val THUMBNAIL = 300
|
||||
}
|
||||
|
||||
object PosterUtils {
|
||||
|
||||
fun optimizePosterUrl(url: String?, targetSize: Int): String? {
|
||||
if (url.isNullOrEmpty() || url == "N/A") return null
|
||||
|
||||
val sizePattern = Regex("\\._V1_(SX|UX|SY|UY)\\d+")
|
||||
return url.replace(sizePattern, "._V1_SX${targetSize.coerceAtLeast(300)}")
|
||||
}
|
||||
|
||||
fun toHighQuality(url: String?): String {
|
||||
return optimizePosterUrl(url, PosterSize.DETAIL) ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
fun String?.toPosterUrl4K(): String {
|
||||
return PosterUtils.optimizePosterUrl(this, PosterSize.BACKGROUND) ?: ""
|
||||
}
|
||||
|
||||
fun String.isValidImdbId(): Boolean {
|
||||
return Constants.IMDB_ID_PATTERN.matches(this)
|
||||
}
|
||||
31
app/src/main/java/com/horrortv/app/util/RetryUtils.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.horrortv.app.util
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
suspend fun <T> retryWithBackoff(
|
||||
maxRetries: Int = 3,
|
||||
initialDelayMs: Long = 1000,
|
||||
maxDelayMs: Long = 10000,
|
||||
factor: Double = 2.0,
|
||||
block: suspend () -> T
|
||||
): T {
|
||||
var currentDelay = initialDelayMs
|
||||
var lastException: Exception? = null
|
||||
|
||||
repeat(maxRetries) { attempt ->
|
||||
try {
|
||||
return block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
if (attempt < maxRetries - 1) {
|
||||
delay(currentDelay)
|
||||
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelayMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastException ?: IllegalStateException("Retry failed without exception")
|
||||
}
|
||||
BIN
app/src/main/res/drawable-hdpi/ic_banner.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
app/src/main/res/drawable-mdpi/ic_banner.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
app/src/main/res/drawable-xhdpi/ic_banner.png
Normal file
|
After Width: | Height: | Size: 604 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_banner.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_banner.png
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
6
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#8B0000"/>
|
||||
<corners android:radius="8dp"/>
|
||||
</shape>
|
||||
9
app/src/main/res/drawable/poster_error.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/horror_gray"/>
|
||||
<item
|
||||
android:gravity="center"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:drawable="@android:drawable/ic_menu_report_image"/>
|
||||
</layer-list>
|
||||
9
app/src/main/res/drawable/poster_placeholder.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/horror_gray"/>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@android:color/transparent"/>
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher"/>
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher"/>
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
10
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#8B0000</color>
|
||||
<color name="horror_black">#0D0D0D</color>
|
||||
<color name="horror_gray">#1A1A1A</color>
|
||||
<color name="horror_light_gray">#4A4A4A</color>
|
||||
<color name="horror_red">#CC0000</color>
|
||||
<color name="horror_accent">#FF4444</color>
|
||||
<color name="horror_white">#FFFFFF</color>
|
||||
</resources>
|
||||
13
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Horror TV</string>
|
||||
<string name="search_title">Buscar películas</string>
|
||||
<string name="play_movie">Ver película</string>
|
||||
<string name="loading">Cargando...</string>
|
||||
<string name="error_loading">Error al cargar</string>
|
||||
<string name="search_hint">Buscar por nombre o IMDB ID (tt1234567)</string>
|
||||
<string name="retry">Reintentar</string>
|
||||
<string name="empty_results">No se encontraron resultados</string>
|
||||
<string name="poster_description">Póster de la película</string>
|
||||
<string name="movie_details">Detalles de la película</string>
|
||||
</resources>
|
||||
9
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.HorrorTV" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:colorPrimary">@color/horror_red</item>
|
||||
<item name="android:windowBackground">@color/horror_black</item>
|
||||
<item name="android:statusBarColor">@color/horror_black</item>
|
||||
<item name="android:navigationBarColor">@color/horror_black</item>
|
||||
</style>
|
||||
</resources>
|
||||