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.
This commit is contained in:
2026-04-26 21:58:59 -03:00
commit 828086ceb3
70 changed files with 4474 additions and 0 deletions

50
.gga Normal file
View File

@@ -0,0 +1,50 @@
# Gentleman Guardian Angel Configuration
# https://github.com/your-org/gga
# AI Provider (required)
# Options: claude, gemini, codex, opencode, ollama:<model>, lmstudio[:model], github:<model>
# 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"

97
.gitignore vendored Normal file
View File

@@ -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

162
README.md Normal file
View File

@@ -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 <CHROMECAST_IP>: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.

152
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,152 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
}
android {
namespace = "com.horrortv.app"
compileSdk = 34
defaultConfig {
applicationId = "com.horrortv.app"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.0"
buildConfigField("String", "APP_NAME", "\"HorrorTV\"")
}
signingConfigs {
create("release") {
storeFile = file("release.keystore")
storePassword = System.getenv("HORRORTV_KEYSTORE_PASSWORD") ?: "placeholder"
keyAlias = System.getenv("HORRORTV_KEY_ALIAS") ?: "release"
keyPassword = System.getenv("HORRORTV_KEY_PASSWORD") ?: "placeholder"
}
getByName("debug") {
storeFile = file("debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
debug {
isDebuggable = true
isMinifyEnabled = false
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug")
}
release {
isDebuggable = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
}
flavorDimensions += "environment"
productFlavors {
create("dev") {
dimension = "environment"
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
buildConfigField("boolean", "DEBUG_MODE", "true")
}
create("prod") {
dimension = "environment"
buildConfigField("boolean", "DEBUG_MODE", "false")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += listOf(
"-Xopt-in=kotlin.RequiresOptIn",
"-Xjvm-default=all"
)
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/DEPENDENCIES"
excludes += "/META-INF/LICENSE"
excludes += "/META-INF/LICENSE.txt"
excludes += "/META-INF/NOTICE"
excludes += "/META-INF/NOTICE.txt"
}
}
lint {
abortOnError = false
checkReleaseBuilds = true
disable += "MissingTranslation"
disable += "ExtraTranslation"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation(platform("androidx.compose:compose-bom:2024.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.navigation:navigation-compose:2.7.6")
implementation("androidx.leanback:leanback:1.0.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-android-compiler:2.50")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.media3:media3-exoplayer:1.4.0")
implementation("androidx.media3:media3-exoplayer-hls:1.4.0")
implementation("androidx.media3:media3-ui:1.4.0")
implementation("androidx.media3:media3-common:1.4.0")
implementation("org.jsoup:jsoup:1.17.2")
debugImplementation(platform("androidx.compose:compose-bom:2024.10.01"))
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
kapt {
correctErrorTypes = true
}

BIN
app/debug.keystore Normal file

Binary file not shown.

72
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,72 @@
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends androidx.lifecycle.ViewModel
-keep class com.horrortv.app.data.remote.omdb.dto.** { *; }
-keep class com.horrortv.app.domain.model.** { *; }
-keep class retrofit2.** { *; }
-keepclassmembernames class * {
@retrofit2.http.* <methods>;
}
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class com.google.gson.** { *; }
-keepclassmembers class * {
@com.google.gson.annotations.SerializedName <fields>;
}
-keep class dagger.** { *; }
-keep class * extends dagger.** { *; }
-keep class * implements dagger.** { *; }
-keep class * extends javax.inject.** { *; }
-keep class * implements javax.inject.** { *; }
-dontwarn dagger.**
-keep class io.coil.** { *; }
-keep class coil.** { *; }
-keepclassmembers class kotlinx.coroutines.** {
volatile <fields>;
}
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembernames class kotlinx.coroutines.** {
@kotlinx.coroutines.InternalCoroutinesApi <methods>;
}
-keep class kotlin.** { *; }
-keep class * implements kotlin.** { *; }
-assumenosideeffects class android.util.Log {
public static boolean isLoggable(...);
public static int v(...);
public static int d(...);
public static int i(...);
}
-optimizationpasses 5
-dontusemixedcaseclassnames
-dontskipnonpubliclibraryclasses
-verbose
-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
-keepattributes RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations
-keepattributes RuntimeVisibleParameterAnnotations,RuntimeInvisibleParameterAnnotations
-keepattributes AnnotationDefault
-renamesourcefileattribute SourceFile
-keepattributes EnclosingMethod
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
-dontwarn retrofit2.Platform$Java8
-dontwarn kotlin.jvm.internal.Reflection

BIN
app/release.keystore Normal file

Binary file not shown.

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-feature android:name="android.hardware.type.tv" android:required="true"/>
<uses-feature android:name="android.software.leanback" android:required="true"/>
<uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
<application
android:name=".HorrorTvApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:banner="@drawable/ic_banner"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="false"
android:theme="@style/Theme.HorrorTV">
<activity
android:name=".presentation.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="horrortv"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="https"
android:host="horrortv.app"
android:pathPrefix="/movie"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,18 @@
package com.horrortv.app
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class HorrorTvApp : Application(), ImageLoaderFactory {
@Inject
lateinit var imageLoader: ImageLoader
override fun newImageLoader(): ImageLoader {
return imageLoader
}
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -0,0 +1,22 @@
package com.horrortv.app.data.remote.omdb.dto
import com.google.gson.annotations.SerializedName
data class OmdbSearchResponse(
@SerializedName("Search") val search: List<OmdbMovieSearchDto>?,
@SerializedName("totalResults") val totalResults: String?,
@SerializedName("Response") val response: String,
@SerializedName("Error") val error: String?
) {
val isSuccess: Boolean get() = response == "True"
}
data class OmdbMovieSearchDto(
@SerializedName("Title") val title: String,
@SerializedName("Year") val year: String,
@SerializedName("imdbID") val imdbId: String,
@SerializedName("Type") val type: String,
@SerializedName("Poster") val poster: String?
) {
val hasValidPoster: Boolean get() = poster != null && poster != "N/A"
}

View File

@@ -0,0 +1,210 @@
package com.horrortv.app.data.repository
import android.util.Log
import android.util.LruCache
import com.horrortv.app.data.remote.omdb.OmdbApiService
import com.horrortv.app.domain.model.AppError
import com.horrortv.app.domain.model.Movie
import com.horrortv.app.domain.model.MovieCategory
import com.horrortv.app.domain.repository.MovieRepository
import com.horrortv.app.domain.repository.Result
import com.horrortv.app.util.ApiException
import com.horrortv.app.util.Constants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MovieRepositoryImpl @Inject constructor(
private val apiService: OmdbApiService
) : MovieRepository {
companion object {
private const val TAG = "MovieRepositoryImpl"
}
private data class CacheEntry<T>(
val data: T,
val timestamp: Long,
val ttlMs: Long
)
private val categoryCache = LruCache<String, CacheEntry<List<Movie>>>(20)
private val searchCache = LruCache<String, CacheEntry<List<Movie>>>(50)
private val detailCache = LruCache<String, CacheEntry<Movie>>(100)
private fun categoryTtlMs(): Long = Constants.Cache.CATEGORY_CACHE_HOURS * 60 * 60 * 1000L
private fun searchTtlMs(): Long = Constants.Cache.SEARCH_CACHE_HOURS * 60 * 60 * 1000L
private fun detailTtlMs(): Long = Constants.Cache.DETAIL_CACHE_HOURS * 60 * 60 * 1000L
private fun buildCategoryKey(category: String): String = "cat:${category.lowercase()}"
private fun buildSearchKey(query: String): String = "search:${query.trim().lowercase()}"
private fun buildDetailKey(imdbId: String): String = "detail:${imdbId.lowercase()}"
private fun <T> getFromCache(cache: LruCache<String, CacheEntry<T>>, key: String): T? {
val entry = cache.get(key) ?: return null
if (System.currentTimeMillis() - entry.timestamp > entry.ttlMs) {
cache.remove(key)
return null
}
return entry.data
}
private fun <T> putInCache(cache: LruCache<String, CacheEntry<T>>, key: String, data: T, ttlMs: Long) {
cache.put(key, CacheEntry(data, System.currentTimeMillis(), ttlMs))
}
override suspend fun getFeaturedCategories(): List<MovieCategory> = withContext(Dispatchers.Default) {
Log.d(TAG, "Fetching featured categories with limited concurrency")
coroutineScope {
Constants.HORROR_CATEGORIES
.chunked(3)
.map { batch ->
batch.map { category ->
async {
val cacheKey = buildCategoryKey(category)
val cached = getFromCache(categoryCache, cacheKey)
if (cached != null) {
Log.d(TAG, "Cache HIT for category: $category")
MovieCategory(category, cached)
} else {
try {
Log.d(TAG, "Fetching category: $category")
val response = apiService.searchByCategory(category, page = 1)
if (response.response == "False") {
Log.w(TAG, "API error for category: $category - ${response.error}")
MovieCategory(category, emptyList())
} else {
val movies = response.search?.filter { it.hasValidPoster }?.map { dto ->
Movie(
imdbId = dto.imdbId,
title = dto.title,
year = dto.year,
posterUrl = dto.poster ?: ""
)
} ?: emptyList()
putInCache(categoryCache, cacheKey, movies, categoryTtlMs())
MovieCategory(category, movies)
}
} catch (e: Exception) {
Log.e(TAG, "Error fetching category: $category", e)
MovieCategory(category, emptyList())
}
}
}
}.awaitAll()
}
.flatten()
}
}
override suspend fun searchMovies(query: String): Result<List<Movie>> {
val cacheKey = buildSearchKey(query)
val cached = getFromCache(searchCache, cacheKey)
if (cached != null) {
Log.d(TAG, "Cache HIT for search: $query")
return Result.Success(cached)
}
return withContext(Dispatchers.Default) {
try {
Log.d(TAG, "Searching for: $query")
val response = apiService.searchMovies(query, page = 1)
if (response.response == "False") {
val errorMsg = response.error ?: "Unknown error"
Result.Error(AppError.ApiError(
userMessage = "Error: $errorMsg",
debugMessage = errorMsg,
code = 400
))
} else {
val movies = response.search?.filter { it.hasValidPoster }?.map { dto ->
Movie(
imdbId = dto.imdbId,
title = dto.title,
year = dto.year,
posterUrl = dto.poster ?: ""
)
} ?: emptyList()
putInCache(searchCache, cacheKey, movies, searchTtlMs())
Result.Success(movies)
}
} catch (e: Exception) {
Log.e(TAG, "Error searching: $query", e)
val appError = if (e is ApiException) e.toAppError() else AppError.UnknownError(
userMessage = "Error inesperado. Intenta de nuevo.",
debugMessage = "Unhandled exception: ${e.message}",
cause = e
)
Result.Error(appError)
}
}
}
override suspend fun getMovieById(imdbId: String): Result<Movie?> {
if (imdbId.isEmpty()) return Result.Success(null)
val cacheKey = buildDetailKey(imdbId)
val cached = getFromCache(detailCache, cacheKey)
if (cached != null) {
Log.d(TAG, "Cache HIT for movie: $imdbId")
return Result.Success(cached)
}
return withContext(Dispatchers.Default) {
try {
Log.d(TAG, "Fetching movie detail: $imdbId")
val detail = apiService.getMovieDetail(imdbId)
if (detail.response != "True") {
val errorMsg = detail.error ?: "Movie not found"
Result.Error(AppError.ApiError(
userMessage = "Error: $errorMsg",
debugMessage = errorMsg,
code = 404
))
} else {
val movie = Movie(
imdbId = detail.imdbId,
title = detail.title,
year = detail.year,
posterUrl = detail.poster ?: "",
rating = detail.imdbRating ?: "N/A",
genre = detail.genre ?: "",
plot = detail.plot ?: "",
runtime = detail.runtime ?: "",
director = detail.director ?: "",
actors = detail.actors ?: ""
)
putInCache(detailCache, cacheKey, movie, detailTtlMs())
Result.Success(movie)
}
} catch (e: Exception) {
Log.e(TAG, "Error fetching movie: $imdbId", e)
val appError = if (e is ApiException) e.toAppError() else AppError.UnknownError(
userMessage = "Error inesperado. Intenta de nuevo.",
debugMessage = "Unhandled exception: ${e.message}",
cause = e
)
Result.Error(appError)
}
}
}
fun clearAllCaches() {
categoryCache.evictAll()
searchCache.evictAll()
detailCache.evictAll()
Log.i(TAG, "All caches cleared")
}
}

View File

@@ -0,0 +1,45 @@
package com.horrortv.app.di
import android.content.Context
import android.graphics.Bitmap
import coil.ImageLoader
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.CachePolicy
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object CoilModule {
private const val MEMORY_CACHE_PERCENT = 0.15
private const val DISK_CACHE_SIZE_MB = 150L
@Provides
@Singleton
fun provideImageLoader(@ApplicationContext context: Context): ImageLoader {
return ImageLoader.Builder(context)
.memoryCache {
MemoryCache.Builder(context)
.maxSizePercent(MEMORY_CACHE_PERCENT)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(context.cacheDir.resolve("image_cache"))
.maxSizeBytes(DISK_CACHE_SIZE_MB * 1024 * 1024)
.build()
}
.bitmapConfig(Bitmap.Config.RGB_565)
.respectCacheHeaders(false)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.networkCachePolicy(CachePolicy.ENABLED)
.build()
}
}

View File

@@ -0,0 +1,54 @@
package com.horrortv.app.di
import android.content.Context
import com.horrortv.app.util.Constants
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient {
val cacheDir = context.cacheDir.resolve("http_cache")
val cache = Cache(cacheDir, Constants.Network.CACHE_SIZE_BYTES)
return OkHttpClient.Builder()
.cache(cache)
.connectTimeout(Constants.Network.CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(Constants.Network.READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(Constants.Network.WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(Constants.OMDB_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideOmdbApiService(retrofit: Retrofit): com.horrortv.app.data.remote.omdb.OmdbApiService {
return retrofit.create(com.horrortv.app.data.remote.omdb.OmdbApiService::class.java)
}
}

View File

@@ -0,0 +1,20 @@
package com.horrortv.app.di
import com.horrortv.app.data.repository.MovieRepositoryImpl
import com.horrortv.app.domain.repository.MovieRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindMovieRepository(
impl: MovieRepositoryImpl
): MovieRepository
}

View File

@@ -0,0 +1,54 @@
package com.horrortv.app.domain.model
sealed class AppError {
abstract val userMessage: String
abstract val debugMessage: String
abstract val cause: Throwable?
abstract val isRetryable: Boolean
data class NetworkError(
override val userMessage: String = "Error de conexión. Verifica tu internet.",
override val debugMessage: String = "Network error",
override val cause: Throwable? = null
) : AppError() {
override val isRetryable: Boolean = true
}
data class ApiError(
override val userMessage: String = "Error del servicio.",
override val debugMessage: String = "API error",
val code: Int = 0,
override val cause: Throwable? = null
) : AppError() {
override val isRetryable: Boolean = code in retryableCodes
}
data class CacheError(
override val userMessage: String = "Error de datos.",
override val debugMessage: String = "Cache error",
override val cause: Throwable? = null
) : AppError() {
override val isRetryable: Boolean = true
}
data class ValidationError(
override val userMessage: String = "Datos inválidos.",
override val debugMessage: String = "Validation error",
val field: String = "",
override val cause: Throwable? = null
) : AppError() {
override val isRetryable: Boolean = false
}
data class UnknownError(
override val userMessage: String = "Error inesperado. Intenta de nuevo.",
override val debugMessage: String = "Unknown error",
override val cause: Throwable? = null
) : AppError() {
override val isRetryable: Boolean = cause is java.io.IOException
}
companion object {
private val retryableCodes = setOf(429, 500, 502, 503, 504)
}
}

View File

@@ -0,0 +1,14 @@
package com.horrortv.app.domain.model
data class Movie(
val imdbId: String,
val title: String,
val year: String,
val posterUrl: String,
val rating: String = "N/A",
val genre: String = "",
val plot: String = "",
val runtime: String = "",
val director: String = "",
val actors: String = ""
)

View File

@@ -0,0 +1,6 @@
package com.horrortv.app.domain.model
data class MovieCategory(
val name: String,
val movies: List<Movie>
)

View File

@@ -0,0 +1,23 @@
package com.horrortv.app.domain.model
enum class VideoType {
HLS,
MP4,
DASH,
UNKNOWN
}
data class SubtitleTrack(
val url: String,
val language: String,
val label: String,
val isDefault: Boolean = false
)
data class VideoSource(
val videoUrl: String,
val videoType: VideoType,
val subtitleTracks: List<SubtitleTrack> = emptyList(),
val posterUrl: String? = null,
val title: String = ""
)

View File

@@ -0,0 +1,15 @@
package com.horrortv.app.domain.repository
import com.horrortv.app.domain.model.Movie
import com.horrortv.app.domain.model.MovieCategory
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val error: com.horrortv.app.domain.model.AppError) : Result<Nothing>()
}
interface MovieRepository {
suspend fun getFeaturedCategories(): List<MovieCategory>
suspend fun searchMovies(query: String): Result<List<Movie>>
suspend fun getMovieById(imdbId: String): Result<Movie?>
}

View File

@@ -0,0 +1,284 @@
package com.horrortv.app.domain.usecase
import android.util.Log
import com.horrortv.app.domain.model.VideoSource
import com.horrortv.app.domain.model.VideoType
import com.horrortv.app.domain.model.SubtitleTrack
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import java.net.HttpURLConnection
import java.net.URL
class VideoExtractor {
companion object {
private const val TAG = "VideoExtractor"
private const val PLAYIMDB_BASE = "https://playimdb.com/title/"
private const val STREAMIMDB_BASE = "https://streamimdb.me/embed/"
private const val TIMEOUT_MS = 15000
}
suspend fun extractVideoSource(imdbId: String): Result<VideoSource> = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Extracting video for: $imdbId")
val embedUrl = "$STREAMIMDB_BASE$imdbId"
Log.d(TAG, "Fetching embed: $embedUrl")
val html = fetchHtml(embedUrl)
Log.d(TAG, "HTML length: ${html.length}")
Log.d(TAG, "HTML snippet (first 2000 chars): ${html.take(2000)}")
var doc = Jsoup.parse(html)
val iframeUrl = doc.selectFirst("iframe")?.attr("src")
if (iframeUrl != null && iframeUrl.isNotEmpty()) {
val fullIframeUrl = if (iframeUrl.startsWith("//")) {
"https:$iframeUrl"
} else {
iframeUrl
}
Log.d(TAG, "Found iframe redirect: $fullIframeUrl")
try {
val iframeHtml = fetchHtml(fullIframeUrl)
Log.d(TAG, "Iframe HTML length: ${iframeHtml.length}")
Log.d(TAG, "IFRAME FULL HTML START =====")
Log.d(TAG, iframeHtml)
Log.d(TAG, "IFRAME FULL HTML END =====")
val prorcpPattern = Regex("['\"]?src['\"]?\\s*:\\s*['\"]([^'\"]+/prorcp/[^'\"]+)['\"]")
val prorcpMatch = prorcpPattern.find(iframeHtml)
if (prorcpMatch != null) {
val innerUrl = prorcpMatch.groupValues[1]
val fullInnerUrl = if (innerUrl.startsWith("/")) {
"https://cloudnestra.com$innerUrl"
} else if (innerUrl.startsWith("//")) {
"https:$innerUrl"
} else {
innerUrl
}
Log.d(TAG, "Found prorcp URL: $fullInnerUrl")
try {
val innerHtml = fetchHtml(fullInnerUrl)
Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}")
Log.d(TAG, "Prorcp FULL HTML: $innerHtml")
doc = Jsoup.parse(innerHtml)
} catch (e: Exception) {
Log.w(TAG, "Failed prorcp fetch: ${e.message}")
}
} else {
Log.d(TAG, "No prorcp pattern found, searching for /prorcp/ directly")
val directPattern = Regex("/prorcp/[A-Za-z0-9+/=]+")
val directMatch = directPattern.find(iframeHtml)
if (directMatch != null) {
val innerUrl = directMatch.value
val fullInnerUrl = "https://cloudnestra.com$innerUrl"
Log.d(TAG, "Found prorcp direct: $fullInnerUrl")
try {
val innerHtml = fetchHtml(fullInnerUrl)
Log.d(TAG, "Prorcp direct HTML length: ${innerHtml.length}")
Log.d(TAG, "Prorcp direct FULL HTML: $innerHtml")
doc = Jsoup.parse(innerHtml)
} catch (e: Exception) {
Log.w(TAG, "Failed prorcp direct fetch: ${e.message}")
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch iframe: ${e.message}")
}
}
val videoUrl = extractVideoUrl(doc)
if (videoUrl == null) {
Log.w(TAG, "No video URL found in HTML")
return@withContext Result.failure(VideoExtractionException("No video URL found"))
}
Log.d(TAG, "Video URL found: $videoUrl")
val subtitles = extractSubtitles(doc)
Log.d(TAG, "Subtitles found: ${subtitles.size}")
val knownDomains = listOf(
"neonhorizonworkshops.com",
"wanderlynest.com",
"orchidpixelgardens.com",
"cloudnestra.com"
)
val finalVideoUrl = if (videoUrl.contains("{v")) {
val basePattern = Regex("https://tmstr3\\.\\{v\\d+\\}/(.+)")
val pathMatch = basePattern.find(videoUrl)
if (pathMatch != null) {
val path = pathMatch.groupValues[1]
val testDomain = knownDomains.first()
val constructedUrl = "https://tmstr1.$testDomain/$path"
Log.d(TAG, "Constructed URL from known domain: $constructedUrl")
constructedUrl
} else {
videoUrl.replace(Regex("\\{v\\d+\\}"), knownDomains.first())
}
} else {
videoUrl
}
Log.d(TAG, "Final video URL: $finalVideoUrl")
val videoType = determineVideoType(finalVideoUrl)
Log.d(TAG, "Video type: $videoType")
Result.success(
VideoSource(
videoUrl = finalVideoUrl,
videoType = videoType,
subtitleTracks = subtitles,
title = imdbId
)
)
} catch (e: Exception) {
Log.e(TAG, "Error extracting video", e)
Result.failure(VideoExtractionException(e.message ?: "Unknown error", e))
}
}
private fun fetchHtml(url: String): String {
val connection = URL(url).openConnection() as HttpURLConnection
connection.apply {
requestMethod = "GET"
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
setRequestProperty("Accept-Language", "en-US,en;q=0.5")
setRequestProperty("Referer", PLAYIMDB_BASE)
instanceFollowRedirects = true
}
val responseCode = connection.responseCode
Log.d(TAG, "Response code: $responseCode")
if (responseCode != HttpURLConnection.HTTP_OK) {
throw VideoExtractionException("HTTP error: $responseCode")
}
return connection.inputStream.bufferedReader().use { it.readText() }
}
private fun extractVideoUrl(doc: org.jsoup.nodes.Document): String? {
val videoElement = doc.selectFirst("video")
if (videoElement != null) {
val src = videoElement.attr("src")
if (src.isNotEmpty()) {
Log.d(TAG, "Found video[src]: $src")
return src
}
val sourceElements = videoElement.select("source")
for (source in sourceElements) {
val srcAttr = source.attr("src")
if (srcAttr.isNotEmpty() && isValidVideoUrl(srcAttr)) {
Log.d(TAG, "Found source[src]: $srcAttr")
return srcAttr
}
}
}
val scripts = doc.select("script")
for (script in scripts) {
val scriptContent = script.html()
val urls = extractUrlsFromScript(scriptContent)
for (url in urls) {
if (isValidVideoUrl(url)) {
Log.d(TAG, "Found URL in script: $url")
return url
}
}
}
val allElements = doc.select("*[src], *[href], *[data-src], *[data-url]")
for (elem in allElements) {
val possibleUrl = elem.attr("src")
.ifEmpty { elem.attr("href") }
.ifEmpty { elem.attr("data-src") }
.ifEmpty { elem.attr("data-url") }
if (possibleUrl.isNotEmpty() && isValidVideoUrl(possibleUrl)) {
Log.d(TAG, "Found URL in element: $possibleUrl")
return possibleUrl
}
}
val htmlText = doc.html()
val m3u8Pattern = Regex("https?://[^\"'<>\\s]+\\.m3u8[^\"'<>\\s]*")
val m3u8Match = m3u8Pattern.find(htmlText)
if (m3u8Match != null) {
Log.d(TAG, "Found .m3u8 via regex: ${m3u8Match.value}")
return m3u8Match.value
}
val mp4Pattern = Regex("https?://[^\"'<>\\s]+\\.mp4[^\"'<>\\s]*")
val mp4Match = mp4Pattern.find(htmlText)
if (mp4Match != null) {
Log.d(TAG, "Found .mp4 via regex: ${mp4Match.value}")
return mp4Match.value
}
return null
}
private fun extractUrlsFromScript(scriptContent: String): List<String> {
val urlPattern = Regex("https?://[^\"'<>\\s]+")
return urlPattern.findAll(scriptContent)
.map { it.value }
.filter { isValidVideoUrl(it) }
.toList()
}
private fun isValidVideoUrl(url: String): Boolean {
return url.contains(".m3u8") ||
url.contains(".mp4") ||
url.contains(".mkv") ||
url.contains("stream") ||
url.contains("video") ||
url.contains("player")
}
private fun determineVideoType(url: String): VideoType {
return when {
url.contains(".m3u8") -> VideoType.HLS
url.contains(".mp4") -> VideoType.MP4
url.contains(".mpd") -> VideoType.DASH
else -> VideoType.UNKNOWN
}
}
private fun extractSubtitles(doc: org.jsoup.nodes.Document): List<SubtitleTrack> {
val tracks = mutableListOf<SubtitleTrack>()
val trackElements = doc.select("track")
for (track in trackElements) {
val src = track.attr("src")
val srclang = track.attr("srclang")
val label = track.attr("label").ifEmpty { srclang }
val isDefault = track.attr("default") == "default"
if (src.isNotEmpty()) {
tracks.add(
SubtitleTrack(
url = src,
language = srclang,
label = label,
isDefault = isDefault
)
)
Log.d(TAG, "Found subtitle: $srclang - $src")
}
}
return tracks
}
}
class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause)

View File

@@ -0,0 +1,123 @@
package com.horrortv.app.presentation
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import com.horrortv.app.presentation.detail.DetailScreen
import com.horrortv.app.presentation.detail.DetailViewModel
import com.horrortv.app.presentation.home.HomeScreen
import com.horrortv.app.presentation.home.HomeViewModel
import com.horrortv.app.presentation.player.PlayerScreen
import com.horrortv.app.presentation.search.SearchScreen
import com.horrortv.app.presentation.search.SearchViewModel
object Routes {
const val HOME = "home"
const val SEARCH = "search"
const val DETAIL = "detail/{imdbId}"
const val PLAYER = "player/{imdbId}/{title}"
object Args {
const val IMDB_ID = "imdbId"
const val TITLE = "title"
}
object DeepLinks {
const val SCHEME = "horrortv"
const val HOST = "horrortv.app"
const val PATH_MOVIE = "/movie"
const val PATH_PLAY = "/play"
}
fun detail(imdbId: String): String = "detail/$imdbId"
fun player(imdbId: String, title: String): String = "player/$imdbId/${encodeTitle(title)}"
private fun encodeTitle(title: String): String = Uri.encode(title)
}
@Composable
fun AppNavigation(
navController: NavHostController,
deepLinkUri: Uri? = null,
modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier
) {
NavHost(
navController = navController,
startDestination = Routes.HOME,
modifier = modifier
) {
composable(Routes.HOME) {
val viewModel: HomeViewModel = hiltViewModel()
HomeScreen(
viewModel = viewModel,
onNavigateToSearch = { navController.navigate(Routes.SEARCH) },
onNavigateToDetail = { imdbId -> navController.navigate(Routes.detail(imdbId)) }
)
}
composable(Routes.SEARCH) {
val viewModel: SearchViewModel = hiltViewModel()
SearchScreen(
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToDetail = { imdbId -> navController.navigate(Routes.detail(imdbId)) }
)
}
composable(
route = Routes.DETAIL,
arguments = listOf(
navArgument(Routes.Args.IMDB_ID) { type = NavType.StringType }
),
deepLinks = listOf(
navDeepLink {
uriPattern = "${Routes.DeepLinks.SCHEME}://movie/{${Routes.Args.IMDB_ID}}"
},
navDeepLink {
uriPattern = "https://${Routes.DeepLinks.HOST}${Routes.DeepLinks.PATH_MOVIE}/{${Routes.Args.IMDB_ID}}"
}
)
) {
val viewModel: DetailViewModel = hiltViewModel()
val imdbId = it.arguments?.getString(Routes.Args.IMDB_ID) ?: ""
DetailScreen(
imdbId = imdbId,
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() },
onNavigateToPlayer = { id, title -> navController.navigate(Routes.player(id, title)) }
)
}
composable(
route = Routes.PLAYER,
arguments = listOf(
navArgument(Routes.Args.IMDB_ID) { type = NavType.StringType },
navArgument(Routes.Args.TITLE) { type = NavType.StringType }
),
deepLinks = listOf(
navDeepLink {
uriPattern = "${Routes.DeepLinks.SCHEME}://play/{${Routes.Args.IMDB_ID}}/{${Routes.Args.TITLE}}"
},
navDeepLink {
uriPattern = "https://${Routes.DeepLinks.HOST}${Routes.DeepLinks.PATH_PLAY}/{${Routes.Args.IMDB_ID}}/{${Routes.Args.TITLE}}"
}
)
) {
val imdbId = it.arguments?.getString(Routes.Args.IMDB_ID) ?: ""
val title = Uri.decode(it.arguments?.getString(Routes.Args.TITLE) ?: "Horror TV")
PlayerScreen(
imdbId = imdbId,
title = title,
onNavigateBack = { navController.popBackStack() }
)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
)
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -0,0 +1,105 @@
package com.horrortv.app.presentation.detail
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.horrortv.app.domain.model.AppError
import com.horrortv.app.domain.model.Movie
import com.horrortv.app.domain.repository.MovieRepository
import com.horrortv.app.domain.repository.Result
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class DetailViewModel @Inject constructor(
private val repository: MovieRepository
) : ViewModel() {
private companion object {
const val TAG = "DetailViewModel"
}
private val _uiState = MutableStateFlow(DetailUiState())
val uiState: StateFlow<DetailUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
private var lastImdbId: String = ""
fun loadMovie(imdbId: String) {
if (imdbId.isEmpty()) {
Log.w(TAG, "Empty IMDB ID provided")
_uiState.update {
it.copy(
isLoading = false,
error = AppError.ValidationError(
userMessage = "ID de película inválido.",
debugMessage = "Empty IMDB ID provided",
field = "imdbId"
)
)
}
return
}
lastImdbId = imdbId
loadJob?.cancel()
loadJob = viewModelScope.launch {
Log.d(TAG, "Loading movie: $imdbId")
_uiState.update { it.copy(isLoading = true, error = null, movie = null) }
val result = withContext(Dispatchers.IO) {
repository.getMovieById(imdbId)
}
when (result) {
is Result.Success -> {
val movie = result.data
if (movie != null) {
Log.d(TAG, "Loaded movie: ${movie.title}")
_uiState.update { it.copy(movie = movie, isLoading = false, error = null) }
} else {
Log.w(TAG, "Movie not found: $imdbId")
_uiState.update {
it.copy(
isLoading = false,
error = AppError.ValidationError(
userMessage = "No se encontró la película.",
debugMessage = "Movie not found",
field = "imdbId"
)
)
}
}
}
is Result.Error -> {
Log.e(TAG, "Error loading movie: ${result.error.debugMessage}")
_uiState.update { it.copy(isLoading = false, error = result.error) }
}
}
}
}
fun retry() {
if (lastImdbId.isNotEmpty()) {
loadMovie(lastImdbId)
}
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
}
data class DetailUiState(
val movie: Movie? = null,
val isLoading: Boolean = false,
val error: AppError? = null
)

View File

@@ -0,0 +1,270 @@
package com.horrortv.app.presentation.home
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.horrortv.app.domain.model.Movie
import com.horrortv.app.domain.model.MovieCategory
import com.horrortv.app.presentation.common.TvErrorDisplay
import com.horrortv.app.presentation.theme.HorrorColors
import com.horrortv.app.presentation.theme.HorrorTypography
@Composable
fun HomeScreen(
viewModel: HomeViewModel,
onNavigateToSearch: () -> Unit,
onNavigateToDetail: (String) -> Unit,
onExit: () -> Unit = {},
modifier: Modifier = Modifier
) {
var showExitDialog by remember { mutableStateOf(false) }
BackHandler(enabled = true) {
showExitDialog = true
}
val uiState by viewModel.uiState.collectAsState()
val firstCardFocusRequester = remember { FocusRequester() }
var hasRequestedInitialFocus by remember { mutableStateOf(false) }
LaunchedEffect(uiState.categories.isNotEmpty()) {
if (uiState.categories.isNotEmpty() && uiState.categories.first().movies.isNotEmpty() && !hasRequestedInitialFocus) {
kotlinx.coroutines.delay(800)
try {
firstCardFocusRequester.requestFocus()
hasRequestedInitialFocus = true
} catch (e: IllegalStateException) {
}
}
}
val onMovieClick = remember(onNavigateToDetail) {
{ movie: Movie -> onNavigateToDetail(movie.imdbId) }
}
Box(
modifier = modifier.fillMaxSize().background(HorrorColors.HorrorBlack)
) {
Column(modifier = Modifier.fillMaxSize()) {
HomeHeader(onSearchClick = onNavigateToSearch)
when {
uiState.isLoading && uiState.categories.isEmpty() -> CategoryPlaceholderList()
uiState.error != null -> {
val error = uiState.error
if (error != null) {
TvErrorDisplay(error = error, onRetry = { viewModel.retry() })
}
}
uiState.categories.isEmpty() -> EmptyState()
else -> CategoriesList(
categories = uiState.categories,
onMovieClick = onMovieClick,
firstCardFocusRequester = firstCardFocusRequester
)
}
}
if (showExitDialog) {
ExitConfirmationDialog(
onConfirm = { showExitDialog = false; onExit() },
onDismiss = { showExitDialog = false }
)
}
}
}
@Composable
private fun CategoryPlaceholderList() {
Column(
modifier = Modifier.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
repeat(3) { CategoryPlaceholderRow() }
}
}
@Composable
private fun EmptyState() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "No hay películas disponibles", style = HorrorTypography.DetailPlot)
}
}
@Composable
private fun CategoriesList(
categories: List<MovieCategory>,
onMovieClick: (Movie) -> Unit,
firstCardFocusRequester: FocusRequester
) {
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
itemsIndexed(
items = categories,
key = { _, category -> category.name }
) { index, category ->
HorrorRow(
category = category,
onMovieClick = onMovieClick,
isFirstRow = index == 0,
focusRequester = if (index == 0) firstCardFocusRequester else null
)
}
}
}
@Composable
fun HomeHeader(onSearchClick: () -> Unit, modifier: Modifier = Modifier) {
var isSearchFocused by remember { mutableStateOf(false) }
val searchFocusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "HORROR TV", style = HorrorTypography.DetailTitle, color = HorrorColors.HorrorRed)
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isSearchFocused) HorrorColors.HorrorGray else Color.Transparent)
.border(if (isSearchFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp))
.focusRequester(searchFocusRequester)
.focusable(interactionSource = interactionSource)
.onFocusChanged { isSearchFocused = it.isFocused }
.clickable(interactionSource = interactionSource, indication = null, onClick = onSearchClick),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = android.R.drawable.ic_menu_search),
contentDescription = "Buscar",
tint = HorrorColors.HorrorWhite,
modifier = Modifier.size(32.dp)
)
}
}
}
@Composable
fun CategoryPlaceholderRow(modifier: Modifier = Modifier) {
Column(modifier = modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Box(
modifier = Modifier.width(200.dp).height(20.dp)
.padding(horizontal = 48.dp).clip(RoundedCornerShape(4.dp))
.background(HorrorColors.HorrorGray)
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.padding(horizontal = 48.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
repeat(6) {
Box(
modifier = Modifier.width(180.dp).height(270.dp)
.clip(RoundedCornerShape(8.dp)).background(HorrorColors.HorrorGray)
)
}
}
}
}
@Composable
fun ExitConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
var isConfirmFocused by remember { mutableStateOf(false) }
val confirmFocusRequester = remember { FocusRequester() }
val confirmInteractionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier.fillMaxSize().background(HorrorColors.HorrorBlack.copy(alpha = 0.85f)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(text = "¿Salir de Horror TV?", style = HorrorTypography.DetailTitle, color = HorrorColors.HorrorWhite)
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
TextButton(onClick = onDismiss) { Text("No", color = HorrorColors.HorrorWhite) }
Box(
modifier = Modifier
.width(200.dp).height(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isConfirmFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed)
.border(if (isConfirmFocused) 3.dp else 1.dp, if (isConfirmFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(8.dp))
.focusRequester(confirmFocusRequester)
.focusable(interactionSource = confirmInteractionSource)
.onFocusChanged { isConfirmFocused = it.isFocused }
.clickable(interactionSource = confirmInteractionSource, indication = null, onClick = onConfirm),
contentAlignment = Alignment.Center
) {
Text("Sí, salir", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
}
}
}
}
}
@Composable
private fun TextButton(onClick: () -> Unit, content: @Composable () -> Unit) {
var isFocused by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.width(100.dp).height(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isFocused) HorrorColors.HorrorGray else Color.Transparent)
.border(if (isFocused) 2.dp else 0.dp, HorrorColors.HorrorWhite, RoundedCornerShape(8.dp))
.focusRequester(focusRequester)
.focusable(interactionSource = interactionSource)
.onFocusChanged { isFocused = it.isFocused }
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick),
contentAlignment = Alignment.Center
) {
content()
}
}

