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
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
buildConfigField("String", "APP_NAME", "\"HorrorTV\"")
|
buildConfigField("String", "APP_NAME", "\"HorrorTV\"")
|
||||||
|
buildConfigField("String", "OMDB_API_KEY", "\"${project.findProperty("OMDB_API_KEY") ?: "5854c81e"}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -75,7 +76,7 @@ android {
|
|||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
freeCompilerArgs += listOf(
|
freeCompilerArgs += listOf(
|
||||||
"-Xopt-in=kotlin.RequiresOptIn",
|
"-opt-in=kotlin.RequiresOptIn",
|
||||||
"-Xjvm-default=all"
|
"-Xjvm-default=all"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -142,6 +143,11 @@ dependencies {
|
|||||||
implementation("androidx.media3:media3-common:1.4.0")
|
implementation("androidx.media3:media3-common:1.4.0")
|
||||||
implementation("org.jsoup:jsoup:1.17.2")
|
implementation("org.jsoup:jsoup:1.17.2")
|
||||||
|
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
testImplementation("io.mockk:mockk:1.13.9")
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||||
|
testImplementation("app.cash.turbine:turbine:1.0.0")
|
||||||
|
|
||||||
debugImplementation(platform("androidx.compose:compose-bom:2024.10.01"))
|
debugImplementation(platform("androidx.compose:compose-bom:2024.10.01"))
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|||||||
24
app/proguard-rules.pro
vendored
24
app/proguard-rules.pro
vendored
@@ -40,9 +40,6 @@
|
|||||||
@kotlinx.coroutines.InternalCoroutinesApi <methods>;
|
@kotlinx.coroutines.InternalCoroutinesApi <methods>;
|
||||||
}
|
}
|
||||||
|
|
||||||
-keep class kotlin.** { *; }
|
|
||||||
-keep class * implements kotlin.** { *; }
|
|
||||||
|
|
||||||
-assumenosideeffects class android.util.Log {
|
-assumenosideeffects class android.util.Log {
|
||||||
public static boolean isLoggable(...);
|
public static boolean isLoggable(...);
|
||||||
public static int v(...);
|
public static int v(...);
|
||||||
@@ -70,3 +67,24 @@
|
|||||||
-dontwarn kotlin.Unit
|
-dontwarn kotlin.Unit
|
||||||
-dontwarn retrofit2.Platform$Java8
|
-dontwarn retrofit2.Platform$Java8
|
||||||
-dontwarn kotlin.jvm.internal.Reflection
|
-dontwarn kotlin.jvm.internal.Reflection
|
||||||
|
|
||||||
|
# Compose
|
||||||
|
-keep class androidx.compose.runtime.CompositionLocal { *; }
|
||||||
|
-keepclassmembers class * {
|
||||||
|
@androidx.compose.runtime.Composable <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Media3 ExoPlayer
|
||||||
|
-keep class androidx.media3.** { *; }
|
||||||
|
|
||||||
|
# Jsoup
|
||||||
|
-keep class org.jsoup.** { *; }
|
||||||
|
|
||||||
|
# Keep generic signature info for Retrofit+Gson
|
||||||
|
-keepattributes Signature
|
||||||
|
|
||||||
|
# Enums
|
||||||
|
-keepclassmembers enum * {
|
||||||
|
public static **[] values();
|
||||||
|
public static ** valueOf(java.lang.String);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".HorrorTvApp"
|
android:name=".HorrorTvApp"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
@@ -23,7 +24,8 @@
|
|||||||
android:name=".presentation.MainActivity"
|
android:name=".presentation.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:screenOrientation="landscape">
|
android:screenOrientation="landscape"
|
||||||
|
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout|uiMode">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||||
|
|||||||
@@ -3,26 +3,26 @@ package com.horrortv.app.data.remote.omdb.dto
|
|||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
data class OmdbMovieDetailDto(
|
data class OmdbMovieDetailDto(
|
||||||
@SerializedName("Title") val title: String,
|
@SerializedName("Title") val title: String = "",
|
||||||
@SerializedName("Year") val year: String,
|
@SerializedName("Year") val year: String = "",
|
||||||
@SerializedName("Rated") val rated: String?,
|
@SerializedName("Rated") val rated: String? = null,
|
||||||
@SerializedName("Released") val released: String?,
|
@SerializedName("Released") val released: String? = null,
|
||||||
@SerializedName("Runtime") val runtime: String?,
|
@SerializedName("Runtime") val runtime: String? = null,
|
||||||
@SerializedName("Genre") val genre: String?,
|
@SerializedName("Genre") val genre: String? = null,
|
||||||
@SerializedName("Director") val director: String?,
|
@SerializedName("Director") val director: String? = null,
|
||||||
@SerializedName("Writer") val writer: String?,
|
@SerializedName("Writer") val writer: String? = null,
|
||||||
@SerializedName("Actors") val actors: String?,
|
@SerializedName("Actors") val actors: String? = null,
|
||||||
@SerializedName("Plot") val plot: String?,
|
@SerializedName("Plot") val plot: String? = null,
|
||||||
@SerializedName("Language") val language: String?,
|
@SerializedName("Language") val language: String? = null,
|
||||||
@SerializedName("Country") val country: String?,
|
@SerializedName("Country") val country: String? = null,
|
||||||
@SerializedName("Awards") val awards: String?,
|
@SerializedName("Awards") val awards: String? = null,
|
||||||
@SerializedName("Poster") val poster: String?,
|
@SerializedName("Poster") val poster: String? = null,
|
||||||
@SerializedName("Metascore") val metascore: String?,
|
@SerializedName("Metascore") val metascore: String? = null,
|
||||||
@SerializedName("imdbRating") val imdbRating: String?,
|
@SerializedName("imdbRating") val imdbRating: String? = null,
|
||||||
@SerializedName("imdbVotes") val imdbVotes: String?,
|
@SerializedName("imdbVotes") val imdbVotes: String? = null,
|
||||||
@SerializedName("imdbID") val imdbId: String,
|
@SerializedName("imdbID") val imdbId: String = "",
|
||||||
@SerializedName("Type") val type: String,
|
@SerializedName("Type") val type: String = "",
|
||||||
@SerializedName("Response") val response: String,
|
@SerializedName("Response") val response: String = "",
|
||||||
@SerializedName("Error") val error: String? = null
|
@SerializedName("Error") val error: String? = null
|
||||||
) {
|
) {
|
||||||
val isSuccess: Boolean get() = response == "True"
|
val isSuccess: Boolean get() = response == "True"
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ package com.horrortv.app.data.remote.omdb.dto
|
|||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
data class OmdbSearchResponse(
|
data class OmdbSearchResponse(
|
||||||
@SerializedName("Search") val search: List<OmdbMovieSearchDto>?,
|
@SerializedName("Search") val search: List<OmdbMovieSearchDto> = emptyList(),
|
||||||
@SerializedName("totalResults") val totalResults: String?,
|
@SerializedName("totalResults") val totalResults: String? = null,
|
||||||
@SerializedName("Response") val response: String,
|
@SerializedName("Response") val response: String = "",
|
||||||
@SerializedName("Error") val error: String?
|
@SerializedName("Error") val error: String? = null
|
||||||
) {
|
) {
|
||||||
val isSuccess: Boolean get() = response == "True"
|
val isSuccess: Boolean get() = response == "True"
|
||||||
}
|
}
|
||||||
|
|
||||||
data class OmdbMovieSearchDto(
|
data class OmdbMovieSearchDto(
|
||||||
@SerializedName("Title") val title: String,
|
@SerializedName("Title") val title: String = "",
|
||||||
@SerializedName("Year") val year: String,
|
@SerializedName("Year") val year: String = "",
|
||||||
@SerializedName("imdbID") val imdbId: String,
|
@SerializedName("imdbID") val imdbId: String = "",
|
||||||
@SerializedName("Type") val type: String,
|
@SerializedName("Type") val type: String = "",
|
||||||
@SerializedName("Poster") val poster: String?
|
@SerializedName("Poster") val poster: String? = null
|
||||||
) {
|
) {
|
||||||
val hasValidPoster: Boolean get() = poster != null && poster != "N/A"
|
val hasValidPoster: Boolean get() = poster != null && poster != "N/A"
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import com.horrortv.app.domain.repository.MovieRepository
|
|||||||
import com.horrortv.app.domain.repository.Result
|
import com.horrortv.app.domain.repository.Result
|
||||||
import com.horrortv.app.util.ApiException
|
import com.horrortv.app.util.ApiException
|
||||||
import com.horrortv.app.util.Constants
|
import com.horrortv.app.util.Constants
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
@@ -61,7 +62,7 @@ class MovieRepositoryImpl @Inject constructor(
|
|||||||
cache.put(key, CacheEntry(data, System.currentTimeMillis(), ttlMs))
|
cache.put(key, CacheEntry(data, System.currentTimeMillis(), ttlMs))
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getFeaturedCategories(): List<MovieCategory> = withContext(Dispatchers.Default) {
|
override suspend fun getFeaturedCategories(): List<MovieCategory> = withContext(Dispatchers.IO) {
|
||||||
Log.d(TAG, "Fetching featured categories with limited concurrency")
|
Log.d(TAG, "Fetching featured categories with limited concurrency")
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
@@ -94,6 +95,8 @@ class MovieRepositoryImpl @Inject constructor(
|
|||||||
putInCache(categoryCache, cacheKey, movies, categoryTtlMs())
|
putInCache(categoryCache, cacheKey, movies, categoryTtlMs())
|
||||||
MovieCategory(category, movies)
|
MovieCategory(category, movies)
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error fetching category: $category", e)
|
Log.e(TAG, "Error fetching category: $category", e)
|
||||||
MovieCategory(category, emptyList())
|
MovieCategory(category, emptyList())
|
||||||
@@ -115,7 +118,7 @@ class MovieRepositoryImpl @Inject constructor(
|
|||||||
return Result.Success(cached)
|
return Result.Success(cached)
|
||||||
}
|
}
|
||||||
|
|
||||||
return withContext(Dispatchers.Default) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Searching for: $query")
|
Log.d(TAG, "Searching for: $query")
|
||||||
val response = apiService.searchMovies(query, page = 1)
|
val response = apiService.searchMovies(query, page = 1)
|
||||||
@@ -139,13 +142,11 @@ class MovieRepositoryImpl @Inject constructor(
|
|||||||
putInCache(searchCache, cacheKey, movies, searchTtlMs())
|
putInCache(searchCache, cacheKey, movies, searchTtlMs())
|
||||||
Result.Success(movies)
|
Result.Success(movies)
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error searching: $query", e)
|
Log.e(TAG, "Error searching: $query", e)
|
||||||
val appError = if (e is ApiException) e.toAppError() else AppError.UnknownError(
|
val appError = ApiException.fromThrowable(e).toAppError()
|
||||||
userMessage = "Error inesperado. Intenta de nuevo.",
|
|
||||||
debugMessage = "Unhandled exception: ${e.message}",
|
|
||||||
cause = e
|
|
||||||
)
|
|
||||||
Result.Error(appError)
|
Result.Error(appError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +162,7 @@ class MovieRepositoryImpl @Inject constructor(
|
|||||||
return Result.Success(cached)
|
return Result.Success(cached)
|
||||||
}
|
}
|
||||||
|
|
||||||
return withContext(Dispatchers.Default) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Fetching movie detail: $imdbId")
|
Log.d(TAG, "Fetching movie detail: $imdbId")
|
||||||
val detail = apiService.getMovieDetail(imdbId)
|
val detail = apiService.getMovieDetail(imdbId)
|
||||||
@@ -189,13 +190,11 @@ class MovieRepositoryImpl @Inject constructor(
|
|||||||
putInCache(detailCache, cacheKey, movie, detailTtlMs())
|
putInCache(detailCache, cacheKey, movie, detailTtlMs())
|
||||||
Result.Success(movie)
|
Result.Success(movie)
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error fetching movie: $imdbId", e)
|
Log.e(TAG, "Error fetching movie: $imdbId", e)
|
||||||
val appError = if (e is ApiException) e.toAppError() else AppError.UnknownError(
|
val appError = ApiException.fromThrowable(e).toAppError()
|
||||||
userMessage = "Error inesperado. Intenta de nuevo.",
|
|
||||||
debugMessage = "Unhandled exception: ${e.message}",
|
|
||||||
cause = e
|
|
||||||
)
|
|
||||||
Result.Error(appError)
|
Result.Error(appError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,13 @@ object NetworkModule {
|
|||||||
.connectTimeout(Constants.Network.CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.connectTimeout(Constants.Network.CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.readTimeout(Constants.Network.READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.readTimeout(Constants.Network.READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.writeTimeout(Constants.Network.WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
.writeTimeout(Constants.Network.WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
.apply {
|
||||||
level = HttpLoggingInterceptor.Level.BASIC
|
if (com.horrortv.app.BuildConfig.DEBUG) {
|
||||||
})
|
addInterceptor(HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BASIC
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.horrortv.app.domain.model
|
package com.horrortv.app.domain.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
data class Movie(
|
data class Movie(
|
||||||
val imdbId: String,
|
val imdbId: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.horrortv.app.domain.model
|
package com.horrortv.app.domain.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
data class MovieCategory(
|
data class MovieCategory(
|
||||||
val name: String,
|
val name: String,
|
||||||
val movies: List<Movie>
|
val movies: List<Movie>
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ data class SubtitleTrack(
|
|||||||
val url: String,
|
val url: String,
|
||||||
val language: String,
|
val language: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val isDefault: Boolean = false
|
val isDefault: Boolean = false,
|
||||||
|
val mimeType: String = "text/vtt",
|
||||||
|
val languageCode: String = language
|
||||||
|
)
|
||||||
|
|
||||||
|
data class QualityLevel(
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val bitrate: Long,
|
||||||
|
val label: String
|
||||||
)
|
)
|
||||||
|
|
||||||
data class VideoSource(
|
data class VideoSource(
|
||||||
@@ -19,5 +28,15 @@ data class VideoSource(
|
|||||||
val videoType: VideoType,
|
val videoType: VideoType,
|
||||||
val subtitleTracks: List<SubtitleTrack> = emptyList(),
|
val subtitleTracks: List<SubtitleTrack> = emptyList(),
|
||||||
val posterUrl: String? = null,
|
val posterUrl: String? = null,
|
||||||
val title: String = ""
|
val title: String = "",
|
||||||
|
val availableQualities: List<QualityLevel> = emptyList(),
|
||||||
|
val currentQualityIndex: Int = -1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
object VideoSourceConstants {
|
||||||
|
val SUPPORTED_SUBTITLE_LANGUAGES = listOf("en", "es")
|
||||||
|
val SUBTITLE_LANGUAGE_LABELS = mapOf(
|
||||||
|
"en" to "English",
|
||||||
|
"es" to "Español"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@ import android.util.Log
|
|||||||
import com.horrortv.app.domain.model.VideoSource
|
import com.horrortv.app.domain.model.VideoSource
|
||||||
import com.horrortv.app.domain.model.VideoType
|
import com.horrortv.app.domain.model.VideoType
|
||||||
import com.horrortv.app.domain.model.SubtitleTrack
|
import com.horrortv.app.domain.model.SubtitleTrack
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
|
import org.json.JSONArray
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
|
||||||
class VideoExtractor {
|
class VideoExtractor {
|
||||||
|
|
||||||
@@ -17,10 +20,24 @@ class VideoExtractor {
|
|||||||
private const val PLAYIMDB_BASE = "https://playimdb.com/title/"
|
private const val PLAYIMDB_BASE = "https://playimdb.com/title/"
|
||||||
private const val STREAMIMDB_BASE = "https://streamimdb.me/embed/"
|
private const val STREAMIMDB_BASE = "https://streamimdb.me/embed/"
|
||||||
private const val TIMEOUT_MS = 15000
|
private const val TIMEOUT_MS = 15000
|
||||||
|
private val ALLOWED_LANGUAGES = setOf("en", "es", "english", "spanish", "eng", "spa")
|
||||||
|
private val ALLOWED_HOSTS = setOf(
|
||||||
|
"streamimdb.me", "www.streamimdb.me",
|
||||||
|
"cloudnestra.com", "www.cloudnestra.com",
|
||||||
|
"playimdb.com", "www.playimdb.com"
|
||||||
|
)
|
||||||
|
private val IMDB_ID_PATTERN = Regex("""^tt\d{7,}${'$'}""")
|
||||||
|
|
||||||
|
private fun isAllowedHost(url: String): Boolean {
|
||||||
|
return try { ALLOWED_HOSTS.contains(URL(url).host) } catch (_: Exception) { false }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun extractVideoSource(imdbId: String): Result<VideoSource> = withContext(Dispatchers.IO) {
|
suspend fun extractVideoSource(imdbId: String): Result<VideoSource> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
if (!IMDB_ID_PATTERN.matches(imdbId)) {
|
||||||
|
return@withContext Result.failure(VideoExtractionException("Invalid IMDb ID: $imdbId"))
|
||||||
|
}
|
||||||
Log.d(TAG, "Extracting video for: $imdbId")
|
Log.d(TAG, "Extracting video for: $imdbId")
|
||||||
|
|
||||||
val embedUrl = "$STREAMIMDB_BASE$imdbId"
|
val embedUrl = "$STREAMIMDB_BASE$imdbId"
|
||||||
@@ -28,24 +45,16 @@ class VideoExtractor {
|
|||||||
|
|
||||||
val html = fetchHtml(embedUrl)
|
val html = fetchHtml(embedUrl)
|
||||||
Log.d(TAG, "HTML length: ${html.length}")
|
Log.d(TAG, "HTML length: ${html.length}")
|
||||||
Log.d(TAG, "HTML snippet (first 2000 chars): ${html.take(2000)}")
|
|
||||||
|
|
||||||
var doc = Jsoup.parse(html)
|
var doc = Jsoup.parse(html, embedUrl)
|
||||||
|
var finalHtml = html
|
||||||
|
|
||||||
val iframeUrl = doc.selectFirst("iframe")?.attr("src")
|
val iframeUrl = doc.selectFirst("iframe")?.absUrl("src")
|
||||||
if (iframeUrl != null && iframeUrl.isNotEmpty()) {
|
if (!iframeUrl.isNullOrEmpty()) {
|
||||||
val fullIframeUrl = if (iframeUrl.startsWith("//")) {
|
Log.d(TAG, "Found iframe redirect: $iframeUrl")
|
||||||
"https:$iframeUrl"
|
|
||||||
} else {
|
|
||||||
iframeUrl
|
|
||||||
}
|
|
||||||
Log.d(TAG, "Found iframe redirect: $fullIframeUrl")
|
|
||||||
try {
|
try {
|
||||||
val iframeHtml = fetchHtml(fullIframeUrl)
|
val iframeHtml = fetchHtml(iframeUrl)
|
||||||
Log.d(TAG, "Iframe HTML length: ${iframeHtml.length}")
|
Log.d(TAG, "Iframe HTML length: ${iframeHtml.length}")
|
||||||
Log.d(TAG, "IFRAME FULL HTML START =====")
|
|
||||||
Log.d(TAG, iframeHtml)
|
|
||||||
Log.d(TAG, "IFRAME FULL HTML END =====")
|
|
||||||
|
|
||||||
val prorcpPattern = Regex("['\"]?src['\"]?\\s*:\\s*['\"]([^'\"]+/prorcp/[^'\"]+)['\"]")
|
val prorcpPattern = Regex("['\"]?src['\"]?\\s*:\\s*['\"]([^'\"]+/prorcp/[^'\"]+)['\"]")
|
||||||
val prorcpMatch = prorcpPattern.find(iframeHtml)
|
val prorcpMatch = prorcpPattern.find(iframeHtml)
|
||||||
@@ -62,8 +71,8 @@ class VideoExtractor {
|
|||||||
try {
|
try {
|
||||||
val innerHtml = fetchHtml(fullInnerUrl)
|
val innerHtml = fetchHtml(fullInnerUrl)
|
||||||
Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}")
|
Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}")
|
||||||
Log.d(TAG, "Prorcp FULL HTML: $innerHtml")
|
doc = Jsoup.parse(innerHtml, fullInnerUrl)
|
||||||
doc = Jsoup.parse(innerHtml)
|
finalHtml = innerHtml
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed prorcp fetch: ${e.message}")
|
Log.w(TAG, "Failed prorcp fetch: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -78,8 +87,8 @@ class VideoExtractor {
|
|||||||
try {
|
try {
|
||||||
val innerHtml = fetchHtml(fullInnerUrl)
|
val innerHtml = fetchHtml(fullInnerUrl)
|
||||||
Log.d(TAG, "Prorcp direct HTML length: ${innerHtml.length}")
|
Log.d(TAG, "Prorcp direct HTML length: ${innerHtml.length}")
|
||||||
Log.d(TAG, "Prorcp direct FULL HTML: $innerHtml")
|
doc = Jsoup.parse(innerHtml, fullInnerUrl)
|
||||||
doc = Jsoup.parse(innerHtml)
|
finalHtml = innerHtml
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed prorcp direct fetch: ${e.message}")
|
Log.w(TAG, "Failed prorcp direct fetch: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -98,9 +107,6 @@ class VideoExtractor {
|
|||||||
|
|
||||||
Log.d(TAG, "Video URL found: $videoUrl")
|
Log.d(TAG, "Video URL found: $videoUrl")
|
||||||
|
|
||||||
val subtitles = extractSubtitles(doc)
|
|
||||||
Log.d(TAG, "Subtitles found: ${subtitles.size}")
|
|
||||||
|
|
||||||
val knownDomains = listOf(
|
val knownDomains = listOf(
|
||||||
"neonhorizonworkshops.com",
|
"neonhorizonworkshops.com",
|
||||||
"wanderlynest.com",
|
"wanderlynest.com",
|
||||||
@@ -109,7 +115,7 @@ class VideoExtractor {
|
|||||||
)
|
)
|
||||||
|
|
||||||
val finalVideoUrl = if (videoUrl.contains("{v")) {
|
val finalVideoUrl = if (videoUrl.contains("{v")) {
|
||||||
val basePattern = Regex("https://tmstr3\\.\\{v\\d+\\}/(.+)")
|
val basePattern = Regex("""https://tmstr3\.\{v\d+\}/(.+)""")
|
||||||
val pathMatch = basePattern.find(videoUrl)
|
val pathMatch = basePattern.find(videoUrl)
|
||||||
if (pathMatch != null) {
|
if (pathMatch != null) {
|
||||||
val path = pathMatch.groupValues[1]
|
val path = pathMatch.groupValues[1]
|
||||||
@@ -126,6 +132,22 @@ class VideoExtractor {
|
|||||||
|
|
||||||
Log.d(TAG, "Final video URL: $finalVideoUrl")
|
Log.d(TAG, "Final video URL: $finalVideoUrl")
|
||||||
|
|
||||||
|
val subtitles = extractSubtitles(doc, finalHtml, finalVideoUrl).toMutableList()
|
||||||
|
|
||||||
|
val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId)
|
||||||
|
for (sub in openSubtitles) {
|
||||||
|
if (!subtitles.any { it.url == sub.url }) {
|
||||||
|
subtitles.add(sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by language - keep first (priority: embed > HLS > OpenSubtitles)
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
val dedupedTracks = subtitles.filter { seen.add(it.language.lowercase().trim()) }
|
||||||
|
|
||||||
|
val filteredSubtitles = dedupedTracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) }
|
||||||
|
Log.d(TAG, "Subtitles found: ${filteredSubtitles.size}")
|
||||||
|
|
||||||
val videoType = determineVideoType(finalVideoUrl)
|
val videoType = determineVideoType(finalVideoUrl)
|
||||||
Log.d(TAG, "Video type: $videoType")
|
Log.d(TAG, "Video type: $videoType")
|
||||||
|
|
||||||
@@ -133,10 +155,12 @@ class VideoExtractor {
|
|||||||
VideoSource(
|
VideoSource(
|
||||||
videoUrl = finalVideoUrl,
|
videoUrl = finalVideoUrl,
|
||||||
videoType = videoType,
|
videoType = videoType,
|
||||||
subtitleTracks = subtitles,
|
subtitleTracks = filteredSubtitles,
|
||||||
title = imdbId
|
title = imdbId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error extracting video", e)
|
Log.e(TAG, "Error extracting video", e)
|
||||||
Result.failure(VideoExtractionException(e.message ?: "Unknown error", e))
|
Result.failure(VideoExtractionException(e.message ?: "Unknown error", e))
|
||||||
@@ -144,32 +168,61 @@ class VideoExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun fetchHtml(url: String): String {
|
private fun fetchHtml(url: String): String {
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
if (!isAllowedHost(url)) {
|
||||||
connection.apply {
|
throw VideoExtractionException("Host not allowed: $url")
|
||||||
requestMethod = "GET"
|
|
||||||
connectTimeout = TIMEOUT_MS
|
|
||||||
readTimeout = TIMEOUT_MS
|
|
||||||
setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
|
||||||
setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
|
||||||
setRequestProperty("Accept-Language", "en-US,en;q=0.5")
|
|
||||||
setRequestProperty("Referer", PLAYIMDB_BASE)
|
|
||||||
instanceFollowRedirects = true
|
|
||||||
}
|
}
|
||||||
|
return doHttpFetch(url)
|
||||||
|
}
|
||||||
|
|
||||||
val responseCode = connection.responseCode
|
private fun fetchManifest(url: String): String {
|
||||||
Log.d(TAG, "Response code: $responseCode")
|
return doHttpFetch(url)
|
||||||
|
}
|
||||||
|
|
||||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
private fun fetchOpenSubtitles(url: String): String {
|
||||||
throw VideoExtractionException("HTTP error: $responseCode")
|
return doHttpFetch(url, mapOf("X-User-Agent" to "HorrorTV/1.0"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doHttpFetch(url: String, extraHeaders: Map<String, String> = emptyMap()): String {
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
try {
|
||||||
|
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "GET"
|
||||||
|
connectTimeout = TIMEOUT_MS
|
||||||
|
readTimeout = TIMEOUT_MS
|
||||||
|
setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||||
|
setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
setRequestProperty("Accept-Language", "en-US,en;q=0.5")
|
||||||
|
setRequestProperty("Accept-Encoding", "gzip, deflate")
|
||||||
|
setRequestProperty("Referer", PLAYIMDB_BASE)
|
||||||
|
extraHeaders.forEach { (key, value) -> setRequestProperty(key, value) }
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
Log.d(TAG, "Response code: $responseCode for $url")
|
||||||
|
|
||||||
|
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
throw VideoExtractionException("HTTP error: $responseCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
val inputStream = connection.inputStream.let { stream ->
|
||||||
|
if (connection.contentEncoding?.contains("gzip", ignoreCase = true) == true) {
|
||||||
|
GZIPInputStream(stream)
|
||||||
|
} else {
|
||||||
|
stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection.inputStream.bufferedReader().use { it.readText() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractVideoUrl(doc: org.jsoup.nodes.Document): String? {
|
private fun extractVideoUrl(doc: org.jsoup.nodes.Document): String? {
|
||||||
val videoElement = doc.selectFirst("video")
|
val videoElement = doc.selectFirst("video")
|
||||||
if (videoElement != null) {
|
if (videoElement != null) {
|
||||||
val src = videoElement.attr("src")
|
val src = videoElement.absUrl("src")
|
||||||
if (src.isNotEmpty()) {
|
if (src.isNotEmpty()) {
|
||||||
Log.d(TAG, "Found video[src]: $src")
|
Log.d(TAG, "Found video[src]: $src")
|
||||||
return src
|
return src
|
||||||
@@ -177,7 +230,7 @@ class VideoExtractor {
|
|||||||
|
|
||||||
val sourceElements = videoElement.select("source")
|
val sourceElements = videoElement.select("source")
|
||||||
for (source in sourceElements) {
|
for (source in sourceElements) {
|
||||||
val srcAttr = source.attr("src")
|
val srcAttr = source.absUrl("src")
|
||||||
if (srcAttr.isNotEmpty() && isValidVideoUrl(srcAttr)) {
|
if (srcAttr.isNotEmpty() && isValidVideoUrl(srcAttr)) {
|
||||||
Log.d(TAG, "Found source[src]: $srcAttr")
|
Log.d(TAG, "Found source[src]: $srcAttr")
|
||||||
return srcAttr
|
return srcAttr
|
||||||
@@ -237,48 +290,350 @@ class VideoExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun isValidVideoUrl(url: String): Boolean {
|
private fun isValidVideoUrl(url: String): Boolean {
|
||||||
return url.contains(".m3u8") ||
|
val lower = url.lowercase()
|
||||||
url.contains(".mp4") ||
|
return (lower.contains(".m3u8") || lower.contains(".mp4") || lower.contains(".mkv") || lower.contains(".mpd")) &&
|
||||||
url.contains(".mkv") ||
|
(url.startsWith("http://") || url.startsWith("https://")) &&
|
||||||
url.contains("stream") ||
|
!lower.contains(".js") && !lower.contains(".css") && !lower.contains(".html")
|
||||||
url.contains("video") ||
|
|
||||||
url.contains("player")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun determineVideoType(url: String): VideoType {
|
private fun determineVideoType(url: String): VideoType {
|
||||||
|
val path = try { URL(url).path.lowercase() } catch (_: Exception) { url.lowercase() }
|
||||||
return when {
|
return when {
|
||||||
url.contains(".m3u8") -> VideoType.HLS
|
path.endsWith(".m3u8") -> VideoType.HLS
|
||||||
url.contains(".mp4") -> VideoType.MP4
|
path.endsWith(".mp4") -> VideoType.MP4
|
||||||
url.contains(".mpd") -> VideoType.DASH
|
path.endsWith(".mpd") -> VideoType.DASH
|
||||||
else -> VideoType.UNKNOWN
|
else -> VideoType.UNKNOWN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractSubtitles(doc: org.jsoup.nodes.Document): List<SubtitleTrack> {
|
private fun extractSubtitles(doc: org.jsoup.nodes.Document, htmlContent: String, videoUrl: String?): List<SubtitleTrack> {
|
||||||
val tracks = mutableListOf<SubtitleTrack>()
|
val tracks = mutableListOf<SubtitleTrack>()
|
||||||
|
|
||||||
val trackElements = doc.select("track")
|
val trackElements = doc.select("track")
|
||||||
for (track in trackElements) {
|
for (track in trackElements) {
|
||||||
val src = track.attr("src")
|
val src = track.absUrl("src")
|
||||||
val srclang = track.attr("srclang")
|
val srclang = track.attr("srclang").lowercase()
|
||||||
val label = track.attr("label").ifEmpty { srclang }
|
val label = track.attr("label").ifEmpty { srclang }
|
||||||
val isDefault = track.attr("default") == "default"
|
val isDefault = track.hasAttr("default")
|
||||||
|
|
||||||
if (src.isNotEmpty()) {
|
if (src.isNotEmpty() && isAllowedLanguage(srclang, label)) {
|
||||||
tracks.add(
|
tracks.add(
|
||||||
SubtitleTrack(
|
SubtitleTrack(
|
||||||
url = src,
|
url = src,
|
||||||
language = srclang,
|
language = normalizeLanguage(srclang),
|
||||||
label = label,
|
label = label,
|
||||||
isDefault = isDefault
|
isDefault = isDefault,
|
||||||
|
mimeType = determineMimeType(src)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Found subtitle: $srclang - $src")
|
Log.d(TAG, "Found track element subtitle: $srclang - $src")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val subtitlePatterns = listOf(
|
||||||
|
Regex("\"sub\"\\s*:\\s*\"([^\"]+)\""),
|
||||||
|
Regex("\"subtitle\"\\s*:\\s*\"([^\"]+)\""),
|
||||||
|
Regex("\"captions\"\\s*:\\s*\"([^\"]+)\""),
|
||||||
|
Regex("\"file\"\\s*:\\s*\"([^\"]+\\.vtt[^\"]*)\""),
|
||||||
|
Regex("\"file\"\\s*:\\s*\"([^\"]+\\.srt[^\"]*)\""),
|
||||||
|
Regex("'sub'\\s*:\\s*'([^']+)'"),
|
||||||
|
Regex("'subtitle'\\s*:\\s*'([^']+)'"),
|
||||||
|
Regex("'captions'\\s*:\\s*'([^']+)'")
|
||||||
|
)
|
||||||
|
|
||||||
|
for (pattern in subtitlePatterns) {
|
||||||
|
pattern.findAll(htmlContent).forEach { match ->
|
||||||
|
val url = match.groupValues[1]
|
||||||
|
if (url.isNotEmpty() && !tracks.any { it.url == url }) {
|
||||||
|
val cleanUrl = cleanSubtitleUrl(url)
|
||||||
|
if (cleanUrl.isNotEmpty() && isSubtitleUrl(cleanUrl)) {
|
||||||
|
tracks.add(
|
||||||
|
SubtitleTrack(
|
||||||
|
url = cleanUrl,
|
||||||
|
language = "en",
|
||||||
|
label = "English",
|
||||||
|
isDefault = false,
|
||||||
|
mimeType = determineMimeType(cleanUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Found JS pattern subtitle: $cleanUrl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val vttPattern = Regex("https?://[^\"'<>\\s]+\\.vtt[^\"'<>\\s]*")
|
||||||
|
vttPattern.findAll(htmlContent).forEach { match ->
|
||||||
|
val url = match.value
|
||||||
|
if (!tracks.any { it.url == url }) {
|
||||||
|
tracks.add(
|
||||||
|
SubtitleTrack(
|
||||||
|
url = url,
|
||||||
|
language = "en",
|
||||||
|
label = "English",
|
||||||
|
isDefault = false,
|
||||||
|
mimeType = "text/vtt"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Found VTT URL: $url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val srtPattern = Regex("https?://[^\"'<>\\s]+\\.srt[^\"'<>\\s]*")
|
||||||
|
srtPattern.findAll(htmlContent).forEach { match ->
|
||||||
|
val url = match.value
|
||||||
|
if (!tracks.any { it.url == url }) {
|
||||||
|
tracks.add(
|
||||||
|
SubtitleTrack(
|
||||||
|
url = url,
|
||||||
|
language = "en",
|
||||||
|
label = "English",
|
||||||
|
isDefault = false,
|
||||||
|
mimeType = "application/x-subrip"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Found SRT URL: $url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonArrayPattern = Regex("\\[[\\s\\S]*?\"url\"[\\s\\S]*?\\]")
|
||||||
|
jsonArrayPattern.findAll(htmlContent).forEach { match ->
|
||||||
|
try {
|
||||||
|
val jsonArray = match.value
|
||||||
|
val urlPattern = Regex("\"url\"\\s*:\\s*\"([^\"]+)\"")
|
||||||
|
val langPattern = Regex("\"lang\"\\s*:\\s*\"([^\"]+)\"")
|
||||||
|
val labelPattern = Regex("\"label\"\\s*:\\s*\"([^\"]+)\"")
|
||||||
|
|
||||||
|
urlPattern.findAll(jsonArray).forEach { urlMatch ->
|
||||||
|
val subUrl = urlMatch.groupValues[1]
|
||||||
|
val langMatch = langPattern.find(jsonArray)
|
||||||
|
val labelMatch = labelPattern.find(jsonArray)
|
||||||
|
|
||||||
|
val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en"
|
||||||
|
val subLabel = labelMatch?.groupValues?.get(1) ?: lang
|
||||||
|
|
||||||
|
if (subUrl.isNotEmpty() && !tracks.any { it.url == subUrl } && isAllowedLanguage(lang, subLabel)) {
|
||||||
|
tracks.add(
|
||||||
|
SubtitleTrack(
|
||||||
|
url = subUrl,
|
||||||
|
language = normalizeLanguage(lang),
|
||||||
|
label = subLabel,
|
||||||
|
isDefault = false,
|
||||||
|
mimeType = determineMimeType(subUrl)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Found JSON array subtitle: $lang - $subUrl")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error parsing subtitle JSON array: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val jwplayerPattern = Regex("\\{[^}]*file:\"([^\"]+)\"[^}]*label:\"([^\"]+)\"[^}]*kind:\"captions\"[^}]*\\}")
|
||||||
|
jwplayerPattern.findAll(htmlContent).forEach { match ->
|
||||||
|
val url = match.groupValues[1]
|
||||||
|
val label = match.groupValues[2].lowercase()
|
||||||
|
|
||||||
|
if (url.isNotEmpty() && !tracks.any { it.url == url } && isAllowedLanguage(label, label)) {
|
||||||
|
tracks.add(
|
||||||
|
SubtitleTrack(
|
||||||
|
url = url,
|
||||||
|
language = normalizeLanguage(label),
|
||||||
|
label = match.groupValues[2],
|
||||||
|
isDefault = false,
|
||||||
|
mimeType = determineMimeType(url)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Found JWPlayer subtitle: $label - $url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoUrl != null && videoUrl.contains(".m3u8")) {
|
||||||
|
val hlsSubtitles = extractHlsSubtitles(videoUrl)
|
||||||
|
for (sub in hlsSubtitles) {
|
||||||
|
if (!tracks.any { it.url == sub.url }) {
|
||||||
|
tracks.add(sub)
|
||||||
|
Log.d(TAG, "Found HLS manifest subtitle: ${sub.language} - ${sub.url}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractHlsSubtitles(videoUrl: String): List<SubtitleTrack> {
|
||||||
|
val tracks = mutableListOf<SubtitleTrack>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val manifestContent = fetchManifest(videoUrl)
|
||||||
|
|
||||||
|
val subtitleMediaPattern = Regex(
|
||||||
|
"#EXT-X-MEDIA:TYPE=SUBTITLES[^\\n]*LANGUAGE=\"([^\"]+)\"[^\\n]*NAME=\"([^\"]+)\"[^\\n]*(?:DEFAULT=YES)?[^\\n]*URI=\"([^\"]+)\""
|
||||||
|
)
|
||||||
|
subtitleMediaPattern.findAll(manifestContent).forEach { match ->
|
||||||
|
val lang = match.groupValues[1].lowercase()
|
||||||
|
val name = match.groupValues[2]
|
||||||
|
val uri = match.groupValues[3]
|
||||||
|
|
||||||
|
val fullUrl = if (uri.startsWith("http")) {
|
||||||
|
uri
|
||||||
|
} else {
|
||||||
|
val baseUrl = videoUrl.substringBeforeLast("/") + "/"
|
||||||
|
baseUrl + uri
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name)) {
|
||||||
|
tracks.add(
|
||||||
|
SubtitleTrack(
|
||||||
|
url = fullUrl,
|
||||||
|
language = normalizeLanguage(lang),
|
||||||
|
label = name,
|
||||||
|
isDefault = match.value.contains("DEFAULT=YES", ignoreCase = true),
|
||||||
|
mimeType = "text/vtt"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val simpleSubtitlePattern = Regex("#EXT-X-MEDIA:TYPE=SUBTITLES[^\n]*")
|
||||||
|
simpleSubtitlePattern.findAll(manifestContent).forEach { match ->
|
||||||
|
val line = match.value
|
||||||
|
val langMatch = Regex("LANGUAGE=\"([^\"]+)\"").find(line)
|
||||||
|
val nameMatch = Regex("NAME=\"([^\"]+)\"").find(line)
|
||||||
|
val uriMatch = Regex("URI=\"([^\"]+)\"").find(line)
|
||||||
|
|
||||||
|
val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en"
|
||||||
|
val name = nameMatch?.groupValues?.get(1) ?: lang
|
||||||
|
val uri = uriMatch?.groupValues?.get(1)
|
||||||
|
|
||||||
|
if (uri != null) {
|
||||||
|
val fullUrl = if (uri.startsWith("http")) {
|
||||||
|
uri
|
||||||
|
} else {
|
||||||
|
val baseUrl = videoUrl.substringBeforeLast("/") + "/"
|
||||||
|
baseUrl + uri
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name) && !tracks.any { it.url == fullUrl }) {
|
||||||
|
tracks.add(
|
||||||
|
SubtitleTrack(
|
||||||
|
url = fullUrl,
|
||||||
|
language = normalizeLanguage(lang),
|
||||||
|
label = name,
|
||||||
|
isDefault = line.contains("DEFAULT=YES", true),
|
||||||
|
mimeType = "text/vtt"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to extract HLS subtitles: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchSubtitlesFromOpenSubtitles(imdbId: String): List<SubtitleTrack> {
|
||||||
|
val tracks = mutableListOf<SubtitleTrack>()
|
||||||
|
val numericId = imdbId.removePrefix("tt").trim()
|
||||||
|
if (numericId.isEmpty()) return tracks
|
||||||
|
|
||||||
|
val languages = listOf(
|
||||||
|
"eng" to "en",
|
||||||
|
"spa" to "es"
|
||||||
|
)
|
||||||
|
|
||||||
|
for ((subLangCode, normalizedLang) in languages) {
|
||||||
|
val url = "https://rest.opensubtitles.org/search/imdbid-$numericId/sublanguageid-$subLangCode"
|
||||||
|
try {
|
||||||
|
val response = fetchOpenSubtitles(url)
|
||||||
|
val jsonArray = JSONArray(response)
|
||||||
|
for (i in 0 until jsonArray.length()) {
|
||||||
|
val obj = jsonArray.getJSONObject(i)
|
||||||
|
val downloadLink = obj.optString("SubDownloadLink", "")
|
||||||
|
val subFormat = obj.optString("SubFormat", "srt")
|
||||||
|
val languageName = obj.optString("LanguageName", normalizedLang)
|
||||||
|
|
||||||
|
val isHearingImpaired = obj.optString("SubHearingImpaired", "0") == "1"
|
||||||
|
if (isHearingImpaired) continue
|
||||||
|
|
||||||
|
if (downloadLink.isNotEmpty() && tracks.none { it.language == normalizedLang }) {
|
||||||
|
tracks.add(
|
||||||
|
SubtitleTrack(
|
||||||
|
url = downloadLink,
|
||||||
|
language = normalizedLang,
|
||||||
|
label = languageName,
|
||||||
|
isDefault = false,
|
||||||
|
mimeType = if (subFormat.equals("vtt", ignoreCase = true)) "text/vtt" else "application/x-subrip"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.d(TAG, "OpenSubtitles track: $normalizedLang - $downloadLink")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to fetch OpenSubtitles for $subLangCode: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks
|
return tracks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isAllowedLanguage(lang: String, label: String): Boolean {
|
||||||
|
val normalizedLang = lang.lowercase().trim()
|
||||||
|
val normalizedLabel = label.lowercase().trim()
|
||||||
|
return normalizedLang == "en" || normalizedLang == "es" ||
|
||||||
|
normalizedLang == "eng" || normalizedLang == "spa" ||
|
||||||
|
normalizedLang.startsWith("english") || normalizedLang.startsWith("spanish") ||
|
||||||
|
normalizedLabel.contains("english") || normalizedLabel.contains("spanish") ||
|
||||||
|
normalizedLabel.contains("español")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeLanguage(lang: String): String {
|
||||||
|
val normalized = lang.lowercase().trim()
|
||||||
|
return when {
|
||||||
|
normalized.contains("english") || normalized.contains("eng") -> "en"
|
||||||
|
normalized.contains("spanish") || normalized.contains("spa") -> "es"
|
||||||
|
normalized == "en" -> "en"
|
||||||
|
normalized == "es" -> "es"
|
||||||
|
else -> normalized.take(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSubtitleUrl(url: String): Boolean {
|
||||||
|
return url.contains(".vtt") || url.contains(".srt") ||
|
||||||
|
url.contains("subtitle") || url.contains("caption") ||
|
||||||
|
url.contains("sub")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cleanSubtitleUrl(url: String, baseUrl: String = ""): String {
|
||||||
|
val clean = url.trim().removeSurrounding("\"").removeSurrounding("'").trim()
|
||||||
|
if (clean.isEmpty()) return ""
|
||||||
|
return when {
|
||||||
|
clean.startsWith("http://") || clean.startsWith("https://") -> clean
|
||||||
|
clean.startsWith("/") -> {
|
||||||
|
try {
|
||||||
|
val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com" })
|
||||||
|
URL(base, clean).toString()
|
||||||
|
} catch (_: Exception) { "https://cloudnestra.com$clean" }
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
try {
|
||||||
|
val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com/" })
|
||||||
|
URL(base, clean).toString()
|
||||||
|
} catch (_: Exception) { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun determineMimeType(url: String): String {
|
||||||
|
return when {
|
||||||
|
url.contains(".srt", ignoreCase = true) -> "application/x-subrip"
|
||||||
|
url.contains(".vtt", ignoreCase = true) -> "text/vtt"
|
||||||
|
else -> "text/vtt"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||||
@@ -44,7 +44,6 @@ object Routes {
|
|||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation(
|
fun AppNavigation(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
deepLinkUri: Uri? = null,
|
|
||||||
modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier
|
modifier: androidx.compose.ui.Modifier = androidx.compose.ui.Modifier
|
||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
@@ -56,8 +55,16 @@ fun AppNavigation(
|
|||||||
val viewModel: HomeViewModel = hiltViewModel()
|
val viewModel: HomeViewModel = hiltViewModel()
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateToSearch = { navController.navigate(Routes.SEARCH) },
|
onNavigateToSearch = {
|
||||||
onNavigateToDetail = { imdbId -> navController.navigate(Routes.detail(imdbId)) }
|
navController.navigate(Routes.SEARCH) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onNavigateToDetail = { imdbId ->
|
||||||
|
navController.navigate(Routes.detail(imdbId)) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +73,11 @@ fun AppNavigation(
|
|||||||
SearchScreen(
|
SearchScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToDetail = { imdbId -> navController.navigate(Routes.detail(imdbId)) }
|
onNavigateToDetail = { imdbId ->
|
||||||
|
navController.navigate(Routes.detail(imdbId)) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +102,11 @@ fun AppNavigation(
|
|||||||
imdbId = imdbId,
|
imdbId = imdbId,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onNavigateToPlayer = { id, title -> navController.navigate(Routes.player(id, title)) }
|
onNavigateToPlayer = { id, title ->
|
||||||
|
navController.navigate(Routes.player(id, title)) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +126,7 @@ fun AppNavigation(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
val imdbId = it.arguments?.getString(Routes.Args.IMDB_ID) ?: ""
|
val imdbId = it.arguments?.getString(Routes.Args.IMDB_ID) ?: ""
|
||||||
val title = Uri.decode(it.arguments?.getString(Routes.Args.TITLE) ?: "Horror TV")
|
val title = it.arguments?.getString(Routes.Args.TITLE) ?: "Horror TV"
|
||||||
|
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
imdbId = imdbId,
|
imdbId = imdbId,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
package com.horrortv.app.presentation
|
package com.horrortv.app.presentation
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.KeyEvent
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -27,23 +26,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
|
||||||
w.decorView.systemUiVisibility = (
|
|
||||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
|
||||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
|
||||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
|
||||||
)
|
|
||||||
|
|
||||||
val deepLinkUri = intent?.data
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
HorrorTheme {
|
HorrorTheme {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
AppNavigation(
|
AppNavigation(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
deepLinkUri = deepLinkUri,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -55,17 +42,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
companion object {
|
||||||
super.onWindowFocusChanged(hasFocus)
|
var keyEventHandler: ((KeyEvent) -> Boolean)? = null
|
||||||
if (hasFocus) {
|
}
|
||||||
window.decorView.systemUiVisibility = (
|
|
||||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
keyEventHandler?.let { handler ->
|
||||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
|
if (handler(event)) return true
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
|
||||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
keyEventHandler = null
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.horrortv.app.presentation.common
|
package com.horrortv.app.presentation.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -46,7 +47,10 @@ fun ErrorState(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(text = message, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorAccent)
|
Text(text = message, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorAccent)
|
||||||
TextButton(onClick = onRetry) {
|
TextButton(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.focusable()
|
||||||
|
) {
|
||||||
Text("Reintentar", color = HorrorColors.HorrorRed)
|
Text("Reintentar", color = HorrorColors.HorrorRed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -50,8 +52,6 @@ fun TvErrorDisplay(
|
|||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
|
||||||
|
|
||||||
val iconRes = when (error) {
|
val iconRes = when (error) {
|
||||||
is AppError.NetworkError -> android.R.drawable.ic_menu_close_clear_cancel
|
is AppError.NetworkError -> android.R.drawable.ic_menu_close_clear_cancel
|
||||||
is AppError.ApiError -> android.R.drawable.ic_dialog_alert
|
is AppError.ApiError -> android.R.drawable.ic_dialog_alert
|
||||||
@@ -87,6 +87,9 @@ fun TvErrorDisplay(
|
|||||||
Text(text = error.userMessage, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorWhite, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(0.6f))
|
Text(text = error.userMessage, style = HorrorTypography.DetailPlot, color = HorrorColors.HorrorWhite, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(0.6f))
|
||||||
|
|
||||||
if (error.isRetryable) {
|
if (error.isRetryable) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try { focusRequester.requestFocus() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -99,7 +102,18 @@ fun TvErrorDisplay(
|
|||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.focusable(interactionSource = interactionSource)
|
.focusable(interactionSource = interactionSource)
|
||||||
.onFocusChanged { isRetryFocused = it.isFocused }
|
.onFocusChanged { isRetryFocused = it.isFocused }
|
||||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onRetry),
|
.onKeyEvent { event ->
|
||||||
|
val native = event.nativeKeyEvent ?: return@onKeyEvent false
|
||||||
|
if (native.action != KeyEvent.ACTION_DOWN) return@onKeyEvent false
|
||||||
|
if (native.repeatCount > 0) return@onKeyEvent false
|
||||||
|
val keyCode = native.keyCode
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||||
|
onRetry()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(text = "REINTENTAR", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
|
Text(text = "REINTENTAR", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
|
||||||
@@ -119,8 +133,6 @@ fun TvSnackbarError(
|
|||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
|
||||||
|
|
||||||
val dismissColor = if (isDismissFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
val dismissColor = if (isDismissFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
@@ -143,6 +155,9 @@ fun TvSnackbarError(
|
|||||||
)
|
)
|
||||||
Text(text = error.userMessage, style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite, modifier = Modifier.weight(1f))
|
Text(text = error.userMessage, style = HorrorTypography.DetailInfo, color = HorrorColors.HorrorWhite, modifier = Modifier.weight(1f))
|
||||||
if (error.isRetryable) {
|
if (error.isRetryable) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try { focusRequester.requestFocus() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(80.dp)
|
.width(80.dp)
|
||||||
|
|||||||
@@ -103,14 +103,20 @@ fun MovieDetailContent(
|
|||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
kotlinx.coroutines.delay(500)
|
try {
|
||||||
playFocusRequester.requestFocus()
|
kotlinx.coroutines.delay(300)
|
||||||
|
playFocusRequester.requestFocus()
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
||||||
val scale = if (isPlayFocused) 1.08f else 1.0f
|
val scale = if (isPlayFocused) 1.08f else 1.0f
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier.focusGroup()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = movie.posterUrl,
|
model = movie.posterUrl,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -134,7 +140,7 @@ fun MovieDetailContent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp).focusGroup(),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -179,17 +185,25 @@ fun MovieDetailContent(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(PlayButtonWidth)
|
.width(PlayButtonWidth)
|
||||||
.height(PlayButtonHeight)
|
.height(PlayButtonHeight)
|
||||||
|
.clip(PlayButtonShape)
|
||||||
|
.background(playButtonColor)
|
||||||
|
.border(
|
||||||
|
width = if (isPlayFocused) 4.dp else 2.dp,
|
||||||
|
color = if (isPlayFocused) Color.White else HorrorColors.HorrorLightGray,
|
||||||
|
shape = PlayButtonShape
|
||||||
|
)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = scale
|
scaleX = scale
|
||||||
scaleY = scale
|
scaleY = scale
|
||||||
}
|
}
|
||||||
.clip(PlayButtonShape)
|
|
||||||
.background(playButtonColor)
|
|
||||||
.border(if (isPlayFocused) 4.dp else 2.dp, if (isPlayFocused) Color.White else HorrorColors.HorrorLightGray, PlayButtonShape)
|
|
||||||
.focusRequester(playFocusRequester)
|
.focusRequester(playFocusRequester)
|
||||||
.focusable(interactionSource = interactionSource)
|
.focusable(interactionSource = remember { MutableInteractionSource() })
|
||||||
.onFocusChanged { isPlayFocused = it.isFocused }
|
.onFocusChanged { isPlayFocused = it.isFocused }
|
||||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onPlayClick),
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = onPlayClick
|
||||||
|
),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(text = "▶ VER PELÍCULA", style = HorrorTypography.PlayButton)
|
Text(text = "▶ VER PELÍCULA", style = HorrorTypography.PlayButton)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.horrortv.app.domain.model.Movie
|
|||||||
import com.horrortv.app.domain.repository.MovieRepository
|
import com.horrortv.app.domain.repository.MovieRepository
|
||||||
import com.horrortv.app.domain.repository.Result
|
import com.horrortv.app.domain.repository.Result
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -53,35 +54,51 @@ class DetailViewModel @Inject constructor(
|
|||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
loadJob = viewModelScope.launch {
|
loadJob = viewModelScope.launch {
|
||||||
Log.d(TAG, "Loading movie: $imdbId")
|
Log.d(TAG, "Loading movie: $imdbId")
|
||||||
_uiState.update { it.copy(isLoading = true, error = null, movie = null) }
|
try {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null, movie = null) }
|
||||||
|
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
repository.getMovieById(imdbId)
|
repository.getMovieById(imdbId)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is Result.Success -> {
|
is Result.Success -> {
|
||||||
val movie = result.data
|
val movie = result.data
|
||||||
if (movie != null) {
|
if (movie != null) {
|
||||||
Log.d(TAG, "Loaded movie: ${movie.title}")
|
Log.d(TAG, "Loaded movie: ${movie.title}")
|
||||||
_uiState.update { it.copy(movie = movie, isLoading = false, error = null) }
|
_uiState.update { it.copy(movie = movie, isLoading = false, error = null) }
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Movie not found: $imdbId")
|
Log.w(TAG, "Movie not found: $imdbId")
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = AppError.ValidationError(
|
error = AppError.ValidationError(
|
||||||
userMessage = "No se encontró la película.",
|
userMessage = "No se encontró la película.",
|
||||||
debugMessage = "Movie not found",
|
debugMessage = "Movie not found",
|
||||||
field = "imdbId"
|
field = "imdbId"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
is Result.Error -> {
|
||||||
|
Log.e(TAG, "Error loading movie: ${result.error.debugMessage}")
|
||||||
|
_uiState.update { it.copy(isLoading = false, error = result.error) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is Result.Error -> {
|
} catch (e: CancellationException) {
|
||||||
Log.e(TAG, "Error loading movie: ${result.error.debugMessage}")
|
throw e
|
||||||
_uiState.update { it.copy(isLoading = false, error = result.error) }
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error loading movie", e)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = AppError.UnknownError(
|
||||||
|
userMessage = "Error inesperado. Intenta de nuevo.",
|
||||||
|
debugMessage = "Unhandled exception: ${e.message}",
|
||||||
|
cause = e
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.horrortv.app.presentation.home
|
package com.horrortv.app.presentation.home
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -38,6 +39,8 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.horrortv.app.domain.model.Movie
|
import com.horrortv.app.domain.model.Movie
|
||||||
@@ -51,13 +54,11 @@ fun HomeScreen(
|
|||||||
viewModel: HomeViewModel,
|
viewModel: HomeViewModel,
|
||||||
onNavigateToSearch: () -> Unit,
|
onNavigateToSearch: () -> Unit,
|
||||||
onNavigateToDetail: (String) -> Unit,
|
onNavigateToDetail: (String) -> Unit,
|
||||||
onExit: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var showExitDialog by remember { mutableStateOf(false) }
|
val context = LocalContext.current
|
||||||
|
BackHandler {
|
||||||
BackHandler(enabled = true) {
|
(context as? android.app.Activity)?.finishAffinity()
|
||||||
showExitDialog = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
@@ -102,13 +103,6 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showExitDialog) {
|
|
||||||
ExitConfirmationDialog(
|
|
||||||
onConfirm = { showExitDialog = false; onExit() },
|
|
||||||
onDismiss = { showExitDialog = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +135,8 @@ private fun CategoriesList(
|
|||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = categories,
|
items = categories,
|
||||||
key = { _, category -> category.name }
|
key = { _, category -> category.name },
|
||||||
|
contentType = { _, _ -> "category_row" }
|
||||||
) { index, category ->
|
) { index, category ->
|
||||||
HorrorRow(
|
HorrorRow(
|
||||||
category = category,
|
category = category,
|
||||||
@@ -173,9 +168,18 @@ fun HomeHeader(onSearchClick: () -> Unit, modifier: Modifier = Modifier) {
|
|||||||
.background(if (isSearchFocused) HorrorColors.HorrorGray else Color.Transparent)
|
.background(if (isSearchFocused) HorrorColors.HorrorGray else Color.Transparent)
|
||||||
.border(if (isSearchFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp))
|
.border(if (isSearchFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp))
|
||||||
.focusRequester(searchFocusRequester)
|
.focusRequester(searchFocusRequester)
|
||||||
.focusable(interactionSource = interactionSource)
|
.focusable()
|
||||||
.onFocusChanged { isSearchFocused = it.isFocused }
|
.onFocusChanged { isSearchFocused = it.isFocused }
|
||||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onSearchClick),
|
.clickable(interactionSource = interactionSource, indication = null, onClick = onSearchClick)
|
||||||
|
.onKeyEvent { event ->
|
||||||
|
val keyCode = event.nativeKeyEvent?.keyCode
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||||
|
onSearchClick()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -210,61 +214,3 @@ fun CategoryPlaceholderRow(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ExitConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
|
||||||
var isConfirmFocused by remember { mutableStateOf(false) }
|
|
||||||
val confirmFocusRequester = remember { FocusRequester() }
|
|
||||||
val confirmInteractionSource = remember { MutableInteractionSource() }
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize().background(HorrorColors.HorrorBlack.copy(alpha = 0.85f)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
|
||||||
) {
|
|
||||||
Text(text = "¿Salir de Horror TV?", style = HorrorTypography.DetailTitle, color = HorrorColors.HorrorWhite)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
|
|
||||||
TextButton(onClick = onDismiss) { Text("No", color = HorrorColors.HorrorWhite) }
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(200.dp).height(56.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(if (isConfirmFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed)
|
|
||||||
.border(if (isConfirmFocused) 3.dp else 1.dp, if (isConfirmFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(8.dp))
|
|
||||||
.focusRequester(confirmFocusRequester)
|
|
||||||
.focusable(interactionSource = confirmInteractionSource)
|
|
||||||
.onFocusChanged { isConfirmFocused = it.isFocused }
|
|
||||||
.clickable(interactionSource = confirmInteractionSource, indication = null, onClick = onConfirm),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text("Sí, salir", style = HorrorTypography.PlayButton, color = HorrorColors.HorrorWhite)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TextButton(onClick: () -> Unit, content: @Composable () -> Unit) {
|
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(100.dp).height(56.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(if (isFocused) HorrorColors.HorrorGray else Color.Transparent)
|
|
||||||
.border(if (isFocused) 2.dp else 0.dp, HorrorColors.HorrorWhite, RoundedCornerShape(8.dp))
|
|
||||||
.focusRequester(focusRequester)
|
|
||||||
.focusable(interactionSource = interactionSource)
|
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
|
||||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -38,12 +39,13 @@ class HomeViewModel @Inject constructor(
|
|||||||
loadJob?.cancel()
|
loadJob?.cancel()
|
||||||
loadJob = viewModelScope.launch {
|
loadJob = viewModelScope.launch {
|
||||||
Log.d(TAG, "Loading featured categories")
|
Log.d(TAG, "Loading featured categories")
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
val categories = repository.getFeaturedCategories()
|
val categories = repository.getFeaturedCategories()
|
||||||
Log.d(TAG, "Loaded ${categories.size} categories")
|
Log.d(TAG, "Loaded ${categories.size} categories")
|
||||||
_uiState.update { it.copy(categories = categories, isLoading = false) }
|
_uiState.update { it.copy(categories = categories, isLoading = false) }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error loading categories", e)
|
Log.e(TAG, "Error loading categories", e)
|
||||||
val appError = if (e is ApiException) {
|
val appError = if (e is ApiException) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
@@ -31,7 +30,9 @@ fun HorrorRow(
|
|||||||
val categoryTitle = remember(category.name) { "${category.name.uppercase()} MOVIES" }
|
val categoryTitle = remember(category.name) { "${category.name.uppercase()} MOVIES" }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp)
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = categoryTitle,
|
text = categoryTitle,
|
||||||
@@ -42,12 +43,13 @@ fun HorrorRow(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
LazyRow(
|
LazyRow(
|
||||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
contentPadding = PaddingValues(horizontal = 48.dp, vertical = 24.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = category.movies,
|
items = category.movies,
|
||||||
key = { _, movie -> movie.imdbId }
|
key = { _, movie -> movie.imdbId },
|
||||||
|
contentType = { _, _ -> "poster_card" }
|
||||||
) { index, movie ->
|
) { index, movie ->
|
||||||
MoviePosterCard(
|
MoviePosterCard(
|
||||||
movie = movie,
|
movie = movie,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.horrortv.app.presentation.home
|
package com.horrortv.app.presentation.home
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -21,7 +22,9 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import android.view.KeyEvent
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.horrortv.app.domain.model.Movie
|
import com.horrortv.app.domain.model.Movie
|
||||||
@@ -50,9 +53,13 @@ fun MoviePosterCard(
|
|||||||
localFocusRequester
|
localFocusRequester
|
||||||
}
|
}
|
||||||
|
|
||||||
val scale = if (isFocused) 1.08f else 1.0f
|
val scale by animateFloatAsState(
|
||||||
val borderW = if (isFocused) 8.dp else 1.dp
|
targetValue = if (isFocused) 1.15f else 1.0f,
|
||||||
val borderC = if (isFocused) Color.Red else HorrorColors.HorrorLightGray
|
animationSpec = tween(durationMillis = 200),
|
||||||
|
label = "poster_scale"
|
||||||
|
)
|
||||||
|
val borderW = if (isFocused) 6.dp else 2.dp
|
||||||
|
val borderC = if (isFocused) Color(0xFFFF1744) else HorrorColors.HorrorLightGray
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -61,17 +68,25 @@ fun MoviePosterCard(
|
|||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = scale
|
scaleX = scale
|
||||||
scaleY = scale
|
scaleY = scale
|
||||||
shadowElevation = if (isFocused) 24.dp.toPx() else 0.dp.toPx()
|
shadowElevation = if (isFocused) 20.dp.toPx() else 0f
|
||||||
ambientShadowColor = if (isFocused) Color.Red else Color.Transparent
|
spotShadowColor = Color.Black.copy(alpha = 0.5f)
|
||||||
spotShadowColor = if (isFocused) Color.Red else Color.Transparent
|
ambientShadowColor = Color.Black.copy(alpha = 0.3f)
|
||||||
}
|
}
|
||||||
.clip(CardShape)
|
.clip(CardShape)
|
||||||
.background(HorrorColors.HorrorGray)
|
.background(HorrorColors.HorrorGray)
|
||||||
.border(borderW, borderC, CardShape)
|
.border(borderW, borderC, CardShape)
|
||||||
.focusRequester(activeFocusRequester)
|
.focusRequester(activeFocusRequester)
|
||||||
.focusable(interactionSource = interactionSource)
|
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
|
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
|
||||||
|
.onKeyEvent { event ->
|
||||||
|
val keyCode = event.nativeKeyEvent?.keyCode
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||||
|
onClick()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
PosterImage(
|
PosterImage(
|
||||||
url = movie.posterUrl,
|
url = movie.posterUrl,
|
||||||
@@ -80,13 +95,5 @@ fun MoviePosterCard(
|
|||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
shape = CardShape
|
shape = CardShape
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isFocused) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Red.copy(alpha = 0.2f))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
package com.horrortv.app.presentation.search
|
package com.horrortv.app.presentation.search
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -25,6 +26,8 @@ import androidx.compose.foundation.lazy.grid.items
|
|||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -37,6 +40,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -46,6 +50,7 @@ import androidx.compose.ui.focus.focusRequester
|
|||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -57,11 +62,12 @@ import com.horrortv.app.util.PosterSize
|
|||||||
import com.horrortv.app.presentation.common.TvErrorDisplay
|
import com.horrortv.app.presentation.common.TvErrorDisplay
|
||||||
import com.horrortv.app.presentation.theme.HorrorColors
|
import com.horrortv.app.presentation.theme.HorrorColors
|
||||||
import com.horrortv.app.presentation.theme.HorrorTypography
|
import com.horrortv.app.presentation.theme.HorrorTypography
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private val CardShape = RoundedCornerShape(8.dp)
|
private val CardShape = RoundedCornerShape(8.dp)
|
||||||
private val SearchFieldShape = RoundedCornerShape(8.dp)
|
private val SearchFieldShape = RoundedCornerShape(8.dp)
|
||||||
|
private val CardWidth = 180.dp
|
||||||
|
private val CardHeight = 270.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(
|
fun SearchScreen(
|
||||||
@@ -71,10 +77,18 @@ fun SearchScreen(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
var searchQuery by remember { mutableStateOf("") }
|
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||||
val searchFieldFocusRequester = remember { FocusRequester() }
|
val searchFieldFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) { searchFieldFocusRequester.requestFocus() }
|
BackHandler(onBack = onNavigateBack)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try {
|
||||||
|
searchFieldFocusRequester.requestFocus()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val movies = uiState.movies
|
val movies = uiState.movies
|
||||||
val isLoading = uiState.isLoading
|
val isLoading = uiState.isLoading
|
||||||
@@ -98,7 +112,8 @@ fun SearchScreen(
|
|||||||
error != null -> TvErrorDisplay(error = error, onRetry = { viewModel.retry() })
|
error != null -> TvErrorDisplay(error = error, onRetry = { viewModel.retry() })
|
||||||
directMovie != null -> DirectMovieResult(
|
directMovie != null -> DirectMovieResult(
|
||||||
movie = directMovie,
|
movie = directMovie,
|
||||||
onPlayClick = { onNavigateToDetail(directMovie.imdbId) }
|
onPlayClick = { onNavigateToDetail(directMovie.imdbId) },
|
||||||
|
onNavigateBack = { viewModel.clearDirectMovie() }
|
||||||
)
|
)
|
||||||
searchQuery.isEmpty() -> SearchInitialState()
|
searchQuery.isEmpty() -> SearchInitialState()
|
||||||
movies.isEmpty() -> SearchNoResultsState()
|
movies.isEmpty() -> SearchNoResultsState()
|
||||||
@@ -142,11 +157,11 @@ private fun SearchResultsGrid(
|
|||||||
onMovieClick: (Movie) -> Unit
|
onMovieClick: (Movie) -> Unit
|
||||||
) {
|
) {
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Fixed(5),
|
columns = GridCells.Fixed(4),
|
||||||
contentPadding = PaddingValues(48.dp),
|
contentPadding = PaddingValues(48.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(32.dp),
|
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||||
modifier = Modifier.focusGroup()
|
modifier = Modifier.fillMaxSize().focusGroup()
|
||||||
) {
|
) {
|
||||||
items(items = movies, key = { it.imdbId }) { movie ->
|
items(items = movies, key = { it.imdbId }) { movie ->
|
||||||
SearchResultCard(movie = movie, onClick = { onMovieClick(movie) })
|
SearchResultCard(movie = movie, onClick = { onMovieClick(movie) })
|
||||||
@@ -165,13 +180,12 @@ fun SearchHeader(
|
|||||||
var isTextFieldFocused by remember { mutableStateOf(false) }
|
var isTextFieldFocused by remember { mutableStateOf(false) }
|
||||||
var isBackFocused by remember { mutableStateOf(false) }
|
var isBackFocused by remember { mutableStateOf(false) }
|
||||||
val backFocusRequester = remember { FocusRequester() }
|
val backFocusRequester = remember { FocusRequester() }
|
||||||
val textFieldInteractionSource = remember { MutableInteractionSource() }
|
|
||||||
val backInteractionSource = remember { MutableInteractionSource() }
|
val backInteractionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
val borderColor = if (isTextFieldFocused) HorrorColors.HorrorRed else HorrorColors.HorrorLightGray
|
val borderColor = if (isTextFieldFocused) HorrorColors.HorrorRed else HorrorColors.HorrorLightGray
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 24.dp),
|
modifier = modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 24.dp).focusGroup(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -180,7 +194,14 @@ fun SearchHeader(
|
|||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(if (isBackFocused) HorrorColors.HorrorGray else Color.Transparent)
|
.background(if (isBackFocused) HorrorColors.HorrorGray else Color.Transparent)
|
||||||
.border(if (isBackFocused) 2.dp else 0.dp, HorrorColors.HorrorRed, RoundedCornerShape(8.dp))
|
.border(if (isBackFocused) 8.dp else 0.dp, Color.Red, RoundedCornerShape(8.dp))
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = if (isBackFocused) 1.08f else 1.0f
|
||||||
|
scaleY = if (isBackFocused) 1.08f else 1.0f
|
||||||
|
shadowElevation = if (isBackFocused) 16.dp.toPx() else 0.dp.toPx()
|
||||||
|
ambientShadowColor = if (isBackFocused) Color.Red else Color.Transparent
|
||||||
|
spotShadowColor = if (isBackFocused) Color.Red else Color.Transparent
|
||||||
|
}
|
||||||
.focusRequester(backFocusRequester)
|
.focusRequester(backFocusRequester)
|
||||||
.focusable(interactionSource = backInteractionSource)
|
.focusable(interactionSource = backInteractionSource)
|
||||||
.onFocusChanged { isBackFocused = it.isFocused }
|
.onFocusChanged { isBackFocused = it.isFocused }
|
||||||
@@ -196,7 +217,7 @@ fun SearchHeader(
|
|||||||
.height(56.dp)
|
.height(56.dp)
|
||||||
.clip(SearchFieldShape)
|
.clip(SearchFieldShape)
|
||||||
.background(HorrorColors.HorrorGray)
|
.background(HorrorColors.HorrorGray)
|
||||||
.border(2.dp, borderColor, SearchFieldShape)
|
.border(3.dp, borderColor, SearchFieldShape)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
contentAlignment = Alignment.CenterStart
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
@@ -213,7 +234,6 @@ fun SearchHeader(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.focusable(interactionSource = textFieldInteractionSource)
|
|
||||||
.onFocusChanged { isTextFieldFocused = it.isFocused }
|
.onFocusChanged { isTextFieldFocused = it.isFocused }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -235,16 +255,26 @@ fun SearchResultCard(
|
|||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
val borderW = if (isFocused) 6.dp else 1.dp
|
val borderW = if (isFocused) 8.dp else 1.dp
|
||||||
val borderC = if (isFocused) HorrorColors.HorrorRed else HorrorColors.HorrorLightGray
|
val borderC = if (isFocused) Color.White else HorrorColors.HorrorLightGray
|
||||||
val scale = if (isFocused) 1.08f else 1.0f
|
val scale by animateFloatAsState(
|
||||||
|
targetValue = if (isFocused) 1.12f else 1.0f,
|
||||||
|
animationSpec = tween(200),
|
||||||
|
label = "search_card_scale"
|
||||||
|
)
|
||||||
|
|
||||||
Column(modifier = modifier.width(180.dp)) {
|
Column(modifier = modifier.width(CardWidth)) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(180.dp)
|
.width(CardWidth)
|
||||||
.height(270.dp)
|
.height(CardHeight)
|
||||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
shadowElevation = if (isFocused) 24.dp.toPx() else 0.dp.toPx()
|
||||||
|
ambientShadowColor = if (isFocused) Color.Black else Color.Transparent
|
||||||
|
spotShadowColor = if (isFocused) Color.Black else Color.Transparent
|
||||||
|
}
|
||||||
.bringIntoViewRequester(bringIntoViewRequester)
|
.bringIntoViewRequester(bringIntoViewRequester)
|
||||||
.clip(CardShape)
|
.clip(CardShape)
|
||||||
.background(HorrorColors.HorrorGray)
|
.background(HorrorColors.HorrorGray)
|
||||||
@@ -254,7 +284,11 @@ fun SearchResultCard(
|
|||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
isFocused = it.isFocused
|
isFocused = it.isFocused
|
||||||
if (it.isFocused) {
|
if (it.isFocused) {
|
||||||
coroutineScope.launch { bringIntoViewRequester.bringIntoView() }
|
try {
|
||||||
|
coroutineScope.launch { bringIntoViewRequester.bringIntoView() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
|
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick)
|
||||||
@@ -268,8 +302,12 @@ fun SearchResultCard(
|
|||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (!isFocused) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)))
|
||||||
|
}
|
||||||
|
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(Color.Red.copy(alpha = 0.2f)))
|
Box(modifier = Modifier.fillMaxSize().background(Color.White.copy(alpha = 0.15f)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,13 +322,22 @@ fun SearchResultCard(
|
|||||||
fun DirectMovieResult(
|
fun DirectMovieResult(
|
||||||
movie: Movie,
|
movie: Movie,
|
||||||
onPlayClick: () -> Unit,
|
onPlayClick: () -> Unit,
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var isPlayFocused by remember { mutableStateOf(false) }
|
var isPlayFocused by remember { mutableStateOf(false) }
|
||||||
val playFocusRequester = remember { FocusRequester() }
|
val playFocusRequester = remember { FocusRequester() }
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) { playFocusRequester.requestFocus() }
|
BackHandler(onBack = onNavigateBack)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
try {
|
||||||
|
playFocusRequester.requestFocus()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
val playButtonColor = if (isPlayFocused) HorrorColors.HorrorAccent else HorrorColors.HorrorRed
|
||||||
|
|
||||||
@@ -322,9 +369,16 @@ fun DirectMovieResult(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(350.dp)
|
.width(350.dp)
|
||||||
.height(60.dp)
|
.height(60.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
scaleX = if (isPlayFocused) 1.05f else 1.0f
|
||||||
|
scaleY = if (isPlayFocused) 1.05f else 1.0f
|
||||||
|
shadowElevation = if (isPlayFocused) 16.dp.toPx() else 0.dp.toPx()
|
||||||
|
ambientShadowColor = if (isPlayFocused) Color.Red else Color.Transparent
|
||||||
|
spotShadowColor = if (isPlayFocused) Color.Red else Color.Transparent
|
||||||
|
}
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(playButtonColor)
|
.background(playButtonColor)
|
||||||
.border(if (isPlayFocused) 4.dp else 2.dp, if (isPlayFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(12.dp))
|
.border(if (isPlayFocused) 8.dp else 2.dp, if (isPlayFocused) Color.White else HorrorColors.HorrorLightGray, RoundedCornerShape(12.dp))
|
||||||
.focusRequester(playFocusRequester)
|
.focusRequester(playFocusRequester)
|
||||||
.focusable(interactionSource = interactionSource)
|
.focusable(interactionSource = interactionSource)
|
||||||
.onFocusChanged { isPlayFocused = it.isFocused }
|
.onFocusChanged { isPlayFocused = it.isFocused }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.horrortv.app.domain.model.Movie
|
|||||||
import com.horrortv.app.domain.repository.MovieRepository
|
import com.horrortv.app.domain.repository.MovieRepository
|
||||||
import com.horrortv.app.domain.repository.Result
|
import com.horrortv.app.domain.repository.Result
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -66,12 +67,28 @@ class SearchViewModel @Inject constructor(
|
|||||||
searchJob?.cancel()
|
searchJob?.cancel()
|
||||||
searchJob = viewModelScope.launch {
|
searchJob = viewModelScope.launch {
|
||||||
Log.d(TAG, "Searching for: $query")
|
Log.d(TAG, "Searching for: $query")
|
||||||
_uiState.update { it.copy(isLoading = true, error = null, directMovie = null) }
|
try {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null, directMovie = null) }
|
||||||
|
|
||||||
if (query.isValidImdbId()) {
|
if (query.isValidImdbId()) {
|
||||||
searchById(query)
|
searchById(query)
|
||||||
} else {
|
} else {
|
||||||
searchByName(query)
|
searchByName(query)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error searching", e)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = AppError.UnknownError(
|
||||||
|
userMessage = "Error inesperado. Intenta de nuevo.",
|
||||||
|
debugMessage = "Unhandled exception: ${e.message}",
|
||||||
|
cause = e
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +146,10 @@ class SearchViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearDirectMovie() {
|
||||||
|
_uiState.update { it.copy(directMovie = null) }
|
||||||
|
}
|
||||||
|
|
||||||
fun clearResults() {
|
fun clearResults() {
|
||||||
searchJob?.cancel()
|
searchJob?.cancel()
|
||||||
_searchQuery.value = ""
|
_searchQuery.value = ""
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import androidx.compose.ui.graphics.Color
|
|||||||
|
|
||||||
object HorrorColors {
|
object HorrorColors {
|
||||||
val HorrorBlack = Color(0xFF121212)
|
val HorrorBlack = Color(0xFF121212)
|
||||||
val HorrorRed = Color(0xFFCC0000) // Brightened for TV contrast (was 0xFF8B0000)
|
val HorrorRed = Color(0xFFFF1744) // brighter red for TV visibility
|
||||||
val HorrorGray = Color(0xFF1E1E1E)
|
val HorrorGray = Color(0xFF2A2A2A)
|
||||||
val HorrorDarkGray = Color(0xFF1A1A1A) // Lightened from 0xFF0A0A0A
|
val HorrorDarkGray = Color(0xFF0A0A0A)
|
||||||
val HorrorLightGray = Color(0xFF2A2A2A)
|
val HorrorLightGray = Color(0xFFBBBBBB)
|
||||||
val HorrorWhite = Color(0xFFE0E0E0)
|
val HorrorWhite = Color(0xFFE0E0E0)
|
||||||
val HorrorAccent = Color(0xFFFF1744)
|
val HorrorAccent = Color(0xFFFF1744)
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
package com.horrortv.app.presentation.theme
|
package com.horrortv.app.presentation.theme
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
private val HorrorColorScheme = darkColorScheme(
|
private val HorrorColorScheme = darkColorScheme(
|
||||||
primary = HorrorColors.HorrorRed,
|
primary = HorrorColors.HorrorRed,
|
||||||
@@ -19,7 +14,10 @@ private val HorrorColorScheme = darkColorScheme(
|
|||||||
onSecondary = HorrorColors.HorrorWhite,
|
onSecondary = HorrorColors.HorrorWhite,
|
||||||
onTertiary = HorrorColors.HorrorWhite,
|
onTertiary = HorrorColors.HorrorWhite,
|
||||||
onBackground = HorrorColors.HorrorWhite,
|
onBackground = HorrorColors.HorrorWhite,
|
||||||
onSurface = HorrorColors.HorrorWhite
|
onSurface = HorrorColors.HorrorWhite,
|
||||||
|
outline = HorrorColors.HorrorLightGray,
|
||||||
|
surfaceVariant = HorrorColors.HorrorLightGray,
|
||||||
|
onSurfaceVariant = HorrorColors.HorrorWhite
|
||||||
)
|
)
|
||||||
|
|
||||||
private val HorrorTypographyConfig = androidx.compose.material3.Typography(
|
private val HorrorTypographyConfig = androidx.compose.material3.Typography(
|
||||||
@@ -41,10 +39,7 @@ private val HorrorTypographyConfig = androidx.compose.material3.Typography(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HorrorTheme(
|
fun HorrorTheme(content: @Composable () -> Unit) {
|
||||||
darkTheme: Boolean = true,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = HorrorColorScheme,
|
colorScheme = HorrorColorScheme,
|
||||||
typography = HorrorTypographyConfig,
|
typography = HorrorTypographyConfig,
|
||||||
|
|||||||
@@ -9,70 +9,75 @@ object HorrorTypography {
|
|||||||
val MovieTitle = TextStyle(
|
val MovieTitle = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 28.sp,
|
fontSize = 32.sp,
|
||||||
lineHeight = 36.sp
|
lineHeight = 44.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
val MovieYear = TextStyle(
|
val MovieYear = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 24.sp,
|
fontSize = 28.sp,
|
||||||
lineHeight = 32.sp
|
lineHeight = 42.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
val CategoryTitle = TextStyle(
|
val CategoryTitle = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 32.sp,
|
fontSize = 36.sp,
|
||||||
lineHeight = 42.sp
|
lineHeight = 49.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
val DetailTitle = TextStyle(
|
val DetailTitle = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 38.sp,
|
fontSize = 40.sp,
|
||||||
lineHeight = 50.sp
|
lineHeight = 54.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
val DetailRating = TextStyle(
|
val DetailRating = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 28.sp,
|
fontSize = 32.sp,
|
||||||
lineHeight = 36.sp
|
lineHeight = 44.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
val DetailInfo = TextStyle(
|
val DetailInfo = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 24.sp,
|
fontSize = 28.sp,
|
||||||
lineHeight = 32.sp
|
lineHeight = 42.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
val DetailGenre = TextStyle(
|
val DetailGenre = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 24.sp,
|
fontSize = 28.sp,
|
||||||
lineHeight = 32.sp
|
lineHeight = 42.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
val DetailPlot = TextStyle(
|
val DetailPlot = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 26.sp,
|
fontSize = 26.sp,
|
||||||
lineHeight = 36.sp
|
lineHeight = 39.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
val PlayButton = TextStyle(
|
val PlayButton = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 28.sp,
|
fontSize = 32.sp,
|
||||||
lineHeight = 36.sp
|
lineHeight = 44.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
val SearchHint = TextStyle(
|
val SearchHint = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 24.sp,
|
fontSize = 28.sp,
|
||||||
lineHeight = 32.sp
|
lineHeight = 42.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.horrortv.app.util
|
package com.horrortv.app.util
|
||||||
|
|
||||||
object Constants {
|
object Constants {
|
||||||
const val OMDB_API_KEY = "5854c81e"
|
val OMDB_API_KEY: String = com.horrortv.app.BuildConfig.OMDB_API_KEY
|
||||||
const val OMDB_BASE_URL = "https://www.omdbapi.com/"
|
const val OMDB_BASE_URL = "https://www.omdbapi.com/"
|
||||||
|
|
||||||
val HORROR_CATEGORIES = listOf(
|
val HORROR_CATEGORIES = listOf(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import android.content.Context
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.Network
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.NetworkRequest
|
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
@@ -22,10 +22,12 @@ class NetworkStatus @Inject constructor(
|
|||||||
fun isNetworkAvailable(): Boolean {
|
fun isNetworkAvailable(): Boolean {
|
||||||
val network = connectivityManager.activeNetwork ?: return false
|
val network = connectivityManager.activeNetwork ?: return false
|
||||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||||
|
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
|
||||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
|
(capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) ||
|
||||||
|
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isMeteredNetwork(): Boolean {
|
fun isMeteredNetwork(): Boolean {
|
||||||
@@ -43,25 +45,34 @@ class NetworkStatus @Inject constructor(
|
|||||||
fun observeNetworkStatus(): Flow<NetworkState> = callbackFlow {
|
fun observeNetworkStatus(): Flow<NetworkState> = callbackFlow {
|
||||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
override fun onAvailable(network: Network) {
|
override fun onAvailable(network: Network) {
|
||||||
trySend(NetworkState.Available())
|
// Don't emit here — wait for onCapabilitiesChanged which has full state
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLost(network: Network) {
|
|
||||||
trySend(NetworkState.Unavailable)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
||||||
val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
val wifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
val wifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
trySend(NetworkState.Available(metered = metered, wifi = wifi))
|
val cellular = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||||
|
val vpn = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
val metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||||
|
|
||||||
|
if (hasInternet && validated) {
|
||||||
|
trySend(NetworkState.Available(wifi = wifi, metered = metered))
|
||||||
|
} else {
|
||||||
|
trySend(NetworkState.Unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
// Check if there's still another active network
|
||||||
|
val activeNetwork = connectivityManager.activeNetworkInfo?.isConnectedOrConnecting == true
|
||||||
|
if (!activeNetwork) {
|
||||||
|
trySend(NetworkState.Unavailable)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val request = NetworkRequest.Builder()
|
connectivityManager.registerDefaultNetworkCallback(callback)
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
connectivityManager.registerNetworkCallback(request, callback)
|
|
||||||
|
|
||||||
trySend(if (isNetworkAvailable()) NetworkState.Available() else NetworkState.Unavailable)
|
trySend(if (isNetworkAvailable()) NetworkState.Available() else NetworkState.Unavailable)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.horrortv.app.util
|
package com.horrortv.app.util
|
||||||
|
|
||||||
object PosterSize {
|
object PosterSize {
|
||||||
const val CARD = 600
|
const val CARD = 300
|
||||||
const val DETAIL = 800
|
const val DETAIL = 800
|
||||||
const val BACKGROUND = 2160
|
const val BACKGROUND = 2160
|
||||||
const val THUMBNAIL = 300
|
const val THUMBNAIL = 300
|
||||||
|
|||||||
Reference in New Issue
Block a user