diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d10c55c..21a5099 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" ) } @@ -141,7 +142,12 @@ dependencies { implementation("androidx.media3:media3-ui:1.4.0") 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") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a7a53cf..2ef9a38 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -40,9 +40,6 @@ @kotlinx.coroutines.InternalCoroutinesApi ; } --keep class kotlin.** { *; } --keep class * implements kotlin.** { *; } - -assumenosideeffects class android.util.Log { public static boolean isLoggable(...); public static int v(...); @@ -69,4 +66,25 @@ -dontwarn javax.annotation.** -dontwarn kotlin.Unit -dontwarn retrofit2.Platform$Java8 --dontwarn kotlin.jvm.internal.Reflection \ No newline at end of file +-dontwarn kotlin.jvm.internal.Reflection + +# Compose +-keep class androidx.compose.runtime.CompositionLocal { *; } +-keepclassmembers class * { + @androidx.compose.runtime.Composable ; +} + +# 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); +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f37bde5..8e8c09e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + android:screenOrientation="landscape" + android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"> diff --git a/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbMovieDetailDto.kt b/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbMovieDetailDto.kt index a73414e..825d647 100644 --- a/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbMovieDetailDto.kt +++ b/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbMovieDetailDto.kt @@ -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" diff --git a/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbSearchResponse.kt b/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbSearchResponse.kt index 51080c3..204abdb 100644 --- a/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbSearchResponse.kt +++ b/app/src/main/java/com/horrortv/app/data/remote/omdb/dto/OmdbSearchResponse.kt @@ -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?, - @SerializedName("totalResults") val totalResults: String?, - @SerializedName("Response") val response: String, - @SerializedName("Error") val error: String? + @SerializedName("Search") val search: List = 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" } \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/data/repository/MovieRepositoryImpl.kt b/app/src/main/java/com/horrortv/app/data/repository/MovieRepositoryImpl.kt index 972e502..df9e51f 100644 --- a/app/src/main/java/com/horrortv/app/data/repository/MovieRepositoryImpl.kt +++ b/app/src/main/java/com/horrortv/app/data/repository/MovieRepositoryImpl.kt @@ -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 = withContext(Dispatchers.Default) { + override suspend fun getFeaturedCategories(): List = 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) } } diff --git a/app/src/main/java/com/horrortv/app/di/NetworkModule.kt b/app/src/main/java/com/horrortv/app/di/NetworkModule.kt index 665f8b4..262cf5c 100644 --- a/app/src/main/java/com/horrortv/app/di/NetworkModule.kt +++ b/app/src/main/java/com/horrortv/app/di/NetworkModule.kt @@ -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 { - level = HttpLoggingInterceptor.Level.BASIC - }) + .apply { + if (com.horrortv.app.BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + }) + } + } .build() } diff --git a/app/src/main/java/com/horrortv/app/domain/model/Movie.kt b/app/src/main/java/com/horrortv/app/domain/model/Movie.kt index a2623eb..e7c20a1 100644 --- a/app/src/main/java/com/horrortv/app/domain/model/Movie.kt +++ b/app/src/main/java/com/horrortv/app/domain/model/Movie.kt @@ -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, diff --git a/app/src/main/java/com/horrortv/app/domain/model/MovieCategory.kt b/app/src/main/java/com/horrortv/app/domain/model/MovieCategory.kt index 5440308..818486c 100644 --- a/app/src/main/java/com/horrortv/app/domain/model/MovieCategory.kt +++ b/app/src/main/java/com/horrortv/app/domain/model/MovieCategory.kt @@ -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 diff --git a/app/src/main/java/com/horrortv/app/domain/model/VideoSource.kt b/app/src/main/java/com/horrortv/app/domain/model/VideoSource.kt index 6b00149..45f11aa 100644 --- a/app/src/main/java/com/horrortv/app/domain/model/VideoSource.kt +++ b/app/src/main/java/com/horrortv/app/domain/model/VideoSource.kt @@ -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 = emptyList(), val posterUrl: String? = null, - val title: String = "" -) \ No newline at end of file + val title: String = "", + val availableQualities: List = 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" + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt b/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt index d69beed..37a426e 100644 --- a/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt +++ b/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt @@ -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 = 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() + 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 { - 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 + if (!isAllowedHost(url)) { + throw VideoExtractionException("Host not allowed: $url") } - - val responseCode = connection.responseCode - Log.d(TAG, "Response code: $responseCode") - - if (responseCode != HttpURLConnection.HTTP_OK) { - throw VideoExtractionException("HTTP error: $responseCode") + 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 = 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? { 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 { + private fun extractSubtitles(doc: org.jsoup.nodes.Document, htmlContent: String, videoUrl: String?): List { val tracks = mutableListOf() 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 { + val tracks = mutableListOf() + + 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 { + val tracks = mutableListOf() + 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) \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/AppNavigation.kt b/app/src/main/java/com/horrortv/app/presentation/AppNavigation.kt index 8cf251a..827f6d4 100644 --- a/app/src/main/java/com/horrortv/app/presentation/AppNavigation.kt +++ b/app/src/main/java/com/horrortv/app/presentation/AppNavigation.kt @@ -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, diff --git a/app/src/main/java/com/horrortv/app/presentation/MainActivity.kt b/app/src/main/java/com/horrortv/app/presentation/MainActivity.kt index 56ef705..b91be37 100644 --- a/app/src/main/java/com/horrortv/app/presentation/MainActivity.kt +++ b/app/src/main/java/com/horrortv/app/presentation/MainActivity.kt @@ -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() } } \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/common/CommonStates.kt b/app/src/main/java/com/horrortv/app/presentation/common/CommonStates.kt index 6bfc26c..b16e651 100644 --- a/app/src/main/java/com/horrortv/app/presentation/common/CommonStates.kt +++ b/app/src/main/java/com/horrortv/app/presentation/common/CommonStates.kt @@ -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) } } @@ -64,4 +68,4 @@ fun EmptyState( ) { Text(text = message, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorLightGray) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/horrortv/app/presentation/common/TvErrorDisplay.kt b/app/src/main/java/com/horrortv/app/presentation/common/TvErrorDisplay.kt index 3820226..259740c 100644 --- a/app/src/main/java/com/horrortv/app/presentation/common/TvErrorDisplay.kt +++ b/app/src/main/java/com/horrortv/app/presentation/common/TvErrorDisplay.kt @@ -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 @@ -49,8 +51,6 @@ fun TvErrorDisplay( var isRetryFocused by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } val interactionSource = remember { MutableInteractionSource() } - - LaunchedEffect(Unit) { focusRequester.requestFocus() } val iconRes = when (error) { is AppError.NetworkError -> android.R.drawable.ic_menu_close_clear_cancel @@ -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) @@ -118,8 +132,6 @@ fun TvSnackbarError( var isDismissFocused by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } val interactionSource = remember { MutableInteractionSource() } - - LaunchedEffect(Unit) { focusRequester.requestFocus() } val dismissColor = if (isDismissFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed @@ -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) @@ -161,4 +176,4 @@ fun TvSnackbarError( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/horrortv/app/presentation/detail/DetailScreen.kt b/app/src/main/java/com/horrortv/app/presentation/detail/DetailScreen.kt index b19d066..deb0fb2 100644 --- a/app/src/main/java/com/horrortv/app/presentation/detail/DetailScreen.kt +++ b/app/src/main/java/com/horrortv/app/presentation/detail/DetailScreen.kt @@ -103,14 +103,20 @@ fun MovieDetailContent( val interactionSource = remember { MutableInteractionSource() } LaunchedEffect(Unit) { - kotlinx.coroutines.delay(500) - playFocusRequester.requestFocus() + 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) diff --git a/app/src/main/java/com/horrortv/app/presentation/detail/DetailViewModel.kt b/app/src/main/java/com/horrortv/app/presentation/detail/DetailViewModel.kt index 593b068..ebaf85f 100644 --- a/app/src/main/java/com/horrortv/app/presentation/detail/DetailViewModel.kt +++ b/app/src/main/java/com/horrortv/app/presentation/detail/DetailViewModel.kt @@ -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,35 +54,51 @@ class DetailViewModel @Inject constructor( loadJob?.cancel() loadJob = viewModelScope.launch { 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) { - repository.getMovieById(imdbId) - } + val result = withContext(Dispatchers.IO) { + repository.getMovieById(imdbId) + } - when (result) { - is Result.Success -> { - val movie = result.data - if (movie != null) { - Log.d(TAG, "Loaded movie: ${movie.title}") - _uiState.update { it.copy(movie = movie, isLoading = false, error = null) } - } else { - Log.w(TAG, "Movie not found: $imdbId") - _uiState.update { - it.copy( - isLoading = false, - error = AppError.ValidationError( - userMessage = "No se encontró la película.", - debugMessage = "Movie not found", - field = "imdbId" + when (result) { + is Result.Success -> { + val movie = result.data + if (movie != null) { + Log.d(TAG, "Loaded movie: ${movie.title}") + _uiState.update { it.copy(movie = movie, isLoading = false, error = null) } + } else { + Log.w(TAG, "Movie not found: $imdbId") + _uiState.update { + it.copy( + isLoading = false, + error = AppError.ValidationError( + userMessage = "No se encontró la película.", + debugMessage = "Movie not found", + field = "imdbId" + ) ) - ) + } } } + is Result.Error -> { + Log.e(TAG, "Error loading movie: ${result.error.debugMessage}") + _uiState.update { it.copy(isLoading = false, error = result.error) } + } } - is Result.Error -> { - Log.e(TAG, "Error loading movie: ${result.error.debugMessage}") - _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 + ) + ) } } } diff --git a/app/src/main/java/com/horrortv/app/presentation/home/HomeScreen.kt b/app/src/main/java/com/horrortv/app/presentation/home/HomeScreen.kt index e59f078..6548937 100644 --- a/app/src/main/java/com/horrortv/app/presentation/home/HomeScreen.kt +++ b/app/src/main/java/com/horrortv/app/presentation/home/HomeScreen.kt @@ -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( @@ -209,62 +213,4 @@ 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() - } } \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/home/HomeViewModel.kt b/app/src/main/java/com/horrortv/app/presentation/home/HomeViewModel.kt index 9c44a37..7cd6e13 100644 --- a/app/src/main/java/com/horrortv/app/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/horrortv/app/presentation/home/HomeViewModel.kt @@ -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) { diff --git a/app/src/main/java/com/horrortv/app/presentation/home/HorrorRow.kt b/app/src/main/java/com/horrortv/app/presentation/home/HorrorRow.kt index 4347e35..fe83599 100644 --- a/app/src/main/java/com/horrortv/app/presentation/home/HorrorRow.kt +++ b/app/src/main/java/com/horrortv/app/presentation/home/HorrorRow.kt @@ -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, @@ -58,4 +60,4 @@ fun HorrorRow( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/horrortv/app/presentation/home/MoviePosterCard.kt b/app/src/main/java/com/horrortv/app/presentation/home/MoviePosterCard.kt index 380b14a..2c12195 100644 --- a/app/src/main/java/com/horrortv/app/presentation/home/MoviePosterCard.kt +++ b/app/src/main/java/com/horrortv/app/presentation/home/MoviePosterCard.kt @@ -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)) - ) - } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt b/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt index b3d606b..e5ea45d 100644 --- a/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt +++ b/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt @@ -1,22 +1,36 @@ package com.horrortv.app.presentation.player +import android.graphics.Typeface +import android.net.Uri +import android.util.Log +import android.util.TypedValue +import android.view.KeyEvent +import androidx.media3.ui.CaptionStyleCompat import androidx.activity.compose.BackHandler +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.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -25,41 +39,63 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester + import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.graphics.graphicsLayer + import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.ui.PlayerView import com.horrortv.app.domain.model.VideoSource import com.horrortv.app.domain.usecase.VideoExtractor +import com.horrortv.app.presentation.MainActivity import com.horrortv.app.presentation.theme.HorrorColors import com.horrortv.app.presentation.theme.HorrorTypography +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import android.util.Log private const val TAG = "PlayerScreen" +private const val AUTO_HIDE_DELAY_MS = 5000L +private const val SEEK_STEP_MS = 5000L private val ButtonShape = RoundedCornerShape(12.dp) +private val SeekBarShape = RoundedCornerShape(8.dp) -@OptIn(ExperimentalMaterial3Api::class) +data class SubtitleOption(val label: String, val languageCode: String?) { + companion object { + val OFF = SubtitleOption("Off", null) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun PlayerScreen( imdbId: String, @@ -69,106 +105,380 @@ fun PlayerScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - + var videoSource by remember { mutableStateOf(null) } var isLoading by remember { mutableStateOf(true) } var hasError by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf("") } var showControls by remember { mutableStateOf(true) } + var selectedButton by remember { mutableStateOf(1) } // 0=seekback, 1=play, 2=seekforward, 3=subtitle var isPlaying by remember { mutableStateOf(false) } var currentTimeMs by remember { mutableLongStateOf(0L) } var durationMs by remember { mutableLongStateOf(0L) } - - val playFocusRequester = remember { FocusRequester() } - val backFocusRequester = remember { FocusRequester() } - - val exoPlayer = remember { ExoPlayer.Builder(context).build() } - - LaunchedEffect(imdbId) { - scope.launch { - try { - isLoading = true - hasError = false - - val result = withContext(Dispatchers.IO) { - VideoExtractor().extractVideoSource(imdbId) - } - - result.fold( - onSuccess = { source -> - videoSource = source - val mediaItem = MediaItem.fromUri(source.videoUrl) - exoPlayer.setMediaItem(mediaItem) - exoPlayer.prepare() - exoPlayer.playWhenReady = true - isLoading = false - }, - onFailure = { error -> - hasError = true - errorMessage = "Error: ${error.message}" - isLoading = false + var selectedSubtitle by remember { mutableStateOf(SubtitleOption.OFF) } + var showSubtitlePicker by remember { mutableStateOf(false) } + var seekMode by remember { mutableStateOf(false) } + var seekPositionMs by remember { mutableLongStateOf(0L) } + var selectedSubtitleIndex by remember { mutableStateOf(0) } + + val availableSubtitles = remember(videoSource) { + buildList { + add(SubtitleOption.OFF) + videoSource?.subtitleTracks + ?.map { it.language.lowercase().trim() } + ?.distinct() + ?.forEach { code -> + when (code) { + "en" -> add(SubtitleOption("English", "en")) + "es" -> add(SubtitleOption("Español", "es")) } - ) - } catch (e: Exception) { - hasError = true - errorMessage = "Error: ${e.message}" - isLoading = false + } + } + } + + val trackSelector = remember { + DefaultTrackSelector(context).apply { + setParameters( + buildUponParameters() + .setForceHighestSupportedBitrate(true) + .setTunnelingEnabled(false) // was true — breaks subtitles on many TVs + ) + } + } + + val exoPlayer = remember { + ExoPlayer.Builder(context) + .setRenderersFactory(DefaultRenderersFactory(context).setEnableDecoderFallback(true)) + .setTrackSelector(trackSelector) + .setLoadControl( + DefaultLoadControl.Builder() + .setBufferDurationsMs( + DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * 2, + DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * 2, + 2500, + 5000 + ) + .build() + ) + .build() + } + + class AutoHideHolder(var job: kotlinx.coroutines.Job? = null) + val autoHide = remember { AutoHideHolder() } + + val resetAutoHideTimer = remember(autoHide, scope, isPlaying, showSubtitlePicker, seekMode) { + { + autoHide.job?.cancel() + autoHide.job = scope.launch { + delay(AUTO_HIDE_DELAY_MS) + if (isPlaying && !showSubtitlePicker && !seekMode) { + showControls = false + } } } } - + + // Register D-pad key handler at Activity level (before PlayerView steals events) + DisposableEffect(Unit) { + MainActivity.keyEventHandler = handler@{ event -> + if (event.action == KeyEvent.ACTION_DOWN) { + when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> { + if (event.repeatCount > 0) return@handler false + if (!isLoading && !hasError) { + when { + showSubtitlePicker -> { + val option = availableSubtitles.getOrNull(selectedSubtitleIndex) + if (option != null) { + selectedSubtitle = option + updateSubtitleTrack(exoPlayer, trackSelector, option) + } + showSubtitlePicker = false + resetAutoHideTimer() + true + } + seekMode -> { + exoPlayer.seekTo(seekPositionMs) + currentTimeMs = seekPositionMs + seekMode = false + resetAutoHideTimer() + true + } + !showControls -> { + showControls = true + resetAutoHideTimer() + true + } + else -> { + // Execute selected button's action + when (selectedButton) { + 0 -> { + val newPos = (exoPlayer.currentPosition - SEEK_STEP_MS).coerceAtLeast(0L) + exoPlayer.seekTo(newPos) + currentTimeMs = newPos + resetAutoHideTimer() + } + 1 -> { + if (isPlaying) exoPlayer.pause() else exoPlayer.play() + resetAutoHideTimer() + } + 2 -> { + val newPos = (exoPlayer.currentPosition + SEEK_STEP_MS).coerceAtMost(durationMs) + exoPlayer.seekTo(newPos) + currentTimeMs = newPos + resetAutoHideTimer() + } + 3 -> { + showSubtitlePicker = true + selectedSubtitleIndex = availableSubtitles.indexOf(selectedSubtitle).coerceAtLeast(0) + resetAutoHideTimer() + } + } + true + } + } + } else false + } + KeyEvent.KEYCODE_DPAD_LEFT -> { + if (!isLoading && !hasError) { + when { + showSubtitlePicker -> false + seekMode -> { + seekPositionMs = (seekPositionMs - 10000).coerceAtLeast(0L) + true + } + !showControls -> { + showControls = true + resetAutoHideTimer() + true + } + else -> { + if (selectedButton > 0) { + selectedButton-- + resetAutoHideTimer() + } + true + } + } + } else false + } + KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (!isLoading && !hasError) { + when { + showSubtitlePicker -> false + seekMode -> { + seekPositionMs = (seekPositionMs + 10000).coerceAtMost(durationMs) + true + } + !showControls -> { + showControls = true + resetAutoHideTimer() + true + } + else -> { + if (selectedButton < 3) { + selectedButton++ + resetAutoHideTimer() + } + true + } + } + } else false + } + KeyEvent.KEYCODE_DPAD_UP -> { + if (!isLoading && !hasError) { + when { + showSubtitlePicker -> { + if (selectedSubtitleIndex > 0) { + selectedSubtitleIndex-- + } + true + } + seekMode -> true + !showControls -> { + showControls = true + resetAutoHideTimer() + true + } + else -> { + seekMode = true + seekPositionMs = currentTimeMs + resetAutoHideTimer() + true + } + } + } else false + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + if (!isLoading && !hasError) { + when { + showSubtitlePicker -> { + if (selectedSubtitleIndex < availableSubtitles.lastIndex) { + selectedSubtitleIndex++ + } + true + } + seekMode -> { + exoPlayer.seekTo(seekPositionMs) + currentTimeMs = seekPositionMs + seekMode = false + resetAutoHideTimer() + true + } + !showControls -> { + showControls = true + resetAutoHideTimer() + true + } + else -> { + resetAutoHideTimer() + false + } + } + } else false + } + KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_ESCAPE -> { + when { + showSubtitlePicker -> { + showSubtitlePicker = false + resetAutoHideTimer() + true + } + seekMode -> { + seekMode = false + resetAutoHideTimer() + true + } + else -> false + } + } + else -> false + } + } else { + false + } + } + onDispose { + MainActivity.keyEventHandler = null + } + } + + LaunchedEffect(imdbId) { + isLoading = true + hasError = false + try { + val result = withContext(Dispatchers.IO) { + VideoExtractor().extractVideoSource(imdbId) + } + result.fold( + onSuccess = { source -> + videoSource = source + val mediaItem = buildMediaItemWithSubtitles(source, selectedSubtitle) + exoPlayer.setMediaItem(mediaItem) + exoPlayer.prepare() + exoPlayer.playWhenReady = true + isLoading = false + }, + onFailure = { error -> + hasError = true + errorMessage = "Error: ${error.message}" + isLoading = false + } + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + hasError = true + errorMessage = "Error: ${e.message}" + isLoading = false + } + } + DisposableEffect(exoPlayer) { val listener = object : Player.Listener { override fun onIsPlayingChanged(playing: Boolean) { isPlaying = playing } + override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { Player.STATE_READY -> { isLoading = false - durationMs = exoPlayer.duration + durationMs = if (exoPlayer.duration == C.TIME_UNSET) 0L else exoPlayer.duration } Player.STATE_BUFFERING -> isLoading = true + Player.STATE_IDLE -> { + isLoading = false + isPlaying = false + } Player.STATE_ENDED -> isPlaying = false } } + + override fun onTracksChanged(tracks: Tracks) { + updateSubtitleTrack(exoPlayer, trackSelector, selectedSubtitle) + } + + override fun onPlayerError(error: PlaybackException) { + hasError = true + errorMessage = error.localizedMessage ?: "Error de reproducción" + isLoading = false + } } exoPlayer.addListener(listener) onDispose { exoPlayer.removeListener(listener) + isPlaying = false exoPlayer.release() } } - - LaunchedEffect(showControls, isPlaying) { + + LaunchedEffect(showControls, isPlaying, showSubtitlePicker, seekMode) { + if (showControls && isPlaying && !showSubtitlePicker && !seekMode) { + resetAutoHideTimer() + } + } + + LaunchedEffect(isPlaying, showControls) { if (showControls && isPlaying) { - delay(4000) - showControls = false - } - } - - LaunchedEffect(isPlaying) { - while (isPlaying) { - currentTimeMs = exoPlayer.currentPosition - delay(500) - } - } - - BackHandler(enabled = true) { onNavigateBack() } - - LaunchedEffect(!isLoading, showControls) { - if (!isLoading && showControls) { - delay(300) - try { - playFocusRequester.requestFocus() - } catch (e: IllegalStateException) { - Log.d(TAG, "FocusRequester not ready yet, will retry") + currentTimeMs = exoPlayer.currentPosition // Immediate update when controls appear + while (isPlaying) { + delay(500) + currentTimeMs = exoPlayer.currentPosition } } } - + + LaunchedEffect(showControls) { + if (showControls) { + selectedButton = 1 // Default to play button + seekMode = false + } + } + + BackHandler(enabled = true) { + when { + showSubtitlePicker -> { + showSubtitlePicker = false + resetAutoHideTimer() + } + seekMode -> { + seekMode = false + resetAutoHideTimer() + } + showControls -> { + showControls = false + } + else -> { + onNavigateBack() + } + } + } + Box( - modifier = modifier.fillMaxSize().background(HorrorColors.HorrorBlack) + modifier = modifier + .fillMaxSize() + .background(HorrorColors.HorrorBlack) ) { if (hasError) { PlayerErrorOverlay( @@ -177,24 +487,32 @@ fun PlayerScreen( hasError = false isLoading = true scope.launch { - val result = withContext(Dispatchers.IO) { - VideoExtractor().extractVideoSource(imdbId) - } - result.fold( - onSuccess = { source -> - videoSource = source - val mediaItem = MediaItem.fromUri(source.videoUrl) - exoPlayer.setMediaItem(mediaItem) - exoPlayer.prepare() - exoPlayer.playWhenReady = true - isLoading = false - }, - onFailure = { error -> - hasError = true - errorMessage = "Error: ${error.message}" - isLoading = false + try { + val result = withContext(Dispatchers.IO) { + VideoExtractor().extractVideoSource(imdbId) } - ) + result.fold( + onSuccess = { source -> + videoSource = source + val mediaItem = buildMediaItemWithSubtitles(source, selectedSubtitle) + exoPlayer.setMediaItem(mediaItem) + exoPlayer.prepare() + exoPlayer.playWhenReady = true + isLoading = false + }, + onFailure = { error -> + hasError = true + errorMessage = "Error: ${error.message}" + isLoading = false + } + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + hasError = true + errorMessage = "Error: ${e.message}" + isLoading = false + } } }, onBack = onNavigateBack @@ -205,127 +523,382 @@ fun PlayerScreen( PlayerView(ctx).apply { player = exoPlayer useController = false + isFocusable = false + descendantFocusability = android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS + keepScreenOn = true + setOnKeyListener { _, _, _ -> false } + subtitleView?.apply { + val captionStyle = CaptionStyleCompat( + android.graphics.Color.WHITE, + android.graphics.Color.BLACK, + android.graphics.Color.TRANSPARENT, + CaptionStyleCompat.EDGE_TYPE_OUTLINE, + android.graphics.Color.BLACK, + Typeface.DEFAULT_BOLD + ) + setApplyEmbeddedStyles(false) + setApplyEmbeddedFontSizes(false) + setStyle(captionStyle) + setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, 24f) + setBottomPaddingFraction(0.08f) + } } }, - modifier = Modifier.fillMaxSize() + update = { playerView -> + playerView.keepScreenOn = isPlaying + }, + onRelease = { playerView -> + playerView.player = null + }, + modifier = Modifier + .fillMaxSize() ) - + if (isLoading) { Box( - modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)), + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)), contentAlignment = Alignment.Center ) { - Text(text = "Cargando...", style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite) + Text( + text = "Cargando...", + style = HorrorTypography.DetailInfo, + color = HorrorColors.HorrorWhite + ) } } - + if (showControls && !isLoading) { PlayerControlsOverlay( + title = title, isPlaying = isPlaying, currentTime = currentTimeMs, duration = durationMs, + selectedSubtitle = selectedSubtitle, + subtitleOptions = availableSubtitles, + showSubtitlePicker = showSubtitlePicker, + selectedButton = selectedButton, + seekMode = seekMode, + seekPosition = seekPositionMs, + selectedSubtitleIndex = selectedSubtitleIndex, onPlayPause = { if (isPlaying) exoPlayer.pause() else exoPlayer.play() showControls = true + resetAutoHideTimer() + }, + onSeekForward = { + val newPos = (exoPlayer.currentPosition + SEEK_STEP_MS).coerceAtMost(durationMs) + exoPlayer.seekTo(newPos) + currentTimeMs = newPos + resetAutoHideTimer() + }, + onSeekBackward = { + val newPos = (exoPlayer.currentPosition - SEEK_STEP_MS).coerceAtLeast(0L) + exoPlayer.seekTo(newPos) + currentTimeMs = newPos + resetAutoHideTimer() + }, + onSubtitleToggle = { + showSubtitlePicker = !showSubtitlePicker + if (showSubtitlePicker) { + selectedSubtitleIndex = availableSubtitles.indexOf(selectedSubtitle).coerceAtLeast(0) + } + resetAutoHideTimer() + }, + onSubtitleSelected = { option -> + selectedSubtitle = option + updateSubtitleTrack(exoPlayer, trackSelector, option) + showSubtitlePicker = false + resetAutoHideTimer() + }, + onSubtitlePickerDismiss = { + showSubtitlePicker = false + resetAutoHideTimer() }, - onSeekForward = { exoPlayer.seekTo(exoPlayer.currentPosition + 10000) }, - onSeekBackward = { exoPlayer.seekTo(exoPlayer.currentPosition - 10000) }, onBack = onNavigateBack, - playFocusRequester = playFocusRequester, - backFocusRequester = backFocusRequester + onControlsReset = { resetAutoHideTimer() } ) } } } } +private fun tryRequestFocus(focusRequester: FocusRequester, tag: String) { + try { + focusRequester.requestFocus() + } catch (e: IllegalStateException) { + Log.d(TAG, "FocusRequester '$tag' not ready: ${e.message}") + } +} + +private fun buildMediaItemWithSubtitles(source: VideoSource, selectedSubtitle: SubtitleOption): MediaItem { + val builder = MediaItem.Builder() + .setUri(source.videoUrl) + + val subtitleConfigs = source.subtitleTracks.map { track -> + MediaItem.SubtitleConfiguration.Builder(Uri.parse(track.url)) + .setMimeType(track.mimeType.ifBlank { getSubtitleMimeType(track.url) }) + .setLanguage(track.language) + .setLabel(track.label) + .setSelectionFlags(if (track.isDefault || selectedSubtitle.languageCode == track.language) C.SELECTION_FLAG_DEFAULT else 0) + .build() + } + + if (subtitleConfigs.isNotEmpty()) { + builder.setSubtitleConfigurations(subtitleConfigs) + } + + return builder.build() +} + +private fun getSubtitleMimeType(url: String): String { + val path = url.substringBefore("?").substringBefore("#") + return when { + path.endsWith(".vtt", ignoreCase = true) -> "text/vtt" + path.endsWith(".srt", ignoreCase = true) -> "application/x-subrip" + path.endsWith(".ttml", ignoreCase = true) -> "application/ttml+xml" + else -> "text/vtt" + } +} + +private fun forceHighestVideoQuality(trackSelector: DefaultTrackSelector, player: ExoPlayer) { + val parameters = trackSelector.buildUponParameters() + .clearOverridesOfType(C.TRACK_TYPE_VIDEO) + val tracks = player.currentTracks + for (trackGroup in tracks.groups) { + if (trackGroup.type == C.TRACK_TYPE_VIDEO && trackGroup.length > 0) { + var highestIndex = 0 + var highestBitrate = Int.MIN_VALUE + for (i in 0 until trackGroup.length) { + val format = trackGroup.getTrackFormat(i) + if (format.bitrate > highestBitrate) { + highestBitrate = format.bitrate + highestIndex = i + } + } + parameters.addOverride(TrackSelectionOverride(trackGroup.mediaTrackGroup, listOf(highestIndex))) + } + } + trackSelector.setParameters(parameters) +} + +private fun updateSubtitleTrack(player: ExoPlayer, trackSelector: DefaultTrackSelector, option: SubtitleOption) { + val parameters = trackSelector.buildUponParameters() + .clearOverridesOfType(C.TRACK_TYPE_TEXT) + + if (option.languageCode == null) { + parameters.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true) + } else { + parameters.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + val tracks = player.currentTracks + for (trackGroup in tracks.groups) { + if (trackGroup.type == C.TRACK_TYPE_TEXT) { + for (i in 0 until trackGroup.length) { + val format = trackGroup.getTrackFormat(i) + if (format.language?.startsWith(option.languageCode) == true) { + parameters.addOverride(TrackSelectionOverride(trackGroup.mediaTrackGroup, listOf(i))) + break + } + } + } + } + } + + trackSelector.setParameters(parameters) +} + @Composable private fun PlayerControlsOverlay( + title: String, isPlaying: Boolean, currentTime: Long, duration: Long, + selectedSubtitle: SubtitleOption, + subtitleOptions: List, + showSubtitlePicker: Boolean, + selectedButton: Int, + seekMode: Boolean, + seekPosition: Long, + selectedSubtitleIndex: Int, onPlayPause: () -> Unit, onSeekForward: () -> Unit, onSeekBackward: () -> Unit, + onSubtitleToggle: () -> Unit, + onSubtitleSelected: (SubtitleOption) -> Unit, + onSubtitlePickerDismiss: () -> Unit, onBack: () -> Unit, - playFocusRequester: FocusRequester, - backFocusRequester: FocusRequester + onControlsReset: () -> Unit ) { - var isPlayFocused by remember { mutableStateOf(false) } - var isBackFocused by remember { mutableStateOf(false) } - var isForwardFocused by remember { mutableStateOf(false) } - var isBackwardFocused by remember { mutableStateOf(false) } - - val playInteraction = remember { MutableInteractionSource() } - val backInteraction = remember { MutableInteractionSource() } - val forwardInteraction = remember { MutableInteractionSource() } - val backwardInteraction = remember { MutableInteractionSource() } - - val currentSec = (currentTime / 1000).toInt() - val durationSec = (duration / 1000).toInt() - - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier.fillMaxSize() + ) { Row( - modifier = Modifier.align(Alignment.TopStart).padding(24.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier + .align(Alignment.TopStart) + .padding(24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically ) { PlayerButton( iconRes = android.R.drawable.ic_menu_close_clear_cancel, label = "BACK", - isFocused = isBackFocused, - onClick = onBack, - focusRequester = backFocusRequester, - interactionSource = backInteraction, - onFocusChange = { isBackFocused = it } + isFocused = false, + onClick = { + onBack() + onControlsReset() + } + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = title, + style = HorrorTypography.MovieTitle, + color = HorrorColors.HorrorWhite ) } - + Column( - modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(bottom = 48.dp, start = 48.dp, end = 48.dp), + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .padding(horizontal = 48.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = formatTime(currentSec), style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite) - Text(text = formatTime(durationSec), style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite) - } - - Spacer(modifier = Modifier.height(32.dp)) - - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), + horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - PlayerButton( - iconRes = android.R.drawable.ic_media_previous, - label = "-10s", - isFocused = isBackwardFocused, - onClick = onSeekBackward, - focusRequester = FocusRequester(), - interactionSource = backwardInteraction, - onFocusChange = { isBackwardFocused = it } + Text( + text = formatTime(currentTime, duration), + style = HorrorTypography.DetailInfo, + color = HorrorColors.HorrorWhite ) - - PlayerButton( - iconRes = if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, - label = if (isPlaying) "PAUSE" else "PLAY", - isFocused = isPlayFocused, - onClick = onPlayPause, - focusRequester = playFocusRequester, - interactionSource = playInteraction, - onFocusChange = { isPlayFocused = it } + + Text( + text = formatTime(duration, duration), + style = HorrorTypography.DetailInfo, + color = HorrorColors.HorrorWhite ) - - PlayerButton( - iconRes = android.R.drawable.ic_media_next, - label = "+10s", - isFocused = isForwardFocused, - onClick = onSeekForward, - focusRequester = FocusRequester(), - interactionSource = forwardInteraction, - onFocusChange = { isForwardFocused = it } + } + + Spacer(modifier = Modifier.height(16.dp)) + + PlayerSeekBar( + currentTime = currentTime, + duration = duration, + seekMode = seekMode, + seekPosition = seekPosition + ) + } + + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp, start = 48.dp, end = 48.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PlayerButton( + iconRes = android.R.drawable.ic_media_previous, + label = "-10s", + isFocused = selectedButton == 0, + onClick = { + onSeekBackward() + onControlsReset() + } + ) + + PlayerButton( + iconRes = if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play, + label = if (isPlaying) "PAUSE" else "PLAY", + isFocused = selectedButton == 1, + onClick = { + onPlayPause() + onControlsReset() + } + ) + + PlayerButton( + iconRes = android.R.drawable.ic_media_next, + label = "+10s", + isFocused = selectedButton == 2, + onClick = { + onSeekForward() + onControlsReset() + } + ) + + PlayerButton( + iconRes = android.R.drawable.ic_menu_sort_by_size, + label = "SUBTITLE", + isFocused = selectedButton == 3, + onClick = { + onSubtitleToggle() + onControlsReset() + } + ) + } + + if (showSubtitlePicker) { + SubtitlePickerOverlay( + options = subtitleOptions, + selectedOption = selectedSubtitle, + selectedIndex = selectedSubtitleIndex, + onOptionSelected = onSubtitleSelected, + onDismiss = onSubtitlePickerDismiss + ) + } + } +} + +@Composable +private fun PlayerSeekBar( + currentTime: Long, + duration: Long, + seekMode: Boolean = false, + seekPosition: Long = 0L +) { + val progress = if (duration > 0) (currentTime.toFloat() / duration.toFloat()) else 0f + val seekProgress = if (duration > 0) (seekPosition.toFloat() / duration.toFloat()) else 0f + val barHeight = if (seekMode) 8.dp else 4.dp + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (seekMode) { + Text( + text = formatTime(seekPosition, duration), + style = HorrorTypography.DetailInfo, + color = HorrorColors.HorrorWhite, + modifier = Modifier.padding(bottom = 4.dp) + ) + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(barHeight) + .clip(RoundedCornerShape(barHeight)) + .background(HorrorColors.HorrorGray.copy(alpha = 0.6f)) + ) { + Box( + modifier = Modifier + .fillMaxWidth(if (seekMode) seekProgress else progress) + .height(barHeight) + .background(if (seekMode) Color.White else HorrorColors.HorrorRed) + ) + + if (seekMode) { + Box( + modifier = Modifier + .offset(x = (maxWidth * seekProgress - 6.dp).coerceAtLeast(0.dp)) + .size(12.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(Color.White) ) } } @@ -338,12 +911,14 @@ private fun PlayerButton( label: String, isFocused: Boolean, onClick: () -> Unit, - focusRequester: FocusRequester, - interactionSource: MutableInteractionSource, - onFocusChange: (Boolean) -> Unit ) { - val scale = if (isFocused) 1.1f else 1.0f - + val scale by animateFloatAsState( + targetValue = if (isFocused) 1.25f else 1.0f, + animationSpec = tween(150), + label = "btn_scale" + ) + val interactionSource = remember { MutableInteractionSource() } + Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp) @@ -351,32 +926,112 @@ private fun PlayerButton( Box( modifier = Modifier .size(64.dp) - .graphicsLayer { scaleX = scale; scaleY = scale } + .graphicsLayer { + scaleX = scale + scaleY = scale + shadowElevation = if (isFocused) 12f else 0f + ambientShadowColor = HorrorColors.HorrorAccent + spotShadowColor = HorrorColors.HorrorAccent + } .clip(ButtonShape) - .background(if (isFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorGray) - .border(if (isFocused) 4.dp else 1.dp, if (isFocused) Color.White else HorrorColors.HorrorLightGray, ButtonShape) - .focusRequester(focusRequester) - .focusable(interactionSource = interactionSource) - .onFocusChanged { onFocusChange(it.isFocused) } - .clickable(interactionSource = interactionSource, indication = null, onClick = onClick), + .background(if (isFocused) Color.White else HorrorColors.HorrorGray) + .border( + width = if (isFocused) 5.dp else 1.dp, + color = if (isFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorLightGray, + shape = ButtonShape + ) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ), contentAlignment = Alignment.Center ) { Icon( painter = painterResource(id = iconRes), contentDescription = label, - tint = HorrorColors.HorrorWhite, + tint = if (isFocused) HorrorColors.HorrorGray else HorrorColors.HorrorWhite, modifier = Modifier.size(32.dp) ) } - + Text( text = label, style = HorrorTypography.MovieYear, - color = if (isFocused) HorrorColors.HorrorWhite else HorrorColors.HorrorLightGray + color = if (isFocused) Color.White else HorrorColors.HorrorLightGray ) } } +@Composable +private fun SubtitlePickerOverlay( + options: List, + selectedOption: SubtitleOption, + selectedIndex: Int, + onOptionSelected: (SubtitleOption) -> Unit, + onDismiss: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .width(300.dp) + .heightIn(max = 400.dp) + .clip(RoundedCornerShape(16.dp)) + .background(HorrorColors.HorrorGray) + .border(2.dp, HorrorColors.HorrorAccent, RoundedCornerShape(16.dp)) + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Subtitles", + style = HorrorTypography.DetailTitle, + color = HorrorColors.HorrorWhite + ) + + Spacer(modifier = Modifier.height(8.dp)) + + options.forEachIndexed { index, option -> + val isHighlighted = selectedIndex == index + val isSelected = option == selectedOption + + Box( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clip(ButtonShape) + .background( + if (isHighlighted) HorrorColors.HorrorAccent + else if (isSelected) HorrorColors.HorrorRed + else HorrorColors.HorrorLightGray + ) + .border( + width = if (isHighlighted || isSelected) 4.dp else 1.dp, + color = if (isHighlighted) Color.White + else if (isSelected) HorrorColors.HorrorAccent + else HorrorColors.HorrorLightGray, + shape = ButtonShape + ) + .clickable { onOptionSelected(option) }, + contentAlignment = Alignment.Center + ) { + Text( + text = option.label, + style = HorrorTypography.PlayButton, + color = HorrorColors.HorrorWhite + ) + } + } + } + } +} + @Composable private fun PlayerErrorOverlay( errorMessage: String, @@ -385,14 +1040,19 @@ private fun PlayerErrorOverlay( ) { var isRetryFocused by remember { mutableStateOf(false) } var isBackFocused by remember { mutableStateOf(true) } - + val retryFocusRequester = remember { FocusRequester() } val backFocusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { backFocusRequester.requestFocus() } - + + LaunchedEffect(Unit) { + delay(100) + tryRequestFocus(backFocusRequester, "ErrorBack") + } + Box( - modifier = Modifier.fillMaxSize().background(HorrorColors.HorrorBlack), + modifier = Modifier + .fillMaxSize() + .background(HorrorColors.HorrorBlack), contentAlignment = Alignment.Center ) { Column( @@ -405,16 +1065,16 @@ private fun PlayerErrorOverlay( tint = HorrorColors.HorrorRed, modifier = Modifier.size(96.dp) ) - + Text( text = errorMessage, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorWhite, modifier = Modifier.padding(horizontal = 48.dp) ) - + Spacer(modifier = Modifier.height(32.dp)) - + Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) { PlayerActionButton( label = "VOLVER", @@ -423,7 +1083,7 @@ private fun PlayerErrorOverlay( focusRequester = backFocusRequester, onFocusChange = { isBackFocused = it } ) - + PlayerActionButton( label = "REINTENTAR", isFocused = isRetryFocused, @@ -436,6 +1096,7 @@ private fun PlayerErrorOverlay( } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun PlayerActionButton( label: String, @@ -446,7 +1107,7 @@ private fun PlayerActionButton( ) { val scale = if (isFocused) 1.08f else 1.0f val interactionSource = remember { MutableInteractionSource() } - + Box( modifier = Modifier .width(200.dp) @@ -454,19 +1115,47 @@ private fun PlayerActionButton( .graphicsLayer { scaleX = scale; scaleY = scale } .clip(RoundedCornerShape(12.dp)) .background(if (isFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed) - .border(if (isFocused) 4.dp else 1.dp, if (isFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(12.dp)) + .border( + width = if (isFocused) 4.dp else 1.dp, + color = if (isFocused) Color.White else HorrorColors.HorrorLightGray, + shape = RoundedCornerShape(12.dp) + ) .focusRequester(focusRequester) .focusable(interactionSource = interactionSource) .onFocusChanged { onFocusChange(it.isFocused) } - .clickable(interactionSource = interactionSource, indication = null, onClick = onClick), + .onKeyEvent { event -> + if (event.nativeKeyEvent?.action != KeyEvent.ACTION_DOWN) return@onKeyEvent false + if (event.nativeKeyEvent?.repeatCount != 0) return@onKeyEvent false + val keyCode = event.nativeKeyEvent?.keyCode + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { + onClick() + true + } else false + }, contentAlignment = Alignment.Center ) { - Text(text = label, style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite) + Text( + text = label, + style = HorrorTypography.PlayButton, + color = HorrorColors.HorrorWhite + ) } } -private fun formatTime(seconds: Int): String { - val mins = seconds / 60 - val secs = seconds % 60 - return "%02d:%02d".format(mins, secs) +private fun formatTime(timeMs: Long, durationMs: Long): String { + if (timeMs < 0 || durationMs < 0) return "00:00" + + val totalSeconds = (timeMs / 1000).toInt() + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + + val durationSeconds = (durationMs / 1000).toInt() + val durationHours = durationSeconds / 3600 + + return if (durationHours > 0 || hours > 0) { + "%02d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%02d:%02d".format(minutes, seconds) + } } \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/search/SearchScreen.kt b/app/src/main/java/com/horrortv/app/presentation/search/SearchScreen.kt index 3006f89..bd62106 100644 --- a/app/src/main/java/com/horrortv/app/presentation/search/SearchScreen.kt +++ b/app/src/main/java/com/horrortv/app/presentation/search/SearchScreen.kt @@ -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) { - coroutineScope.launch { bringIntoViewRequester.bringIntoView() } + 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 } diff --git a/app/src/main/java/com/horrortv/app/presentation/search/SearchViewModel.kt b/app/src/main/java/com/horrortv/app/presentation/search/SearchViewModel.kt index 661969d..3e37ed6 100644 --- a/app/src/main/java/com/horrortv/app/presentation/search/SearchViewModel.kt +++ b/app/src/main/java/com/horrortv/app/presentation/search/SearchViewModel.kt @@ -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,12 +67,28 @@ class SearchViewModel @Inject constructor( searchJob?.cancel() searchJob = viewModelScope.launch { 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()) { - searchById(query) - } else { - searchByName(query) + if (query.isValidImdbId()) { + searchById(query) + } 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 = "" diff --git a/app/src/main/java/com/horrortv/app/presentation/theme/HorrorColors.kt b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorColors.kt index 8de6f37..061ba98 100644 --- a/app/src/main/java/com/horrortv/app/presentation/theme/HorrorColors.kt +++ b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorColors.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTheme.kt b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTheme.kt index d6a5a0f..fbff710 100644 --- a/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTheme.kt +++ b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTheme.kt @@ -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, diff --git a/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTypography.kt b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTypography.kt index 4672e47..ec96b7c 100644 --- a/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTypography.kt +++ b/app/src/main/java/com/horrortv/app/presentation/theme/HorrorTypography.kt @@ -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 ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/horrortv/app/util/Constants.kt b/app/src/main/java/com/horrortv/app/util/Constants.kt index 6527a60..460f53b 100644 --- a/app/src/main/java/com/horrortv/app/util/Constants.kt +++ b/app/src/main/java/com/horrortv/app/util/Constants.kt @@ -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( diff --git a/app/src/main/java/com/horrortv/app/util/NetworkStatus.kt b/app/src/main/java/com/horrortv/app/util/NetworkStatus.kt index eee5416..14e0693 100644 --- a/app/src/main/java/com/horrortv/app/util/NetworkStatus.kt +++ b/app/src/main/java/com/horrortv/app/util/NetworkStatus.kt @@ -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) || - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || - capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + 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_VPN) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) } fun isMeteredNetwork(): Boolean { @@ -43,28 +45,37 @@ class NetworkStatus @Inject constructor( fun observeNetworkStatus(): Flow = callbackFlow { val callback = object : ConnectivityManager.NetworkCallback() { 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) { - 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) + } + } + + 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() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - - connectivityManager.registerNetworkCallback(request, callback) - + + connectivityManager.registerDefaultNetworkCallback(callback) + trySend(if (isNetworkAvailable()) NetworkState.Available() else NetworkState.Unavailable) - + awaitClose { connectivityManager.unregisterNetworkCallback(callback) } }.distinctUntilChanged() diff --git a/app/src/main/java/com/horrortv/app/util/PosterUtils.kt b/app/src/main/java/com/horrortv/app/util/PosterUtils.kt index a56e534..3b30511 100644 --- a/app/src/main/java/com/horrortv/app/util/PosterUtils.kt +++ b/app/src/main/java/com/horrortv/app/util/PosterUtils.kt @@ -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