View File

@@ -0,0 +1,76 @@
package com.horrortv.app.presentation.home
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.horrortv.app.domain.model.AppError
import com.horrortv.app.domain.model.MovieCategory
import com.horrortv.app.domain.repository.MovieRepository
import com.horrortv.app.util.ApiException
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: MovieRepository
) : ViewModel() {
private companion object {
const val TAG = "HomeViewModel"
}
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
init {
loadFeaturedCategories()
}
fun loadFeaturedCategories() {
loadJob?.cancel()
loadJob = viewModelScope.launch {
Log.d(TAG, "Loading featured categories")
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val categories = repository.getFeaturedCategories()
Log.d(TAG, "Loaded ${categories.size} categories")
_uiState.update { it.copy(categories = categories, isLoading = false) }
} catch (e: Exception) {
Log.e(TAG, "Error loading categories", e)
val appError = if (e is ApiException) {
e.toAppError()
} else {
AppError.UnknownError(
userMessage = "Error inesperado. Intenta de nuevo.",
debugMessage = "Unhandled exception: ${e.message}",
cause = e
)
}
_uiState.update { it.copy(isLoading = false, error = appError) }
}
}
}
fun retry() {
loadFeaturedCategories()
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
}
data class HomeUiState(
val categories: List<MovieCategory> = emptyList(),
val isLoading: Boolean = false,
val error: AppError? = null
)

