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