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:
@@ -9,151 +9,137 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jsoup.Jsoup
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.util.zip.GZIPInputStream
|
||||
|
||||
class VideoExtractor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "VideoExtractor"
|
||||
private const val PLAYIMDB_BASE = "https://playimdb.com/title/"
|
||||
private const val STREAMIMDB_BASE = "https://streamimdb.me/embed/"
|
||||
private const val PLAYIMDB_BASE = "https://www.playimdb.com/title/"
|
||||
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, cacheDir: File? = null): Result<VideoSource> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (!IMDB_ID_PATTERN.matches(imdbId)) {
|
||||
return@withContext Result.failure(VideoExtractionException("Invalid IMDb ID: $imdbId"))
|
||||
}
|
||||
Log.d(TAG, "Extracting video for: $imdbId")
|
||||
|
||||
val embedUrl = "$STREAMIMDB_BASE$imdbId"
|
||||
Log.d(TAG, "Fetching embed: $embedUrl")
|
||||
// Step 1: Fetch playimdb.com entry page
|
||||
val entryUrl = "$PLAYIMDB_BASE$imdbId"
|
||||
Log.d(TAG, "Step 1: Fetching entry: $entryUrl")
|
||||
val entryHtml = fetchPage(entryUrl)
|
||||
|
||||
val html = fetchHtml(embedUrl)
|
||||
Log.d(TAG, "HTML length: ${html.length}")
|
||||
|
||||
var doc = Jsoup.parse(html, embedUrl)
|
||||
var finalHtml = html
|
||||
|
||||
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
|
||||
// Step 2: Find iframe URL (could be any domain)
|
||||
val entryDoc = Jsoup.parse(entryHtml, entryUrl)
|
||||
val embedUrl = entryDoc.selectFirst("iframe")?.absUrl("src")
|
||||
if (embedUrl.isNullOrEmpty()) {
|
||||
return@withContext Result.failure(VideoExtractionException("No embed iframe found"))
|
||||
}
|
||||
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 {
|
||||
val innerHtml = fetchHtml(fullInnerUrl)
|
||||
Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}")
|
||||
doc = Jsoup.parse(innerHtml, fullInnerUrl)
|
||||
finalHtml = innerHtml
|
||||
val apiResponse = fetchJson(apiUrl, embedUrl)
|
||||
val streamUrl = extractStreamUrlFromApi(apiResponse)
|
||||
if (streamUrl != null) {
|
||||
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) {
|
||||
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 {
|
||||
Log.d(TAG, "No prorcp pattern found, searching for /prorcp/ directly")
|
||||
val directPattern = Regex("/prorcp/[A-Za-z0-9+/=]+")
|
||||
val directMatch = directPattern.find(iframeHtml)
|
||||
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) {
|
||||
Log.w(TAG, "Failed to fetch iframe: ${e.message}")
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
val videoUrl = extractVideoUrl(doc)
|
||||
if (videoUrl == null) {
|
||||
Log.w(TAG, "No video URL found in HTML")
|
||||
// Step 5: If API didn't work, try extracting from HTML (old approach for cloudnestra-style pages)
|
||||
val finalVideoUrl = videoUrl ?: run {
|
||||
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"))
|
||||
}
|
||||
|
||||
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(
|
||||
"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 {
|
||||
videoUrl
|
||||
}
|
||||
|
||||
Log.d(TAG, "Final video URL: $finalVideoUrl")
|
||||
|
||||
val subtitles = extractSubtitles(doc, finalHtml, finalVideoUrl).toMutableList()
|
||||
|
||||
val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId)
|
||||
// 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) {
|
||||
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 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(finalVideoUrl)
|
||||
val videoType = determineVideoType(resolvedUrl)
|
||||
Log.d(TAG, "Video type: $videoType")
|
||||
|
||||
Result.success(
|
||||
VideoSource(
|
||||
videoUrl = finalVideoUrl,
|
||||
videoUrl = resolvedUrl,
|
||||
videoType = videoType,
|
||||
subtitleTracks = filteredSubtitles,
|
||||
title = imdbId
|
||||
@@ -167,10 +153,128 @@ class VideoExtractor {
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchHtml(url: String): String {
|
||||
if (!isAllowedHost(url)) {
|
||||
throw VideoExtractionException("Host not allowed: $url")
|
||||
private fun parsePlayerConfig(html: String): Map<String, Any>? {
|
||||
// Find CONFIG = {...} in the HTML
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
val videoElement = doc.selectFirst("video")
|
||||
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 ->
|
||||
val line = match.value
|
||||
val langMatch = Regex("LANGUAGE=\"([^\"]+)\"").find(line)
|
||||
@@ -535,13 +778,12 @@ class VideoExtractor {
|
||||
return tracks
|
||||
}
|
||||
|
||||
fun fetchSubtitlesFromOpenSubtitles(imdbId: String): List<SubtitleTrack> {
|
||||
fun fetchSubtitlesFromOpenSubtitles(imdbId: String, cacheDir: File? = null): 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"
|
||||
)
|
||||
|
||||
@@ -553,23 +795,49 @@ class VideoExtractor {
|
||||
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 }) {
|
||||
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(
|
||||
SubtitleTrack(
|
||||
url = downloadLink,
|
||||
url = subtitleUrl,
|
||||
language = normalizedLang,
|
||||
label = languageName,
|
||||
isDefault = false,
|
||||
mimeType = if (subFormat.equals("vtt", ignoreCase = true)) "text/vtt" else "application/x-subrip"
|
||||
label = displayName,
|
||||
isDefault = currentCount == 0,
|
||||
mimeType = "application/x-subrip"
|
||||
)
|
||||
)
|
||||
Log.d(TAG, "OpenSubtitles track: $normalizedLang - $downloadLink")
|
||||
Log.d(TAG, "OpenSubtitles track: $normalizedLang - $displayName - $subtitleUrl")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -580,6 +848,32 @@ class VideoExtractor {
|
||||
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 {
|
||||
val normalizedLang = lang.lowercase().trim()
|
||||
val normalizedLabel = label.lowercase().trim()
|
||||
@@ -614,14 +908,14 @@ class VideoExtractor {
|
||||
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" }
|
||||
val base = if (baseUrl.isEmpty()) PLAYIMDB_BASE else baseUrl
|
||||
URL(URL(base), clean).toString()
|
||||
} catch (_: Exception) { "" }
|
||||
}
|
||||
else -> {
|
||||
try {
|
||||
val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com/" })
|
||||
URL(base, clean).toString()
|
||||
val base = if (baseUrl.isEmpty()) PLAYIMDB_BASE else baseUrl
|
||||
URL(URL(base), clean).toString()
|
||||
} catch (_: Exception) { "" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
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 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 {
|
||||
val OFF = SubtitleOption("Off", null)
|
||||
}
|
||||
@@ -124,14 +125,8 @@ fun PlayerScreen(
|
||||
val availableSubtitles = remember(videoSource) {
|
||||
buildList {
|
||||
add(SubtitleOption.OFF)
|
||||
videoSource?.subtitleTracks
|
||||
?.map { it.language.lowercase().trim() }
|
||||
?.distinct()
|
||||
?.forEach { code ->
|
||||
when (code) {
|
||||
"en" -> add(SubtitleOption("English", "en"))
|
||||
"es" -> add(SubtitleOption("Español", "es"))
|
||||
}
|
||||
videoSource?.subtitleTracks?.forEachIndexed { index, track ->
|
||||
add(SubtitleOption(track.label, track.language, index))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +135,7 @@ fun PlayerScreen(
|
||||
DefaultTrackSelector(context).apply {
|
||||
setParameters(
|
||||
buildUponParameters()
|
||||
.setForceHighestSupportedBitrate(true)
|
||||
.setForceHighestSupportedBitrate(false)
|
||||
.setTunnelingEnabled(false) // was true — breaks subtitles on many TVs
|
||||
)
|
||||
}
|
||||
@@ -153,10 +148,10 @@ fun PlayerScreen(
|
||||
.setLoadControl(
|
||||
DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * 2,
|
||||
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * 2,
|
||||
2500,
|
||||
5000
|
||||
15_000, // minBuffer: 15s — enough to absorb jitter
|
||||
30_000, // maxBuffer: 30s — don't hog RAM on Chromecast
|
||||
2_000, // playAfterBuffer: 2s
|
||||
3_000 // playAfterRebuffer: 3s — resume faster after stalls
|
||||
)
|
||||
.build()
|
||||
)
|
||||
@@ -179,7 +174,8 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
// 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 ->
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
when (event.keyCode) {
|
||||
@@ -368,12 +364,12 @@ fun PlayerScreen(
|
||||
hasError = false
|
||||
try {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
VideoExtractor().extractVideoSource(imdbId)
|
||||
VideoExtractor().extractVideoSource(imdbId, context.cacheDir)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { source ->
|
||||
videoSource = source
|
||||
val mediaItem = buildMediaItemWithSubtitles(source, selectedSubtitle)
|
||||
val mediaItem = buildMediaItemWithSubtitles(source)
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
@@ -489,12 +485,12 @@ fun PlayerScreen(
|
||||
scope.launch {
|
||||
try {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
VideoExtractor().extractVideoSource(imdbId)
|
||||
VideoExtractor().extractVideoSource(imdbId, context.cacheDir)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { source ->
|
||||
videoSource = source
|
||||
val mediaItem = buildMediaItemWithSubtitles(source, selectedSubtitle)
|
||||
val mediaItem = buildMediaItemWithSubtitles(source)
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
exoPlayer.prepare()
|
||||
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()
|
||||
.setUri(source.videoUrl)
|
||||
|
||||
@@ -641,7 +637,7 @@ private fun buildMediaItemWithSubtitles(source: VideoSource, selectedSubtitle: S
|
||||
.setMimeType(track.mimeType.ifBlank { getSubtitleMimeType(track.url) })
|
||||
.setLanguage(track.language)
|
||||
.setLabel(track.label)
|
||||
.setSelectionFlags(if (track.isDefault || selectedSubtitle.languageCode == track.language) C.SELECTION_FLAG_DEFAULT else 0)
|
||||
.setSelectionFlags(0)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -696,7 +692,9 @@ private fun updateSubtitleTrack(player: ExoPlayer, trackSelector: DefaultTrackSe
|
||||
if (trackGroup.type == C.TRACK_TYPE_TEXT) {
|
||||
for (i in 0 until trackGroup.length) {
|
||||
val format = trackGroup.getTrackFormat(i)
|
||||
if (format.language?.startsWith(option.languageCode) == true) {
|
||||
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)))
|
||||
break
|
||||
}
|
||||
@@ -977,6 +975,19 @@ private fun SubtitlePickerOverlay(
|
||||
.background(Color.Black.copy(alpha = 0.7f)),
|
||||
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(
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
@@ -985,12 +996,12 @@ private fun SubtitlePickerOverlay(
|
||||
.background(HorrorColors.HorrorGray)
|
||||
.border(2.dp, HorrorColors.HorrorAccent, RoundedCornerShape(16.dp))
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Subtitles",
|
||||
text = "Subtítulos",
|
||||
style = HorrorTypography.DetailTitle,
|
||||
color = HorrorColors.HorrorWhite
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user