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
versionName = "1.0"
buildConfigField("String", "APP_NAME", "\"HorrorTV\"")
buildConfigField("String", "OMDB_API_KEY", "\"${project.findProperty("OMDB_API_KEY") ?: "5854c81e"}\"")
}
signingConfigs {
@@ -75,7 +76,7 @@ android {
kotlinOptions {
jvmTarget = "17"
freeCompilerArgs += listOf(
"-Xopt-in=kotlin.RequiresOptIn",
"-opt-in=kotlin.RequiresOptIn",
"-Xjvm-default=all"
)
}
@@ -142,6 +143,11 @@ dependencies {
implementation("androidx.media3:media3-common:1.4.0")
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("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")

View File

@@ -40,9 +40,6 @@
@kotlinx.coroutines.InternalCoroutinesApi <methods>;
}
-keep class kotlin.** { *; }
-keep class * implements kotlin.** { *; }
-assumenosideeffects class android.util.Log {
public static boolean isLoggable(...);
public static int v(...);
@@ -70,3 +67,24 @@
-dontwarn kotlin.Unit
-dontwarn retrofit2.Platform$Java8
-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
android:name=".HorrorTvApp"
android:enableOnBackInvokedCallback="true"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
@@ -23,7 +24,8 @@
android:name=".presentation.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:screenOrientation="landscape">
android:screenOrientation="landscape"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<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
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("Title") val title: String = "",
@SerializedName("Year") val year: String = "",
@SerializedName("Rated") val rated: String? = null,
@SerializedName("Released") val released: String? = null,
@SerializedName("Runtime") val runtime: String? = null,
@SerializedName("Genre") val genre: String? = null,
@SerializedName("Director") val director: String? = null,
@SerializedName("Writer") val writer: String? = null,
@SerializedName("Actors") val actors: String? = null,
@SerializedName("Plot") val plot: String? = null,
@SerializedName("Language") val language: String? = null,
@SerializedName("Country") val country: String? = null,
@SerializedName("Awards") val awards: String? = null,
@SerializedName("Poster") val poster: String? = null,
@SerializedName("Metascore") val metascore: String? = null,
@SerializedName("imdbRating") val imdbRating: String? = null,
@SerializedName("imdbVotes") val imdbVotes: String? = null,
@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"

View File

@@ -3,20 +3,20 @@ 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?
@SerializedName("Search") val search: List<OmdbMovieSearchDto> = emptyList(),
@SerializedName("totalResults") val totalResults: String? = null,
@SerializedName("Response") val response: String = "",
@SerializedName("Error") val error: String? = null
) {
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?
@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? = null
) {
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.util.ApiException
import com.horrortv.app.util.Constants
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -61,7 +62,7 @@ class MovieRepositoryImpl @Inject constructor(
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")
coroutineScope {
@@ -94,6 +95,8 @@ class MovieRepositoryImpl @Inject constructor(
putInCache(categoryCache, cacheKey, movies, categoryTtlMs())
MovieCategory(category, movies)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Error fetching category: $category", e)
MovieCategory(category, emptyList())
@@ -115,7 +118,7 @@ class MovieRepositoryImpl @Inject constructor(
return Result.Success(cached)
}
return withContext(Dispatchers.Default) {
return withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Searching for: $query")
val response = apiService.searchMovies(query, page = 1)
@@ -139,13 +142,11 @@ class MovieRepositoryImpl @Inject constructor(
putInCache(searchCache, cacheKey, movies, searchTtlMs())
Result.Success(movies)
}
} catch (e: CancellationException) {
throw e
} 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
)
val appError = ApiException.fromThrowable(e).toAppError()
Result.Error(appError)
}
}
@@ -161,7 +162,7 @@ class MovieRepositoryImpl @Inject constructor(
return Result.Success(cached)
}
return withContext(Dispatchers.Default) {
return withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Fetching movie detail: $imdbId")
val detail = apiService.getMovieDetail(imdbId)
@@ -189,13 +190,11 @@ class MovieRepositoryImpl @Inject constructor(
putInCache(detailCache, cacheKey, movie, detailTtlMs())
Result.Success(movie)
}
} catch (e: CancellationException) {
throw e
} 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
)
val appError = ApiException.fromThrowable(e).toAppError()
Result.Error(appError)
}
}

View File

