commit 828086ceb3200b9316fc7368635853a33882b7e3 Author: renato97 Date: Sun Apr 26 21:58:59 2026 -0300 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. diff --git a/.gga b/.gga new file mode 100644 index 0000000..4b63f44 --- /dev/null +++ b/.gga @@ -0,0 +1,50 @@ +# Gentleman Guardian Angel Configuration +# https://github.com/your-org/gga + +# AI Provider (required) +# Options: claude, gemini, codex, opencode, ollama:, lmstudio[:model], github: +# Examples: +# PROVIDER="claude" +# PROVIDER="gemini" +# PROVIDER="codex" +# PROVIDER="opencode" +# PROVIDER="opencode:anthropic/claude-opus-4-5" +# PROVIDER="ollama:llama3.2" +# PROVIDER="ollama:codellama" +# PROVIDER="lmstudio" +# PROVIDER="lmstudio:qwen2.5-coder-7b-instruct" +# PROVIDER="github:gpt-4o" +# PROVIDER="github:deepseek-r1" +PROVIDER="claude" + +# File patterns to include in review (comma-separated) +# Default: * (all files) +# Examples: +# FILE_PATTERNS="*.ts,*.tsx" +# FILE_PATTERNS="*.py" +# FILE_PATTERNS="*.go,*.mod" +FILE_PATTERNS="*.ts,*.tsx,*.js,*.jsx" + +# File patterns to exclude from review (comma-separated) +# Default: none +# Examples: +# EXCLUDE_PATTERNS="*.test.ts,*.spec.ts" +# EXCLUDE_PATTERNS="*_test.go,*.mock.ts" +EXCLUDE_PATTERNS="*.test.ts,*.spec.ts,*.test.tsx,*.spec.tsx,*.d.ts" + +# File containing code review rules +# Default: AGENTS.md +RULES_FILE="AGENTS.md" + +# Strict mode: fail if AI response is ambiguous +# Default: true +STRICT_MODE="true" + +# Timeout in seconds for AI provider response +# Default: 300 (5 minutes) +# Increase for large changesets or slow connections +TIMEOUT="300" + +# Base branch for --pr-mode (auto-detects main/master/develop if empty) +# Default: auto-detect +# PR_BASE_BRANCH="main" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9663d55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,97 @@ +# Built application files +*.apk +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/caches + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +lint/reports/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Kotlin +*.kotlin_module + +# Compose +*.compose + +# Secrets +*.env +credentials.json +secrets.properties \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ab9a26 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# HorrorTV + +A horror movie streaming app for Android TV and Chromecast with Google TV 4K. + +## Features + +- **Featured Horror Categories**: Browse curated horror movie collections (Scream, Halloween, Conjuring, Exorcist, Nightmare, Insidious, Terrifier, Hereditary, It, Poltergeist, Saw, Paranormal) +- **Search Movies**: Search by movie name or IMDB ID (e.g., `tt1234567`) +- **WebView Streaming**: Stream movies via playimdb.com integration +- **TV-Optimized UI**: Built with Jetpack Compose for TV with D-pad navigation support +- **Movie Details**: View plot, year, rating, and poster information from OMDb API + +## Tech Stack + +| Category | Technology | +|----------|------------| +| Language | Kotlin | +| UI Framework | Jetpack Compose for TV (tv-foundation, tv-material) | +| Networking | Retrofit + OkHttp + Gson | +| Image Loading | Coil | +| Dependency Injection | Hilt | +| Architecture | MVVM with Clean Architecture layers | +| API | OMDb API | + +## Project Structure + +``` +HorrorTV/ +├── app/ +│ ├── src/main/ +│ │ ├── java/com/horrortv/app/ +│ │ │ ├── data/ +│ │ │ │ ├── remote/omdb/ # OMDb API service & DTOs +│ │ │ │ └── repository/ # Repository implementations +│ │ │ ├── di/ # Hilt modules +│ │ │ ├── domain/ +│ │ │ │ ├── model/ # Domain models (Movie) +│ │ │ │ └ repository/ # Repository interfaces +│ │ │ ├── presentation/ +│ │ │ │ ├── home/ # Home screen, rows, cards +│ │ │ │ ├── search/ # Search screen & ViewModel +│ │ │ │ ├── detail/ # Movie detail screen +│ │ │ │ ├── player/ # WebView streaming player +│ │ │ │ └ theme/ # Horror-themed colors, typography +│ │ │ ├── util/ # Constants, utilities +│ │ │ └── MainApplication.kt # Hilt entry point +│ │ ├── res/ +│ │ │ ├── drawable/ # Launcher background +│ │ │ ├── mipmap/ # App icons & banner +│ │ │ └ values/strings.xml # App name +│ │ └ AndroidManifest.xml # TV features & activities +│ ├── build.gradle.kts # App dependencies +│ └ proguard-rules.pro +├── gradle/ +│ └ wrapper/gradle-wrapper.properties +├── build.gradle.kts # Project config +├── settings.gradle.kts +└ .gitignore +``` + +## How to Build + +### Prerequisites + +- Android Studio with Android SDK 34 +- JDK 17 +- Gradle 8.x + +### Build Commands + +```bash +# Debug APK +./gradlew assembleDebug + +# Release APK +./gradlew assembleRelease + +# Clean build +./gradlew clean assembleDebug + +# Install on connected device +./gradlew installDebug +``` + +On Windows: +```powershell +gradlew.bat assembleDebug +``` + +## How to Install on Chromecast 4K + +1. **Enable Developer Options**: + - Go to Settings → System → About + - Click on "Build" 7 times until "Developer options unlocked" + +2. **Enable ADB Debugging**: + - Settings → System → Developer options → ADB debugging → On + +3. **Connect via ADB**: + ```bash + # Find Chromecast IP in Settings → Network → WiFi + adb connect :5555 + ``` + +4. **Install APK**: + ```bash + adb install -r app/build/outputs/apk/debug/app-debug.apk + ``` + +5. **Launch App**: + - The app will appear in your Apps section on Chromecast + - Select "HorrorTV" from the home screen + +## API Configuration + +The app uses the **OMDb API** to fetch movie metadata: + +- **API Key**: `5854c81e` (hardcoded in `Constants.kt`) +- **Base URL**: `https://www.omdbapi.com/` + +To use your own API key, modify `Constants.kt`: +```kotlin +const val OMDB_API_KEY = "your_api_key_here" +``` + +Get a free API key at: https://www.omdbapi.com/apikey.aspx + +## Usage + +### Navigation + +| Action | D-Pad Control | +|--------|---------------| +| Move focus | Arrow keys (Up/Down/Left/Right) | +| Select | Center button (Enter) | +| Back | Back button | +| Search | Search button or menu | + +### Features + +1. **Browse Categories**: Navigate through horror movie rows using Up/Down arrows +2. **View Movie**: Press Select on a movie poster to see details +3. **Play Movie**: Press Play button on detail screen to stream via WebView +4. **Search**: Access search from the top menu, type movie name or IMDB ID + +### IMDB ID Search + +Enter an IMDB ID in the format `tt1234567` or `tt12345678` to find specific movies. + +## Architecture + +The app follows Clean Architecture principles: + +- **Presentation Layer**: Jetpack Compose TV UI with ViewModels +- **Domain Layer**: Business models and repository interfaces +- **Data Layer**: OMDb API implementation and repository + +Dependency flow: Presentation → Domain → Data + +## License + +This project is for educational purposes. Movie streaming via playimdb.com may require appropriate licensing in your region. \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..d10c55c --- /dev/null +++ b/app/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/app/debug.keystore b/app/debug.keystore new file mode 100644 index 0000000..516ea1b Binary files /dev/null and b/app/debug.keystore differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..a7a53cf --- /dev/null +++ b/app/proguard-rules.pro @@ -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.* ; +} + +-keep class okhttp3.** { *; } +-keep interface okhttp3.** { *; } +-dontwarn okhttp3.** +-dontwarn okio.** + +-keep class com.google.gson.** { *; } +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; +} + +-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 ; +} +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembernames class kotlinx.coroutines.** { + @kotlinx.coroutines.InternalCoroutinesApi ; +} + +-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 \ No newline at end of file diff --git a/app/release.keystore b/app/release.keystore new file mode 100644 index 0000000..6981d39 Binary files /dev/null and b/app/release.keystore differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f37bde5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/HorrorTvApp.kt b/app/src/main/java/com/horrortv/app/HorrorTvApp.kt new file mode 100644 index 0000000..596a6f1 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/HorrorTvApp.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/data/remote/omdb/OmdbApiService.kt b/app/src/main/java/com/horrortv/app/data/remote/omdb/OmdbApiService.kt new file mode 100644 index 0000000..5f795bf --- /dev/null +++ b/app/src/main/java/com/horrortv/app/data/remote/omdb/OmdbApiService.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbMovieDetailDto.kt b/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbMovieDetailDto.kt new file mode 100644 index 0000000..a73414e --- /dev/null +++ b/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbMovieDetailDto.kt @@ -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" +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbSearchResponse.kt b/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbSearchResponse.kt new file mode 100644 index 0000000..51080c3 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbSearchResponse.kt @@ -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?, + @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" +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/data/repository/MovieRepositoryImpl.kt b/app/src/main/java/com/horrortv/app/data/repository/MovieRepositoryImpl.kt new file mode 100644 index 0000000..972e502 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/data/repository/MovieRepositoryImpl.kt @@ -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( + val data: T, + val timestamp: Long, + val ttlMs: Long + ) + + private val categoryCache = LruCache>>(20) + private val searchCache = LruCache>>(50) + private val detailCache = LruCache>(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 getFromCache(cache: LruCache>, 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 putInCache(cache: LruCache>, key: String, data: T, ttlMs: Long) { + cache.put(key, CacheEntry(data, System.currentTimeMillis(), ttlMs)) + } + + override suspend fun getFeaturedCategories(): List = 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> { + 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 { + 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") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/di/CoilModule.kt b/app/src/main/java/com/horrortv/app/di/CoilModule.kt new file mode 100644 index 0000000..1c42bf1 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/di/CoilModule.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/di/NetworkModule.kt b/app/src/main/java/com/horrortv/app/di/NetworkModule.kt new file mode 100644 index 0000000..665f8b4 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/di/NetworkModule.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/di/RepositoryModule.kt b/app/src/main/java/com/horrortv/app/di/RepositoryModule.kt new file mode 100644 index 0000000..42a8701 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/di/RepositoryModule.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/domain/model/AppError.kt b/app/src/main/java/com/horrortv/app/domain/model/AppError.kt new file mode 100644 index 0000000..5b12196 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/domain/model/AppError.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/domain/model/Movie.kt b/app/src/main/java/com/horrortv/app/domain/model/Movie.kt new file mode 100644 index 0000000..a2623eb --- /dev/null +++ b/app/src/main/java/com/horrortv/app/domain/model/Movie.kt @@ -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 = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/domain/model/MovieCategory.kt b/app/src/main/java/com/horrortv/app/domain/model/MovieCategory.kt new file mode 100644 index 0000000..5440308 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/domain/model/MovieCategory.kt @@ -0,0 +1,6 @@ +package com.horrortv.app.domain.model + +data class MovieCategory( + val name: String, + val movies: List +) \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/domain/model/VideoSource.kt b/app/src/main/java/com/horrortv/app/domain/model/VideoSource.kt new file mode 100644 index 0000000..6b00149 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/domain/model/VideoSource.kt @@ -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 = emptyList(), + val posterUrl: String? = null, + val title: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/domain/repository/MovieRepository.kt b/app/src/main/java/com/horrortv/app/domain/repository/MovieRepository.kt new file mode 100644 index 0000000..8240cc9 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/domain/repository/MovieRepository.kt @@ -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 { + data class Success(val data: T) : Result() + data class Error(val error: com.horrortv.app.domain.model.AppError) : Result() +} + +interface MovieRepository { + suspend fun getFeaturedCategories(): List + suspend fun searchMovies(query: String): Result> + suspend fun getMovieById(imdbId: String): Result +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt b/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt new file mode 100644 index 0000000..d69beed --- /dev/null +++ b/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt @@ -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 = 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 { + 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 { + val tracks = mutableListOf() + + 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) \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/AppNavigation.kt b/app/src/main/java/com/horrortv/app/presentation/AppNavigation.kt new file mode 100644 index 0000000..8cf251a --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/AppNavigation.kt @@ -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() } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/MainActivity.kt b/app/src/main/java/com/horrortv/app/presentation/MainActivity.kt new file mode 100644 index 0000000..56ef705 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/MainActivity.kt @@ -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 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/common/CommonStates.kt b/app/src/main/java/com/horrortv/app/presentation/common/CommonStates.kt new file mode 100644 index 0000000..6bfc26c --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/common/CommonStates.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/common/PosterImage.kt b/app/src/main/java/com/horrortv/app/presentation/common/PosterImage.kt new file mode 100644 index 0000000..058012d --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/common/PosterImage.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/common/TvErrorDisplay.kt b/app/src/main/java/com/horrortv/app/presentation/common/TvErrorDisplay.kt new file mode 100644 index 0000000..3820226 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/common/TvErrorDisplay.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/detail/DetailScreen.kt b/app/src/main/java/com/horrortv/app/presentation/detail/DetailScreen.kt new file mode 100644 index 0000000..b19d066 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/detail/DetailScreen.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/detail/DetailViewModel.kt b/app/src/main/java/com/horrortv/app/presentation/detail/DetailViewModel.kt new file mode 100644 index 0000000..593b068 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/detail/DetailViewModel.kt @@ -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 = _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 +) \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/home/HomeScreen.kt b/app/src/main/java/com/horrortv/app/presentation/home/HomeScreen.kt new file mode 100644 index 0000000..e59f078 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/home/HomeScreen.kt @@ -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, + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/home/HomeViewModel.kt b/app/src/main/java/com/horrortv/app/presentation/home/HomeViewModel.kt new file mode 100644 index 0000000..9c44a37 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/home/HomeViewModel.kt @@ -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 = _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 = emptyList(), + val isLoading: Boolean = false, + val error: AppError? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/home/HorrorRow.kt b/app/src/main/java/com/horrortv/app/presentation/home/HorrorRow.kt new file mode 100644 index 0000000..4347e35 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/home/HorrorRow.kt @@ -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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/home/MoviePosterCard.kt b/app/src/main/java/com/horrortv/app/presentation/home/MoviePosterCard.kt new file mode 100644 index 0000000..380b14a --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/home/MoviePosterCard.kt @@ -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)) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt b/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt new file mode 100644 index 0000000..b3d606b --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt @@ -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(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) +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/search/SearchScreen.kt b/app/src/main/java/com/horrortv/app/presentation/search/SearchScreen.kt new file mode 100644 index 0000000..3006f89 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/search/SearchScreen.kt @@ -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, + 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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/search/SearchViewModel.kt b/app/src/main/java/com/horrortv/app/presentation/search/SearchViewModel.kt new file mode 100644 index 0000000..661969d --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/search/SearchViewModel.kt @@ -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 = _uiState.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _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 = 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) +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/theme/HorrorColors.kt b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorColors.kt new file mode 100644 index 0000000..8de6f37 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorColors.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTheme.kt b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTheme.kt new file mode 100644 index 0000000..d6a5a0f --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTheme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTypography.kt b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTypography.kt new file mode 100644 index 0000000..4672e47 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTypography.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/util/ApiException.kt b/app/src/main/java/com/horrortv/app/util/ApiException.kt new file mode 100644 index 0000000..6dce655 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/util/ApiException.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/util/Constants.kt b/app/src/main/java/com/horrortv/app/util/Constants.kt new file mode 100644 index 0000000..6527a60 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/util/Constants.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/util/ErrorLogger.kt b/app/src/main/java/com/horrortv/app/util/ErrorLogger.kt new file mode 100644 index 0000000..8440afb --- /dev/null +++ b/app/src/main/java/com/horrortv/app/util/ErrorLogger.kt @@ -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 = 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") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/util/NetworkStatus.kt b/app/src/main/java/com/horrortv/app/util/NetworkStatus.kt new file mode 100644 index 0000000..eee5416 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/util/NetworkStatus.kt @@ -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 = 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/util/PosterUtils.kt b/app/src/main/java/com/horrortv/app/util/PosterUtils.kt new file mode 100644 index 0000000..a56e534 --- /dev/null +++ b/app/src/main/java/com/horrortv/app/util/PosterUtils.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/util/RetryUtils.kt b/app/src/main/java/com/horrortv/app/util/RetryUtils.kt new file mode 100644 index 0000000..d06c72d --- /dev/null +++ b/app/src/main/java/com/horrortv/app/util/RetryUtils.kt @@ -0,0 +1,31 @@ +package com.horrortv.app.util + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay + +suspend fun 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") +} \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_banner.png b/app/src/main/res/drawable-hdpi/ic_banner.png new file mode 100644 index 0000000..501aab1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_banner.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_banner.png b/app/src/main/res/drawable-mdpi/ic_banner.png new file mode 100644 index 0000000..692c44b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_banner.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_banner.png b/app/src/main/res/drawable-xhdpi/ic_banner.png new file mode 100644 index 0000000..17f672f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_banner.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_banner.png b/app/src/main/res/drawable-xxhdpi/ic_banner.png new file mode 100644 index 0000000..4e2bcd1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_banner.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_banner.png b/app/src/main/res/drawable-xxxhdpi/ic_banner.png new file mode 100644 index 0000000..7d4b266 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_banner.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..2ddfab0 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/poster_error.xml b/app/src/main/res/drawable/poster_error.xml new file mode 100644 index 0000000..efa3f94 --- /dev/null +++ b/app/src/main/res/drawable/poster_error.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/poster_placeholder.xml b/app/src/main/res/drawable/poster_placeholder.xml new file mode 100644 index 0000000..404c237 --- /dev/null +++ b/app/src/main/res/drawable/poster_placeholder.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..9412204 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..9412204 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..ba72141 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..47d0c6c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..ad837cd Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..3981b61 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..92fa1f6 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..2f9a2b1 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #8B0000 + #0D0D0D + #1A1A1A + #4A4A4A + #CC0000 + #FF4444 + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6368149 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Horror TV + Buscar películas + Ver película + Cargando... + Error al cargar + Buscar por nombre o IMDB ID (tt1234567) + Reintentar + No se encontraron resultados + Póster de la película + Detalles de la película + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1cf439f --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a78d38a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("com.android.application") version "8.2.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false + id("com.google.dagger.hilt.android") version "2.50" apply false +} + +tasks.register("clean", Delete::class) { + delete(rootProject.layout.buildDirectory) +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7c6bb8e --- /dev/null +++ b/gradle.properties @@ -0,0 +1,20 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -XX:+UseParallelGC +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true + +org.gradle.parallel=true +org.gradle.configuration-cache=true +org.gradle.configuration-cache.problems=warn +org.gradle.caching=true +org.gradle.workers.max=4 + +android.enableJetifier=false +android.defaults.buildfeatures.aidl=false +android.defaults.buildfeatures.renderscript=false +android.defaults.buildfeatures.resvalues=false +android.defaults.buildfeatures.shaders=false + +kotlin.incremental=true +kotlin.incremental.java=true +kotlin.incremental.useClasspathSnapshot=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7cf0814 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..772b477 --- /dev/null +++ b/gradlew @@ -0,0 +1,106 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var:+alt}», +# «${var:?error}», «$( command )», «$( ( expr ) )», «$( ( func() ) )», +# «`command`», «`expr`»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for script development: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, Mksh, Zsh, and others, unless those extensions are +# already in the POSIX spec. However, this script does allow a few +# Bash extensions when they are extremely common and unlikely to cause +# problems. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (see: https://github.com/gradle/gradle/issues/2506) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +# Here we go! +{ } > /dev/null 2>&1 || { echo "Cannot redirect stdout and stderr to /dev/null"; exit 1; } + +warn_die_ () { + printf -- "%s\n" "$@" + exit 1 +} + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, we need to convert the Windows path to a Unix path. +# We do this lazily to avoid unnecessary conversions. + +# Setup the command line + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@ + +# Execute + +exec "$JAVACMD" "$@" \ No newline at end of file diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..42c5bdb --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,87 @@ +@echo off +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem having the script completely exit via exit /b 1 +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..3e2b4fb --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "HorrorTV" +include(":app") \ No newline at end of file