View File

@@ -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
)
}
}
}
}

View File

@@ -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))
)
}
}
}

View File

@@ -0,0 +1,472 @@
package com.horrortv.app.presentation.player
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import com.horrortv.app.domain.model.VideoSource
import com.horrortv.app.domain.usecase.VideoExtractor
import com.horrortv.app.presentation.theme.HorrorColors
import com.horrortv.app.presentation.theme.HorrorTypography
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import android.util.Log
private const val TAG = "PlayerScreen"
private val ButtonShape = RoundedCornerShape(12.dp)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlayerScreen(
imdbId: String,
title: String = "Horror TV",
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var videoSource by remember { mutableStateOf<VideoSource?>(null) }
var isLoading by remember { mutableStateOf(true) }
var hasError by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
var showControls by remember { mutableStateOf(true) }
var isPlaying by remember { mutableStateOf(false) }
var currentTimeMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
val playFocusRequester = remember { FocusRequester() }
val backFocusRequester = remember { FocusRequester() }
val exoPlayer = remember { ExoPlayer.Builder(context).build() }
LaunchedEffect(imdbId) {
scope.launch {
try {
isLoading = true
hasError = false
val result = withContext(Dispatchers.IO) {
VideoExtractor().extractVideoSource(imdbId)
}
result.fold(
onSuccess = { source ->
videoSource = source
val mediaItem = MediaItem.fromUri(source.videoUrl)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
isLoading = false
},
onFailure = { error ->
hasError = true
errorMessage = "Error: ${error.message}"
isLoading = false
}
)
} catch (e: Exception) {
hasError = true
errorMessage = "Error: ${e.message}"
isLoading = false
}
}
}
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
isLoading = false
durationMs = exoPlayer.duration
}
Player.STATE_BUFFERING -> isLoading = true
Player.STATE_ENDED -> isPlaying = false
}
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
LaunchedEffect(showControls, isPlaying) {
if (showControls && isPlaying) {
delay(4000)
showControls = false
}
}
LaunchedEffect(isPlaying) {
while (isPlaying) {
currentTimeMs = exoPlayer.currentPosition
delay(500)
}
}
BackHandler(enabled = true) { onNavigateBack() }
LaunchedEffect(!isLoading, showControls) {
if (!isLoading && showControls) {
delay(300)
try {
playFocusRequester.requestFocus()
} catch (e: IllegalStateException) {
Log.d(TAG, "FocusRequester not ready yet, will retry")
}
}
}
Box(
modifier = modifier.fillMaxSize().background(HorrorColors.HorrorBlack)
) {
if (hasError) {
PlayerErrorOverlay(
errorMessage = errorMessage,
onRetry = {
hasError = false
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) {
VideoExtractor().extractVideoSource(imdbId)
}
result.fold(
onSuccess = { source ->
videoSource = source
val mediaItem = MediaItem.fromUri(source.videoUrl)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
exoPlayer.playWhenReady = true
isLoading = false
},
onFailure = { error ->
hasError = true
errorMessage = "Error: ${error.message}"
isLoading = false
}
)
}
},
onBack = onNavigateBack
)
} else {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = false
}
},
modifier = Modifier.fillMaxSize()
)
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Text(text = "Cargando...", style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite)
}
}
if (showControls && !isLoading) {
PlayerControlsOverlay(
isPlaying = isPlaying,
currentTime = currentTimeMs,
duration = durationMs,
onPlayPause = {
if (isPlaying) exoPlayer.pause() else exoPlayer.play()
showControls = true
},
onSeekForward = { exoPlayer.seekTo(exoPlayer.currentPosition + 10000) },
onSeekBackward = { exoPlayer.seekTo(exoPlayer.currentPosition - 10000) },
onBack = onNavigateBack,
playFocusRequester = playFocusRequester,
backFocusRequester = backFocusRequester
)
}
}
}
}
@Composable
private fun PlayerControlsOverlay(
isPlaying: Boolean,
currentTime: Long,
duration: Long,
onPlayPause: () -> Unit,
onSeekForward: () -> Unit,
onSeekBackward: () -> Unit,
onBack: () -> Unit,
playFocusRequester: FocusRequester,
backFocusRequester: FocusRequester
) {
var isPlayFocused by remember { mutableStateOf(false) }
var isBackFocused by remember { mutableStateOf(false) }
var isForwardFocused by remember { mutableStateOf(false) }
var isBackwardFocused by remember { mutableStateOf(false) }
val playInteraction = remember { MutableInteractionSource() }
val backInteraction = remember { MutableInteractionSource() }
val forwardInteraction = remember { MutableInteractionSource() }
val backwardInteraction = remember { MutableInteractionSource() }
val currentSec = (currentTime / 1000).toInt()
val durationSec = (duration / 1000).toInt()
Box(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.align(Alignment.TopStart).padding(24.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
PlayerButton(
iconRes = android.R.drawable.ic_menu_close_clear_cancel,
label = "BACK",
isFocused = isBackFocused,
onClick = onBack,
focusRequester = backFocusRequester,
interactionSource = backInteraction,
onFocusChange = { isBackFocused = it }
)
}
Column(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(bottom = 48.dp, start = 48.dp, end = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = formatTime(currentSec), style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite)
Text(text = formatTime(durationSec), style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite)
}
Spacer(modifier = Modifier.height(32.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
PlayerButton(
iconRes = android.R.drawable.ic_media_previous,
label = "-10s",
isFocused = isBackwardFocused,
onClick = onSeekBackward,
focusRequester = FocusRequester(),
interactionSource = backwardInteraction,
onFocusChange = { isBackwardFocused = it }
)
PlayerButton(
iconRes = if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
label = if (isPlaying) "PAUSE" else "PLAY",
isFocused = isPlayFocused,
onClick = onPlayPause,
focusRequester = playFocusRequester,
interactionSource = playInteraction,
onFocusChange = { isPlayFocused = it }
)
PlayerButton(
iconRes = android.R.drawable.ic_media_next,
label = "+10s",
isFocused = isForwardFocused,
onClick = onSeekForward,
focusRequester = FocusRequester(),
interactionSource = forwardInteraction,
onFocusChange = { isForwardFocused = it }
)
}
}
}
}
@Composable
private fun PlayerButton(
iconRes: Int,
label: String,
isFocused: Boolean,
onClick: () -> Unit,
focusRequester: FocusRequester,
interactionSource: MutableInteractionSource,
onFocusChange: (Boolean) -> Unit
) {
val scale = if (isFocused) 1.1f else 1.0f
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(64.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(ButtonShape)
.background(if (isFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorGray)
.border(if (isFocused) 4.dp else 1.dp, if (isFocused) Color.White else HorrorColors.HorrorLightGray, ButtonShape)
.focusRequester(focusRequester)
.focusable(interactionSource = interactionSource)
.onFocusChanged { onFocusChange(it.isFocused) }
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = label,
tint = HorrorColors.HorrorWhite,
modifier = Modifier.size(32.dp)
)
}
Text(
text = label,
style = HorrorTypography.MovieYear,
color = if (isFocused) HorrorColors.HorrorWhite else HorrorColors.HorrorLightGray
)
}
}
@Composable
private fun PlayerErrorOverlay(
errorMessage: String,
onRetry: () -> Unit,
onBack: () -> Unit
) {
var isRetryFocused by remember { mutableStateOf(false) }
var isBackFocused by remember { mutableStateOf(true) }
val retryFocusRequester = remember { FocusRequester() }
val backFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { backFocusRequester.requestFocus() }
Box(
modifier = Modifier.fillMaxSize().background(HorrorColors.HorrorBlack),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Icon(
painter = painterResource(id = android.R.drawable.ic_menu_close_clear_cancel),
contentDescription = "Error",
tint = HorrorColors.HorrorRed,
modifier = Modifier.size(96.dp)
)
Text(
text = errorMessage,
style = HorrorTypography.DetailPlot,
color = HorrorColors.HorrorWhite,
modifier = Modifier.padding(horizontal = 48.dp)
)
Spacer(modifier = Modifier.height(32.dp))
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
PlayerActionButton(
label = "VOLVER",
isFocused = isBackFocused,
onClick = onBack,
focusRequester = backFocusRequester,
onFocusChange = { isBackFocused = it }
)
PlayerActionButton(
label = "REINTENTAR",
isFocused = isRetryFocused,
onClick = onRetry,
focusRequester = retryFocusRequester,
onFocusChange = { isRetryFocused = it }
)
}
}
}
}
@Composable
private fun PlayerActionButton(
label: String,
isFocused: Boolean,
onClick: () -> Unit,
focusRequester: FocusRequester,
onFocusChange: (Boolean) -> Unit
) {
val scale = if (isFocused) 1.08f else 1.0f
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.width(200.dp)
.height(64.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(RoundedCornerShape(12.dp))
.background(if (isFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed)
.border(if (isFocused) 4.dp else 1.dp, if (isFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(12.dp))
.focusRequester(focusRequester)
.focusable(interactionSource = interactionSource)
.onFocusChanged { onFocusChange(it.isFocused) }
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick),
contentAlignment = Alignment.Center
) {
Text(text = label, style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
}
}
private fun formatTime(seconds: Int): String {
val mins = seconds / 60
val secs = seconds % 60
return "%02d:%02d".format(mins, secs)
}

View File

@@ -0,0 +1,339 @@
package com.horrortv.app.presentation.search
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.horrortv.app.domain.model.AppError
import com.horrortv.app.domain.model.Movie
import com.horrortv.app.presentation.common.PosterImage
import com.horrortv.app.util.PosterSize
import com.horrortv.app.presentation.common.TvErrorDisplay
import com.horrortv.app.presentation.theme.HorrorColors
import com.horrortv.app.presentation.theme.HorrorTypography
import androidx.compose.ui.graphics.graphicsLayer
import kotlinx.coroutines.launch
private val CardShape = RoundedCornerShape(8.dp)
private val SearchFieldShape = RoundedCornerShape(8.dp)
@Composable
fun SearchScreen(
viewModel: SearchViewModel,
onNavigateBack: () -> Unit = {},
onNavigateToDetail: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
val uiState by viewModel.uiState.collectAsState()
var searchQuery by remember { mutableStateOf("") }
val searchFieldFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { searchFieldFocusRequester.requestFocus() }
val movies = uiState.movies
val isLoading = uiState.isLoading
val error = uiState.error
val directMovie = uiState.directMovie
Column(modifier = modifier.fillMaxSize().background(HorrorColors.HorrorBlack)) {
SearchHeader(
query = searchQuery,
onQueryChange = { newQuery ->
searchQuery = newQuery
viewModel.updateQuery(newQuery)
},
onBackClick = onNavigateBack,
focusRequester = searchFieldFocusRequester
)
Box(modifier = Modifier.fillMaxSize()) {
when {
isLoading -> SearchLoadingIndicator()
error != null -> TvErrorDisplay(error = error, onRetry = { viewModel.retry() })
directMovie != null -> DirectMovieResult(
movie = directMovie,
onPlayClick = { onNavigateToDetail(directMovie.imdbId) }
)
searchQuery.isEmpty() -> SearchInitialState()
movies.isEmpty() -> SearchNoResultsState()
else -> SearchResultsGrid(
movies = movies,
onMovieClick = { movie -> onNavigateToDetail(movie.imdbId) }
)
}
}
}
}
@Composable
private fun SearchLoadingIndicator() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = HorrorColors.HorrorRed, modifier = Modifier.size(48.dp))
}
}
@Composable
private fun SearchInitialState() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Icon(painter = painterResource(id = android.R.drawable.ic_menu_search), contentDescription = null, tint = HorrorColors.HorrorLightGray, modifier = Modifier.size(64.dp))
Text(text = "Busca películas por nombre o IMDB ID", style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorLightGray)
Text(text = "Ejemplo: \"Scream\" o \"tt11245972\"", style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorGray)
}
}
}
@Composable
private fun SearchNoResultsState() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "No se encontraron resultados", style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorLightGray)
}
}
@Composable
private fun SearchResultsGrid(
movies: List<Movie>,
onMovieClick: (Movie) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Fixed(5),
contentPadding = PaddingValues(48.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalArrangement = Arrangement.spacedBy(32.dp),
modifier = Modifier.focusGroup()
) {
items(items = movies, key = { it.imdbId }) { movie ->
SearchResultCard(movie = movie, onClick = { onMovieClick(movie) })
}
}
}
@Composable
fun SearchHeader(
query: String,
onQueryChange: (String) -> Unit,
onBackClick: () -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier
) {
var isTextFieldFocused by remember { mutableStateOf(false) }
var isBackFocused by remember { mutableStateOf(false) }
val backFocusRequester = remember { FocusRequester() }
val textFieldInteractionSource = remember { MutableInteractionSource() }
val backInteractionSource = remember { MutableInteractionSource() }
val borderColor = if (isTextFieldFocused) HorrorColors.HorrorRed else HorrorColors.HorrorLightGray
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 24.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isBackFocused) HorrorColors.HorrorGray else Color.Transparent)
.border(if (isBackFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp))
.focusRequester(backFocusRequester)
.focusable(interactionSource = backInteractionSource)
.onFocusChanged { isBackFocused = it.isFocused }
.clickable(interactionSource = backInteractionSource, indication = null, onClick = onBackClick),
contentAlignment = Alignment.Center
) {
Icon(painter = painterResource(id = android.R.drawable.ic_menu_close_clear_cancel), contentDescription = "Cerrar", tint = HorrorColors.HorrorWhite, modifier = Modifier.size(32.dp))
}
Box(
modifier = Modifier
.weight(1f)
.height(56.dp)
.clip(SearchFieldShape)
.background(HorrorColors.HorrorGray)
.border(2.dp, borderColor, SearchFieldShape)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart
) {
if (query.isEmpty() && !isTextFieldFocused) {
Text(text = "Buscar por nombre o IMDB ID", style = HorrorTypography.SearchHint)
}
BasicTextField(
value = query,
onValueChange = onQueryChange,
textStyle = HorrorTypography.DetailPlot.copy(color = HorrorColors.HorrorWhite),
cursorBrush = SolidColor(HorrorColors.HorrorRed),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.focusable(interactionSource = textFieldInteractionSource)
.onFocusChanged { isTextFieldFocused = it.isFocused }
)
}
Icon(painter = painterResource(id = android.R.drawable.ic_menu_search), contentDescription = "Buscar", tint = HorrorColors.HorrorWhite, modifier = Modifier.size(32.dp))
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SearchResultCard(
movie: Movie,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
var isFocused by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val coroutineScope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() }
val borderW = if (isFocused) 6.dp else 1.dp
val borderC = if (isFocused) HorrorColors.HorrorRed else HorrorColors.HorrorLightGray
val scale = if (isFocused) 1.08f else 1.0f
Column(modifier = modifier.width(180.dp)) {
Box(
modifier = Modifier
.width(180.dp)
.height(270.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.bringIntoViewRequester(bringIntoViewRequester)
.clip(CardShape)
.background(HorrorColors.HorrorGray)
.border(borderW, borderC, CardShape)
.focusRequester(focusRequester)
.focusable(interactionSource = interactionSource)
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
coroutineScope.launch { bringIntoViewRequester.bringIntoView() }
}
}
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
) {
PosterImage(
url = movie.posterUrl,
contentDescription = movie.title,
size = PosterSize.CARD,
contentScale = ContentScale.Crop,
shape = CardShape,
modifier = Modifier.fillMaxSize()
)
if (isFocused) {
Box(modifier = Modifier.fillMaxSize().background(Color.Red.copy(alpha = 0.2f)))
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = movie.title, style = HorrorTypography.MovieTitle, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(horizontal = 4.dp))
Text(text = movie.year, style = HorrorTypography.MovieYear, modifier = Modifier.padding(horizontal = 4.dp))
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DirectMovieResult(
movie: Movie,
onPlayClick: () -> Unit,
modifier: Modifier = Modifier
) {
var isPlayFocused by remember { mutableStateOf(false) }
val playFocusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(Unit) { playFocusRequester.requestFocus() }
val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
Column(
modifier = modifier.fillMaxSize().padding(48.dp).focusGroup(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(32.dp)
) {
Text(text = "RESULTADO ENCONTRADO", style = HorrorTypography.CategoryTitle, color = HorrorColors.HorrorAccent)
Row(horizontalArrangement = Arrangement.spacedBy(48.dp), modifier = Modifier.fillMaxWidth(0.7f)) {
PosterImage(
url = movie.posterUrl,
contentDescription = movie.title,
size = PosterSize.DETAIL,
contentScale = ContentScale.Crop,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.width(300.dp).height(450.dp).border(2.dp, HorrorColors.HorrorRed, RoundedCornerShape(12.dp))
)
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.weight(1f)) {
Text(text = "${movie.title} (${movie.year})", style = HorrorTypography.DetailTitle)
Text(text = "IMDb: ⭐ ${movie.rating}", style = HorrorTypography.DetailRating)
Text(text = movie.genre, style = HorrorTypography.DetailGenre)
Text(text = movie.plot, style = HorrorTypography.DetailPlot, maxLines = 6, overflow = TextOverflow.Ellipsis)
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier
.width(350.dp)
.height(60.dp)
.clip(RoundedCornerShape(12.dp))
.background(playButtonColor)
.border(if (isPlayFocused) 4.dp else 2.dp, if (isPlayFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(12.dp))
.focusRequester(playFocusRequester)
.focusable(interactionSource = interactionSource)
.onFocusChanged { isPlayFocused = it.isFocused }
.clickable(interactionSource = interactionSource, indication = null, onClick = onPlayClick),
contentAlignment = Alignment.Center
) {
Text(text = "▶ VER PELÍCULA", style = HorrorTypography.PlayButton)
}
}
}
}
}

View File

@@ -0,0 +1,152 @@
package com.horrortv.app.presentation.search
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.horrortv.app.domain.model.AppError
import com.horrortv.app.domain.model.Movie
import com.horrortv.app.domain.repository.MovieRepository
import com.horrortv.app.domain.repository.Result
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(FlowPreview::class)
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repository: MovieRepository
) : ViewModel() {
private companion object {
const val TAG = "SearchViewModel"
const val DEBOUNCE_DELAY_MS = 300L
}
private val _uiState = MutableStateFlow(SearchUiState())
val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
private var searchJob: Job? = null
init {
_searchQuery
.debounce(DEBOUNCE_DELAY_MS)
.onEach { query ->
if (query.isNotEmpty()) {
performSearch(query)
}
}
.launchIn(viewModelScope)
}
fun updateQuery(query: String) {
val trimmedQuery = query.trim()
_searchQuery.value = trimmedQuery
if (trimmedQuery.isEmpty()) {
searchJob?.cancel()
_uiState.update { it.copy(movies = emptyList(), directMovie = null, error = null, isLoading = false) }
}
}
private fun performSearch(query: String) {
if (query.isEmpty()) return
searchJob?.cancel()
searchJob = viewModelScope.launch {
Log.d(TAG, "Searching for: $query")
_uiState.update { it.copy(isLoading = true, error = null, directMovie = null) }
if (query.isValidImdbId()) {
searchById(query)
} else {
searchByName(query)
}
}
}
private suspend fun searchById(imdbId: String) {
val result = repository.getMovieById(imdbId)
when (result) {
is Result.Success -> {
val movie = result.data
if (movie != null) {
Log.d(TAG, "Found movie: ${movie.title}")
_uiState.update { it.copy(directMovie = movie, movies = emptyList(), isLoading = false) }
} else {
Log.w(TAG, "Movie not found: $imdbId")
_uiState.update {
it.copy(
isLoading = false,
error = AppError.ValidationError(
userMessage = "No se encontró la película con ID: $imdbId",
debugMessage = "Movie not found for IMDB ID",
field = "imdbId"
)
)
}
}
}
is Result.Error -> {
Log.e(TAG, "Error searching by ID: ${result.error.debugMessage}")
_uiState.update { it.copy(isLoading = false, error = result.error) }
}
}
}
private suspend fun searchByName(query: String) {
val result = repository.searchMovies(query)
when (result) {
is Result.Success -> {
val movies = result.data
Log.d(TAG, "Found ${movies.size} movies")
_uiState.update { it.copy(movies = movies, directMovie = null, isLoading = false) }
}
is Result.Error -> {
Log.e(TAG, "Error searching: ${result.error.debugMessage}")
_uiState.update { it.copy(isLoading = false, error = result.error) }
}
}
}
fun retry() {
val currentQuery = _searchQuery.value
if (currentQuery.isNotEmpty()) {
performSearch(currentQuery)
}
}
fun clearResults() {
searchJob?.cancel()
_searchQuery.value = ""
_uiState.update { SearchUiState() }
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
}
data class SearchUiState(
val movies: List<Movie> = emptyList(),
val directMovie: Movie? = null,
val isLoading: Boolean = false,
val error: AppError? = null
)
fun String.isValidImdbId(): Boolean {
return com.horrortv.app.util.Constants.IMDB_ID_PATTERN.matches(this)
}

View File

@@ -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)
}

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -0,0 +1,99 @@
package com.horrortv.app.util
import android.util.Log
import com.horrortv.app.domain.model.AppError
import retrofit2.HttpException
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
sealed class ApiException : Exception() {
abstract fun toAppError(): AppError
data class NetworkUnavailable(
override val message: String = "No hay conexión a internet",
override val cause: Throwable? = null
) : ApiException() {
override fun toAppError(): AppError = AppError.NetworkError(
userMessage = message,
debugMessage = "Network unavailable: ${cause?.message}",
cause = cause
)
}
data class Timeout(
override val message: String = "Tiempo de espera agotado",
override val cause: Throwable? = null
) : ApiException() {
override fun toAppError(): AppError = AppError.NetworkError(
userMessage = message,
debugMessage = "Request timeout",
cause = cause
)
}
data class ServerError(
val code: Int,
override val message: String = "Error del servidor",
override val cause: Throwable? = null
) : ApiException() {
override fun toAppError(): AppError = AppError.ApiError(
userMessage = message,
debugMessage = "Server error: $code",
code = code,
cause = cause
)
}
data class RateLimited(
override val message: String = "Demasiadas solicitudes. Intenta más tarde.",
override val cause: Throwable? = null
) : ApiException() {
override fun toAppError(): AppError = AppError.ApiError(
userMessage = message,
debugMessage = "Rate limited (429)",
code = 429,
cause = cause
)
}
data class Unknown(
override val message: String = "Error inesperado",
override val cause: Throwable? = null
) : ApiException() {
override fun toAppError(): AppError = AppError.UnknownError(
userMessage = message,
debugMessage = cause?.message ?: "Unknown error",
cause = cause
)
}
companion object {
private val retryableCodes = setOf(429, 500, 502, 503, 504)
fun isRetryable(error: ApiException): Boolean {
return error is Timeout ||
error is RateLimited ||
(error is ServerError && error.code in retryableCodes)
}
fun fromThrowable(throwable: Throwable): ApiException {
return when (throwable) {
is ApiException -> throwable
is SocketTimeoutException -> Timeout(cause = throwable)
is ConnectException -> NetworkUnavailable(cause = throwable)
is UnknownHostException -> NetworkUnavailable(cause = throwable)
is IOException -> NetworkUnavailable(cause = throwable)
is HttpException -> {
val code = throwable.code()
if (code == 429) RateLimited(cause = throwable)
else if (code in retryableCodes) ServerError(code, cause = throwable)
else ServerError(code, cause = throwable)
}
else -> Unknown(cause = throwable)
}
}
}
}

