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 html = fetchHtml(embedUrl) val entryHtml = fetchPage(entryUrl)
Log.d(TAG, "HTML length: ${html.length}")
// Step 2: Find iframe URL (could be any domain)
var doc = Jsoup.parse(html, embedUrl) val entryDoc = Jsoup.parse(entryHtml, entryUrl)
var finalHtml = html val embedUrl = entryDoc.selectFirst("iframe")?.absUrl("src")
if (embedUrl.isNullOrEmpty()) {
val iframeUrl = doc.selectFirst("iframe")?.absUrl("src") return@withContext Result.failure(VideoExtractionException("No embed iframe found"))
if (!iframeUrl.isNullOrEmpty()) { }
Log.d(TAG, "Found iframe redirect: $iframeUrl") 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 iframeHtml = fetchHtml(iframeUrl) val apiResponse = fetchJson(apiUrl, embedUrl)
Log.d(TAG, "Iframe HTML length: ${iframeHtml.length}") val streamUrl = extractStreamUrlFromApi(apiResponse)
if (streamUrl != null) {
val prorcpPattern = Regex("['\"]?src['\"]?\\s*:\\s*['\"]([^'\"]+/prorcp/[^'\"]+)['\"]") videoUrl = streamUrl
val prorcpMatch = prorcpPattern.find(iframeHtml) pageUrl = embedUrl
if (prorcpMatch != null) { Log.d(TAG, "Got stream URL from API: $streamUrl")
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")
try {
val innerHtml = fetchHtml(fullInnerUrl)
Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}")
doc = Jsoup.parse(innerHtml, fullInnerUrl)
finalHtml = innerHtml
} catch (e: Exception) {
Log.w(TAG, "Failed prorcp fetch: ${e.message}")
}
} else { } else {
Log.d(TAG, "No prorcp pattern found, searching for /prorcp/ directly") Log.w(TAG, "API returned no stream URLs, falling back to HTML extraction")
val directPattern = Regex("/prorcp/[A-Za-z0-9+/=]+") videoUrl = null
val directMatch = directPattern.find(iframeHtml) pageUrl = embedUrl
if (directMatch != null) {
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) { } catch (e: Exception) {
Log.w(TAG, "Failed to fetch iframe: ${e.message}") Log.w(TAG, "Stream API failed: ${e.message}, falling back to HTML extraction")
} videoUrl = null
} pageUrl = embedUrl
val videoUrl = extractVideoUrl(doc)
if (videoUrl == null) {
Log.w(TAG, "No video URL found in HTML")
return@withContext Result.failure(VideoExtractionException("No video URL found"))
}
Log.d(TAG, "Video URL found: $videoUrl")
val knownDomains = listOf(
"neonhorizonworkshops.com",
"wanderlynest.com",
"orchidpixelgardens.com",
"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 { } else {
videoUrl // Step 4b: No CONFIG found — old approach, extract from HTML directly
Log.d(TAG, "Step 4b: No CONFIG found, extracting from HTML")
videoUrl = null
pageUrl = embedUrl
} }
Log.d(TAG, "Final video URL: $finalVideoUrl") // Step 5: If API didn't work, try extracting from HTML (old approach for cloudnestra-style pages)
val finalVideoUrl = videoUrl ?: run {
val subtitles = extractSubtitles(doc, finalHtml, finalVideoUrl).toMutableList() val doc = Jsoup.parse(embedHtml, embedUrl)
val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId) // 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"))
}
// Step 6: Resolve video URL template if needed
val resolvedUrl = resolveVideoUrl(finalVideoUrl, pageUrl)
Log.d(TAG, "Final video URL: $resolvedUrl")
// Step 7: Get subtitles
val doc = Jsoup.parse(embedHtml, embedUrl)
val subtitles = extractSubtitles(doc, embedHtml, resolvedUrl).toMutableList()
val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId, cacheDir)
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
@@ -166,11 +152,129 @@ class VideoExtractor {
Result.failure(VideoExtractionException(e.message ?: "Unknown error", e)) Result.failure(VideoExtractionException(e.message ?: "Unknown error", e))
} }
} }
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)
} }
@@ -218,7 +322,146 @@ class VideoExtractor {
connection?.disconnect() connection?.disconnect()
} }
} }
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) {
@@ -227,7 +470,7 @@ class VideoExtractor {
Log.d(TAG, "Found video[src]: $src") Log.d(TAG, "Found video[src]: $src")
return src return src
} }
val sourceElements = videoElement.select("source") val sourceElements = videoElement.select("source")
for (source in sourceElements) { for (source in sourceElements) {
val srcAttr = source.absUrl("src") val srcAttr = source.absUrl("src")
@@ -237,7 +480,7 @@ class VideoExtractor {
} }
} }
} }
val scripts = doc.select("script") val scripts = doc.select("script")
for (script in scripts) { for (script in scripts) {
val scriptContent = script.html() val scriptContent = script.html()
@@ -249,20 +492,20 @@ class VideoExtractor {
} }
} }
} }
val allElements = doc.select("*[src], *[href], *[data-src], *[data-url]") val allElements = doc.select("*[src], *[href], *[data-src], *[data-url]")
for (elem in allElements) { for (elem in allElements) {
val possibleUrl = elem.attr("src") val possibleUrl = elem.attr("src")
.ifEmpty { elem.attr("href") } .ifEmpty { elem.attr("href") }
.ifEmpty { elem.attr("data-src") } .ifEmpty { elem.attr("data-src") }
.ifEmpty { elem.attr("data-url") } .ifEmpty { elem.attr("data-url") }
if (possibleUrl.isNotEmpty() && isValidVideoUrl(possibleUrl)) { if (possibleUrl.isNotEmpty() && isValidVideoUrl(possibleUrl)) {
Log.d(TAG, "Found URL in element: $possibleUrl") Log.d(TAG, "Found URL in element: $possibleUrl")
return possibleUrl return possibleUrl
} }
} }
val htmlText = doc.html() val htmlText = doc.html()
val m3u8Pattern = Regex("https?://[^\"'<>\\s]+\\.m3u8[^\"'<>\\s]*") val m3u8Pattern = Regex("https?://[^\"'<>\\s]+\\.m3u8[^\"'<>\\s]*")
val m3u8Match = m3u8Pattern.find(htmlText) val m3u8Match = m3u8Pattern.find(htmlText)
@@ -270,17 +513,17 @@ class VideoExtractor {
Log.d(TAG, "Found .m3u8 via regex: ${m3u8Match.value}") Log.d(TAG, "Found .m3u8 via regex: ${m3u8Match.value}")
return m3u8Match.value return m3u8Match.value
} }
val mp4Pattern = Regex("https?://[^\"'<>\\s]+\\.mp4[^\"'<>\\s]*") val mp4Pattern = Regex("https?://[^\"'<>\\s]+\\.mp4[^\"'<>\\s]*")
val mp4Match = mp4Pattern.find(htmlText) val mp4Match = mp4Pattern.find(htmlText)
if (mp4Match != null) { if (mp4Match != null) {
Log.d(TAG, "Found .mp4 via regex: ${mp4Match.value}") Log.d(TAG, "Found .mp4 via regex: ${mp4Match.value}")
return mp4Match.value return mp4Match.value
} }
return null return null
} }
private fun extractUrlsFromScript(scriptContent: String): List<String> { private fun extractUrlsFromScript(scriptContent: String): List<String> {
val urlPattern = Regex("https?://[^\"'<>\\s]+") val urlPattern = Regex("https?://[^\"'<>\\s]+")
return urlPattern.findAll(scriptContent) return urlPattern.findAll(scriptContent)
@@ -288,14 +531,14 @@ class VideoExtractor {
.filter { isValidVideoUrl(it) } .filter { isValidVideoUrl(it) }
.toList() .toList()
} }
private fun isValidVideoUrl(url: String): Boolean { private fun isValidVideoUrl(url: String): Boolean {
val lower = url.lowercase() val lower = url.lowercase()
return (lower.contains(".m3u8") || lower.contains(".mp4") || lower.contains(".mkv") || lower.contains(".mpd")) && return (lower.contains(".m3u8") || lower.contains(".mp4") || lower.contains(".mkv") || lower.contains(".mpd")) &&
(url.startsWith("http://") || url.startsWith("https://")) && (url.startsWith("http://") || url.startsWith("https://")) &&
!lower.contains(".js") && !lower.contains(".css") && !lower.contains(".html") !lower.contains(".js") && !lower.contains(".css") && !lower.contains(".html")
} }
private fun determineVideoType(url: String): VideoType { private fun determineVideoType(url: String): VideoType {
val path = try { URL(url).path.lowercase() } catch (_: Exception) { url.lowercase() } val path = try { URL(url).path.lowercase() } catch (_: Exception) { url.lowercase() }
return when { return when {
@@ -305,17 +548,17 @@ class VideoExtractor {
else -> VideoType.UNKNOWN else -> VideoType.UNKNOWN
} }
} }
private fun extractSubtitles(doc: org.jsoup.nodes.Document, htmlContent: String, videoUrl: String?): 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.absUrl("src") val src = track.absUrl("src")
val srclang = track.attr("srclang").lowercase() val srclang = track.attr("srclang").lowercase()
val label = track.attr("label").ifEmpty { srclang } val label = track.attr("label").ifEmpty { srclang }
val isDefault = track.hasAttr("default") val isDefault = track.hasAttr("default")
if (src.isNotEmpty() && isAllowedLanguage(srclang, label)) { if (src.isNotEmpty() && isAllowedLanguage(srclang, label)) {
tracks.add( tracks.add(
SubtitleTrack( SubtitleTrack(
@@ -329,7 +572,7 @@ class VideoExtractor {
Log.d(TAG, "Found track element subtitle: $srclang - $src") Log.d(TAG, "Found track element subtitle: $srclang - $src")
} }
} }
val subtitlePatterns = listOf( val subtitlePatterns = listOf(
Regex("\"sub\"\\s*:\\s*\"([^\"]+)\""), Regex("\"sub\"\\s*:\\s*\"([^\"]+)\""),
Regex("\"subtitle\"\\s*:\\s*\"([^\"]+)\""), Regex("\"subtitle\"\\s*:\\s*\"([^\"]+)\""),
@@ -340,7 +583,7 @@ class VideoExtractor {
Regex("'subtitle'\\s*:\\s*'([^']+)'"), Regex("'subtitle'\\s*:\\s*'([^']+)'"),
Regex("'captions'\\s*:\\s*'([^']+)'") Regex("'captions'\\s*:\\s*'([^']+)'")
) )
for (pattern in subtitlePatterns) { for (pattern in subtitlePatterns) {
pattern.findAll(htmlContent).forEach { match -> pattern.findAll(htmlContent).forEach { match ->
val url = match.groupValues[1] val url = match.groupValues[1]
@@ -361,7 +604,7 @@ class VideoExtractor {
} }
} }
} }
val vttPattern = Regex("https?://[^\"'<>\\s]+\\.vtt[^\"'<>\\s]*") val vttPattern = Regex("https?://[^\"'<>\\s]+\\.vtt[^\"'<>\\s]*")
vttPattern.findAll(htmlContent).forEach { match -> vttPattern.findAll(htmlContent).forEach { match ->
val url = match.value val url = match.value
@@ -378,7 +621,7 @@ class VideoExtractor {
Log.d(TAG, "Found VTT URL: $url") Log.d(TAG, "Found VTT URL: $url")
} }
} }
val srtPattern = Regex("https?://[^\"'<>\\s]+\\.srt[^\"'<>\\s]*") val srtPattern = Regex("https?://[^\"'<>\\s]+\\.srt[^\"'<>\\s]*")
srtPattern.findAll(htmlContent).forEach { match -> srtPattern.findAll(htmlContent).forEach { match ->
val url = match.value val url = match.value
@@ -395,7 +638,7 @@ class VideoExtractor {
Log.d(TAG, "Found SRT URL: $url") Log.d(TAG, "Found SRT URL: $url")
} }
} }
val jsonArrayPattern = Regex("\\[[\\s\\S]*?\"url\"[\\s\\S]*?\\]") val jsonArrayPattern = Regex("\\[[\\s\\S]*?\"url\"[\\s\\S]*?\\]")
jsonArrayPattern.findAll(htmlContent).forEach { match -> jsonArrayPattern.findAll(htmlContent).forEach { match ->
try { try {
@@ -403,15 +646,15 @@ class VideoExtractor {
val urlPattern = Regex("\"url\"\\s*:\\s*\"([^\"]+)\"") val urlPattern = Regex("\"url\"\\s*:\\s*\"([^\"]+)\"")
val langPattern = Regex("\"lang\"\\s*:\\s*\"([^\"]+)\"") val langPattern = Regex("\"lang\"\\s*:\\s*\"([^\"]+)\"")
val labelPattern = Regex("\"label\"\\s*:\\s*\"([^\"]+)\"") val labelPattern = Regex("\"label\"\\s*:\\s*\"([^\"]+)\"")
urlPattern.findAll(jsonArray).forEach { urlMatch -> urlPattern.findAll(jsonArray).forEach { urlMatch ->
val subUrl = urlMatch.groupValues[1] val subUrl = urlMatch.groupValues[1]
val langMatch = langPattern.find(jsonArray) val langMatch = langPattern.find(jsonArray)
val labelMatch = labelPattern.find(jsonArray) val labelMatch = labelPattern.find(jsonArray)
val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en" val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en"
val subLabel = labelMatch?.groupValues?.get(1) ?: lang val subLabel = labelMatch?.groupValues?.get(1) ?: lang
if (subUrl.isNotEmpty() && !tracks.any { it.url == subUrl } && isAllowedLanguage(lang, subLabel)) { if (subUrl.isNotEmpty() && !tracks.any { it.url == subUrl } && isAllowedLanguage(lang, subLabel)) {
tracks.add( tracks.add(
SubtitleTrack( SubtitleTrack(
@@ -429,12 +672,12 @@ class VideoExtractor {
Log.w(TAG, "Error parsing subtitle JSON array: ${e.message}") Log.w(TAG, "Error parsing subtitle JSON array: ${e.message}")
} }
} }
val jwplayerPattern = Regex("\\{[^}]*file:\"([^\"]+)\"[^}]*label:\"([^\"]+)\"[^}]*kind:\"captions\"[^}]*\\}") val jwplayerPattern = Regex("\\{[^}]*file:\"([^\"]+)\"[^}]*label:\"([^\"]+)\"[^}]*kind:\"captions\"[^}]*\\}")
jwplayerPattern.findAll(htmlContent).forEach { match -> jwplayerPattern.findAll(htmlContent).forEach { match ->
val url = match.groupValues[1] val url = match.groupValues[1]
val label = match.groupValues[2].lowercase() val label = match.groupValues[2].lowercase()
if (url.isNotEmpty() && !tracks.any { it.url == url } && isAllowedLanguage(label, label)) { if (url.isNotEmpty() && !tracks.any { it.url == url } && isAllowedLanguage(label, label)) {
tracks.add( tracks.add(
SubtitleTrack( SubtitleTrack(
@@ -448,7 +691,7 @@ class VideoExtractor {
Log.d(TAG, "Found JWPlayer subtitle: $label - $url") Log.d(TAG, "Found JWPlayer subtitle: $label - $url")
} }
} }
if (videoUrl != null && videoUrl.contains(".m3u8")) { if (videoUrl != null && videoUrl.contains(".m3u8")) {
val hlsSubtitles = extractHlsSubtitles(videoUrl) val hlsSubtitles = extractHlsSubtitles(videoUrl)
for (sub in hlsSubtitles) { for (sub in hlsSubtitles) {
@@ -458,16 +701,16 @@ class VideoExtractor {
} }
} }
} }
return tracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) } return tracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) }
} }
private fun extractHlsSubtitles(videoUrl: String): List<SubtitleTrack> { private fun extractHlsSubtitles(videoUrl: String): List<SubtitleTrack> {
val tracks = mutableListOf<SubtitleTrack>() val tracks = mutableListOf<SubtitleTrack>()
try { try {
val manifestContent = fetchManifest(videoUrl) val manifestContent = fetchManifest(videoUrl)
val subtitleMediaPattern = Regex( val subtitleMediaPattern = Regex(
"#EXT-X-MEDIA:TYPE=SUBTITLES[^\\n]*LANGUAGE=\"([^\"]+)\"[^\\n]*NAME=\"([^\"]+)\"[^\\n]*(?:DEFAULT=YES)?[^\\n]*URI=\"([^\"]+)\"" "#EXT-X-MEDIA:TYPE=SUBTITLES[^\\n]*LANGUAGE=\"([^\"]+)\"[^\\n]*NAME=\"([^\"]+)\"[^\\n]*(?:DEFAULT=YES)?[^\\n]*URI=\"([^\"]+)\""
) )
@@ -475,14 +718,14 @@ class VideoExtractor {
val lang = match.groupValues[1].lowercase() val lang = match.groupValues[1].lowercase()
val name = match.groupValues[2] val name = match.groupValues[2]
val uri = match.groupValues[3] val uri = match.groupValues[3]
val fullUrl = if (uri.startsWith("http")) { val fullUrl = if (uri.startsWith("http")) {
uri uri
} else { } else {
val baseUrl = videoUrl.substringBeforeLast("/") + "/" val baseUrl = videoUrl.substringBeforeLast("/") + "/"
baseUrl + uri baseUrl + uri
} }
if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name)) { if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name)) {
tracks.add( tracks.add(
SubtitleTrack( SubtitleTrack(
@@ -495,18 +738,18 @@ 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)
val nameMatch = Regex("NAME=\"([^\"]+)\"").find(line) val nameMatch = Regex("NAME=\"([^\"]+)\"").find(line)
val uriMatch = Regex("URI=\"([^\"]+)\"").find(line) val uriMatch = Regex("URI=\"([^\"]+)\"").find(line)
val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en" val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en"
val name = nameMatch?.groupValues?.get(1) ?: lang val name = nameMatch?.groupValues?.get(1) ?: lang
val uri = uriMatch?.groupValues?.get(1) val uri = uriMatch?.groupValues?.get(1)
if (uri != null) { if (uri != null) {
val fullUrl = if (uri.startsWith("http")) { val fullUrl = if (uri.startsWith("http")) {
uri uri
@@ -514,7 +757,7 @@ class VideoExtractor {
val baseUrl = videoUrl.substringBeforeLast("/") + "/" val baseUrl = videoUrl.substringBeforeLast("/") + "/"
baseUrl + uri baseUrl + uri
} }
if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name) && !tracks.any { it.url == fullUrl }) { if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name) && !tracks.any { it.url == fullUrl }) {
tracks.add( tracks.add(
SubtitleTrack( SubtitleTrack(
@@ -531,17 +774,16 @@ class VideoExtractor {
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to extract HLS subtitles: ${e.message}") Log.w(TAG, "Failed to extract HLS subtitles: ${e.message}")
} }
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()
@@ -589,7 +883,7 @@ class VideoExtractor {
normalizedLabel.contains("english") || normalizedLabel.contains("spanish") || normalizedLabel.contains("english") || normalizedLabel.contains("spanish") ||
normalizedLabel.contains("español") normalizedLabel.contains("español")
} }
private fun normalizeLanguage(lang: String): String { private fun normalizeLanguage(lang: String): String {
val normalized = lang.lowercase().trim() val normalized = lang.lowercase().trim()
return when { return when {
@@ -600,13 +894,13 @@ class VideoExtractor {
else -> normalized.take(2) else -> normalized.take(2)
} }
} }
private fun isSubtitleUrl(url: String): Boolean { private fun isSubtitleUrl(url: String): Boolean {
return url.contains(".vtt") || url.contains(".srt") || return url.contains(".vtt") || url.contains(".srt") ||
url.contains("subtitle") || url.contains("caption") || url.contains("subtitle") || url.contains("caption") ||
url.contains("sub") url.contains("sub")
} }
private fun cleanSubtitleUrl(url: String, baseUrl: String = ""): String { private fun cleanSubtitleUrl(url: String, baseUrl: String = ""): String {
val clean = url.trim().removeSurrounding("\"").removeSurrounding("'").trim() val clean = url.trim().removeSurrounding("\"").removeSurrounding("'").trim()
if (clean.isEmpty()) return "" if (clean.isEmpty()) return ""
@@ -614,19 +908,19 @@ 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) { "" }
} }
} }
} }
private fun determineMimeType(url: String): String { private fun determineMimeType(url: String): String {
return when { return when {
url.contains(".srt", ignoreCase = true) -> "application/x-subrip" url.contains(".srt", ignoreCase = true) -> "application/x-subrip"
@@ -636,4 +930,4 @@ class VideoExtractor {
} }
} }
class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause) class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause)

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,15 +125,9 @@ 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
) )