feat: state-based D-pad navigation, seek mode, subtitle picker, 20-agent audit fixes

- Replace broken Compose focus system with state-based selectedButton approach
- Add seek mode (DPAD_UP to enter, LEFT/RIGHT to seek, CENTER/DOWN to apply)
- Make SubtitlePickerOverlay state-based (DPAD UP/DOWN navigate, CENTER select)
- Fix 25 P0 crashes from 20-agent audit (SSRF, CancellationException, DTO defaults)
- Fix UX issues: animations, padding, TvErrorDisplay, navigation, finishAffinity
- Fix pause button: remove clickable, onKeyEvent only, repeatCount guard
- Add subtitle extraction: OpenSubtitles EN/ES, HI filtering, dedup
- Fix override accumulation: clearOverridesOfType before setting new tracks
- Disable tunneling for subtitle compatibility
- Performance: AutoHideHolder, @Immutable models, contentType
- Fix SearchScreen focus visibility: animated scale, white border, dimming
- Fix PlayerButton invisible text: white on HorrorGray bg
- Fix isLoading stuck in Home/Detail/SearchViewModel
- Fix VideoExtractor CancellationException rethrow, IMDB_ID_PATTERN 7+ digits
This commit is contained in:
2026-04-27 16:05:53 -03:00
parent 828086ceb3
commit 38c5342e88
30 changed files with 1745 additions and 550 deletions

View File