View File

@@ -0,0 +1,41 @@
package com.horrortv.app.util
object Constants {
const val OMDB_API_KEY = "5854c81e"
const val OMDB_BASE_URL = "https://www.omdbapi.com/"
val HORROR_CATEGORIES = listOf(
"Scream",
"Halloween",
"Conjuring",
"Exorcist",
"Nightmare",
"Insidious",
"Terrifier",
"Hereditary",
"It",
"Poltergeist",
"Saw",
"Paranormal"
)
val IMDB_ID_PATTERN = Regex("^tt\\d{7,8}$")
object Network {
const val CONNECT_TIMEOUT_SECONDS = 15L
const val READ_TIMEOUT_SECONDS = 30L
const val WRITE_TIMEOUT_SECONDS = 30L
const val CACHE_SIZE_BYTES = 10 * 1024 * 1024L
const val MAX_RETRIES = 2
const val RETRY_DELAY_MS = 1000L
}
object Cache {
const val CATEGORY_CACHE_HOURS = 12L
const val SEARCH_CACHE_HOURS = 4L
const val DETAIL_CACHE_HOURS = 24L
const val CATEGORY_CACHE_SIZE = 20
const val SEARCH_CACHE_SIZE = 50
const val DETAIL_CACHE_SIZE = 100
}
}

