feat: dynamic video extractor, subtitle encoding fix, adaptive bitrate, seek mode

- Rewrite VideoExtractor for dynamic redirect following (no hardcoded domains)
- Parse player CONFIG JSON and call stream API directly (vaplayer.ru)
- Fallback to HTML scraping for old-style pages (cloudnestra)
- Fix subtitle encoding: detect ISO-8859-1, save as UTF-8 for proper ñ/accents
- Show up to 10 Spanish subtitle sources from OpenSubtitles
- Subtitle picker auto-scrolls to selected option
- State-based D-pad navigation for all controls and subtitle picker
- Seek mode: DPAD_UP enters, LEFT/RIGHT seeks, CENTER/DOWN applies
- Adaptive bitrate: ExoPlayer auto-selects quality based on bandwidth
- Reduce buffer sizes for Chromecast (15s/30s instead of 100s)
- Fix availableSubtitles stale capture in DisposableEffect key
This commit is contained in:
2026-04-27 17:42:53 -03:00
parent 38c5342e88
commit b1001e3bb7
2 changed files with 504 additions and 199 deletions

View File

@@ -9,151 +9,137 @@ 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 org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.net.URLEncoder
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
class VideoExtractor { class VideoExtractor {
companion object { companion object {
private const val TAG = "VideoExtractor" private const val TAG = "VideoExtractor"
private const val PLAYIMDB_BASE = "https://playimdb.com/title/" private const val PLAYIMDB_BASE = "https://www.playimdb.com/title/"
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_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 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, cacheDir: File? = null): Result<VideoSource> = withContext(Dispatchers.IO) {
try { try {
if (!IMDB_ID_PATTERN.matches(imdbId)) { if (!IMDB_ID_PATTERN.matches(imdbId)) {
return@withContext Result.failure(VideoExtractionException("Invalid IMDb ID: $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" // Step 1: Fetch playimdb.com entry page
Log.d(TAG, "Fetching embed: $embedUrl") val entryUrl = "$PLAYIMDB_BASE$imdbId"
Log.d(TAG, "Step 1: Fetching entry: $entryUrl")
val entryHtml = fetchPage(entryUrl)
val html = fetchHtml(embedUrl) // Step 2: Find iframe URL (could be any domain)
Log.d(TAG, "HTML length: ${html.length}") val entryDoc = Jsoup.parse(entryHtml, entryUrl)
val embedUrl = entryDoc.selectFirst("iframe")?.absUrl("src")
var doc = Jsoup.parse(html, embedUrl) if (embedUrl.isNullOrEmpty()) {
var finalHtml = html return@withContext Result.failure(VideoExtractionException("No embed iframe found"))
val iframeUrl = doc.selectFirst("iframe")?.absUrl("src")
if (!iframeUrl.isNullOrEmpty()) {
Log.d(TAG, "Found iframe redirect: $iframeUrl")
try {
val iframeHtml = fetchHtml(iframeUrl)
Log.d(TAG, "Iframe HTML length: ${iframeHtml.length}")
val prorcpPattern = Regex("['\"]?src['\"]?\\s*:\\s*['\"]([^'\"]+/prorcp/[^'\"]+)['\"]")
val prorcpMatch = prorcpPattern.find(iframeHtml)
if (prorcpMatch != null) {
val innerUrl = prorcpMatch.groupValues[1]
val fullInnerUrl = if (innerUrl.startsWith("/")) {
"https://cloudnestra.com$innerUrl"
} else if (innerUrl.startsWith("//")) {
"https:$innerUrl"
} else {
innerUrl
} }
Log.d(TAG, "Found prorcp URL: $fullInnerUrl") Log.d(TAG, "Step 2: Embed URL: $embedUrl")
// Step 3: Fetch embed page and extract CONFIG JSON
val embedHtml = fetchPage(embedUrl)
val config = parsePlayerConfig(embedHtml)
var videoUrl: String?
var pageUrl: String
if (config != null && config.containsKey("streamDataApiUrl")) {
// Step 4a: Call the stream data API (vaplayer.ru approach)
val apiUrl = buildStreamApiUrl(config, embedUrl)
Log.d(TAG, "Step 4a: Calling stream API: $apiUrl")
try { try {
val innerHtml = fetchHtml(fullInnerUrl) val apiResponse = fetchJson(apiUrl, embedUrl)
Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}") val streamUrl = extractStreamUrlFromApi(apiResponse)
doc = Jsoup.parse(innerHtml, fullInnerUrl) if (streamUrl != null) {
finalHtml = innerHtml videoUrl = streamUrl
pageUrl = embedUrl
Log.d(TAG, "Got stream URL from API: $streamUrl")
} else {
Log.w(TAG, "API returned no stream URLs, falling back to HTML extraction")
videoUrl = null
pageUrl = embedUrl
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed prorcp fetch: ${e.message}") Log.w(TAG, "Stream API failed: ${e.message}, falling back to HTML extraction")
videoUrl = null
pageUrl = embedUrl
} }
} else { } else {
Log.d(TAG, "No prorcp pattern found, searching for /prorcp/ directly") // Step 4b: No CONFIG found — old approach, extract from HTML directly
val directPattern = Regex("/prorcp/[A-Za-z0-9+/=]+") Log.d(TAG, "Step 4b: No CONFIG found, extracting from HTML")
val directMatch = directPattern.find(iframeHtml) videoUrl = null
if (directMatch != null) { pageUrl = embedUrl
val innerUrl = directMatch.value
val fullInnerUrl = "https://cloudnestra.com$innerUrl"
Log.d(TAG, "Found prorcp direct: $fullInnerUrl")
try {
val innerHtml = fetchHtml(fullInnerUrl)
Log.d(TAG, "Prorcp direct HTML length: ${innerHtml.length}")
doc = Jsoup.parse(innerHtml, fullInnerUrl)
finalHtml = innerHtml
} catch (e: Exception) {
Log.w(TAG, "Failed prorcp direct fetch: ${e.message}")
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch iframe: ${e.message}")
}
} }
val videoUrl = extractVideoUrl(doc) // Step 5: If API didn't work, try extracting from HTML (old approach for cloudnestra-style pages)
if (videoUrl == null) { val finalVideoUrl = videoUrl ?: run {
Log.w(TAG, "No video URL found in HTML") val doc = Jsoup.parse(embedHtml, embedUrl)
// Try direct video URL extraction
extractVideoUrl(doc)?.let { return@run it }
// Try following deeper iframes (old cloudnestra prorcp pattern)
val innerUrls = discoverStreamingUrls(doc, embedUrl)
for (innerUrl in innerUrls) {
try {
Log.d(TAG, "Following inner: $innerUrl")
val innerHtml = fetchPage(innerUrl)
val innerDoc = Jsoup.parse(innerHtml, innerUrl)
extractVideoUrl(innerDoc)?.let { return@run it }
// One more level
val deepUrls = discoverStreamingUrls(innerDoc, innerUrl)
for (deepUrl in deepUrls) {
try {
val deepHtml = fetchPage(deepUrl)
val deepDoc = Jsoup.parse(deepHtml, deepUrl)
extractVideoUrl(deepDoc)?.let { return@run it }
} catch (_: Exception) {}
}
} catch (_: Exception) {}
}
null
}
if (finalVideoUrl == null) {
return@withContext Result.failure(VideoExtractionException("No video URL found")) return@withContext Result.failure(VideoExtractionException("No video URL found"))
} }
Log.d(TAG, "Video URL found: $videoUrl") // Step 6: Resolve video URL template if needed
val resolvedUrl = resolveVideoUrl(finalVideoUrl, pageUrl)
Log.d(TAG, "Final video URL: $resolvedUrl")
val knownDomains = listOf( // Step 7: Get subtitles
"neonhorizonworkshops.com", val doc = Jsoup.parse(embedHtml, embedUrl)
"wanderlynest.com", val subtitles = extractSubtitles(doc, embedHtml, resolvedUrl).toMutableList()
"orchidpixelgardens.com", val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId, cacheDir)
"cloudnestra.com"
)
val finalVideoUrl = if (videoUrl.contains("{v")) {
val basePattern = Regex("""https://tmstr3\.\{v\d+\}/(.+)""")
val pathMatch = basePattern.find(videoUrl)
if (pathMatch != null) {
val path = pathMatch.groupValues[1]
val testDomain = knownDomains.first()
val constructedUrl = "https://tmstr1.$testDomain/$path"
Log.d(TAG, "Constructed URL from known domain: $constructedUrl")
constructedUrl
} else {
videoUrl.replace(Regex("\\{v\\d+\\}"), knownDomains.first())
}
} else {
videoUrl
}
Log.d(TAG, "Final video URL: $finalVideoUrl")
val subtitles = extractSubtitles(doc, finalHtml, finalVideoUrl).toMutableList()
val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId)
for (sub in openSubtitles) { for (sub in openSubtitles) {
if (!subtitles.any { it.url == sub.url }) { if (!subtitles.any { it.url == sub.url }) {
subtitles.add(sub) subtitles.add(sub)
} }
} }
val seenUrls = mutableSetOf<String>()
// Deduplicate by language - keep first (priority: embed > HLS > OpenSubtitles) val dedupedTracks = subtitles.filter { seenUrls.add(it.url) }
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()) } val filteredSubtitles = dedupedTracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) }
Log.d(TAG, "Subtitles found: ${filteredSubtitles.size}") Log.d(TAG, "Subtitles found: ${filteredSubtitles.size}")
val videoType = determineVideoType(finalVideoUrl) val videoType = determineVideoType(resolvedUrl)
Log.d(TAG, "Video type: $videoType") Log.d(TAG, "Video type: $videoType")
Result.success( Result.success(
VideoSource( VideoSource(
videoUrl = finalVideoUrl, videoUrl = resolvedUrl,
videoType = videoType, videoType = videoType,
subtitleTracks = filteredSubtitles, subtitleTracks = filteredSubtitles,
title = imdbId title = imdbId
@@ -167,10 +153,128 @@ class VideoExtractor {
} }
} }
private fun fetchHtml(url: String): String { private fun parsePlayerConfig(html: String): Map<String, Any>? {
if (!isAllowedHost(url)) { // Find CONFIG = {...} in the HTML
throw VideoExtractionException("Host not allowed: $url") val configPattern = Regex("""const\s+CONFIG\s*=\s*(\{[^<]+?\})\s*;""")
val match = configPattern.find(html) ?: return null
val jsonStr = match.groupValues[1]
return try {
val jsonObject = JSONObject(jsonStr)
val map = mutableMapOf<String, Any>()
for (key in jsonObject.keys()) {
val value = jsonObject.get(key)
map[key] = value
} }
Log.d(TAG, "Parsed CONFIG: streamDataApiUrl=${map["streamDataApiUrl"]}, mediaId=${map["mediaId"]}, availableSources=${map["availableSources"]}")
map
} catch (e: Exception) {
Log.w(TAG, "Failed to parse CONFIG JSON: ${e.message}")
null
}
}
private fun buildStreamApiUrl(config: Map<String, Any>, embedUrl: String): String {
val mediaId = config["mediaId"] as? String ?: ""
val idType = config["idType"] as? String ?: "imdb"
val mediaType = config["mediaType"] as? String ?: "movie"
val streamDataApiUrl = config["streamDataApiUrl"] as? String ?: ""
val sourceApiUrl = config["sourceApiUrl"] as? String ?: ""
val availableSources = config["availableSources"]
// Use streamDataApiUrl (absolute URL like https://streamdata.vaplayer.ru/api.php)
val baseUrl = if (streamDataApiUrl.startsWith("http")) {
streamDataApiUrl
} else {
// Relative URL — resolve against embed page
resolveUrl(streamDataApiUrl, embedUrl)
}
val sb = StringBuilder(baseUrl)
sb.append(if ('?' in baseUrl) '&' else '?')
if (idType == "imdb") {
sb.append("imdb=").append(URLEncoder.encode(mediaId, "UTF-8"))
} else {
sb.append("tmdb=").append(URLEncoder.encode(mediaId, "UTF-8"))
}
sb.append("&type=").append(mediaType)
// For TV shows, add season/episode
val season = config["season"]
val episode = config["episode"]
if (mediaType == "tv" && season != null && episode != null) {
sb.append("&season=").append(season).append("&episode=").append(episode)
}
return sb.toString()
}
private fun fetchJson(url: String, referer: String): 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", "application/json, */*")
setRequestProperty("Referer", referer)
instanceFollowRedirects = true
}
val responseCode = connection.responseCode
Log.d(TAG, "API response code: $responseCode for $url")
if (responseCode != HttpURLConnection.HTTP_OK) {
throw VideoExtractionException("API HTTP error: $responseCode")
}
val stream = connection.inputStream.let { s ->
if (connection.contentEncoding?.contains("gzip", ignoreCase = true) == true) {
GZIPInputStream(s)
} else s
}
return stream.bufferedReader().use { it.readText() }
} finally {
connection?.disconnect()
}
}
private fun extractStreamUrlFromApi(apiResponse: String): String? {
return try {
val json = JSONObject(apiResponse)
val statusCode = json.opt("status_code")
// Handle both string and int status codes
val isSuccess = statusCode == "200" || statusCode == 200 || statusCode.toString() == "200"
if (!isSuccess) {
Log.w(TAG, "API returned status: $statusCode")
return null
}
val data = json.optJSONObject("data") ?: return null
val streamUrls = data.optJSONArray("stream_urls") ?: return null
// Return the first stream URL
for (i in 0 until streamUrls.length()) {
val url = streamUrls.getString(i)
if (url.contains(".m3u8") || url.contains(".mp4")) {
return url
}
}
// If no known extension, return first URL anyway
if (streamUrls.length() > 0) return streamUrls.getString(0)
null
} catch (e: Exception) {
Log.w(TAG, "Failed to parse stream API response: ${e.message}")
null
}
}
private fun fetchPage(url: String): String {
require(url.startsWith("https://")) { "Only HTTPS URLs are allowed: $url" }
return doHttpFetch(url) return doHttpFetch(url)
} }
@@ -219,6 +323,145 @@ class VideoExtractor {
} }
} }
private fun discoverStreamingUrls(doc: org.jsoup.nodes.Document, baseUrl: String): List<String> {
val urls = mutableListOf<String>()
val seen = mutableSetOf<String>()
// 1. Find all iframes
for (iframe in doc.select("iframe")) {
val src = iframe.absUrl("src").ifEmpty { resolveUrl(iframe.attr("src"), baseUrl) }
if (src.isNotEmpty() && src.startsWith("https://") && seen.add(src)) {
urls.add(src)
Log.d(TAG, "Discovered iframe: $src")
}
}
// 2. Find JavaScript-generated player URLs (like prorcp patterns)
for (script in doc.select("script")) {
val content = script.html()
// Pattern: src: "/prorcp/..." or src: "https://..."
val srcPatterns = listOf(
Regex("""['"]?src['"]?\s*:\s*['"]([^'"]+)['"]"""),
Regex("""['"]?file['"]?\s*:\s*['"]([^'"]+)['"]"""),
Regex("""['"]?source['"]?\s*:\s*['"]([^'"]+)['"]""")
)
for (pattern in srcPatterns) {
pattern.findAll(content).forEach { match ->
val rawUrl = match.groupValues[1]
val resolved = resolveUrl(rawUrl, baseUrl)
if (resolved.startsWith("https://") && !isValidVideoUrl(resolved) && seen.add(resolved)) {
urls.add(resolved)
Log.d(TAG, "Discovered JS URL: $resolved")
}
}
}
// Direct path patterns like /prorcp/..., /player/..., /embed/...
val pathPatterns = listOf(
Regex("""(/(?:prorcp|player|embed|rcp)/[A-Za-z0-9+/=_\-]+)"""),
Regex("""['"]((/(?:prorcp|player|embed|rcp)/[^'"]+))['"]""")
)
for (pattern in pathPatterns) {
pattern.findAll(content).forEach { match ->
val rawPath = match.groupValues[1]
val resolved = resolveUrl(rawPath, baseUrl)
if (resolved.startsWith("https://") && seen.add(resolved)) {
urls.add(resolved)
Log.d(TAG, "Discovered path: $resolved")
}
}
}
}
// 3. Find anchor links containing embed/stream
for (a in doc.select("a[href]")) {
val href = a.attr("href")
if (href.contains("embed", ignoreCase = true) || href.contains("stream", ignoreCase = true)) {
val resolved = resolveUrl(href, baseUrl)
if (resolved.startsWith("https://") && seen.add(resolved)) {
urls.add(resolved)
Log.d(TAG, "Discovered anchor: $resolved")
}
}
}
return urls
}
private fun resolveUrl(raw: String, baseUrl: String): String {
if (raw.startsWith("https://") || raw.startsWith("http://")) return raw
if (raw.startsWith("//")) return "https:$raw"
if (raw.startsWith("/")) {
return try {
val base = URL(baseUrl)
"${base.protocol}://${base.host}$raw"
} catch (_: Exception) { raw }
}
return try {
URL(URL(baseUrl), raw).toString()
} catch (_: Exception) { raw }
}
private fun resolveVideoUrl(videoUrl: String, pageUrl: String): String {
if (!videoUrl.contains("{v")) return videoUrl
return try {
val pageHost = URL(pageUrl).host
// Pattern: https://tmstr3.{v1}/path -> https://tmstr1.actualdomain/path
val templatePattern = Regex("""https?://([^/]+)\.\{v\d+\}/(.+)""")
val match = templatePattern.find(videoUrl)
if (match != null) {
val prefix = match.groupValues[1]
val path = match.groupValues[2]
"https://${prefix.substringBefore('.')}.$pageHost/$path"
} else {
videoUrl.replace(Regex("""\{v\d+\}"""), pageHost)
}
} catch (_: Exception) {
videoUrl
}
}
private fun buildResult(
rawVideoUrl: String,
doc: org.jsoup.nodes.Document,
html: String,
imdbId: String,
cacheDir: File?,
pageUrl: String
): Result<VideoSource> {
val videoUrl = resolveVideoUrl(rawVideoUrl, pageUrl)
Log.d(TAG, "Video URL found: $videoUrl")
val subtitles = extractSubtitles(doc, html, videoUrl).toMutableList()
val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId, cacheDir)
for (sub in openSubtitles) {
if (!subtitles.any { it.url == sub.url }) {
subtitles.add(sub)
}
}
val seenUrls = mutableSetOf<String>()
val dedupedTracks = subtitles.filter { seenUrls.add(it.url) }
val filteredSubtitles = dedupedTracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) }
Log.d(TAG, "Subtitles found: ${filteredSubtitles.size}")
val videoType = determineVideoType(videoUrl)
Log.d(TAG, "Video type: $videoType")
return Result.success(
VideoSource(
videoUrl = videoUrl,
videoType = videoType,
subtitleTracks = filteredSubtitles,
title = imdbId
)
)
}
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) {
@@ -496,7 +739,7 @@ class VideoExtractor {
} }
} }
val simpleSubtitlePattern = Regex("#EXT-X-MEDIA:TYPE=SUBTITLES[^\n]*") val simpleSubtitlePattern = Regex("#EXT-X-MEDIA:TYPE=SUBTITLES[^\\n]*")
simpleSubtitlePattern.findAll(manifestContent).forEach { match -> simpleSubtitlePattern.findAll(manifestContent).forEach { match ->
val line = match.value val line = match.value
val langMatch = Regex("LANGUAGE=\"([^\"]+)\"").find(line) val langMatch = Regex("LANGUAGE=\"([^\"]+)\"").find(line)
@@ -535,13 +778,12 @@ class VideoExtractor {
return tracks return tracks
} }
fun fetchSubtitlesFromOpenSubtitles(imdbId: String): List<SubtitleTrack> { fun fetchSubtitlesFromOpenSubtitles(imdbId: String, cacheDir: File? = null): List<SubtitleTrack> {
val tracks = mutableListOf<SubtitleTrack>() val tracks = mutableListOf<SubtitleTrack>()
val numericId = imdbId.removePrefix("tt").trim() val numericId = imdbId.removePrefix("tt").trim()
if (numericId.isEmpty()) return tracks if (numericId.isEmpty()) return tracks
val languages = listOf( val languages = listOf(
"eng" to "en",
"spa" to "es" "spa" to "es"
) )
@@ -553,23 +795,49 @@ class VideoExtractor {
for (i in 0 until jsonArray.length()) { for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i) val obj = jsonArray.getJSONObject(i)
val downloadLink = obj.optString("SubDownloadLink", "") val downloadLink = obj.optString("SubDownloadLink", "")
val subFormat = obj.optString("SubFormat", "srt")
val languageName = obj.optString("LanguageName", normalizedLang) val languageName = obj.optString("LanguageName", normalizedLang)
val isHearingImpaired = obj.optString("SubHearingImpaired", "0") == "1" val isHearingImpaired = obj.optString("SubHearingImpaired", "0") == "1"
if (isHearingImpaired) continue if (isHearingImpaired) continue
if (downloadLink.isNotEmpty() && tracks.none { it.language == normalizedLang }) { val maxPerLanguage = 10
val currentCount = tracks.count { it.language == normalizedLang }
if (downloadLink.isNotEmpty() && currentCount < maxPerLanguage) {
val rawFileName = obj.optString("SubFileName", "")
val displayName = if (rawFileName.isNotEmpty()) {
rawFileName.removeSuffix(".srt").removeSuffix(".sub")
} else {
if (currentCount == 0) languageName else "$languageName #${currentCount + 1}"
}
val subtitleUrl = if (downloadLink.endsWith(".gz") && cacheDir != null) {
val localFile = File(cacheDir, "subtitle_${normalizedLang}_${currentCount}.srt")
try {
// Delete any previous subtitle file for this slot
if (localFile.exists()) {
localFile.delete()
}
downloadAndDecompressSubtitle(downloadLink, localFile)
"file://${localFile.absolutePath}"
} catch (e: Exception) {
Log.w(TAG, "Failed to decompress subtitle: ${e.message}")
downloadLink
}
} else {
downloadLink
}
tracks.add( tracks.add(
SubtitleTrack( SubtitleTrack(
url = downloadLink, url = subtitleUrl,
language = normalizedLang, language = normalizedLang,
label = languageName, label = displayName,
isDefault = false, isDefault = currentCount == 0,
mimeType = if (subFormat.equals("vtt", ignoreCase = true)) "text/vtt" else "application/x-subrip" mimeType = "application/x-subrip"
) )
) )
Log.d(TAG, "OpenSubtitles track: $normalizedLang - $downloadLink") Log.d(TAG, "OpenSubtitles track: $normalizedLang - $displayName - $subtitleUrl")
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -580,6 +848,32 @@ class VideoExtractor {
return tracks return tracks
} }
private fun downloadAndDecompressSubtitle(url: String, outputFile: File) {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = TIMEOUT_MS
connection.readTimeout = TIMEOUT_MS
connection.setRequestProperty("User-Agent", "Mozilla/5.0")
try {
val inputStream = GZIPInputStream(connection.inputStream)
val rawBytes = inputStream.readBytes()
inputStream.close()
// Decode: try UTF-8 first, fall back to ISO-8859-1 for Spanish/Latin subtitles
val text = String(rawBytes, Charsets.UTF_8)
val hasBadChars = text.contains('\uFFFD') // Unicode replacement character
val decodedText = if (hasBadChars) {
String(rawBytes, Charsets.ISO_8859_1)
} else {
text
}
// Write as UTF-8 so ExoPlayer can handle it correctly
outputFile.writeText(decodedText, Charsets.UTF_8)
} finally {
connection.disconnect()
}
}
private fun isAllowedLanguage(lang: String, label: String): Boolean { private fun isAllowedLanguage(lang: String, label: String): Boolean {
val normalizedLang = lang.lowercase().trim() val normalizedLang = lang.lowercase().trim()
val normalizedLabel = label.lowercase().trim() val normalizedLabel = label.lowercase().trim()
@@ -614,14 +908,14 @@ class VideoExtractor {
clean.startsWith("http://") || clean.startsWith("https://") -> clean clean.startsWith("http://") || clean.startsWith("https://") -> clean
clean.startsWith("/") -> { clean.startsWith("/") -> {
try { try {
val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com" }) val base = if (baseUrl.isEmpty()) PLAYIMDB_BASE else baseUrl
URL(base, clean).toString() URL(URL(base), clean).toString()
} catch (_: Exception) { "https://cloudnestra.com$clean" } } catch (_: Exception) { "" }
} }
else -> { else -> {
try { try {
val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com/" }) val base = if (baseUrl.isEmpty()) PLAYIMDB_BASE else baseUrl
URL(base, clean).toString() URL(URL(base), clean).toString()
} catch (_: Exception) { "" } } catch (_: Exception) { "" }
} }
} }

View File

@@ -58,6 +58,7 @@ import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
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 androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -89,7 +90,7 @@ private const val SEEK_STEP_MS = 5000L
private val ButtonShape = RoundedCornerShape(12.dp) private val ButtonShape = RoundedCornerShape(12.dp)
private val SeekBarShape = RoundedCornerShape(8.dp) private val SeekBarShape = RoundedCornerShape(8.dp)
data class SubtitleOption(val label: String, val languageCode: String?) { data class SubtitleOption(val label: String, val languageCode: String?, val trackIndex: Int = -1) {
companion object { companion object {
val OFF = SubtitleOption("Off", null) val OFF = SubtitleOption("Off", null)
} }
@@ -124,14 +125,8 @@ fun PlayerScreen(
val availableSubtitles = remember(videoSource) { val availableSubtitles = remember(videoSource) {
buildList { buildList {
add(SubtitleOption.OFF) add(SubtitleOption.OFF)
videoSource?.subtitleTracks videoSource?.subtitleTracks?.forEachIndexed { index, track ->
?.map { it.language.lowercase().trim() } add(SubtitleOption(track.label, track.language, index))
?.distinct()
?.forEach { code ->
when (code) {
"en" -> add(SubtitleOption("English", "en"))
"es" -> add(SubtitleOption("Español", "es"))
}
} }
} }
} }
@@ -140,7 +135,7 @@ fun PlayerScreen(
DefaultTrackSelector(context).apply { DefaultTrackSelector(context).apply {
setParameters( setParameters(
buildUponParameters() buildUponParameters()
.setForceHighestSupportedBitrate(true) .setForceHighestSupportedBitrate(false)
.setTunnelingEnabled(false) // was true — breaks subtitles on many TVs .setTunnelingEnabled(false) // was true — breaks subtitles on many TVs
) )
} }
@@ -153,10 +148,10 @@ fun PlayerScreen(
.setLoadControl( .setLoadControl(
DefaultLoadControl.Builder() DefaultLoadControl.Builder()
.setBufferDurationsMs( .setBufferDurationsMs(
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * 2, 15_000, // minBuffer: 15s — enough to absorb jitter
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * 2, 30_000, // maxBuffer: 30s — don't hog RAM on Chromecast
2500, 2_000, // playAfterBuffer: 2s
5000 3_000 // playAfterRebuffer: 3s — resume faster after stalls
) )
.build() .build()
) )
@@ -179,7 +174,8 @@ fun PlayerScreen(
} }
// Register D-pad key handler at Activity level (before PlayerView steals events) // Register D-pad key handler at Activity level (before PlayerView steals events)
DisposableEffect(Unit) { // Key: availableSubtitles changes when videoSource loads, handler must see updated list
DisposableEffect(availableSubtitles) {
MainActivity.keyEventHandler = handler@{ event -> MainActivity.keyEventHandler = handler@{ event ->
if (event.action == KeyEvent.ACTION_DOWN) { if (event.action == KeyEvent.ACTION_DOWN) {
when (event.keyCode) { when (event.keyCode) {
@@ -368,12 +364,12 @@ fun PlayerScreen(
hasError = false hasError = false
try { try {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
VideoExtractor().extractVideoSource(imdbId) VideoExtractor().extractVideoSource(imdbId, context.cacheDir)
} }
result.fold( result.fold(
onSuccess = { source -> onSuccess = { source ->
videoSource = source videoSource = source
val mediaItem = buildMediaItemWithSubtitles(source, selectedSubtitle) val mediaItem = buildMediaItemWithSubtitles(source)
exoPlayer.setMediaItem(mediaItem) exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare() exoPlayer.prepare()
exoPlayer.playWhenReady = true exoPlayer.playWhenReady = true
@@ -489,12 +485,12 @@ fun PlayerScreen(
scope.launch { scope.launch {
try { try {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
VideoExtractor().extractVideoSource(imdbId) VideoExtractor().extractVideoSource(imdbId, context.cacheDir)
} }
result.fold( result.fold(
onSuccess = { source -> onSuccess = { source ->
videoSource = source videoSource = source
val mediaItem = buildMediaItemWithSubtitles(source, selectedSubtitle) val mediaItem = buildMediaItemWithSubtitles(source)
exoPlayer.setMediaItem(mediaItem) exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare() exoPlayer.prepare()
exoPlayer.playWhenReady = true exoPlayer.playWhenReady = true
@@ -632,7 +628,7 @@ private fun tryRequestFocus(focusRequester: FocusRequester, tag: String) {
} }
} }
private fun buildMediaItemWithSubtitles(source: VideoSource, selectedSubtitle: SubtitleOption): MediaItem { private fun buildMediaItemWithSubtitles(source: VideoSource): MediaItem {
val builder = MediaItem.Builder() val builder = MediaItem.Builder()
.setUri(source.videoUrl) .setUri(source.videoUrl)
@@ -641,7 +637,7 @@ private fun buildMediaItemWithSubtitles(source: VideoSource, selectedSubtitle: S
.setMimeType(track.mimeType.ifBlank { getSubtitleMimeType(track.url) }) .setMimeType(track.mimeType.ifBlank { getSubtitleMimeType(track.url) })
.setLanguage(track.language) .setLanguage(track.language)
.setLabel(track.label) .setLabel(track.label)
.setSelectionFlags(if (track.isDefault || selectedSubtitle.languageCode == track.language) C.SELECTION_FLAG_DEFAULT else 0) .setSelectionFlags(0)
.build() .build()
} }
@@ -696,7 +692,9 @@ private fun updateSubtitleTrack(player: ExoPlayer, trackSelector: DefaultTrackSe
if (trackGroup.type == C.TRACK_TYPE_TEXT) { if (trackGroup.type == C.TRACK_TYPE_TEXT) {
for (i in 0 until trackGroup.length) { for (i in 0 until trackGroup.length) {
val format = trackGroup.getTrackFormat(i) val format = trackGroup.getTrackFormat(i)
if (format.language?.startsWith(option.languageCode) == true) { val matchesLanguage = format.language?.startsWith(option.languageCode) == true
val matchesLabel = format.label?.toString() == option.label
if (matchesLanguage && matchesLabel) {
parameters.addOverride(TrackSelectionOverride(trackGroup.mediaTrackGroup, listOf(i))) parameters.addOverride(TrackSelectionOverride(trackGroup.mediaTrackGroup, listOf(i)))
break break
} }
@@ -977,6 +975,19 @@ private fun SubtitlePickerOverlay(
.background(Color.Black.copy(alpha = 0.7f)), .background(Color.Black.copy(alpha = 0.7f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
val scrollState = rememberScrollState()
val density = LocalDensity.current
// Auto-scroll to keep selected item visible
LaunchedEffect(selectedIndex) {
// Each item is 64dp height + 16dp spacing = 80dp
val itemHeightPx = with(density) { 80.dp.toPx() }.toInt()
// Title (~40dp) + spacer (8dp) + padding top (24dp) ≈ 72dp
val headerHeightPx = with(density) { 72.dp.toPx() }.toInt()
val targetOffset = headerHeightPx + (selectedIndex * itemHeightPx) - itemHeightPx
scrollState.animateScrollTo(targetOffset.coerceAtLeast(0))
}
Column( Column(
modifier = Modifier modifier = Modifier
.width(300.dp) .width(300.dp)
@@ -985,12 +996,12 @@ private fun SubtitlePickerOverlay(
.background(HorrorColors.HorrorGray) .background(HorrorColors.HorrorGray)
.border(2.dp, HorrorColors.HorrorAccent, RoundedCornerShape(16.dp)) .border(2.dp, HorrorColors.HorrorAccent, RoundedCornerShape(16.dp))
.padding(24.dp) .padding(24.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Text(
text = "Subtitles", text = "Subtítulos",
style = HorrorTypography.DetailTitle, style = HorrorTypography.DetailTitle,
color = HorrorColors.HorrorWhite color = HorrorColors.HorrorWhite
) )