feat: state-based D-pad navigation, seek mode, subtitle picker, 20-agent audit fixes
- Replace broken Compose focus system with state-based selectedButton approach - Add seek mode (DPAD_UP to enter, LEFT/RIGHT to seek, CENTER/DOWN to apply) - Make SubtitlePickerOverlay state-based (DPAD UP/DOWN navigate, CENTER select) - Fix 25 P0 crashes from 20-agent audit (SSRF, CancellationException, DTO defaults) - Fix UX issues: animations, padding, TvErrorDisplay, navigation, finishAffinity - Fix pause button: remove clickable, onKeyEvent only, repeatCount guard - Add subtitle extraction: OpenSubtitles EN/ES, HI filtering, dedup - Fix override accumulation: clearOverridesOfType before setting new tracks - Disable tunneling for subtitle compatibility - Performance: AutoHideHolder, @Immutable models, contentType - Fix SearchScreen focus visibility: animated scale, white border, dimming - Fix PlayerButton invisible text: white on HorrorGray bg - Fix isLoading stuck in Home/Detail/SearchViewModel - Fix VideoExtractor CancellationException rethrow, IMDB_ID_PATTERN 7+ digits
This commit is contained in:
@@ -16,6 +16,7 @@ android {
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
buildConfigField("String", "APP_NAME", "\"HorrorTV\"")
|
||||
buildConfigField("String", "OMDB_API_KEY", "\"${project.findProperty("OMDB_API_KEY") ?: "5854c81e"}\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -75,7 +76,7 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
freeCompilerArgs += listOf(
|
||||
"-Xopt-in=kotlin.RequiresOptIn",
|
||||
"-opt-in=kotlin.RequiresOptIn",
|
||||
"-Xjvm-default=all"
|
||||
)
|
||||
}
|
||||
@@ -142,6 +143,11 @@ dependencies {
|
||||
implementation("androidx.media3:media3-common:1.4.0")
|
||||
implementation("org.jsoup:jsoup:1.17.2")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("io.mockk:mockk:1.13.9")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
testImplementation("app.cash.turbine:turbine:1.0.0")
|
||||
|
||||
debugImplementation(platform("androidx.compose:compose-bom:2024.10.01"))
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
|
||||
24
app/proguard-rules.pro
vendored
24
app/proguard-rules.pro
vendored
@@ -40,9 +40,6 @@
|
||||
@kotlinx.coroutines.InternalCoroutinesApi <methods>;
|
||||
}
|
||||
|
||||
-keep class kotlin.** { *; }
|
||||
-keep class * implements kotlin.** { *; }
|
||||
|
||||
-assumenosideeffects class android.util.Log {
|
||||
public static boolean isLoggable(...);
|
||||
public static int v(...);
|
||||
@@ -70,3 +67,24 @@
|
||||
-dontwarn kotlin.Unit
|
||||
-dontwarn retrofit2.Platform$Java8
|
||||
-dontwarn kotlin.jvm.internal.Reflection
|
||||
|
||||
# Compose
|
||||
-keep class androidx.compose.runtime.CompositionLocal { *; }
|
||||
-keepclassmembers class * {
|
||||
@androidx.compose.runtime.Composable <methods>;
|
||||
}
|
||||
|
||||
# Media3 ExoPlayer
|
||||
-keep class androidx.media3.** { *; }
|
||||
|
||||
# Jsoup
|
||||
-keep class org.jsoup.** { *; }
|
||||
|
||||
# Keep generic signature info for Retrofit+Gson
|
||||
-keepattributes Signature
|
||||
|
||||
# Enums
|
||||
-keepclassmembers enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<application
|
||||
android:name=".HorrorTvApp"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
@@ -23,7 +24,8 @@
|
||||
android:name=".presentation.MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:screenOrientation="landscape">
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3,20 +3,20 @@ package com.horrortv.app.data.remote.omdb.dto
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class OmdbSearchResponse(
|
||||
@SerializedName("Search") val search: List<OmdbMovieSearchDto>?,
|
||||
@SerializedName("totalResults") val totalResults: String?,
|
||||
@SerializedName("Response") val response: String,
|
||||
@SerializedName("Error") val error: String?
|
||||
@SerializedName("Search") val search: List<OmdbMovieSearchDto> = emptyList(),
|
||||
@SerializedName("totalResults") val totalResults: String? = null,
|
||||
@SerializedName("Response") val response: String = "",
|
||||
@SerializedName("Error") val error: String? = null
|
||||
) {
|
||||
val isSuccess: Boolean get() = response == "True"
|
||||
}
|
||||
|
||||
data class OmdbMovieSearchDto(
|
||||
@SerializedName("Title") val title: String,
|
||||
@SerializedName("Year") val year: String,
|
||||
@SerializedName("imdbID") val imdbId: String,
|
||||
@SerializedName("Type") val type: String,
|
||||
@SerializedName("Poster") val poster: String?
|
||||
@SerializedName("Title") val title: String = "",
|
||||
@SerializedName("Year") val year: String = "",
|
||||
@SerializedName("imdbID") val imdbId: String = "",
|
||||
@SerializedName("Type") val type: String = "",
|
||||
@SerializedName("Poster") val poster: String? = null
|
||||
) {
|
||||
val hasValidPoster: Boolean get() = poster != null && poster != "N/A"
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.horrortv.app.domain.repository.MovieRepository
|
||||
import com.horrortv.app.domain.repository.Result
|
||||
import com.horrortv.app.util.ApiException
|
||||
import com.horrortv.app.util.Constants
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -61,7 +62,7 @@ class MovieRepositoryImpl @Inject constructor(
|
||||
cache.put(key, CacheEntry(data, System.currentTimeMillis(), ttlMs))
|
||||
}
|
||||
|
||||
override suspend fun getFeaturedCategories(): List<MovieCategory> = withContext(Dispatchers.Default) {
|
||||
override suspend fun getFeaturedCategories(): List<MovieCategory> = withContext(Dispatchers.IO) {
|
||||
Log.d(TAG, "Fetching featured categories with limited concurrency")
|
||||
|
||||
coroutineScope {
|
||||
@@ -94,6 +95,8 @@ class MovieRepositoryImpl @Inject constructor(
|
||||
putInCache(categoryCache, cacheKey, movies, categoryTtlMs())
|
||||
MovieCategory(category, movies)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error fetching category: $category", e)
|
||||
MovieCategory(category, emptyList())
|
||||
@@ -115,7 +118,7 @@ class MovieRepositoryImpl @Inject constructor(
|
||||
return Result.Success(cached)
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.Default) {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Log.d(TAG, "Searching for: $query")
|
||||
val response = apiService.searchMovies(query, page = 1)
|
||||
@@ -139,13 +142,11 @@ class MovieRepositoryImpl @Inject constructor(
|
||||
putInCache(searchCache, cacheKey, movies, searchTtlMs())
|
||||
Result.Success(movies)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error searching: $query", e)
|
||||
val appError = if (e is ApiException) e.toAppError() else AppError.UnknownError(
|
||||
userMessage = "Error inesperado. Intenta de nuevo.",
|
||||
debugMessage = "Unhandled exception: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
val appError = ApiException.fromThrowable(e).toAppError()
|
||||
Result.Error(appError)
|
||||
}
|
||||
}
|
||||
@@ -161,7 +162,7 @@ class MovieRepositoryImpl @Inject constructor(
|
||||
return Result.Success(cached)
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.Default) {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Log.d(TAG, "Fetching movie detail: $imdbId")
|
||||
val detail = apiService.getMovieDetail(imdbId)
|
||||
@@ -189,13 +190,11 @@ class MovieRepositoryImpl @Inject constructor(
|
||||
putInCache(detailCache, cacheKey, movie, detailTtlMs())
|
||||
Result.Success(movie)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error fetching movie: $imdbId", e)
|
||||
val appError = if (e is ApiException) e.toAppError() else AppError.UnknownError(
|
||||
userMessage = "Error inesperado. Intenta de nuevo.",
|
||||
debugMessage = "Unhandled exception: ${e.message}",
|
||||
cause = e
|
||||
)
|
||||
val appError = ApiException.fromThrowable(e).toAppError()
|
||||
Result.Error(appError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.horrortv.app.domain.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class MovieCategory(
|
||||
val name: String,
|
||||
val movies: List<Movie>
|
||||
|
||||
@@ -11,7 +11,16 @@ data class SubtitleTrack(
|
||||
val url: String,
|
||||
val language: String,
|
||||
val label: String,
|
||||
val isDefault: Boolean = false
|
||||
val isDefault: Boolean = false,
|
||||
val mimeType: String = "text/vtt",
|
||||
val languageCode: String = language
|
||||
)
|
||||
|
||||
data class QualityLevel(
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val bitrate: Long,
|
||||
val label: String
|
||||
)
|
||||
|
||||
data class VideoSource(
|
||||
@@ -19,5 +28,15 @@ data class VideoSource(
|
||||
val videoType: VideoType,
|
||||
val subtitleTracks: List<SubtitleTrack> = emptyList(),
|
||||
val posterUrl: String? = null,
|
||||
val title: String = ""
|
||||
val title: String = "",
|
||||
val availableQualities: List<QualityLevel> = emptyList(),
|
||||
val currentQualityIndex: Int = -1
|
||||
)
|
||||
|
||||
object VideoSourceConstants {
|
||||
val SUPPORTED_SUBTITLE_LANGUAGES = listOf("en", "es")
|
||||
val SUBTITLE_LANGUAGE_LABELS = mapOf(
|
||||
"en" to "English",
|
||||
"es" to "Español"
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import android.util.Log
|
||||
import com.horrortv.app.domain.model.VideoSource
|
||||
import com.horrortv.app.domain.model.VideoType
|
||||
import com.horrortv.app.domain.model.SubtitleTrack
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jsoup.Jsoup
|
||||
import org.json.JSONArray
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
class VideoExtractor {
|
||||
|
||||
@@ -17,10 +20,24 @@ class VideoExtractor {
|
||||
private const val PLAYIMDB_BASE = "https://playimdb.com/title/"
|
||||
private const val STREAMIMDB_BASE = "https://streamimdb.me/embed/"
|
||||
private const val TIMEOUT_MS = 15000
|
||||
private val ALLOWED_LANGUAGES = setOf("en", "es", "english", "spanish", "eng", "spa")
|
||||
private val ALLOWED_HOSTS = setOf(
|
||||
"streamimdb.me", "www.streamimdb.me",
|
||||
"cloudnestra.com", "www.cloudnestra.com",
|
||||
"playimdb.com", "www.playimdb.com"
|
||||
)
|
||||
private val IMDB_ID_PATTERN = Regex("""^tt\d{7,}${'$'}""")
|
||||
|
||||
private fun isAllowedHost(url: String): Boolean {
|
||||
return try { ALLOWED_HOSTS.contains(URL(url).host) } catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun extractVideoSource(imdbId: String): Result<VideoSource> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!IMDB_ID_PATTERN.matches(imdbId)) {
|
||||
return@withContext Result.failure(VideoExtractionException("Invalid IMDb ID: $imdbId"))
|
||||
}
|
||||
Log.d(TAG, "Extracting video for: $imdbId")
|
||||
|
||||
val embedUrl = "$STREAMIMDB_BASE$imdbId"
|
||||
@@ -28,24 +45,16 @@ class VideoExtractor {
|
||||
|
||||
val html = fetchHtml(embedUrl)
|
||||
Log.d(TAG, "HTML length: ${html.length}")
|
||||
Log.d(TAG, "HTML snippet (first 2000 chars): ${html.take(2000)}")
|
||||
|
||||
var doc = Jsoup.parse(html)
|
||||
var doc = Jsoup.parse(html, embedUrl)
|
||||
var finalHtml = html
|
||||
|
||||
val iframeUrl = doc.selectFirst("iframe")?.attr("src")
|
||||
if (iframeUrl != null && iframeUrl.isNotEmpty()) {
|
||||
val fullIframeUrl = if (iframeUrl.startsWith("//")) {
|
||||
"https:$iframeUrl"
|
||||
} else {
|
||||
iframeUrl
|
||||
}
|
||||
Log.d(TAG, "Found iframe redirect: $fullIframeUrl")
|
||||
val iframeUrl = doc.selectFirst("iframe")?.absUrl("src")
|
||||
if (!iframeUrl.isNullOrEmpty()) {
|
||||
Log.d(TAG, "Found iframe redirect: $iframeUrl")
|
||||
try {
|
||||
val iframeHtml = fetchHtml(fullIframeUrl)
|
||||
val iframeHtml = fetchHtml(iframeUrl)
|
||||
Log.d(TAG, "Iframe HTML length: ${iframeHtml.length}")
|
||||
Log.d(TAG, "IFRAME FULL HTML START =====")
|
||||
Log.d(TAG, iframeHtml)
|
||||
Log.d(TAG, "IFRAME FULL HTML END =====")
|
||||
|
||||
val prorcpPattern = Regex("['\"]?src['\"]?\\s*:\\s*['\"]([^'\"]+/prorcp/[^'\"]+)['\"]")
|
||||
val prorcpMatch = prorcpPattern.find(iframeHtml)
|
||||
@@ -62,8 +71,8 @@ class VideoExtractor {
|
||||
try {
|
||||
val innerHtml = fetchHtml(fullInnerUrl)
|
||||
Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}")
|
||||
Log.d(TAG, "Prorcp FULL HTML: $innerHtml")
|
||||
doc = Jsoup.parse(innerHtml)
|
||||
doc = Jsoup.parse(innerHtml, fullInnerUrl)
|
||||
finalHtml = innerHtml
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed prorcp fetch: ${e.message}")
|
||||
}
|
||||
@@ -78,8 +87,8 @@ class VideoExtractor {
|
||||
try {
|
||||
val innerHtml = fetchHtml(fullInnerUrl)
|
||||
Log.d(TAG, "Prorcp direct HTML length: ${innerHtml.length}")
|
||||
Log.d(TAG, "Prorcp direct FULL HTML: $innerHtml")
|
||||
doc = Jsoup.parse(innerHtml)
|
||||
doc = Jsoup.parse(innerHtml, fullInnerUrl)
|
||||
finalHtml = innerHtml
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed prorcp direct fetch: ${e.message}")
|
||||
}
|
||||
@@ -98,9 +107,6 @@ class VideoExtractor {
|
||||
|
||||
Log.d(TAG, "Video URL found: $videoUrl")
|
||||
|
||||
val subtitles = extractSubtitles(doc)
|
||||
Log.d(TAG, "Subtitles found: ${subtitles.size}")
|
||||
|
||||
val knownDomains = listOf(
|
||||
"neonhorizonworkshops.com",
|
||||
"wanderlynest.com",
|
||||
@@ -109,7 +115,7 @@ class VideoExtractor {
|
||||
)
|
||||
|
||||
val finalVideoUrl = if (videoUrl.contains("{v")) {
|
||||
val basePattern = Regex("https://tmstr3\\.\\{v\\d+\\}/(.+)")
|
||||
val basePattern = Regex("""https://tmstr3\.\{v\d+\}/(.+)""")
|
||||
val pathMatch = basePattern.find(videoUrl)
|
||||
if (pathMatch != null) {
|
||||
val path = pathMatch.groupValues[1]
|
||||
@@ -126,6 +132,22 @@ class VideoExtractor {
|
||||
|
||||
Log.d(TAG, "Final video URL: $finalVideoUrl")
|
||||
|
||||
val subtitles = extractSubtitles(doc, finalHtml, finalVideoUrl).toMutableList()
|
||||
|
||||
val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId)
|
||||
for (sub in openSubtitles) {
|
||||
if (!subtitles.any { it.url == sub.url }) {
|
||||
subtitles.add(sub)
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by language - keep first (priority: embed > HLS > OpenSubtitles)
|
||||
val seen = mutableSetOf<String>()
|
||||
val dedupedTracks = subtitles.filter { seen.add(it.language.lowercase().trim()) }
|
||||
|
||||
val filteredSubtitles = dedupedTracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) }
|
||||
Log.d(TAG, "Subtitles found: ${filteredSubtitles.size}")
|
||||
|
||||
val videoType = determineVideoType(finalVideoUrl)
|
||||
Log.d(TAG, "Video type: $videoType")
|
||||
|
||||
@@ -133,10 +155,12 @@ class VideoExtractor {
|
||||
VideoSource(
|
||||
videoUrl = finalVideoUrl,
|
||||
videoType = videoType,
|
||||
subtitleTracks = subtitles,
|
||||
subtitleTracks = filteredSubtitles,
|
||||
title = imdbId
|
||||
)
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error extracting video", e)
|
||||
Result.failure(VideoExtractionException(e.message ?: "Unknown error", e))
|
||||
@@ -144,32 +168,61 @@ class VideoExtractor {
|
||||
}
|
||||
|
||||
private fun fetchHtml(url: String): String {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.apply {
|
||||
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")
|
||||
}
|
||||
return doHttpFetch(url)
|
||||
}
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
Log.d(TAG, "Response code: $responseCode")
|
||||
private fun fetchManifest(url: String): String {
|
||||
return doHttpFetch(url)
|
||||
}
|
||||
|
||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw VideoExtractionException("HTTP error: $responseCode")
|
||||
private fun fetchOpenSubtitles(url: String): String {
|
||||
return doHttpFetch(url, mapOf("X-User-Agent" to "HorrorTV/1.0"))
|
||||
}
|
||||
|
||||
private fun doHttpFetch(url: String, extraHeaders: Map<String, String> = emptyMap()): String {
|
||||
var connection: HttpURLConnection? = null
|
||||
try {
|
||||
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = TIMEOUT_MS
|
||||
readTimeout = TIMEOUT_MS
|
||||
setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
setRequestProperty("Accept-Language", "en-US,en;q=0.5")
|
||||
setRequestProperty("Accept-Encoding", "gzip, deflate")
|
||||
setRequestProperty("Referer", PLAYIMDB_BASE)
|
||||
extraHeaders.forEach { (key, value) -> setRequestProperty(key, value) }
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
Log.d(TAG, "Response code: $responseCode 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<SubtitleTrack> {
|
||||
private fun extractSubtitles(doc: org.jsoup.nodes.Document, htmlContent: String, videoUrl: String?): List<SubtitleTrack> {
|
||||
val tracks = mutableListOf<SubtitleTrack>()
|
||||
|
||||
val trackElements = doc.select("track")
|
||||
for (track in trackElements) {
|
||||
val src = track.attr("src")
|
||||
val srclang = track.attr("srclang")
|
||||
val src = track.absUrl("src")
|
||||
val srclang = track.attr("srclang").lowercase()
|
||||
val label = track.attr("label").ifEmpty { srclang }
|
||||
val isDefault = track.attr("default") == "default"
|
||||
val isDefault = track.hasAttr("default")
|
||||
|
||||
if (src.isNotEmpty()) {
|
||||
if (src.isNotEmpty() && isAllowedLanguage(srclang, label)) {
|
||||
tracks.add(
|
||||
SubtitleTrack(
|
||||
url = src,
|
||||
language = srclang,
|
||||
language = normalizeLanguage(srclang),
|
||||
label = label,
|
||||
isDefault = isDefault
|
||||
isDefault = isDefault,
|
||||
mimeType = determineMimeType(src)
|
||||
)
|
||||
)
|
||||
Log.d(TAG, "Found subtitle: $srclang - $src")
|
||||
Log.d(TAG, "Found track element subtitle: $srclang - $src")
|
||||
}
|
||||
}
|
||||
|
||||
val subtitlePatterns = listOf(
|
||||
Regex("\"sub\"\\s*:\\s*\"([^\"]+)\""),
|
||||
Regex("\"subtitle\"\\s*:\\s*\"([^\"]+)\""),
|
||||
Regex("\"captions\"\\s*:\\s*\"([^\"]+)\""),
|
||||
Regex("\"file\"\\s*:\\s*\"([^\"]+\\.vtt[^\"]*)\""),
|
||||
Regex("\"file\"\\s*:\\s*\"([^\"]+\\.srt[^\"]*)\""),
|
||||
Regex("'sub'\\s*:\\s*'([^']+)'"),
|
||||
Regex("'subtitle'\\s*:\\s*'([^']+)'"),
|
||||
Regex("'captions'\\s*:\\s*'([^']+)'")
|
||||
)
|
||||
|
||||
for (pattern in subtitlePatterns) {
|
||||
pattern.findAll(htmlContent).forEach { match ->
|
||||
val url = match.groupValues[1]
|
||||
if (url.isNotEmpty() && !tracks.any { it.url == url }) {
|
||||
val cleanUrl = cleanSubtitleUrl(url)
|
||||
if (cleanUrl.isNotEmpty() && isSubtitleUrl(cleanUrl)) {
|
||||
tracks.add(
|
||||
SubtitleTrack(
|
||||
url = cleanUrl,
|
||||
language = "en",
|
||||
label = "English",
|
||||
isDefault = false,
|
||||
mimeType = determineMimeType(cleanUrl)
|
||||
)
|
||||
)
|
||||
Log.d(TAG, "Found JS pattern subtitle: $cleanUrl")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val vttPattern = Regex("https?://[^\"'<>\\s]+\\.vtt[^\"'<>\\s]*")
|
||||
vttPattern.findAll(htmlContent).forEach { match ->
|
||||
val url = match.value
|
||||
if (!tracks.any { it.url == url }) {
|
||||
tracks.add(
|
||||
SubtitleTrack(
|
||||
url = url,
|
||||
language = "en",
|
||||
label = "English",
|
||||
isDefault = false,
|
||||
mimeType = "text/vtt"
|
||||
)
|
||||
)
|
||||
Log.d(TAG, "Found VTT URL: $url")
|
||||
}
|
||||
}
|
||||
|
||||
val srtPattern = Regex("https?://[^\"'<>\\s]+\\.srt[^\"'<>\\s]*")
|
||||
srtPattern.findAll(htmlContent).forEach { match ->
|
||||
val url = match.value
|
||||
if (!tracks.any { it.url == url }) {
|
||||
tracks.add(
|
||||
SubtitleTrack(
|
||||
url = url,
|
||||
language = "en",
|
||||
label = "English",
|
||||
isDefault = false,
|
||||
mimeType = "application/x-subrip"
|
||||
)
|
||||
)
|
||||
Log.d(TAG, "Found SRT URL: $url")
|
||||
}
|
||||
}
|
||||
|
||||
val jsonArrayPattern = Regex("\\[[\\s\\S]*?\"url\"[\\s\\S]*?\\]")
|
||||
jsonArrayPattern.findAll(htmlContent).forEach { match ->
|
||||
try {
|
||||
val jsonArray = match.value
|
||||
val urlPattern = Regex("\"url\"\\s*:\\s*\"([^\"]+)\"")
|
||||
val langPattern = Regex("\"lang\"\\s*:\\s*\"([^\"]+)\"")
|
||||
val labelPattern = Regex("\"label\"\\s*:\\s*\"([^\"]+)\"")
|
||||
|
||||
urlPattern.findAll(jsonArray).forEach { urlMatch ->
|
||||
val subUrl = urlMatch.groupValues[1]
|
||||
val langMatch = langPattern.find(jsonArray)
|
||||
val labelMatch = labelPattern.find(jsonArray)
|
||||
|
||||
val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en"
|
||||
val subLabel = labelMatch?.groupValues?.get(1) ?: lang
|
||||
|
||||
if (subUrl.isNotEmpty() && !tracks.any { it.url == subUrl } && isAllowedLanguage(lang, subLabel)) {
|
||||
tracks.add(
|
||||
SubtitleTrack(
|
||||
url = subUrl,
|
||||
language = normalizeLanguage(lang),
|
||||
label = subLabel,
|
||||
isDefault = false,
|
||||
mimeType = determineMimeType(subUrl)
|
||||
)
|
||||
)
|
||||
Log.d(TAG, "Found JSON array subtitle: $lang - $subUrl")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error parsing subtitle JSON array: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
val jwplayerPattern = Regex("\\{[^}]*file:\"([^\"]+)\"[^}]*label:\"([^\"]+)\"[^}]*kind:\"captions\"[^}]*\\}")
|
||||
jwplayerPattern.findAll(htmlContent).forEach { match ->
|
||||
val url = match.groupValues[1]
|
||||
val label = match.groupValues[2].lowercase()
|
||||
|
||||
if (url.isNotEmpty() && !tracks.any { it.url == url } && isAllowedLanguage(label, label)) {
|
||||
tracks.add(
|
||||
SubtitleTrack(
|
||||
url = url,
|
||||
language = normalizeLanguage(label),
|
||||
label = match.groupValues[2],
|
||||
isDefault = false,
|
||||
mimeType = determineMimeType(url)
|
||||
)
|
||||
)
|
||||
Log.d(TAG, "Found JWPlayer subtitle: $label - $url")
|
||||
}
|
||||
}
|
||||
|
||||
if (videoUrl != null && videoUrl.contains(".m3u8")) {
|
||||
val hlsSubtitles = extractHlsSubtitles(videoUrl)
|
||||
for (sub in hlsSubtitles) {
|
||||
if (!tracks.any { it.url == sub.url }) {
|
||||
tracks.add(sub)
|
||||
Log.d(TAG, "Found HLS manifest subtitle: ${sub.language} - ${sub.url}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) }
|
||||
}
|
||||
|
||||
private fun extractHlsSubtitles(videoUrl: String): List<SubtitleTrack> {
|
||||
val tracks = mutableListOf<SubtitleTrack>()
|
||||
|
||||
try {
|
||||
val manifestContent = fetchManifest(videoUrl)
|
||||
|
||||
val subtitleMediaPattern = Regex(
|
||||
"#EXT-X-MEDIA:TYPE=SUBTITLES[^\\n]*LANGUAGE=\"([^\"]+)\"[^\\n]*NAME=\"([^\"]+)\"[^\\n]*(?:DEFAULT=YES)?[^\\n]*URI=\"([^\"]+)\""
|
||||
)
|
||||
subtitleMediaPattern.findAll(manifestContent).forEach { match ->
|
||||
val lang = match.groupValues[1].lowercase()
|
||||
val name = match.groupValues[2]
|
||||
val uri = match.groupValues[3]
|
||||
|
||||
val fullUrl = if (uri.startsWith("http")) {
|
||||
uri
|
||||
} else {
|
||||
val baseUrl = videoUrl.substringBeforeLast("/") + "/"
|
||||
baseUrl + uri
|
||||
}
|
||||
|
||||
if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name)) {
|
||||
tracks.add(
|
||||
SubtitleTrack(
|
||||
url = fullUrl,
|
||||
language = normalizeLanguage(lang),
|
||||
label = name,
|
||||
isDefault = match.value.contains("DEFAULT=YES", ignoreCase = true),
|
||||
mimeType = "text/vtt"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val simpleSubtitlePattern = Regex("#EXT-X-MEDIA:TYPE=SUBTITLES[^\n]*")
|
||||
simpleSubtitlePattern.findAll(manifestContent).forEach { match ->
|
||||
val line = match.value
|
||||
val langMatch = Regex("LANGUAGE=\"([^\"]+)\"").find(line)
|
||||
val nameMatch = Regex("NAME=\"([^\"]+)\"").find(line)
|
||||
val uriMatch = Regex("URI=\"([^\"]+)\"").find(line)
|
||||
|
||||
val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en"
|
||||
val name = nameMatch?.groupValues?.get(1) ?: lang
|
||||
val uri = uriMatch?.groupValues?.get(1)
|
||||
|
||||
if (uri != null) {
|
||||
val fullUrl = if (uri.startsWith("http")) {
|
||||
uri
|
||||
} else {
|
||||
val baseUrl = videoUrl.substringBeforeLast("/") + "/"
|
||||
baseUrl + uri
|
||||
}
|
||||
|
||||
if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name) && !tracks.any { it.url == fullUrl }) {
|
||||
tracks.add(
|
||||
SubtitleTrack(
|
||||
url = fullUrl,
|
||||
language = normalizeLanguage(lang),
|
||||
label = name,
|
||||
isDefault = line.contains("DEFAULT=YES", true),
|
||||
mimeType = "text/vtt"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to extract HLS subtitles: ${e.message}")
|
||||
}
|
||||
|
||||
return tracks
|
||||
}
|
||||
|
||||
fun fetchSubtitlesFromOpenSubtitles(imdbId: String): List<SubtitleTrack> {
|
||||
val tracks = mutableListOf<SubtitleTrack>()
|
||||
val numericId = imdbId.removePrefix("tt").trim()
|
||||
if (numericId.isEmpty()) return tracks
|
||||
|
||||
val languages = listOf(
|
||||
"eng" to "en",
|
||||
"spa" to "es"
|
||||
)
|
||||
|
||||
for ((subLangCode, normalizedLang) in languages) {
|
||||
val url = "https://rest.opensubtitles.org/search/imdbid-$numericId/sublanguageid-$subLangCode"
|
||||
try {
|
||||
val response = fetchOpenSubtitles(url)
|
||||
val jsonArray = JSONArray(response)
|
||||
for (i in 0 until jsonArray.length()) {
|
||||
val obj = jsonArray.getJSONObject(i)
|
||||
val downloadLink = obj.optString("SubDownloadLink", "")
|
||||
val subFormat = obj.optString("SubFormat", "srt")
|
||||
val languageName = obj.optString("LanguageName", normalizedLang)
|
||||
|
||||
val isHearingImpaired = obj.optString("SubHearingImpaired", "0") == "1"
|
||||
if (isHearingImpaired) continue
|
||||
|
||||
if (downloadLink.isNotEmpty() && tracks.none { it.language == normalizedLang }) {
|
||||
tracks.add(
|
||||
SubtitleTrack(
|
||||
url = downloadLink,
|
||||
language = normalizedLang,
|
||||
label = languageName,
|
||||
isDefault = false,
|
||||
mimeType = if (subFormat.equals("vtt", ignoreCase = true)) "text/vtt" else "application/x-subrip"
|
||||
)
|
||||
)
|
||||
Log.d(TAG, "OpenSubtitles track: $normalizedLang - $downloadLink")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to fetch OpenSubtitles for $subLangCode: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
return tracks
|
||||
}
|
||||
|
||||
private fun isAllowedLanguage(lang: String, label: String): Boolean {
|
||||
val normalizedLang = lang.lowercase().trim()
|
||||
val normalizedLabel = label.lowercase().trim()
|
||||
return normalizedLang == "en" || normalizedLang == "es" ||
|
||||
normalizedLang == "eng" || normalizedLang == "spa" ||
|
||||
normalizedLang.startsWith("english") || normalizedLang.startsWith("spanish") ||
|
||||
normalizedLabel.contains("english") || normalizedLabel.contains("spanish") ||
|
||||
normalizedLabel.contains("español")
|
||||
}
|
||||
|
||||
private fun normalizeLanguage(lang: String): String {
|
||||
val normalized = lang.lowercase().trim()
|
||||
return when {
|
||||
normalized.contains("english") || normalized.contains("eng") -> "en"
|
||||
normalized.contains("spanish") || normalized.contains("spa") -> "es"
|
||||
normalized == "en" -> "en"
|
||||
normalized == "es" -> "es"
|
||||
else -> normalized.take(2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSubtitleUrl(url: String): Boolean {
|
||||
return url.contains(".vtt") || url.contains(".srt") ||
|
||||
url.contains("subtitle") || url.contains("caption") ||
|
||||
url.contains("sub")
|
||||
}
|
||||
|
||||
private fun cleanSubtitleUrl(url: String, baseUrl: String = ""): String {
|
||||
val clean = url.trim().removeSurrounding("\"").removeSurrounding("'").trim()
|
||||
if (clean.isEmpty()) return ""
|
||||
return when {
|
||||
clean.startsWith("http://") || clean.startsWith("https://") -> clean
|
||||
clean.startsWith("/") -> {
|
||||
try {
|
||||
val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com" })
|
||||
URL(base, clean).toString()
|
||||
} catch (_: Exception) { "https://cloudnestra.com$clean" }
|
||||
}
|
||||
else -> {
|
||||
try {
|
||||
val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com/" })
|
||||
URL(base, clean).toString()
|
||||
} catch (_: Exception) { "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun determineMimeType(url: String): String {
|
||||
return when {
|
||||
url.contains(".srt", ignoreCase = true) -> "application/x-subrip"
|
||||
url.contains(".vtt", ignoreCase = true) -> "text/vtt"
|
||||
else -> "text/vtt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -50,8 +52,6 @@ fun TvErrorDisplay(
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
val iconRes = when (error) {
|
||||
is AppError.NetworkError -> android.R.drawable.ic_menu_close_clear_cancel
|
||||
is AppError.ApiError -> android.R.drawable.ic_dialog_alert
|
||||
@@ -87,6 +87,9 @@ fun TvErrorDisplay(
|
||||
Text(text = error.userMessage, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorWhite, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(0.6f))
|
||||
|
||||
if (error.isRetryable) {
|
||||
LaunchedEffect(Unit) {
|
||||
try { focusRequester.requestFocus() } catch (_: Exception) {}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Box(
|
||||
@@ -99,7 +102,18 @@ fun TvErrorDisplay(
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { isRetryFocused = it.isFocused }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onRetry),
|
||||
.onKeyEvent { event ->
|
||||
val native = event.nativeKeyEvent ?: return@onKeyEvent false
|
||||
if (native.action != KeyEvent.ACTION_DOWN) return@onKeyEvent false
|
||||
if (native.repeatCount > 0) return@onKeyEvent false
|
||||
val keyCode = native.keyCode
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
onRetry()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = "REINTENTAR", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
|
||||
@@ -119,8 +133,6 @@ fun TvSnackbarError(
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
val dismissColor = if (isDismissFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
||||
|
||||
Box(
|
||||
@@ -143,6 +155,9 @@ fun TvSnackbarError(
|
||||
)
|
||||
Text(text = error.userMessage, style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite, modifier = Modifier.weight(1f))
|
||||
if (error.isRetryable) {
|
||||
LaunchedEffect(Unit) {
|
||||
try { focusRequester.requestFocus() } catch (_: Exception) {}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.horrortv.app.presentation.home
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -38,6 +39,8 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.horrortv.app.domain.model.Movie
|
||||
@@ -51,13 +54,11 @@ fun HomeScreen(
|
||||
viewModel: HomeViewModel,
|
||||
onNavigateToSearch: () -> Unit,
|
||||
onNavigateToDetail: (String) -> Unit,
|
||||
onExit: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = true) {
|
||||
showExitDialog = true
|
||||
val context = LocalContext.current
|
||||
BackHandler {
|
||||
(context as? android.app.Activity)?.finishAffinity()
|
||||
}
|
||||
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
@@ -102,13 +103,6 @@ fun HomeScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showExitDialog) {
|
||||
ExitConfirmationDialog(
|
||||
onConfirm = { showExitDialog = false; onExit() },
|
||||
onDismiss = { showExitDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +135,8 @@ private fun CategoriesList(
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = categories,
|
||||
key = { _, category -> category.name }
|
||||
key = { _, category -> category.name },
|
||||
contentType = { _, _ -> "category_row" }
|
||||
) { index, category ->
|
||||
HorrorRow(
|
||||
category = category,
|
||||
@@ -173,9 +168,18 @@ fun HomeHeader(onSearchClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
.background(if (isSearchFocused) HorrorColors.HorrorGray else Color.Transparent)
|
||||
.border(if (isSearchFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp))
|
||||
.focusRequester(searchFocusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.focusable()
|
||||
.onFocusChanged { isSearchFocused = it.isFocused }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onSearchClick),
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onSearchClick)
|
||||
.onKeyEvent { event ->
|
||||
val keyCode = event.nativeKeyEvent?.keyCode
|
||||
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||
onSearchClick()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
@@ -210,61 +214,3 @@ fun CategoryPlaceholderRow(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExitConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
var isConfirmFocused by remember { mutableStateOf(false) }
|
||||
val confirmFocusRequester = remember { FocusRequester() }
|
||||
val confirmInteractionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(HorrorColors.HorrorBlack.copy(alpha = 0.85f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Text(text = "¿Salir de Horror TV?", style = HorrorTypography.DetailTitle, color = HorrorColors.HorrorWhite)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
|
||||
TextButton(onClick = onDismiss) { Text("No", color = HorrorColors.HorrorWhite) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(200.dp).height(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isConfirmFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed)
|
||||
.border(if (isConfirmFocused) 3.dp else 1.dp, if (isConfirmFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(8.dp))
|
||||
.focusRequester(confirmFocusRequester)
|
||||
.focusable(interactionSource = confirmInteractionSource)
|
||||
.onFocusChanged { isConfirmFocused = it.isFocused }
|
||||
.clickable(interactionSource = confirmInteractionSource, indication = null, onClick = onConfirm),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Sí, salir", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextButton(onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(100.dp).height(56.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isFocused) HorrorColors.HorrorGray else Color.Transparent)
|
||||
.border(if (isFocused) 2.dp else 0.dp, HorrorColors.HorrorWhite, RoundedCornerShape(8.dp))
|
||||
.focusRequester(focusRequester)
|
||||
.focusable(interactionSource = interactionSource)
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,25 +45,34 @@ class NetworkStatus @Inject constructor(
|
||||
fun observeNetworkStatus(): Flow<NetworkState> = callbackFlow {
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(NetworkState.Available())
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(NetworkState.Unavailable)
|
||||
// Don't emit here — wait for onCapabilitiesChanged which has full state
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||
val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
val wifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
trySend(NetworkState.Available(metered = metered, wifi = wifi))
|
||||
val cellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val vpn = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||
val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
|
||||
if (hasInternet && validated) {
|
||||
trySend(NetworkState.Available(wifi = wifi, metered = metered))
|
||||
} else {
|
||||
trySend(NetworkState.Unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user