View File

@@ -0,0 +1,59 @@
package com.horrortv.app.util
import android.util.Log
import com.horrortv.app.domain.model.AppError
object ErrorLogger {
private const val TAG_PREFIX = "HorrorTV"
fun logError(
component: String,
operation: String,
error: AppError,
additionalContext: Map<String, Any?> = emptyMap()
) {
val fullTag = "$TAG_PREFIX-$component"
val contextStr = if (additionalContext.isNotEmpty()) {
additionalContext.entries.joinToString(", ") { "${it.key}=${it.value}" }
} else ""
val message = buildString {
append("[$operation] ")
append(error.debugMessage)
if (contextStr.isNotEmpty()) {
append(" | Context: $contextStr")
}
append(" | Retryable: ${error.isRetryable}")
}
when (error) {
is AppError.NetworkError -> {
Log.w(fullTag, message)
error.cause?.let { Log.w(fullTag, "Cause: ${it.message}", it) }
}
is AppError.ApiError -> {
val priority = if (error.code >= 500) Log.ERROR else Log.WARN
Log.println(priority, fullTag, "$message | Status: ${error.code}")
}
is AppError.CacheError -> Log.e(fullTag, message)
is AppError.ValidationError -> Log.w(fullTag, message)
is AppError.UnknownError -> {
Log.e(fullTag, message)
error.cause?.let { Log.e(fullTag, "Cause: ${it.message}", it) }
}
}
}
fun logInfo(component: String, operation: String, message: String) {
Log.i("$TAG_PREFIX-$component", "[$operation] $message")
}
fun logDebug(component: String, operation: String, message: String) {
Log.d("$TAG_PREFIX-$component", "[$operation] $message")
}
fun logWarning(component: String, operation: String, message: String) {
Log.w("$TAG_PREFIX-$component", "[$operation] $message")
}
}