@@ -30,9 +30,13 @@ object NetworkModule {
.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 {
.apply {
if (com.horrortv.app.BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
}
}
.build()
}

View File

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

View File

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

View File

@@ -11,7 +11,16 @@ data class SubtitleTrack(
val url: String,
val language: 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(
@@ -19,5 +28,15 @@ data class VideoSource(
val videoType: VideoType,
val subtitleTracks: List<SubtitleTrack> = emptyList(),
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.VideoType
import com.horrortv.app.domain.model.SubtitleTrack
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.json.JSONArray
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.GZIPInputStream
class VideoExtractor {
@@ -17,10 +20,24 @@ class 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
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) {
try {
if (!IMDB_ID_PATTERN.matches(imdbId)) {
return@withContext Result.failure(VideoExtractionException("Invalid IMDb ID: $imdbId"))
}
Log.d(TAG, "Extracting video for: $imdbId")
val embedUrl = "$STREAMIMDB_BASE$imdbId"
@@ -28,24 +45,16 @@ class VideoExtractor {
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)
var doc = Jsoup.parse(html, embedUrl)
var finalHtml = 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")
val iframeUrl = doc.selectFirst("iframe")?.absUrl("src")
if (!iframeUrl.isNullOrEmpty()) {
Log.d(TAG, "Found iframe redirect: $iframeUrl")
try {
val iframeHtml = fetchHtml(fullIframeUrl)
val iframeHtml = fetchHtml(iframeUrl)
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)
@@ -62,8 +71,8 @@ class VideoExtractor {
try {
val innerHtml = fetchHtml(fullInnerUrl)
Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}")
Log.d(TAG, "Prorcp FULL HTML: $innerHtml")
doc = Jsoup.parse(innerHtml)
doc = Jsoup.parse(innerHtml, fullInnerUrl)
finalHtml = innerHtml
} catch (e: Exception) {
Log.w(TAG, "Failed prorcp fetch: ${e.message}")
}
@@ -78,8 +87,8 @@ class VideoExtractor {
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)
doc = Jsoup.parse(innerHtml, fullInnerUrl)
finalHtml = innerHtml
} catch (e: Exception) {
Log.w(TAG, "Failed prorcp direct fetch: ${e.message}")
}
@@ -98,9 +107,6 @@ class VideoExtractor {
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",
@@ -109,7 +115,7 @@ class VideoExtractor {
)
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)
if (pathMatch != null) {
val path = pathMatch.groupValues[1]
@@ -126,6 +132,22 @@ class VideoExtractor {
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)
Log.d(TAG, "Video type: $videoType")
@@ -133,10 +155,12 @@ class VideoExtractor {
VideoSource(
videoUrl = finalVideoUrl,
videoType = videoType,
subtitleTracks = subtitles,
subtitleTracks = filteredSubtitles,
title = imdbId
)
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Error extracting video", e)
Result.failure(VideoExtractionException(e.message ?: "Unknown error", e))
@@ -144,32 +168,61 @@ class VideoExtractor {
}
private fun fetchHtml(url: String): String {
val connection = URL(url).openConnection() as HttpURLConnection
connection.apply {
if (!isAllowedHost(url)) {
throw VideoExtractionException("Host not allowed: $url")
}
return doHttpFetch(url)
}
private fun fetchManifest(url: String): String {
return doHttpFetch(url)
}
private fun fetchOpenSubtitles(url: String): String {
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")
Log.d(TAG, "Response code: $responseCode for $url")
if (responseCode != HttpURLConnection.HTTP_OK) {
throw VideoExtractionException("HTTP error: $responseCode")
}
return connection.inputStream.bufferedReader().use { it.readText() }
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()
}
}
private fun extractVideoUrl(doc: org.jsoup.nodes.Document): String? {
val videoElement = doc.selectFirst("video")
if (videoElement != null) {
val src = videoElement.attr("src")
val src = videoElement.absUrl("src")
if (src.isNotEmpty()) {
Log.d(TAG, "Found video[src]: $src")
return src
@@ -177,7 +230,7 @@ class VideoExtractor {
val sourceElements = videoElement.select("source")
for (source in sourceElements) {
val srcAttr = source.attr("src")
val srcAttr = source.absUrl("src")
if (srcAttr.isNotEmpty() && isValidVideoUrl(srcAttr)) {
Log.d(TAG, "Found source[src]: $srcAttr")
return srcAttr
@@ -237,48 +290,350 @@ class VideoExtractor {
}
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")
val lower = url.lowercase()
return (lower.contains(".m3u8") || lower.contains(".mp4") || lower.contains(".mkv") || lower.contains(".mpd")) &&
(url.startsWith("http://") || url.startsWith("https://")) &&
!lower.contains(".js") && !lower.contains(".css") && !lower.contains(".html")
}
private fun determineVideoType(url: String): VideoType {
val path = try { URL(url).path.lowercase() } catch (_: Exception) { url.lowercase() }
return when {
url.contains(".m3u8") -> VideoType.HLS
url.contains(".mp4") -> VideoType.MP4
url.contains(".mpd") -> VideoType.DASH
path.endsWith(".m3u8") -> VideoType.HLS
path.endsWith(".mp4") -> VideoType.MP4
path.endsWith(".mpd") -> VideoType.DASH
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 trackElements = doc.select("track")
for (track in trackElements) {
val src = track.attr("src")
val srclang = track.attr("srclang")
val src = track.absUrl("src")
val srclang = track.attr("srclang").lowercase()
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(
SubtitleTrack(
url = src,
language = srclang,
language = normalizeLanguage(srclang),
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
}
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)

View File

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

View File

@@ -1,9 +1,8 @@
package com.horrortv.app.presentation
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.KeyEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
@@ -27,23 +26,11 @@ class MainActivity : ComponentActivity() {
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()
)
}
@@ -55,17 +42,19 @@ class MainActivity : ComponentActivity() {
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
)
companion object {
var keyEventHandler: ((KeyEvent) -> Boolean)? = null
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
keyEventHandler?.let { handler ->
if (handler(event)) return true
}
return super.dispatchKeyEvent(event)
}
override fun onDestroy() {
keyEventHandler = null
super.onDestroy()
}
}

View File

@@ -1,5 +1,6 @@
package com.horrortv.app.presentation.common
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -46,7 +47,10 @@ fun ErrorState(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = message, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorAccent)
TextButton(onClick = onRetry) {
TextButton(
onClick = onRetry,
modifier = Modifier.focusable()
) {
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.onFocusChanged
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -50,8 +52,6 @@ fun TvErrorDisplay(
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
@@ -87,6 +87,9 @@ fun TvErrorDisplay(
Text(text = error.userMessage, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorWhite, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(0.6f))
if (error.isRetryable) {
LaunchedEffect(Unit) {
try { focusRequester.requestFocus() } catch (_: Exception) {}
}
Spacer(modifier = Modifier.height(24.dp))
Box(
@@ -99,7 +102,18 @@ fun TvErrorDisplay(
.focusRequester(focusRequester)
.focusable(interactionSource = interactionSource)
.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
) {
Text(text = "REINTENTAR", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
@@ -119,8 +133,6 @@ fun TvSnackbarError(
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
val dismissColor = if (isDismissFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
Box(
@@ -143,6 +155,9 @@ fun TvSnackbarError(
)
Text(text = error.userMessage, style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite, modifier = Modifier.weight(1f))
if (error.isRetryable) {
LaunchedEffect(Unit) {
try { focusRequester.requestFocus() } catch (_: Exception) {}
}
Box(
modifier = Modifier
.width(80.dp)

View File

@@ -103,14 +103,20 @@ fun MovieDetailContent(
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(500)
try {
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 scale = if (isPlayFocused) 1.08f else 1.0f
Box(modifier = modifier) {
Box(modifier = modifier.focusGroup()) {
AsyncImage(
model = movie.posterUrl,
contentDescription = null,
@@ -134,7 +140,7 @@ fun MovieDetailContent(
)
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).focusGroup(),
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
@@ -179,17 +185,25 @@ fun MovieDetailContent(
modifier = Modifier
.width(PlayButtonWidth)
.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 {
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)
.focusable(interactionSource = remember { MutableInteractionSource() })
.onFocusChanged { isPlayFocused = it.isFocused }
.clickable(interactionSource = interactionSource, indication = null, onClick = onPlayClick),
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onPlayClick
),
contentAlignment = Alignment.Center
) {
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.Result
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -53,6 +54,7 @@ class DetailViewModel @Inject constructor(
loadJob?.cancel()
loadJob = viewModelScope.launch {
Log.d(TAG, "Loading movie: $imdbId")
try {
_uiState.update { it.copy(isLoading = true, error = null, movie = null) }
val result = withContext(Dispatchers.IO) {
@@ -84,6 +86,21 @@ class DetailViewModel @Inject constructor(
_uiState.update { it.copy(isLoading = false, error = result.error) }
}
}
} catch (e: CancellationException) {
throw e
} 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
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
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.onFocusChanged
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.unit.dp
import com.horrortv.app.domain.model.Movie
@@ -51,13 +54,11 @@ 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 context = LocalContext.current
BackHandler {
(context as? android.app.Activity)?.finishAffinity()
}
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(
items = categories,
key = { _, category -> category.name }
key = { _, category -> category.name },
contentType = { _, _ -> "category_row" }
) { index, category ->
HorrorRow(
category = category,
@@ -173,9 +168,18 @@ fun HomeHeader(onSearchClick: () -> Unit, modifier: Modifier = Modifier) {
.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)
.focusable()
.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
) {
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.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -38,12 +39,13 @@ class HomeViewModel @Inject constructor(
loadJob?.cancel()
loadJob = viewModelScope.launch {
Log.d(TAG, "Loading featured categories")
_uiState.update { it.copy(isLoading = true, error = null) }
try {
_uiState.update { it.copy(isLoading = true, error = null) }
val categories = repository.getFeaturedCategories()
Log.d(TAG, "Loaded ${categories.size} categories")
_uiState.update { it.copy(categories = categories, isLoading = false) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.e(TAG, "Error loading categories", e)
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.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
@@ -31,7 +30,9 @@ fun HorrorRow(
val categoryTitle = remember(category.name) { "${category.name.uppercase()} MOVIES" }
Column(
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp)
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Text(
text = categoryTitle,
@@ -42,12 +43,13 @@ fun HorrorRow(
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
contentPadding = PaddingValues(horizontal = 48.dp),
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
itemsIndexed(
items = category.movies,
key = { _, movie -> movie.imdbId }
key = { _, movie -> movie.imdbId },
contentType = { _, _ -> "poster_card" }
) { index, movie ->
MoviePosterCard(
movie = movie,

View File

@@ -1,9 +1,10 @@
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.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
@@ -21,7 +22,9 @@ 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 android.view.KeyEvent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import com.horrortv.app.domain.model.Movie
@@ -50,9 +53,13 @@ fun MoviePosterCard(
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
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.15f else 1.0f,
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(
modifier = modifier
@@ -61,17 +68,25 @@ fun MoviePosterCard(
.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
shadowElevation = if (isFocused) 20.dp.toPx() else 0f
spotShadowColor = Color.Black.copy(alpha = 0.5f)
ambientShadowColor = Color.Black.copy(alpha = 0.3f)
}
.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)
.onKeyEvent { event ->
val keyCode = event.nativeKeyEvent?.keyCode
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
onClick()
true
} else {
false
}
}
) {
PosterImage(
url = movie.posterUrl,
@@ -80,13 +95,5 @@ fun MoviePosterCard(
contentScale = ContentScale.Crop,
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
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.ExperimentalFoundationApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator
@@ -37,6 +40,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
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.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)
private val CardWidth = 180.dp
private val CardHeight = 270.dp
@Composable
fun SearchScreen(
@@ -71,10 +77,18 @@ fun SearchScreen(
modifier: Modifier = Modifier
) {
val uiState by viewModel.uiState.collectAsState()
var searchQuery by remember { mutableStateOf("") }
var searchQuery by rememberSaveable { mutableStateOf("") }
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 isLoading = uiState.isLoading
@@ -98,7 +112,8 @@ fun SearchScreen(
error != null -> TvErrorDisplay(error = error, onRetry = { viewModel.retry() })
directMovie != null -> DirectMovieResult(
movie = directMovie,
onPlayClick = { onNavigateToDetail(directMovie.imdbId) }
onPlayClick = { onNavigateToDetail(directMovie.imdbId) },
onNavigateBack = { viewModel.clearDirectMovie() }
)
searchQuery.isEmpty() -> SearchInitialState()
movies.isEmpty() -> SearchNoResultsState()
@@ -142,11 +157,11 @@ private fun SearchResultsGrid(
onMovieClick: (Movie) -> Unit
) {
LazyVerticalGrid(
columns = GridCells.Fixed(5),
columns = GridCells.Fixed(4),
contentPadding = PaddingValues(48.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalArrangement = Arrangement.spacedBy(32.dp),
modifier = Modifier.focusGroup()
modifier = Modifier.fillMaxSize().focusGroup()
) {
items(items = movies, key = { it.imdbId }) { movie ->
SearchResultCard(movie = movie, onClick = { onMovieClick(movie) })
@@ -165,13 +180,12 @@ fun SearchHeader(
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),
modifier = modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 24.dp).focusGroup(),
horizontalArrangement = Arrangement.spacedBy(24.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -180,7 +194,14 @@ fun SearchHeader(
.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))
.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)
.focusable(interactionSource = backInteractionSource)
.onFocusChanged { isBackFocused = it.isFocused }
@@ -196,7 +217,7 @@ fun SearchHeader(
.height(56.dp)
.clip(SearchFieldShape)
.background(HorrorColors.HorrorGray)
.border(2.dp, borderColor, SearchFieldShape)
.border(3.dp, borderColor, SearchFieldShape)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart
) {
@@ -213,7 +234,6 @@ fun SearchHeader(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.focusable(interactionSource = textFieldInteractionSource)
.onFocusChanged { isTextFieldFocused = it.isFocused }
)
}
@@ -235,16 +255,26 @@ fun SearchResultCard(
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
val borderW = if (isFocused) 8.dp else 1.dp
val borderC = if (isFocused) Color.White else HorrorColors.HorrorLightGray
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(
modifier = Modifier
.width(180.dp)
.height(270.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.width(CardWidth)
.height(CardHeight)
.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)
.clip(CardShape)
.background(HorrorColors.HorrorGray)
@@ -254,7 +284,11 @@ fun SearchResultCard(
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) {
try {
coroutineScope.launch { bringIntoViewRequester.bringIntoView() }
} catch (e: Exception) {
e.printStackTrace()
}
}
}
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
@@ -268,8 +302,12 @@ fun SearchResultCard(
modifier = Modifier.fillMaxSize()
)
if (!isFocused) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)))
}
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(
movie: Movie,
onPlayClick: () -> Unit,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier
) {
var isPlayFocused by remember { mutableStateOf(false) }
val playFocusRequester = remember { FocusRequester() }
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
@@ -322,9 +369,16 @@ fun DirectMovieResult(
modifier = Modifier
.width(350.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))
.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)
.focusable(interactionSource = interactionSource)
.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.Result
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -66,6 +67,7 @@ class SearchViewModel @Inject constructor(
searchJob?.cancel()
searchJob = viewModelScope.launch {
Log.d(TAG, "Searching for: $query")
try {
_uiState.update { it.copy(isLoading = true, error = null, directMovie = null) }
if (query.isValidImdbId()) {
@@ -73,6 +75,21 @@ class SearchViewModel @Inject constructor(
} else {
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() {
searchJob?.cancel()
_searchQuery.value = ""

View File

@@ -4,10 +4,10 @@ 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 HorrorRed = Color(0xFFFF1744) // brighter red for TV visibility
val HorrorGray = Color(0xFF2A2A2A)
val HorrorDarkGray = Color(0xFF0A0A0A)
val HorrorLightGray = Color(0xFFBBBBBB)
val HorrorWhite = Color(0xFFE0E0E0)
val HorrorAccent = Color(0xFFFF1744)
}

View File

@@ -1,13 +1,8 @@
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,
@@ -19,7 +14,10 @@ private val HorrorColorScheme = darkColorScheme(
onSecondary = HorrorColors.HorrorWhite,
onTertiary = 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(
@@ -41,10 +39,7 @@ private val HorrorTypographyConfig = androidx.compose.material3.Typography(
)
@Composable
fun HorrorTheme(
darkTheme: Boolean = true,
content: @Composable () -> Unit
) {
fun HorrorTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = HorrorColorScheme,
typography = HorrorTypographyConfig,

View File

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

View File

@@ -1,7 +1,7 @@
package com.horrortv.app.util
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/"
val HORROR_CATEGORIES = listOf(

View File

@@ -4,7 +4,7 @@ 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
@@ -22,10 +22,12 @@ class NetworkStatus @Inject constructor(
fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET))
}
fun isMeteredNetwork(): Boolean {
@@ -43,25 +45,34 @@ class NetworkStatus @Inject constructor(
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)
// Don't emit here — wait for onCapabilitiesChanged which has full state
}
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)
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)
}
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
override fun onLost(network: Network) {
// Check if there's still another active network
val activeNetwork = connectivityManager.activeNetworkInfo?.isConnectedOrConnecting == true
if (!activeNetwork) {
trySend(NetworkState.Unavailable)
}
}
}
connectivityManager.registerNetworkCallback(request, callback)
connectivityManager.registerDefaultNetworkCallback(callback)
trySend(if (isNetworkAvailable()) NetworkState.Available() else NetworkState.Unavailable)

View File

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