@@ -16,6 +16,7 @@ android {
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
buildConfigField("String", "APP_NAME", "\"HorrorTV\"") buildConfigField("String", "APP_NAME", "\"HorrorTV\"")
buildConfigField("String", "OMDB_API_KEY", "\"${project.findProperty("OMDB_API_KEY") ?: "5854c81e"}\"")
} }
signingConfigs { signingConfigs {
@@ -75,7 +76,7 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = "17"
freeCompilerArgs += listOf( freeCompilerArgs += listOf(
"-Xopt-in=kotlin.RequiresOptIn", "-opt-in=kotlin.RequiresOptIn",
"-Xjvm-default=all" "-Xjvm-default=all"
) )
} }
@@ -142,6 +143,11 @@ dependencies {
implementation("androidx.media3:media3-common:1.4.0") implementation("androidx.media3:media3-common:1.4.0")
implementation("org.jsoup:jsoup:1.17.2") implementation("org.jsoup:jsoup:1.17.2")
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.9")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("app.cash.turbine:turbine:1.0.0")
debugImplementation(platform("androidx.compose:compose-bom:2024.10.01")) debugImplementation(platform("androidx.compose:compose-bom:2024.10.01"))
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")

View File

@@ -40,9 +40,6 @@
@kotlinx.coroutines.InternalCoroutinesApi <methods>; @kotlinx.coroutines.InternalCoroutinesApi <methods>;
} }
-keep class kotlin.** { *; }
-keep class * implements kotlin.** { *; }
-assumenosideeffects class android.util.Log { -assumenosideeffects class android.util.Log {
public static boolean isLoggable(...); public static boolean isLoggable(...);
public static int v(...); public static int v(...);
@@ -70,3 +67,24 @@
-dontwarn kotlin.Unit -dontwarn kotlin.Unit
-dontwarn retrofit2.Platform$Java8 -dontwarn retrofit2.Platform$Java8
-dontwarn kotlin.jvm.internal.Reflection -dontwarn kotlin.jvm.internal.Reflection
# Compose
-keep class androidx.compose.runtime.CompositionLocal { *; }
-keepclassmembers class * {
@androidx.compose.runtime.Composable <methods>;
}
# Media3 ExoPlayer
-keep class androidx.media3.** { *; }
# Jsoup
-keep class org.jsoup.** { *; }
# Keep generic signature info for Retrofit+Gson
-keepattributes Signature
# Enums
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}

View File

@@ -10,6 +10,7 @@
<application <application
android:name=".HorrorTvApp" android:name=".HorrorTvApp"
android:enableOnBackInvokedCallback="true"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher"
@@ -23,7 +24,8 @@
android:name=".presentation.MainActivity" android:name=".presentation.MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:screenOrientation="landscape"> android:screenOrientation="landscape"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/> <category android:name="android.intent.category.LEANBACK_LAUNCHER"/>

View File

@@ -3,26 +3,26 @@ package com.horrortv.app.data.remote.omdb.dto
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
data class OmdbMovieDetailDto( data class OmdbMovieDetailDto(
@SerializedName("Title") val title: String, @SerializedName("Title") val title: String = "",
@SerializedName("Year") val year: String, @SerializedName("Year") val year: String = "",
@SerializedName("Rated") val rated: String?, @SerializedName("Rated") val rated: String? = null,
@SerializedName("Released") val released: String?, @SerializedName("Released") val released: String? = null,
@SerializedName("Runtime") val runtime: String?, @SerializedName("Runtime") val runtime: String? = null,
@SerializedName("Genre") val genre: String?, @SerializedName("Genre") val genre: String? = null,
@SerializedName("Director") val director: String?, @SerializedName("Director") val director: String? = null,
@SerializedName("Writer") val writer: String?, @SerializedName("Writer") val writer: String? = null,
@SerializedName("Actors") val actors: String?, @SerializedName("Actors") val actors: String? = null,
@SerializedName("Plot") val plot: String?, @SerializedName("Plot") val plot: String? = null,
@SerializedName("Language") val language: String?, @SerializedName("Language") val language: String? = null,
@SerializedName("Country") val country: String?, @SerializedName("Country") val country: String? = null,
@SerializedName("Awards") val awards: String?, @SerializedName("Awards") val awards: String? = null,
@SerializedName("Poster") val poster: String?, @SerializedName("Poster") val poster: String? = null,
@SerializedName("Metascore") val metascore: String?, @SerializedName("Metascore") val metascore: String? = null,
@SerializedName("imdbRating") val imdbRating: String?, @SerializedName("imdbRating") val imdbRating: String? = null,
@SerializedName("imdbVotes") val imdbVotes: String?, @SerializedName("imdbVotes") val imdbVotes: String? = null,
@SerializedName("imdbID") val imdbId: String, @SerializedName("imdbID") val imdbId: String = "",
@SerializedName("Type") val type: String, @SerializedName("Type") val type: String = "",
@SerializedName("Response") val response: String, @SerializedName("Response") val response: String = "",
@SerializedName("Error") val error: String? = null @SerializedName("Error") val error: String? = null
) { ) {
val isSuccess: Boolean get() = response == "True" val isSuccess: Boolean get() = response == "True"

View File

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

View File

@@ -10,6 +10,7 @@ import com.horrortv.app.domain.repository.MovieRepository
import com.horrortv.app.domain.repository.Result import com.horrortv.app.domain.repository.Result
import com.horrortv.app.util.ApiException import com.horrortv.app.util.ApiException
import com.horrortv.app.util.Constants import com.horrortv.app.util.Constants
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -61,7 +62,7 @@ class MovieRepositoryImpl @Inject constructor(
cache.put(key, CacheEntry(data, System.currentTimeMillis(), ttlMs)) cache.put(key, CacheEntry(data, System.currentTimeMillis(), ttlMs))
} }
override suspend fun getFeaturedCategories(): List<MovieCategory> = withContext(Dispatchers.Default) { override suspend fun getFeaturedCategories(): List<MovieCategory> = withContext(Dispatchers.IO) {
Log.d(TAG, "Fetching featured categories with limited concurrency") Log.d(TAG, "Fetching featured categories with limited concurrency")
coroutineScope { coroutineScope {
@@ -94,6 +95,8 @@ class MovieRepositoryImpl @Inject constructor(
putInCache(categoryCache, cacheKey, movies, categoryTtlMs()) putInCache(categoryCache, cacheKey, movies, categoryTtlMs())
MovieCategory(category, movies) MovieCategory(category, movies)
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error fetching category: $category", e) Log.e(TAG, "Error fetching category: $category", e)
MovieCategory(category, emptyList()) MovieCategory(category, emptyList())
@@ -115,7 +118,7 @@ class MovieRepositoryImpl @Inject constructor(
return Result.Success(cached) return Result.Success(cached)
} }
return withContext(Dispatchers.Default) { return withContext(Dispatchers.IO) {
try { try {
Log.d(TAG, "Searching for: $query") Log.d(TAG, "Searching for: $query")
val response = apiService.searchMovies(query, page = 1) val response = apiService.searchMovies(query, page = 1)
@@ -139,13 +142,11 @@ class MovieRepositoryImpl @Inject constructor(
putInCache(searchCache, cacheKey, movies, searchTtlMs()) putInCache(searchCache, cacheKey, movies, searchTtlMs())
Result.Success(movies) Result.Success(movies)
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error searching: $query", e) Log.e(TAG, "Error searching: $query", e)
val appError = if (e is ApiException) e.toAppError() else AppError.UnknownError( val appError = ApiException.fromThrowable(e).toAppError()
userMessage = "Error inesperado. Intenta de nuevo.",
debugMessage = "Unhandled exception: ${e.message}",
cause = e
)
Result.Error(appError) Result.Error(appError)
} }
} }
@@ -161,7 +162,7 @@ class MovieRepositoryImpl @Inject constructor(
return Result.Success(cached) return Result.Success(cached)
} }
return withContext(Dispatchers.Default) { return withContext(Dispatchers.IO) {
try { try {
Log.d(TAG, "Fetching movie detail: $imdbId") Log.d(TAG, "Fetching movie detail: $imdbId")
val detail = apiService.getMovieDetail(imdbId) val detail = apiService.getMovieDetail(imdbId)
@@ -189,13 +190,11 @@ class MovieRepositoryImpl @Inject constructor(
putInCache(detailCache, cacheKey, movie, detailTtlMs()) putInCache(detailCache, cacheKey, movie, detailTtlMs())
Result.Success(movie) Result.Success(movie)
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error fetching movie: $imdbId", e) Log.e(TAG, "Error fetching movie: $imdbId", e)
val appError = if (e is ApiException) e.toAppError() else AppError.UnknownError( val appError = ApiException.fromThrowable(e).toAppError()
userMessage = "Error inesperado. Intenta de nuevo.",
debugMessage = "Unhandled exception: ${e.message}",
cause = e
)
Result.Error(appError) Result.Error(appError)
} }
} }

View File

@@ -30,9 +30,13 @@ object NetworkModule {
.connectTimeout(Constants.Network.CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) .connectTimeout(Constants.Network.CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(Constants.Network.READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .readTimeout(Constants.Network.READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(Constants.Network.WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) .writeTimeout(Constants.Network.WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply { .apply {
level = HttpLoggingInterceptor.Level.BASIC if (com.horrortv.app.BuildConfig.DEBUG) {
}) addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
}
}
.build() .build()
} }

View File

@@ -1,5 +1,8 @@
package com.horrortv.app.domain.model package com.horrortv.app.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class Movie( data class Movie(
val imdbId: String, val imdbId: String,
val title: String, val title: String,

View File

@@ -1,5 +1,8 @@
package com.horrortv.app.domain.model package com.horrortv.app.domain.model
import androidx.compose.runtime.Immutable
@Immutable
data class MovieCategory( data class MovieCategory(
val name: String, val name: String,
val movies: List<Movie> val movies: List<Movie>

View File

@@ -11,7 +11,16 @@ data class SubtitleTrack(
val url: String, val url: String,
val language: String, val language: String,
val label: String, val label: String,
val isDefault: Boolean = false val isDefault: Boolean = false,
val mimeType: String = "text/vtt",
val languageCode: String = language
)
data class QualityLevel(
val width: Int,
val height: Int,
val bitrate: Long,
val label: String
) )
data class VideoSource( data class VideoSource(
@@ -19,5 +28,15 @@ data class VideoSource(
val videoType: VideoType, val videoType: VideoType,
val subtitleTracks: List<SubtitleTrack> = emptyList(), val subtitleTracks: List<SubtitleTrack> = emptyList(),
val posterUrl: String? = null, val posterUrl: String? = null,
val title: String = "" val title: String = "",
val availableQualities: List<QualityLevel> = emptyList(),
val currentQualityIndex: Int = -1
) )
object VideoSourceConstants {
val SUPPORTED_SUBTITLE_LANGUAGES = listOf("en", "es")
val SUBTITLE_LANGUAGE_LABELS = mapOf(
"en" to "English",
"es" to "Español"
)
}

View File

@@ -4,11 +4,14 @@ import android.util.Log
import com.horrortv.app.domain.model.VideoSource import com.horrortv.app.domain.model.VideoSource
import com.horrortv.app.domain.model.VideoType import com.horrortv.app.domain.model.VideoType
import com.horrortv.app.domain.model.SubtitleTrack import com.horrortv.app.domain.model.SubtitleTrack
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.json.JSONArray
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.zip.GZIPInputStream
class VideoExtractor { class VideoExtractor {
@@ -17,10 +20,24 @@ class VideoExtractor {
private const val PLAYIMDB_BASE = "https://playimdb.com/title/" private const val PLAYIMDB_BASE = "https://playimdb.com/title/"
private const val STREAMIMDB_BASE = "https://streamimdb.me/embed/" private const val STREAMIMDB_BASE = "https://streamimdb.me/embed/"
private const val TIMEOUT_MS = 15000 private const val TIMEOUT_MS = 15000
private val ALLOWED_LANGUAGES = setOf("en", "es", "english", "spanish", "eng", "spa")
private val ALLOWED_HOSTS = setOf(
"streamimdb.me", "www.streamimdb.me",
"cloudnestra.com", "www.cloudnestra.com",
"playimdb.com", "www.playimdb.com"
)
private val IMDB_ID_PATTERN = Regex("""^tt\d{7,}${'$'}""")
private fun isAllowedHost(url: String): Boolean {
return try { ALLOWED_HOSTS.contains(URL(url).host) } catch (_: Exception) { false }
}
} }
suspend fun extractVideoSource(imdbId: String): Result<VideoSource> = withContext(Dispatchers.IO) { suspend fun extractVideoSource(imdbId: String): Result<VideoSource> = withContext(Dispatchers.IO) {
try { try {
if (!IMDB_ID_PATTERN.matches(imdbId)) {
return@withContext Result.failure(VideoExtractionException("Invalid IMDb ID: $imdbId"))
}
Log.d(TAG, "Extracting video for: $imdbId") Log.d(TAG, "Extracting video for: $imdbId")
val embedUrl = "$STREAMIMDB_BASE$imdbId" val embedUrl = "$STREAMIMDB_BASE$imdbId"
@@ -28,24 +45,16 @@ class VideoExtractor {
val html = fetchHtml(embedUrl) val html = fetchHtml(embedUrl)
Log.d(TAG, "HTML length: ${html.length}") Log.d(TAG, "HTML length: ${html.length}")
Log.d(TAG, "HTML snippet (first 2000 chars): ${html.take(2000)}")
var doc = Jsoup.parse(html) var doc = Jsoup.parse(html, embedUrl)
var finalHtml = html
val iframeUrl = doc.selectFirst("iframe")?.attr("src") val iframeUrl = doc.selectFirst("iframe")?.absUrl("src")
if (iframeUrl != null && iframeUrl.isNotEmpty()) { if (!iframeUrl.isNullOrEmpty()) {
val fullIframeUrl = if (iframeUrl.startsWith("//")) { Log.d(TAG, "Found iframe redirect: $iframeUrl")
"https:$iframeUrl"
} else {
iframeUrl
}
Log.d(TAG, "Found iframe redirect: $fullIframeUrl")
try { try {
val iframeHtml = fetchHtml(fullIframeUrl) val iframeHtml = fetchHtml(iframeUrl)
Log.d(TAG, "Iframe HTML length: ${iframeHtml.length}") 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 prorcpPattern = Regex("['\"]?src['\"]?\\s*:\\s*['\"]([^'\"]+/prorcp/[^'\"]+)['\"]")
val prorcpMatch = prorcpPattern.find(iframeHtml) val prorcpMatch = prorcpPattern.find(iframeHtml)
@@ -62,8 +71,8 @@ class VideoExtractor {
try { try {
val innerHtml = fetchHtml(fullInnerUrl) val innerHtml = fetchHtml(fullInnerUrl)
Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}") Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}")
Log.d(TAG, "Prorcp FULL HTML: $innerHtml") doc = Jsoup.parse(innerHtml, fullInnerUrl)
doc = Jsoup.parse(innerHtml) finalHtml = innerHtml
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed prorcp fetch: ${e.message}") Log.w(TAG, "Failed prorcp fetch: ${e.message}")
} }
@@ -78,8 +87,8 @@ class VideoExtractor {
try { try {
val innerHtml = fetchHtml(fullInnerUrl) val innerHtml = fetchHtml(fullInnerUrl)
Log.d(TAG, "Prorcp direct HTML length: ${innerHtml.length}") Log.d(TAG, "Prorcp direct HTML length: ${innerHtml.length}")
Log.d(TAG, "Prorcp direct FULL HTML: $innerHtml") doc = Jsoup.parse(innerHtml, fullInnerUrl)
doc = Jsoup.parse(innerHtml) finalHtml = innerHtml
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed prorcp direct fetch: ${e.message}") Log.w(TAG, "Failed prorcp direct fetch: ${e.message}")
} }
@@ -98,9 +107,6 @@ class VideoExtractor {
Log.d(TAG, "Video URL found: $videoUrl") Log.d(TAG, "Video URL found: $videoUrl")
val subtitles = extractSubtitles(doc)
Log.d(TAG, "Subtitles found: ${subtitles.size}")
val knownDomains = listOf( val knownDomains = listOf(
"neonhorizonworkshops.com", "neonhorizonworkshops.com",
"wanderlynest.com", "wanderlynest.com",
@@ -109,7 +115,7 @@ class VideoExtractor {
) )
val finalVideoUrl = if (videoUrl.contains("{v")) { val finalVideoUrl = if (videoUrl.contains("{v")) {
val basePattern = Regex("https://tmstr3\\.\\{v\\d+\\}/(.+)") val basePattern = Regex("""https://tmstr3\.\{v\d+\}/(.+)""")
val pathMatch = basePattern.find(videoUrl) val pathMatch = basePattern.find(videoUrl)
if (pathMatch != null) { if (pathMatch != null) {
val path = pathMatch.groupValues[1] val path = pathMatch.groupValues[1]
@@ -126,6 +132,22 @@ class VideoExtractor {
Log.d(TAG, "Final video URL: $finalVideoUrl") Log.d(TAG, "Final video URL: $finalVideoUrl")
val subtitles = extractSubtitles(doc, finalHtml, finalVideoUrl).toMutableList()
val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId)
for (sub in openSubtitles) {
if (!subtitles.any { it.url == sub.url }) {
subtitles.add(sub)
}
}
// Deduplicate by language - keep first (priority: embed > HLS > OpenSubtitles)
val seen = mutableSetOf<String>()
val dedupedTracks = subtitles.filter { seen.add(it.language.lowercase().trim()) }
val filteredSubtitles = dedupedTracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) }
Log.d(TAG, "Subtitles found: ${filteredSubtitles.size}")
val videoType = determineVideoType(finalVideoUrl) val videoType = determineVideoType(finalVideoUrl)
Log.d(TAG, "Video type: $videoType") Log.d(TAG, "Video type: $videoType")
@@ -133,10 +155,12 @@ class VideoExtractor {
VideoSource( VideoSource(
videoUrl = finalVideoUrl, videoUrl = finalVideoUrl,
videoType = videoType, videoType = videoType,
subtitleTracks = subtitles, subtitleTracks = filteredSubtitles,
title = imdbId title = imdbId
) )
) )
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error extracting video", e) Log.e(TAG, "Error extracting video", e)
Result.failure(VideoExtractionException(e.message ?: "Unknown error", e)) Result.failure(VideoExtractionException(e.message ?: "Unknown error", e))
@@ -144,32 +168,61 @@ class VideoExtractor {
} }
private fun fetchHtml(url: String): String { private fun fetchHtml(url: String): String {
val connection = URL(url).openConnection() as HttpURLConnection if (!isAllowedHost(url)) {
connection.apply { throw VideoExtractionException("Host not allowed: $url")
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
} }
return doHttpFetch(url)
}
val responseCode = connection.responseCode private fun fetchManifest(url: String): String {
Log.d(TAG, "Response code: $responseCode") return doHttpFetch(url)
}
if (responseCode != HttpURLConnection.HTTP_OK) { private fun fetchOpenSubtitles(url: String): String {
throw VideoExtractionException("HTTP error: $responseCode") return doHttpFetch(url, mapOf("X-User-Agent" to "HorrorTV/1.0"))
}
private fun doHttpFetch(url: String, extraHeaders: Map<String, String> = emptyMap()): String {
var connection: HttpURLConnection? = null
try {
connection = (URL(url).openConnection() as HttpURLConnection).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("Accept-Encoding", "gzip, deflate")
setRequestProperty("Referer", PLAYIMDB_BASE)
extraHeaders.forEach { (key, value) -> setRequestProperty(key, value) }
instanceFollowRedirects = true
}
val responseCode = connection.responseCode
Log.d(TAG, "Response code: $responseCode for $url")
if (responseCode != HttpURLConnection.HTTP_OK) {
throw VideoExtractionException("HTTP error: $responseCode")
}
val inputStream = connection.inputStream.let { stream ->
if (connection.contentEncoding?.contains("gzip", ignoreCase = true) == true) {
GZIPInputStream(stream)
} else {
stream
}
}
return inputStream.bufferedReader().use { it.readText() }
} finally {
connection?.disconnect()
} }
return connection.inputStream.bufferedReader().use { it.readText() }
} }
private fun extractVideoUrl(doc: org.jsoup.nodes.Document): String? { private fun extractVideoUrl(doc: org.jsoup.nodes.Document): String? {
val videoElement = doc.selectFirst("video") val videoElement = doc.selectFirst("video")
if (videoElement != null) { if (videoElement != null) {
val src = videoElement.attr("src") val src = videoElement.absUrl("src")
if (src.isNotEmpty()) { if (src.isNotEmpty()) {
Log.d(TAG, "Found video[src]: $src") Log.d(TAG, "Found video[src]: $src")
return src return src
@@ -177,7 +230,7 @@ class VideoExtractor {
val sourceElements = videoElement.select("source") val sourceElements = videoElement.select("source")
for (source in sourceElements) { for (source in sourceElements) {
val srcAttr = source.attr("src") val srcAttr = source.absUrl("src")
if (srcAttr.isNotEmpty() && isValidVideoUrl(srcAttr)) { if (srcAttr.isNotEmpty() && isValidVideoUrl(srcAttr)) {
Log.d(TAG, "Found source[src]: $srcAttr") Log.d(TAG, "Found source[src]: $srcAttr")
return srcAttr return srcAttr
@@ -237,48 +290,350 @@ class VideoExtractor {
} }
private fun isValidVideoUrl(url: String): Boolean { private fun isValidVideoUrl(url: String): Boolean {
return url.contains(".m3u8") || val lower = url.lowercase()
url.contains(".mp4") || return (lower.contains(".m3u8") || lower.contains(".mp4") || lower.contains(".mkv") || lower.contains(".mpd")) &&
url.contains(".mkv") || (url.startsWith("http://") || url.startsWith("https://")) &&
url.contains("stream") || !lower.contains(".js") && !lower.contains(".css") && !lower.contains(".html")
url.contains("video") ||
url.contains("player")
} }
private fun determineVideoType(url: String): VideoType { private fun determineVideoType(url: String): VideoType {
val path = try { URL(url).path.lowercase() } catch (_: Exception) { url.lowercase() }
return when { return when {
url.contains(".m3u8") -> VideoType.HLS path.endsWith(".m3u8") -> VideoType.HLS
url.contains(".mp4") -> VideoType.MP4 path.endsWith(".mp4") -> VideoType.MP4
url.contains(".mpd") -> VideoType.DASH path.endsWith(".mpd") -> VideoType.DASH
else -> VideoType.UNKNOWN else -> VideoType.UNKNOWN
} }
} }
private fun extractSubtitles(doc: org.jsoup.nodes.Document): List<SubtitleTrack> { private fun extractSubtitles(doc: org.jsoup.nodes.Document, htmlContent: String, videoUrl: String?): List<SubtitleTrack> {
val tracks = mutableListOf<SubtitleTrack>() val tracks = mutableListOf<SubtitleTrack>()
val trackElements = doc.select("track") val trackElements = doc.select("track")
for (track in trackElements) { for (track in trackElements) {
val src = track.attr("src") val src = track.absUrl("src")
val srclang = track.attr("srclang") val srclang = track.attr("srclang").lowercase()
val label = track.attr("label").ifEmpty { srclang } val label = track.attr("label").ifEmpty { srclang }
val isDefault = track.attr("default") == "default" val isDefault = track.hasAttr("default")
if (src.isNotEmpty()) { if (src.isNotEmpty() && isAllowedLanguage(srclang, label)) {
tracks.add( tracks.add(
SubtitleTrack( SubtitleTrack(
url = src, url = src,
language = srclang, language = normalizeLanguage(srclang),
label = label, label = label,
isDefault = isDefault isDefault = isDefault,
mimeType = determineMimeType(src)
) )
) )
Log.d(TAG, "Found subtitle: $srclang - $src") Log.d(TAG, "Found track element subtitle: $srclang - $src")
}
}
val subtitlePatterns = listOf(
Regex("\"sub\"\\s*:\\s*\"([^\"]+)\""),
Regex("\"subtitle\"\\s*:\\s*\"([^\"]+)\""),
Regex("\"captions\"\\s*:\\s*\"([^\"]+)\""),
Regex("\"file\"\\s*:\\s*\"([^\"]+\\.vtt[^\"]*)\""),
Regex("\"file\"\\s*:\\s*\"([^\"]+\\.srt[^\"]*)\""),
Regex("'sub'\\s*:\\s*'([^']+)'"),
Regex("'subtitle'\\s*:\\s*'([^']+)'"),
Regex("'captions'\\s*:\\s*'([^']+)'")
)
for (pattern in subtitlePatterns) {
pattern.findAll(htmlContent).forEach { match ->
val url = match.groupValues[1]
if (url.isNotEmpty() && !tracks.any { it.url == url }) {
val cleanUrl = cleanSubtitleUrl(url)
if (cleanUrl.isNotEmpty() && isSubtitleUrl(cleanUrl)) {
tracks.add(
SubtitleTrack(
url = cleanUrl,
language = "en",
label = "English",
isDefault = false,
mimeType = determineMimeType(cleanUrl)
)
)
Log.d(TAG, "Found JS pattern subtitle: $cleanUrl")
}
}
}
}
val vttPattern = Regex("https?://[^\"'<>\\s]+\\.vtt[^\"'<>\\s]*")
vttPattern.findAll(htmlContent).forEach { match ->
val url = match.value
if (!tracks.any { it.url == url }) {
tracks.add(
SubtitleTrack(
url = url,
language = "en",
label = "English",
isDefault = false,
mimeType = "text/vtt"
)
)
Log.d(TAG, "Found VTT URL: $url")
}
}
val srtPattern = Regex("https?://[^\"'<>\\s]+\\.srt[^\"'<>\\s]*")
srtPattern.findAll(htmlContent).forEach { match ->
val url = match.value
if (!tracks.any { it.url == url }) {
tracks.add(
SubtitleTrack(
url = url,
language = "en",
label = "English",
isDefault = false,
mimeType = "application/x-subrip"
)
)
Log.d(TAG, "Found SRT URL: $url")
}
}
val jsonArrayPattern = Regex("\\[[\\s\\S]*?\"url\"[\\s\\S]*?\\]")
jsonArrayPattern.findAll(htmlContent).forEach { match ->
try {
val jsonArray = match.value
val urlPattern = Regex("\"url\"\\s*:\\s*\"([^\"]+)\"")
val langPattern = Regex("\"lang\"\\s*:\\s*\"([^\"]+)\"")
val labelPattern = Regex("\"label\"\\s*:\\s*\"([^\"]+)\"")
urlPattern.findAll(jsonArray).forEach { urlMatch ->
val subUrl = urlMatch.groupValues[1]
val langMatch = langPattern.find(jsonArray)
val labelMatch = labelPattern.find(jsonArray)
val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en"
val subLabel = labelMatch?.groupValues?.get(1) ?: lang
if (subUrl.isNotEmpty() && !tracks.any { it.url == subUrl } && isAllowedLanguage(lang, subLabel)) {
tracks.add(
SubtitleTrack(
url = subUrl,
language = normalizeLanguage(lang),
label = subLabel,
isDefault = false,
mimeType = determineMimeType(subUrl)
)
)
Log.d(TAG, "Found JSON array subtitle: $lang - $subUrl")
}
}
} catch (e: Exception) {
Log.w(TAG, "Error parsing subtitle JSON array: ${e.message}")
}
}
val jwplayerPattern = Regex("\\{[^}]*file:\"([^\"]+)\"[^}]*label:\"([^\"]+)\"[^}]*kind:\"captions\"[^}]*\\}")
jwplayerPattern.findAll(htmlContent).forEach { match ->
val url = match.groupValues[1]
val label = match.groupValues[2].lowercase()
if (url.isNotEmpty() && !tracks.any { it.url == url } && isAllowedLanguage(label, label)) {
tracks.add(
SubtitleTrack(
url = url,
language = normalizeLanguage(label),
label = match.groupValues[2],
isDefault = false,
mimeType = determineMimeType(url)
)
)
Log.d(TAG, "Found JWPlayer subtitle: $label - $url")
}
}
if (videoUrl != null && videoUrl.contains(".m3u8")) {
val hlsSubtitles = extractHlsSubtitles(videoUrl)
for (sub in hlsSubtitles) {
if (!tracks.any { it.url == sub.url }) {
tracks.add(sub)
Log.d(TAG, "Found HLS manifest subtitle: ${sub.language} - ${sub.url}")
}
}
}
return tracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) }
}
private fun extractHlsSubtitles(videoUrl: String): List<SubtitleTrack> {
val tracks = mutableListOf<SubtitleTrack>()
try {
val manifestContent = fetchManifest(videoUrl)
val subtitleMediaPattern = Regex(
"#EXT-X-MEDIA:TYPE=SUBTITLES[^\\n]*LANGUAGE=\"([^\"]+)\"[^\\n]*NAME=\"([^\"]+)\"[^\\n]*(?:DEFAULT=YES)?[^\\n]*URI=\"([^\"]+)\""
)
subtitleMediaPattern.findAll(manifestContent).forEach { match ->
val lang = match.groupValues[1].lowercase()
val name = match.groupValues[2]
val uri = match.groupValues[3]
val fullUrl = if (uri.startsWith("http")) {
uri
} else {
val baseUrl = videoUrl.substringBeforeLast("/") + "/"
baseUrl + uri
}
if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name)) {
tracks.add(
SubtitleTrack(
url = fullUrl,
language = normalizeLanguage(lang),
label = name,
isDefault = match.value.contains("DEFAULT=YES", ignoreCase = true),
mimeType = "text/vtt"
)
)
}
}
val simpleSubtitlePattern = Regex("#EXT-X-MEDIA:TYPE=SUBTITLES[^\n]*")
simpleSubtitlePattern.findAll(manifestContent).forEach { match ->
val line = match.value
val langMatch = Regex("LANGUAGE=\"([^\"]+)\"").find(line)
val nameMatch = Regex("NAME=\"([^\"]+)\"").find(line)
val uriMatch = Regex("URI=\"([^\"]+)\"").find(line)
val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en"
val name = nameMatch?.groupValues?.get(1) ?: lang
val uri = uriMatch?.groupValues?.get(1)
if (uri != null) {
val fullUrl = if (uri.startsWith("http")) {
uri
} else {
val baseUrl = videoUrl.substringBeforeLast("/") + "/"
baseUrl + uri
}
if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name) && !tracks.any { it.url == fullUrl }) {
tracks.add(
SubtitleTrack(
url = fullUrl,
language = normalizeLanguage(lang),
label = name,
isDefault = line.contains("DEFAULT=YES", true),
mimeType = "text/vtt"
)
)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to extract HLS subtitles: ${e.message}")
}
return tracks
}
fun fetchSubtitlesFromOpenSubtitles(imdbId: String): List<SubtitleTrack> {
val tracks = mutableListOf<SubtitleTrack>()
val numericId = imdbId.removePrefix("tt").trim()
if (numericId.isEmpty()) return tracks
val languages = listOf(
"eng" to "en",
"spa" to "es"
)
for ((subLangCode, normalizedLang) in languages) {
val url = "https://rest.opensubtitles.org/search/imdbid-$numericId/sublanguageid-$subLangCode"
try {
val response = fetchOpenSubtitles(url)
val jsonArray = JSONArray(response)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val downloadLink = obj.optString("SubDownloadLink", "")
val subFormat = obj.optString("SubFormat", "srt")
val languageName = obj.optString("LanguageName", normalizedLang)
val isHearingImpaired = obj.optString("SubHearingImpaired", "0") == "1"
if (isHearingImpaired) continue
if (downloadLink.isNotEmpty() && tracks.none { it.language == normalizedLang }) {
tracks.add(
SubtitleTrack(
url = downloadLink,
language = normalizedLang,
label = languageName,
isDefault = false,
mimeType = if (subFormat.equals("vtt", ignoreCase = true)) "text/vtt" else "application/x-subrip"
)
)
Log.d(TAG, "OpenSubtitles track: $normalizedLang - $downloadLink")
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch OpenSubtitles for $subLangCode: ${e.message}")
} }
} }
return tracks return tracks
} }
private fun isAllowedLanguage(lang: String, label: String): Boolean {
val normalizedLang = lang.lowercase().trim()
val normalizedLabel = label.lowercase().trim()
return normalizedLang == "en" || normalizedLang == "es" ||
normalizedLang == "eng" || normalizedLang == "spa" ||
normalizedLang.startsWith("english") || normalizedLang.startsWith("spanish") ||
normalizedLabel.contains("english") || normalizedLabel.contains("spanish") ||
normalizedLabel.contains("español")
}
private fun normalizeLanguage(lang: String): String {
val normalized = lang.lowercase().trim()
return when {
normalized.contains("english") || normalized.contains("eng") -> "en"
normalized.contains("spanish") || normalized.contains("spa") -> "es"
normalized == "en" -> "en"
normalized == "es" -> "es"
else -> normalized.take(2)
}
}
private fun isSubtitleUrl(url: String): Boolean {
return url.contains(".vtt") || url.contains(".srt") ||
url.contains("subtitle") || url.contains("caption") ||
url.contains("sub")
}
private fun cleanSubtitleUrl(url: String, baseUrl: String = ""): String {
val clean = url.trim().removeSurrounding("\"").removeSurrounding("'").trim()
if (clean.isEmpty()) return ""
return when {
clean.startsWith("http://") || clean.startsWith("https://") -> clean
clean.startsWith("/") -> {
try {
val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com" })
URL(base, clean).toString()
} catch (_: Exception) { "https://cloudnestra.com$clean" }
}
else -> {
try {
val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com/" })
URL(base, clean).toString()
} catch (_: Exception) { "" }
}
}
}
private fun determineMimeType(url: String): String {
return when {
url.contains(".srt", ignoreCase = true) -> "application/x-subrip"
url.contains(".vtt", ignoreCase = true) -> "text/vtt"
else -> "text/vtt"
}
}
} }
class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause) class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause)

View File

@@ -44,7 +44,6 @@ object Routes {
@Composable @Composable
fun AppNavigation( fun AppNavigation(
navController: NavHostController, navController: NavHostController,
deepLinkUri: Uri? = null,
modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier
) { ) {
NavHost( NavHost(
@@ -56,8 +55,16 @@ fun AppNavigation(
val viewModel: HomeViewModel = hiltViewModel() val viewModel: HomeViewModel = hiltViewModel()
HomeScreen( HomeScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToSearch = { navController.navigate(Routes.SEARCH) }, onNavigateToSearch = {
onNavigateToDetail = { imdbId -> navController.navigate(Routes.detail(imdbId)) } navController.navigate(Routes.SEARCH) {
launchSingleTop = true
}
},
onNavigateToDetail = { imdbId ->
navController.navigate(Routes.detail(imdbId)) {
launchSingleTop = true
}
}
) )
} }
@@ -66,7 +73,11 @@ fun AppNavigation(
SearchScreen( SearchScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToDetail = { imdbId -> navController.navigate(Routes.detail(imdbId)) } onNavigateToDetail = { imdbId ->
navController.navigate(Routes.detail(imdbId)) {
launchSingleTop = true
}
}
) )
} }
@@ -91,7 +102,11 @@ fun AppNavigation(
imdbId = imdbId, imdbId = imdbId,
viewModel = viewModel, viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToPlayer = { id, title -> navController.navigate(Routes.player(id, title)) } onNavigateToPlayer = { id, title ->
navController.navigate(Routes.player(id, title)) {
launchSingleTop = true
}
}
) )
} }
@@ -111,7 +126,7 @@ fun AppNavigation(
) )
) { ) {
val imdbId = it.arguments?.getString(Routes.Args.IMDB_ID) ?: "" val imdbId = it.arguments?.getString(Routes.Args.IMDB_ID) ?: ""
val title = Uri.decode(it.arguments?.getString(Routes.Args.TITLE) ?: "Horror TV") val title = it.arguments?.getString(Routes.Args.TITLE) ?: "Horror TV"
PlayerScreen( PlayerScreen(
imdbId = imdbId, imdbId = imdbId,

View File

@@ -1,9 +1,8 @@
package com.horrortv.app.presentation package com.horrortv.app.presentation
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.KeyEvent
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -27,23 +26,11 @@ class MainActivity : ComponentActivity() {
controller.hide(WindowInsetsCompat.Type.systemBars()) controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE 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 { setContent {
HorrorTheme { HorrorTheme {
val navController = rememberNavController() val navController = rememberNavController()
AppNavigation( AppNavigation(
navController = navController, navController = navController,
deepLinkUri = deepLinkUri,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
@@ -55,17 +42,19 @@ class MainActivity : ComponentActivity() {
setIntent(intent) setIntent(intent)
} }
override fun onWindowFocusChanged(hasFocus: Boolean) { companion object {
super.onWindowFocusChanged(hasFocus) var keyEventHandler: ((KeyEvent) -> Boolean)? = null
if (hasFocus) { }
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_FULLSCREEN or override fun dispatchKeyEvent(event: KeyEvent): Boolean {
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or keyEventHandler?.let { handler ->
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or if (handler(event)) return true
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
)
} }
return super.dispatchKeyEvent(event)
}
override fun onDestroy() {
keyEventHandler = null
super.onDestroy()
} }
} }

View File

@@ -1,5 +1,6 @@
package com.horrortv.app.presentation.common package com.horrortv.app.presentation.common
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -46,7 +47,10 @@ fun ErrorState(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text(text = message, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorAccent) Text(text = message, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorAccent)
TextButton(onClick = onRetry) { TextButton(
onClick = onRetry,
modifier = Modifier.focusable()
) {
Text("Reintentar", color = HorrorColors.HorrorRed) Text("Reintentar", color = HorrorColors.HorrorRed)
} }
} }

View File

@@ -33,6 +33,8 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import android.view.KeyEvent
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -50,8 +52,6 @@ fun TvErrorDisplay(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
val iconRes = when (error) { val iconRes = when (error) {
is AppError.NetworkError -> android.R.drawable.ic_menu_close_clear_cancel is AppError.NetworkError -> android.R.drawable.ic_menu_close_clear_cancel
is AppError.ApiError -> android.R.drawable.ic_dialog_alert is AppError.ApiError -> android.R.drawable.ic_dialog_alert
@@ -87,6 +87,9 @@ fun TvErrorDisplay(
Text(text = error.userMessage, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorWhite, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(0.6f)) Text(text = error.userMessage, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorWhite, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(0.6f))
if (error.isRetryable) { if (error.isRetryable) {
LaunchedEffect(Unit) {
try { focusRequester.requestFocus() } catch (_: Exception) {}
}
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Box( Box(
@@ -99,7 +102,18 @@ fun TvErrorDisplay(
.focusRequester(focusRequester) .focusRequester(focusRequester)
.focusable(interactionSource = interactionSource) .focusable(interactionSource = interactionSource)
.onFocusChanged { isRetryFocused = it.isFocused } .onFocusChanged { isRetryFocused = it.isFocused }
.clickable(interactionSource = interactionSource, indication = null, onClick = onRetry), .onKeyEvent { event ->
val native = event.nativeKeyEvent ?: return@onKeyEvent false
if (native.action != KeyEvent.ACTION_DOWN) return@onKeyEvent false
if (native.repeatCount > 0) return@onKeyEvent false
val keyCode = native.keyCode
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
onRetry()
true
} else {
false
}
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(text = "REINTENTAR", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite) Text(text = "REINTENTAR", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
@@ -119,8 +133,6 @@ fun TvSnackbarError(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
val dismissColor = if (isDismissFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed val dismissColor = if (isDismissFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
Box( Box(
@@ -143,6 +155,9 @@ fun TvSnackbarError(
) )
Text(text = error.userMessage, style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite, modifier = Modifier.weight(1f)) Text(text = error.userMessage, style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite, modifier = Modifier.weight(1f))
if (error.isRetryable) { if (error.isRetryable) {
LaunchedEffect(Unit) {
try { focusRequester.requestFocus() } catch (_: Exception) {}
}
Box( Box(
modifier = Modifier modifier = Modifier
.width(80.dp) .width(80.dp)

View File

@@ -103,14 +103,20 @@ fun MovieDetailContent(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
kotlinx.coroutines.delay(500) try {
playFocusRequester.requestFocus() kotlinx.coroutines.delay(300)
playFocusRequester.requestFocus()
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
e.printStackTrace()
}
} }
val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
val scale = if (isPlayFocused) 1.08f else 1.0f val scale = if (isPlayFocused) 1.08f else 1.0f
Box(modifier = modifier) { Box(modifier = modifier.focusGroup()) {
AsyncImage( AsyncImage(
model = movie.posterUrl, model = movie.posterUrl,
contentDescription = null, contentDescription = null,
@@ -134,7 +140,7 @@ fun MovieDetailContent(
) )
Column( Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).focusGroup(), modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp) verticalArrangement = Arrangement.spacedBy(24.dp)
) { ) {
Text( Text(
@@ -179,17 +185,25 @@ fun MovieDetailContent(
modifier = Modifier modifier = Modifier
.width(PlayButtonWidth) .width(PlayButtonWidth)
.height(PlayButtonHeight) .height(PlayButtonHeight)
.clip(PlayButtonShape)
.background(playButtonColor)
.border(
width = if (isPlayFocused) 4.dp else 2.dp,
color = if (isPlayFocused) Color.White else HorrorColors.HorrorLightGray,
shape = PlayButtonShape
)
.graphicsLayer { .graphicsLayer {
scaleX = scale scaleX = scale
scaleY = 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) .focusRequester(playFocusRequester)
.focusable(interactionSource = interactionSource) .focusable(interactionSource = remember { MutableInteractionSource() })
.onFocusChanged { isPlayFocused = it.isFocused } .onFocusChanged { isPlayFocused = it.isFocused }
.clickable(interactionSource = interactionSource, indication = null, onClick = onPlayClick), .clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onPlayClick
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(text = "▶ VER PELÍCULA", style = HorrorTypography.PlayButton) Text(text = "▶ VER PELÍCULA", style = HorrorTypography.PlayButton)

View File

@@ -8,6 +8,7 @@ import com.horrortv.app.domain.model.Movie
import com.horrortv.app.domain.repository.MovieRepository import com.horrortv.app.domain.repository.MovieRepository
import com.horrortv.app.domain.repository.Result import com.horrortv.app.domain.repository.Result
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -53,35 +54,51 @@ class DetailViewModel @Inject constructor(
loadJob?.cancel() loadJob?.cancel()
loadJob = viewModelScope.launch { loadJob = viewModelScope.launch {
Log.d(TAG, "Loading movie: $imdbId") Log.d(TAG, "Loading movie: $imdbId")
_uiState.update { it.copy(isLoading = true, error = null, movie = null) } try {
_uiState.update { it.copy(isLoading = true, error = null, movie = null) }
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
repository.getMovieById(imdbId) repository.getMovieById(imdbId)
} }
when (result) { when (result) {
is Result.Success -> { is Result.Success -> {
val movie = result.data val movie = result.data
if (movie != null) { if (movie != null) {
Log.d(TAG, "Loaded movie: ${movie.title}") Log.d(TAG, "Loaded movie: ${movie.title}")
_uiState.update { it.copy(movie = movie, isLoading = false, error = null) } _uiState.update { it.copy(movie = movie, isLoading = false, error = null) }
} else { } else {
Log.w(TAG, "Movie not found: $imdbId") Log.w(TAG, "Movie not found: $imdbId")
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
error = AppError.ValidationError( error = AppError.ValidationError(
userMessage = "No se encontró la película.", userMessage = "No se encontró la película.",
debugMessage = "Movie not found", debugMessage = "Movie not found",
field = "imdbId" field = "imdbId"
)
) )
) }
} }
} }
is Result.Error -> {
Log.e(TAG, "Error loading movie: ${result.error.debugMessage}")
_uiState.update { it.copy(isLoading = false, error = result.error) }
}
} }
is Result.Error -> { } catch (e: CancellationException) {
Log.e(TAG, "Error loading movie: ${result.error.debugMessage}") throw e
_uiState.update { it.copy(isLoading = false, error = result.error) } } catch (e: Exception) {
Log.e(TAG, "Error loading movie", e)
_uiState.update {
it.copy(
isLoading = false,
error = AppError.UnknownError(
userMessage = "Error inesperado. Intenta de nuevo.",
debugMessage = "Unhandled exception: ${e.message}",
cause = e
)
)
} }
} }
} }

View File

@@ -1,5 +1,6 @@
package com.horrortv.app.presentation.home package com.horrortv.app.presentation.home
import android.view.KeyEvent
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -38,6 +39,8 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.horrortv.app.domain.model.Movie import com.horrortv.app.domain.model.Movie
@@ -51,13 +54,11 @@ fun HomeScreen(
viewModel: HomeViewModel, viewModel: HomeViewModel,
onNavigateToSearch: () -> Unit, onNavigateToSearch: () -> Unit,
onNavigateToDetail: (String) -> Unit, onNavigateToDetail: (String) -> Unit,
onExit: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var showExitDialog by remember { mutableStateOf(false) } val context = LocalContext.current
BackHandler {
BackHandler(enabled = true) { (context as? android.app.Activity)?.finishAffinity()
showExitDialog = true
} }
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@@ -102,13 +103,6 @@ fun HomeScreen(
) )
} }
} }
if (showExitDialog) {
ExitConfirmationDialog(
onConfirm = { showExitDialog = false; onExit() },
onDismiss = { showExitDialog = false }
)
}
} }
} }
@@ -141,7 +135,8 @@ private fun CategoriesList(
) { ) {
itemsIndexed( itemsIndexed(
items = categories, items = categories,
key = { _, category -> category.name } key = { _, category -> category.name },
contentType = { _, _ -> "category_row" }
) { index, category -> ) { index, category ->
HorrorRow( HorrorRow(
category = category, category = category,
@@ -173,9 +168,18 @@ fun HomeHeader(onSearchClick: () -> Unit, modifier: Modifier = Modifier) {
.background(if (isSearchFocused) HorrorColors.HorrorGray else Color.Transparent) .background(if (isSearchFocused) HorrorColors.HorrorGray else Color.Transparent)
.border(if (isSearchFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp)) .border(if (isSearchFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp))
.focusRequester(searchFocusRequester) .focusRequester(searchFocusRequester)
.focusable(interactionSource = interactionSource) .focusable()
.onFocusChanged { isSearchFocused = it.isFocused } .onFocusChanged { isSearchFocused = it.isFocused }
.clickable(interactionSource = interactionSource, indication = null, onClick = onSearchClick), .clickable(interactionSource = interactionSource, indication = null, onClick = onSearchClick)
.onKeyEvent { event ->
val keyCode = event.nativeKeyEvent?.keyCode
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
onSearchClick()
true
} else {
false
}
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -210,61 +214,3 @@ fun CategoryPlaceholderRow(modifier: Modifier = Modifier) {
} }
} }
} }
@Composable
fun ExitConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
var isConfirmFocused by remember { mutableStateOf(false) }
val confirmFocusRequester = remember { FocusRequester() }
val confirmInteractionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier.fillMaxSize().background(HorrorColors.HorrorBlack.copy(alpha = 0.85f)),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(text = "¿Salir de Horror TV?", style = HorrorTypography.DetailTitle, color = HorrorColors.HorrorWhite)
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
TextButton(onClick = onDismiss) { Text("No", color = HorrorColors.HorrorWhite) }
Box(
modifier = Modifier
.width(200.dp).height(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isConfirmFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed)
.border(if (isConfirmFocused) 3.dp else 1.dp, if (isConfirmFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(8.dp))
.focusRequester(confirmFocusRequester)
.focusable(interactionSource = confirmInteractionSource)
.onFocusChanged { isConfirmFocused = it.isFocused }
.clickable(interactionSource = confirmInteractionSource, indication = null, onClick = onConfirm),
contentAlignment = Alignment.Center
) {
Text("Sí, salir", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
}
}
}
}
}
@Composable
private fun TextButton(onClick: () -> Unit, content: @Composable () -> Unit) {
var isFocused by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.width(100.dp).height(56.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isFocused) HorrorColors.HorrorGray else Color.Transparent)
.border(if (isFocused) 2.dp else 0.dp, HorrorColors.HorrorWhite, RoundedCornerShape(8.dp))
.focusRequester(focusRequester)
.focusable(interactionSource = interactionSource)
.onFocusChanged { isFocused = it.isFocused }
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick),
contentAlignment = Alignment.Center
) {
content()
}
}

View File

@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -38,12 +39,13 @@ class HomeViewModel @Inject constructor(
loadJob?.cancel() loadJob?.cancel()
loadJob = viewModelScope.launch { loadJob = viewModelScope.launch {
Log.d(TAG, "Loading featured categories") Log.d(TAG, "Loading featured categories")
_uiState.update { it.copy(isLoading = true, error = null) }
try { try {
_uiState.update { it.copy(isLoading = true, error = null) }
val categories = repository.getFeaturedCategories() val categories = repository.getFeaturedCategories()
Log.d(TAG, "Loaded ${categories.size} categories") Log.d(TAG, "Loaded ${categories.size} categories")
_uiState.update { it.copy(categories = categories, isLoading = false) } _uiState.update { it.copy(categories = categories, isLoading = false) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error loading categories", e) Log.e(TAG, "Error loading categories", e)
val appError = if (e is ApiException) { val appError = if (e is ApiException) {

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@@ -31,7 +30,9 @@ fun HorrorRow(
val categoryTitle = remember(category.name) { "${category.name.uppercase()} MOVIES" } val categoryTitle = remember(category.name) { "${category.name.uppercase()} MOVIES" }
Column( Column(
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp) modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) { ) {
Text( Text(
text = categoryTitle, text = categoryTitle,
@@ -42,12 +43,13 @@ fun HorrorRow(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = 48.dp), contentPadding = PaddingValues(horizontal = 48.dp, vertical = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
itemsIndexed( itemsIndexed(
items = category.movies, items = category.movies,
key = { _, movie -> movie.imdbId } key = { _, movie -> movie.imdbId },
contentType = { _, _ -> "poster_card" }
) { index, movie -> ) { index, movie ->
MoviePosterCard( MoviePosterCard(
movie = movie, movie = movie,

View File

@@ -1,9 +1,10 @@
package com.horrortv.app.presentation.home package com.horrortv.app.presentation.home
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -21,7 +22,9 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import android.view.KeyEvent
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.horrortv.app.domain.model.Movie import com.horrortv.app.domain.model.Movie
@@ -50,9 +53,13 @@ fun MoviePosterCard(
localFocusRequester localFocusRequester
} }
val scale = if (isFocused) 1.08f else 1.0f val scale by animateFloatAsState(
val borderW = if (isFocused) 8.dp else 1.dp targetValue = if (isFocused) 1.15f else 1.0f,
val borderC = if (isFocused) Color.Red else HorrorColors.HorrorLightGray animationSpec = tween(durationMillis = 200),
label = "poster_scale"
)
val borderW = if (isFocused) 6.dp else 2.dp
val borderC = if (isFocused) Color(0xFFFF1744) else HorrorColors.HorrorLightGray
Box( Box(
modifier = modifier modifier = modifier
@@ -61,17 +68,25 @@ fun MoviePosterCard(
.graphicsLayer { .graphicsLayer {
scaleX = scale scaleX = scale
scaleY = scale scaleY = scale
shadowElevation = if (isFocused) 24.dp.toPx() else 0.dp.toPx() shadowElevation = if (isFocused) 20.dp.toPx() else 0f
ambientShadowColor = if (isFocused) Color.Red else Color.Transparent spotShadowColor = Color.Black.copy(alpha = 0.5f)
spotShadowColor = if (isFocused) Color.Red else Color.Transparent ambientShadowColor = Color.Black.copy(alpha = 0.3f)
} }
.clip(CardShape) .clip(CardShape)
.background(HorrorColors.HorrorGray) .background(HorrorColors.HorrorGray)
.border(borderW, borderC, CardShape) .border(borderW, borderC, CardShape)
.focusRequester(activeFocusRequester) .focusRequester(activeFocusRequester)
.focusable(interactionSource = interactionSource)
.onFocusChanged { isFocused = it.isFocused } .onFocusChanged { isFocused = it.isFocused }
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick) .clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
.onKeyEvent { event ->
val keyCode = event.nativeKeyEvent?.keyCode
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
onClick()
true
} else {
false
}
}
) { ) {
PosterImage( PosterImage(
url = movie.posterUrl, url = movie.posterUrl,
@@ -80,13 +95,5 @@ fun MoviePosterCard(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
shape = CardShape shape = CardShape
) )
if (isFocused) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Red.copy(alpha = 0.2f))
)
}
} }
} }

View File

@@ -1,6 +1,7 @@
package com.horrortv.app.presentation.search package com.horrortv.app.presentation.search
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -25,6 +26,8 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -37,6 +40,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -46,6 +50,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -57,11 +62,12 @@ import com.horrortv.app.util.PosterSize
import com.horrortv.app.presentation.common.TvErrorDisplay import com.horrortv.app.presentation.common.TvErrorDisplay
import com.horrortv.app.presentation.theme.HorrorColors import com.horrortv.app.presentation.theme.HorrorColors
import com.horrortv.app.presentation.theme.HorrorTypography import com.horrortv.app.presentation.theme.HorrorTypography
import androidx.compose.ui.graphics.graphicsLayer
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private val CardShape = RoundedCornerShape(8.dp) private val CardShape = RoundedCornerShape(8.dp)
private val SearchFieldShape = RoundedCornerShape(8.dp) private val SearchFieldShape = RoundedCornerShape(8.dp)
private val CardWidth = 180.dp
private val CardHeight = 270.dp
@Composable @Composable
fun SearchScreen( fun SearchScreen(
@@ -71,10 +77,18 @@ fun SearchScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var searchQuery by remember { mutableStateOf("") } var searchQuery by rememberSaveable { mutableStateOf("") }
val searchFieldFocusRequester = remember { FocusRequester() } val searchFieldFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { searchFieldFocusRequester.requestFocus() } BackHandler(onBack = onNavigateBack)
LaunchedEffect(Unit) {
try {
searchFieldFocusRequester.requestFocus()
} catch (e: Exception) {
e.printStackTrace()
}
}
val movies = uiState.movies val movies = uiState.movies
val isLoading = uiState.isLoading val isLoading = uiState.isLoading
@@ -98,7 +112,8 @@ fun SearchScreen(
error != null -> TvErrorDisplay(error = error, onRetry = { viewModel.retry() }) error != null -> TvErrorDisplay(error = error, onRetry = { viewModel.retry() })
directMovie != null -> DirectMovieResult( directMovie != null -> DirectMovieResult(
movie = directMovie, movie = directMovie,
onPlayClick = { onNavigateToDetail(directMovie.imdbId) } onPlayClick = { onNavigateToDetail(directMovie.imdbId) },
onNavigateBack = { viewModel.clearDirectMovie() }
) )
searchQuery.isEmpty() -> SearchInitialState() searchQuery.isEmpty() -> SearchInitialState()
movies.isEmpty() -> SearchNoResultsState() movies.isEmpty() -> SearchNoResultsState()
@@ -142,11 +157,11 @@ private fun SearchResultsGrid(
onMovieClick: (Movie) -> Unit onMovieClick: (Movie) -> Unit
) { ) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(5), columns = GridCells.Fixed(4),
contentPadding = PaddingValues(48.dp), contentPadding = PaddingValues(48.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalArrangement = Arrangement.spacedBy(32.dp), verticalArrangement = Arrangement.spacedBy(32.dp),
modifier = Modifier.focusGroup() modifier = Modifier.fillMaxSize().focusGroup()
) { ) {
items(items = movies, key = { it.imdbId }) { movie -> items(items = movies, key = { it.imdbId }) { movie ->
SearchResultCard(movie = movie, onClick = { onMovieClick(movie) }) SearchResultCard(movie = movie, onClick = { onMovieClick(movie) })
@@ -165,13 +180,12 @@ fun SearchHeader(
var isTextFieldFocused by remember { mutableStateOf(false) } var isTextFieldFocused by remember { mutableStateOf(false) }
var isBackFocused by remember { mutableStateOf(false) } var isBackFocused by remember { mutableStateOf(false) }
val backFocusRequester = remember { FocusRequester() } val backFocusRequester = remember { FocusRequester() }
val textFieldInteractionSource = remember { MutableInteractionSource() }
val backInteractionSource = remember { MutableInteractionSource() } val backInteractionSource = remember { MutableInteractionSource() }
val borderColor = if (isTextFieldFocused) HorrorColors.HorrorRed else HorrorColors.HorrorLightGray val borderColor = if (isTextFieldFocused) HorrorColors.HorrorRed else HorrorColors.HorrorLightGray
Row( Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 24.dp), modifier = modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 24.dp).focusGroup(),
horizontalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -180,7 +194,14 @@ fun SearchHeader(
.size(48.dp) .size(48.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(if (isBackFocused) HorrorColors.HorrorGray else Color.Transparent) .background(if (isBackFocused) HorrorColors.HorrorGray else Color.Transparent)
.border(if (isBackFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp)) .border(if (isBackFocused) 8.dp else 0.dp, Color.Red, RoundedCornerShape(8.dp))
.graphicsLayer {
scaleX = if (isBackFocused) 1.08f else 1.0f
scaleY = if (isBackFocused) 1.08f else 1.0f
shadowElevation = if (isBackFocused) 16.dp.toPx() else 0.dp.toPx()
ambientShadowColor = if (isBackFocused) Color.Red else Color.Transparent
spotShadowColor = if (isBackFocused) Color.Red else Color.Transparent
}
.focusRequester(backFocusRequester) .focusRequester(backFocusRequester)
.focusable(interactionSource = backInteractionSource) .focusable(interactionSource = backInteractionSource)
.onFocusChanged { isBackFocused = it.isFocused } .onFocusChanged { isBackFocused = it.isFocused }
@@ -196,7 +217,7 @@ fun SearchHeader(
.height(56.dp) .height(56.dp)
.clip(SearchFieldShape) .clip(SearchFieldShape)
.background(HorrorColors.HorrorGray) .background(HorrorColors.HorrorGray)
.border(2.dp, borderColor, SearchFieldShape) .border(3.dp, borderColor, SearchFieldShape)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
@@ -213,7 +234,6 @@ fun SearchHeader(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.focusRequester(focusRequester) .focusRequester(focusRequester)
.focusable(interactionSource = textFieldInteractionSource)
.onFocusChanged { isTextFieldFocused = it.isFocused } .onFocusChanged { isTextFieldFocused = it.isFocused }
) )
} }
@@ -235,16 +255,26 @@ fun SearchResultCard(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val borderW = if (isFocused) 6.dp else 1.dp val borderW = if (isFocused) 8.dp else 1.dp
val borderC = if (isFocused) HorrorColors.HorrorRed else HorrorColors.HorrorLightGray val borderC = if (isFocused) Color.White else HorrorColors.HorrorLightGray
val scale = if (isFocused) 1.08f else 1.0f val scale by animateFloatAsState(
targetValue = if (isFocused) 1.12f else 1.0f,
animationSpec = tween(200),
label = "search_card_scale"
)
Column(modifier = modifier.width(180.dp)) { Column(modifier = modifier.width(CardWidth)) {
Box( Box(
modifier = Modifier modifier = Modifier
.width(180.dp) .width(CardWidth)
.height(270.dp) .height(CardHeight)
.graphicsLayer { scaleX = scale; scaleY = scale } .graphicsLayer {
scaleX = scale
scaleY = scale
shadowElevation = if (isFocused) 24.dp.toPx() else 0.dp.toPx()
ambientShadowColor = if (isFocused) Color.Black else Color.Transparent
spotShadowColor = if (isFocused) Color.Black else Color.Transparent
}
.bringIntoViewRequester(bringIntoViewRequester) .bringIntoViewRequester(bringIntoViewRequester)
.clip(CardShape) .clip(CardShape)
.background(HorrorColors.HorrorGray) .background(HorrorColors.HorrorGray)
@@ -254,7 +284,11 @@ fun SearchResultCard(
.onFocusChanged { .onFocusChanged {
isFocused = it.isFocused isFocused = it.isFocused
if (it.isFocused) { if (it.isFocused) {
coroutineScope.launch { bringIntoViewRequester.bringIntoView() } try {
coroutineScope.launch { bringIntoViewRequester.bringIntoView() }
} catch (e: Exception) {
e.printStackTrace()
}
} }
} }
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick) .clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
@@ -268,8 +302,12 @@ fun SearchResultCard(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
if (!isFocused) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)))
}
if (isFocused) { if (isFocused) {
Box(modifier = Modifier.fillMaxSize().background(Color.Red.copy(alpha = 0.2f))) Box(modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.15f)))
} }
} }
@@ -284,13 +322,22 @@ fun SearchResultCard(
fun DirectMovieResult( fun DirectMovieResult(
movie: Movie, movie: Movie,
onPlayClick: () -> Unit, onPlayClick: () -> Unit,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var isPlayFocused by remember { mutableStateOf(false) } var isPlayFocused by remember { mutableStateOf(false) }
val playFocusRequester = remember { FocusRequester() } val playFocusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(Unit) { playFocusRequester.requestFocus() } BackHandler(onBack = onNavigateBack)
LaunchedEffect(Unit) {
try {
playFocusRequester.requestFocus()
} catch (e: Exception) {
e.printStackTrace()
}
}
val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
@@ -322,9 +369,16 @@ fun DirectMovieResult(
modifier = Modifier modifier = Modifier
.width(350.dp) .width(350.dp)
.height(60.dp) .height(60.dp)
.graphicsLayer {
scaleX = if (isPlayFocused) 1.05f else 1.0f
scaleY = if (isPlayFocused) 1.05f else 1.0f
shadowElevation = if (isPlayFocused) 16.dp.toPx() else 0.dp.toPx()
ambientShadowColor = if (isPlayFocused) Color.Red else Color.Transparent
spotShadowColor = if (isPlayFocused) Color.Red else Color.Transparent
}
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(playButtonColor) .background(playButtonColor)
.border(if (isPlayFocused) 4.dp else 2.dp, if (isPlayFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(12.dp)) .border(if (isPlayFocused) 8.dp else 2.dp, if (isPlayFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(12.dp))
.focusRequester(playFocusRequester) .focusRequester(playFocusRequester)
.focusable(interactionSource = interactionSource) .focusable(interactionSource = interactionSource)
.onFocusChanged { isPlayFocused = it.isFocused } .onFocusChanged { isPlayFocused = it.isFocused }

View File

@@ -8,6 +8,7 @@ import com.horrortv.app.domain.model.Movie
import com.horrortv.app.domain.repository.MovieRepository import com.horrortv.app.domain.repository.MovieRepository
import com.horrortv.app.domain.repository.Result import com.horrortv.app.domain.repository.Result
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -66,12 +67,28 @@ class SearchViewModel @Inject constructor(
searchJob?.cancel() searchJob?.cancel()
searchJob = viewModelScope.launch { searchJob = viewModelScope.launch {
Log.d(TAG, "Searching for: $query") Log.d(TAG, "Searching for: $query")
_uiState.update { it.copy(isLoading = true, error = null, directMovie = null) } try {
_uiState.update { it.copy(isLoading = true, error = null, directMovie = null) }
if (query.isValidImdbId()) { if (query.isValidImdbId()) {
searchById(query) searchById(query)
} else { } else {
searchByName(query) searchByName(query)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Error searching", e)
_uiState.update {
it.copy(
isLoading = false,
error = AppError.UnknownError(
userMessage = "Error inesperado. Intenta de nuevo.",
debugMessage = "Unhandled exception: ${e.message}",
cause = e
)
)
}
} }
} }
} }
@@ -129,6 +146,10 @@ class SearchViewModel @Inject constructor(
} }
} }
fun clearDirectMovie() {
_uiState.update { it.copy(directMovie = null) }
}
fun clearResults() { fun clearResults() {
searchJob?.cancel() searchJob?.cancel()
_searchQuery.value = "" _searchQuery.value = ""

View File

@@ -4,10 +4,10 @@ import androidx.compose.ui.graphics.Color
object HorrorColors { object HorrorColors {
val HorrorBlack = Color(0xFF121212) val HorrorBlack = Color(0xFF121212)
val HorrorRed = Color(0xFFCC0000) // Brightened for TV contrast (was 0xFF8B0000) val HorrorRed = Color(0xFFFF1744) // brighter red for TV visibility
val HorrorGray = Color(0xFF1E1E1E) val HorrorGray = Color(0xFF2A2A2A)
val HorrorDarkGray = Color(0xFF1A1A1A) // Lightened from 0xFF0A0A0A val HorrorDarkGray = Color(0xFF0A0A0A)
val HorrorLightGray = Color(0xFF2A2A2A) val HorrorLightGray = Color(0xFFBBBBBB)
val HorrorWhite = Color(0xFFE0E0E0) val HorrorWhite = Color(0xFFE0E0E0)
val HorrorAccent = Color(0xFFFF1744) val HorrorAccent = Color(0xFFFF1744)
} }

View File

@@ -1,13 +1,8 @@
package com.horrortv.app.presentation.theme package com.horrortv.app.presentation.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable 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( private val HorrorColorScheme = darkColorScheme(
primary = HorrorColors.HorrorRed, primary = HorrorColors.HorrorRed,
@@ -19,7 +14,10 @@ private val HorrorColorScheme = darkColorScheme(
onSecondary = HorrorColors.HorrorWhite, onSecondary = HorrorColors.HorrorWhite,
onTertiary = HorrorColors.HorrorWhite, onTertiary = HorrorColors.HorrorWhite,
onBackground = HorrorColors.HorrorWhite, onBackground = HorrorColors.HorrorWhite,
onSurface = HorrorColors.HorrorWhite onSurface = HorrorColors.HorrorWhite,
outline = HorrorColors.HorrorLightGray,
surfaceVariant = HorrorColors.HorrorLightGray,
onSurfaceVariant = HorrorColors.HorrorWhite
) )
private val HorrorTypographyConfig = androidx.compose.material3.Typography( private val HorrorTypographyConfig = androidx.compose.material3.Typography(
@@ -41,10 +39,7 @@ private val HorrorTypographyConfig = androidx.compose.material3.Typography(
) )
@Composable @Composable
fun HorrorTheme( fun HorrorTheme(content: @Composable () -> Unit) {
darkTheme: Boolean = true,
content: @Composable () -> Unit
) {
MaterialTheme( MaterialTheme(
colorScheme = HorrorColorScheme, colorScheme = HorrorColorScheme,
typography = HorrorTypographyConfig, typography = HorrorTypographyConfig,

View File

@@ -9,70 +9,75 @@ object HorrorTypography {
val MovieTitle = TextStyle( val MovieTitle = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 28.sp, fontSize = 32.sp,
lineHeight = 36.sp lineHeight = 44.sp
) )
val MovieYear = TextStyle( val MovieYear = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 24.sp, fontSize = 28.sp,
lineHeight = 32.sp lineHeight = 42.sp,
letterSpacing = 0.5.sp
) )
val CategoryTitle = TextStyle( val CategoryTitle = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 32.sp, fontSize = 36.sp,
lineHeight = 42.sp lineHeight = 49.sp
) )
val DetailTitle = TextStyle( val DetailTitle = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 38.sp, fontSize = 40.sp,
lineHeight = 50.sp lineHeight = 54.sp
) )
val DetailRating = TextStyle( val DetailRating = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 28.sp, fontSize = 32.sp,
lineHeight = 36.sp lineHeight = 44.sp
) )
val DetailInfo = TextStyle( val DetailInfo = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 24.sp, fontSize = 28.sp,
lineHeight = 32.sp lineHeight = 42.sp,
letterSpacing = 0.5.sp
) )
val DetailGenre = TextStyle( val DetailGenre = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 24.sp, fontSize = 28.sp,
lineHeight = 32.sp lineHeight = 42.sp,
letterSpacing = 0.5.sp
) )
val DetailPlot = TextStyle( val DetailPlot = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 26.sp, fontSize = 26.sp,
lineHeight = 36.sp lineHeight = 39.sp,
letterSpacing = 0.5.sp
) )
val PlayButton = TextStyle( val PlayButton = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 28.sp, fontSize = 32.sp,
lineHeight = 36.sp lineHeight = 44.sp
) )
val SearchHint = TextStyle( val SearchHint = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 24.sp, fontSize = 28.sp,
lineHeight = 32.sp lineHeight = 42.sp,
letterSpacing = 0.5.sp
) )
} }

View File

@@ -1,7 +1,7 @@
package com.horrortv.app.util package com.horrortv.app.util
object Constants { object Constants {
const val OMDB_API_KEY = "5854c81e" val OMDB_API_KEY: String = com.horrortv.app.BuildConfig.OMDB_API_KEY
const val OMDB_BASE_URL = "https://www.omdbapi.com/" const val OMDB_BASE_URL = "https://www.omdbapi.com/"
val HORROR_CATEGORIES = listOf( val HORROR_CATEGORIES = listOf(

View File

@@ -4,7 +4,7 @@ import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
@@ -22,10 +22,12 @@ class NetworkStatus @Inject constructor(
fun isNetworkAvailable(): Boolean { fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET))
} }
fun isMeteredNetwork(): Boolean { fun isMeteredNetwork(): Boolean {
@@ -43,25 +45,34 @@ class NetworkStatus @Inject constructor(
fun observeNetworkStatus(): Flow<NetworkState> = callbackFlow { fun observeNetworkStatus(): Flow<NetworkState> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() { val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
trySend(NetworkState.Available()) // Don't emit here — wait for onCapabilitiesChanged which has full state
}
override fun onLost(network: Network) {
trySend(NetworkState.Unavailable)
} }
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
val wifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) val wifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
trySend(NetworkState.Available(metered = metered, wifi = wifi)) val cellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val vpn = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
if (hasInternet && validated) {
trySend(NetworkState.Available(wifi = wifi, metered = metered))
} else {
trySend(NetworkState.Unavailable)
}
}
override fun onLost(network: Network) {
// Check if there's still another active network
val activeNetwork = connectivityManager.activeNetworkInfo?.isConnectedOrConnecting == true
if (!activeNetwork) {
trySend(NetworkState.Unavailable)
}
} }
} }
val request = NetworkRequest.Builder() connectivityManager.registerDefaultNetworkCallback(callback)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
trySend(if (isNetworkAvailable()) NetworkState.Available() else NetworkState.Unavailable) trySend(if (isNetworkAvailable()) NetworkState.Available() else NetworkState.Unavailable)

View File

@@ -1,7 +1,7 @@
package com.horrortv.app.util package com.horrortv.app.util
object PosterSize { object PosterSize {
const val CARD = 600 const val CARD = 300
const val DETAIL = 800 const val DETAIL = 800
const val BACKGROUND = 2160 const val BACKGROUND = 2160
const val THUMBNAIL = 300 const val THUMBNAIL = 300