View File

@@ -0,0 +1,75 @@
package com.horrortv.app.util
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkStatus @Inject constructor(
private val context: Context
) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
}
fun isMeteredNetwork(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
fun isWifi(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}
fun observeNetworkStatus(): Flow<NetworkState> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(NetworkState.Available())
}
override fun onLost(network: Network) {
trySend(NetworkState.Unavailable)
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
val wifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
trySend(NetworkState.Available(metered = metered, wifi = wifi))
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
trySend(if (isNetworkAvailable()) NetworkState.Available() else NetworkState.Unavailable)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
sealed class NetworkState {
object Unavailable : NetworkState()
data class Available(val metered: Boolean = false, val wifi: Boolean = false) : NetworkState()
}
}

View File

@@ -0,0 +1,30 @@
package com.horrortv.app.util
object PosterSize {
const val CARD = 600
const val DETAIL = 800
const val BACKGROUND = 2160
const val THUMBNAIL = 300
}
object PosterUtils {
fun optimizePosterUrl(url: String?, targetSize: Int): String? {
if (url.isNullOrEmpty() || url == "N/A") return null
val sizePattern = Regex("\\._V1_(SX|UX|SY|UY)\\d+")
return url.replace(sizePattern, "._V1_SX${targetSize.coerceAtLeast(300)}")
}
fun toHighQuality(url: String?): String {
return optimizePosterUrl(url, PosterSize.DETAIL) ?: ""
}
}
fun String?.toPosterUrl4K(): String {
return PosterUtils.optimizePosterUrl(this, PosterSize.BACKGROUND) ?: ""
}
fun String.isValidImdbId(): Boolean {
return Constants.IMDB_ID_PATTERN.matches(this)
}

View File

@@ -0,0 +1,31 @@
package com.horrortv.app.util
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
suspend fun <T> retryWithBackoff(
maxRetries: Int = 3,
initialDelayMs: Long = 1000,
maxDelayMs: Long = 10000,
factor: Double = 2.0,
block: suspend () -> T
): T {
var currentDelay = initialDelayMs
var lastException: Exception? = null
repeat(maxRetries) { attempt ->
try {
return block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
lastException = e
if (attempt < maxRetries - 1) {
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelayMs)
}
}
}
throw lastException ?: IllegalStateException("Retry failed without exception")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#8B0000"/>
<corners android:radius="8dp"/>
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/horror_gray"/>
<item
android:gravity="center"
android:width="48dp"
android:height="48dp"
android:drawable="@android:drawable/ic_menu_report_image"/>
</layer-list>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/horror_gray"/>
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#8B0000</color>
<color name="horror_black">#0D0D0D</color>
<color name="horror_gray">#1A1A1A</color>
<color name="horror_light_gray">#4A4A4A</color>
<color name="horror_red">#CC0000</color>
<color name="horror_accent">#FF4444</color>
<color name="horror_white">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Horror TV</string>
<string name="search_title">Buscar películas</string>
<string name="play_movie">Ver película</string>
<string name="loading">Cargando...</string>
<string name="error_loading">Error al cargar</string>
<string name="search_hint">Buscar por nombre o IMDB ID (tt1234567)</string>
<string name="retry">Reintentar</string>
<string name="empty_results">No se encontraron resultados</string>
<string name="poster_description">Póster de la película</string>
<string name="movie_details">Detalles de la película</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HorrorTV" parent="android:Theme.Material.NoActionBar">
<item name="android:colorPrimary">@color/horror_red</item>
<item name="android:windowBackground">@color/horror_black</item>
<item name="android:statusBarColor">@color/horror_black</item>
<item name="android:navigationBarColor">@color/horror_black</item>
</style>
</resources>

9
build.gradle.kts Normal file
View File

@@ -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)
}

20
gradle.properties Normal file
View File

@@ -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

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -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

106
gradlew vendored Normal file
View File

@@ -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" "$@"

87
gradlew.bat vendored Normal file
View File

@@ -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

17
settings.gradle.kts Normal file
View File

@@ -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")