fix: support streamtp event playback on tv

This commit is contained in:
Renato
2026-03-11 15:58:03 -03:00
parent 49ed737663
commit 3ca31c70b3
4 changed files with 298 additions and 57 deletions

View File

@@ -48,7 +48,9 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.media3:media3-exoplayer:1.4.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.4.1'
implementation 'androidx.media3:media3-exoplayer-dash:1.4.1'
implementation 'androidx.media3:media3-ui:1.4.1'
implementation 'androidx.media3:media3-datasource-okhttp:1.4.1'
// OkHttp con DNS over HTTPS (para StreamUrlResolver)
implementation 'com.squareup.okhttp3:okhttp:4.12.0'

View File

@@ -8,7 +8,6 @@ import java.util.List;
public final class ChannelRepository {
private static final List<StreamChannel> CHANNELS = createChannels();
private static final Comparator<StreamChannel> CHANNEL_NAME_COMPARATOR =
new Comparator<StreamChannel>() {
@Override
@@ -16,6 +15,7 @@ public final class ChannelRepository {
return String.CASE_INSENSITIVE_ORDER.compare(left.getName(), right.getName());
}
};
private static final List<StreamChannel> CHANNELS = createChannels();
private static List<StreamChannel> createChannels() {
List<StreamChannel> channels = new ArrayList<>(Arrays.asList(

View File

@@ -5,6 +5,7 @@ import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
@@ -14,16 +15,23 @@ import android.widget.TextView;
import androidx.annotation.OptIn;
import androidx.appcompat.app.AppCompatActivity;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.datasource.DefaultHttpDataSource;
import androidx.media3.datasource.HttpDataSource;
import androidx.media3.datasource.okhttp.OkHttpDataSource;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.hls.HlsMediaSource;
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager;
import androidx.media3.exoplayer.drm.FrameworkMediaDrm;
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.ui.PlayerView;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
@@ -48,7 +56,7 @@ public class PlayerActivity extends AppCompatActivity {
private String channelUrl;
private boolean overlayVisible = true;
private int retryCount = 0;
private String lastStreamUrl;
private StreamUrlResolver.ResolvedStream lastResolvedStream;
private String currentChannelPageUrl;
private boolean playbackStarted = false;
private boolean alternateSourceAttempted = false;
@@ -118,14 +126,17 @@ public class PlayerActivity extends AppCompatActivity {
new Thread(() -> {
try {
String resolvedUrl = StreamUrlResolver.resolve(pageUrl);
Log.d(TAG, "Stream resuelto: " + resolvedUrl + " (req=" + requestGeneration + ")");
StreamUrlResolver.ResolvedStream resolvedStream = StreamUrlResolver.resolve(pageUrl);
Log.d(TAG, "Stream resuelto: " + resolvedStream.getStreamUrl()
+ " | mime=" + resolvedStream.getMimeType()
+ " | drm=" + resolvedStream.hasClearKey()
+ " (req=" + requestGeneration + ")");
runOnUiThread(() -> {
if (!isLatestResolveRequest(requestGeneration)) {
Log.d(TAG, "Ignorando resultado viejo (req=" + requestGeneration + ")");
return;
}
startPlayback(resolvedUrl);
startPlayback(resolvedStream);
});
} catch (IOException e) {
runOnUiThread(() -> {
@@ -155,28 +166,18 @@ public class PlayerActivity extends AppCompatActivity {
}
}
private void startPlayback(String streamUrl) {
private void startPlayback(StreamUrlResolver.ResolvedStream resolvedStream) {
try {
releasePlayer();
lastStreamUrl = streamUrl;
lastResolvedStream = resolvedStream;
retryCount = 0;
playbackStarted = false;
scheduleStartupTimeout();
Log.d(TAG, "Iniciando reproducción: " + streamUrl);
Log.d(TAG, "Iniciando reproducción: " + resolvedStream.getStreamUrl()
+ " | mime=" + resolvedStream.getMimeType()
+ " | drm=" + resolvedStream.hasClearKey());
DefaultHttpDataSource.Factory httpFactory = new DefaultHttpDataSource.Factory()
.setUserAgent(VlcPlayerConfig.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
.setConnectTimeoutMs(15000)
.setReadTimeoutMs(20000);
Map<String, String> headers = new HashMap<>();
headers.put("User-Agent", VlcPlayerConfig.USER_AGENT);
httpFactory.setDefaultRequestProperties(headers);
HlsMediaSource mediaSource = new HlsMediaSource.Factory(httpFactory)
.setAllowChunklessPreparation(true)
.createMediaSource(MediaItem.fromUri(Uri.parse(streamUrl)));
MediaSource mediaSource = buildMediaSource(resolvedStream);
player = new ExoPlayer.Builder(this).build();
playerView.setPlayer(player);
@@ -195,6 +196,85 @@ public class PlayerActivity extends AppCompatActivity {
}
}
private MediaSource buildMediaSource(StreamUrlResolver.ResolvedStream resolvedStream) {
HttpDataSource.Factory httpFactory = createHttpDataSourceFactory(currentChannelPageUrl);
MediaItem.Builder mediaItemBuilder = new MediaItem.Builder()
.setUri(Uri.parse(resolvedStream.getStreamUrl()))
.setMimeType(resolvedStream.getMimeType());
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(httpFactory);
if (resolvedStream.hasClearKey()) {
mediaItemBuilder.setDrmConfiguration(
new MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build());
DefaultDrmSessionManager drmSessionManager = new DefaultDrmSessionManager.Builder()
.setUuidAndExoMediaDrmProvider(C.CLEARKEY_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER)
.build(new LocalMediaDrmCallback(buildClearKeyLicenseResponse(
resolvedStream.getClearKeyIdHex(),
resolvedStream.getClearKeyHex())));
mediaSourceFactory.setDrmSessionManagerProvider(mediaItem -> drmSessionManager);
}
return mediaSourceFactory.createMediaSource(mediaItemBuilder.build());
}
private HttpDataSource.Factory createHttpDataSourceFactory(String pageUrl) {
Map<String, String> headers = new HashMap<>();
headers.put("User-Agent", VlcPlayerConfig.USER_AGENT);
headers.put("Accept", "*/*");
String origin = buildOrigin(pageUrl);
if (origin != null) {
headers.put("Origin", origin);
headers.put("Referer", origin + "/");
}
return new OkHttpDataSource.Factory(NetworkUtils.getClient())
.setUserAgent(VlcPlayerConfig.USER_AGENT)
.setDefaultRequestProperties(headers);
}
private String buildOrigin(String pageUrl) {
if (pageUrl == null || pageUrl.isEmpty()) {
return null;
}
Uri uri = Uri.parse(pageUrl);
if (uri.getScheme() == null || uri.getHost() == null) {
return null;
}
StringBuilder origin = new StringBuilder()
.append(uri.getScheme())
.append("://")
.append(uri.getHost());
if (uri.getPort() != -1) {
origin.append(":").append(uri.getPort());
}
return origin.toString();
}
private byte[] buildClearKeyLicenseResponse(String keyIdHex, String keyHex) {
String keyIdBase64Url = encodeBase64Url(hexToBytes(keyIdHex));
String keyBase64Url = encodeBase64Url(hexToBytes(keyHex));
String response = "{\"keys\":[{\"k\":\"" + keyBase64Url
+ "\",\"kid\":\"" + keyIdBase64Url
+ "\",\"kty\":\"oct\"}],\"type\":\"temporary\"}";
return response.getBytes(StandardCharsets.UTF_8);
}
private byte[] hexToBytes(String value) {
int length = value.length();
byte[] bytes = new byte[length / 2];
for (int i = 0; i < length; i += 2) {
bytes[i / 2] = (byte) Integer.parseInt(value.substring(i, i + 2), 16);
}
return bytes;
}
private String encodeBase64Url(byte[] value) {
return Base64.encodeToString(value, Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE);
}
private void setupPlayerListener() {
if (player == null) {
return;
@@ -278,8 +358,8 @@ public class PlayerActivity extends AppCompatActivity {
});
mainHandler.postDelayed(() -> {
if (lastStreamUrl != null) {
startPlayback(lastStreamUrl);
if (lastResolvedStream != null) {
startPlayback(lastResolvedStream);
} else {
loadChannel();
}

View File

@@ -2,6 +2,8 @@ package com.streamplayer;
import android.util.Base64;
import androidx.media3.common.MimeTypes;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@@ -32,13 +34,13 @@ public final class StreamUrlResolver {
private static final Pattern VIDEO_SOURCE_PATTERN =
Pattern.compile("<source[^>]+src=[\"']([^\"']+)[\"']");
// Patrón para URLs M3U8 en cualquier parte del HTML
private static final Pattern M3U8_URL_PATTERN =
Pattern.compile("(https?://[^\\s'\"<>]+\\.m3u8[^\\s'\"<>]*)");
// Patrón para URLs HLS/DASH en cualquier parte del HTML
private static final Pattern STREAM_MANIFEST_URL_PATTERN =
Pattern.compile("(https?://[^\\s'\"<>]+\\.(?:m3u8|mpd)[^\\s'\"<>]*)");
// Patrón para URLs de stream en comillas dobles o simples
private static final Pattern STREAM_URL_PATTERN =
Pattern.compile("['\"](https?://[^'\"<>\\s]+?\\.(?:m3u8|mp4|ts)[^'\"<>\\s]*)['\"]");
Pattern.compile("['\"](https?://[^'\"<>\\s]+?\\.(?:m3u8|mpd|mp4|ts)[^'\"<>\\s]*)['\"]");
// Patrón para file: o url: en JavaScript
private static final Pattern JS_URL_PATTERN =
@@ -47,7 +49,8 @@ public final class StreamUrlResolver {
// Patrón para JWPlayer sources con "file": "url"
private static final Pattern JWPLAYER_FILE_PATTERN =
Pattern.compile("\"file\"\\s*:\\s*\"([^\"]+\\.m3u8[^\"]*)\"");
Pattern.compile("\"file\"\\s*:\\s*\"([^\"]+\\.(?:m3u8|mpd)[^\"]*)\"",
Pattern.CASE_INSENSITIVE);
// Patrón para pares [indice, "base64"] del nuevo playbackURL ofuscado
private static final Pattern OBFUSCATED_PAIR_PATTERN =
@@ -62,20 +65,32 @@ public final class StreamUrlResolver {
Pattern.compile("function\\s+([A-Za-z_$][\\w$]*)\\s*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*;?\\s*\\}",
Pattern.DOTALL);
// Patrón para IIFEs que calculan k con dos returns inline.
private static final Pattern INLINE_OBFUSCATED_K_PATTERN =
Pattern.compile("k\\s*=\\s*\\(function\\s+[A-Za-z_$][\\w$]*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*\\}\\)\\(\\)\\s*\\+\\s*\\(function\\s+[A-Za-z_$][\\w$]*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*\\}\\)\\(\\)",
Pattern.DOTALL);
private static final Pattern CLEAR_KEY_HEX_PATTERN =
Pattern.compile("^[0-9a-fA-F]{32}$");
private StreamUrlResolver() {
}
public static String resolve(String pageUrl) throws IOException {
public static ResolvedStream resolve(String pageUrl) throws IOException {
// Primero verificar si la URL ya parece ser un stream directo
if (isDirectStreamUrl(pageUrl)) {
return pageUrl;
return ResolvedStream.fromUrl(pageUrl);
}
String html = downloadPage(pageUrl);
String trimmedHtml = html.trim();
// Si el contenido ya parece ser un stream M3U8, retornarlo directamente
if (html.startsWith("#EXTM3U") || html.startsWith("#EXT")) {
return pageUrl;
// Si el contenido ya es un manifiesto directo, reproducirlo como tal.
if (trimmedHtml.startsWith("#EXTM3U") || trimmedHtml.startsWith("#EXT")) {
return ResolvedStream.hls(pageUrl);
}
if (isDashManifest(trimmedHtml)) {
return ResolvedStream.dash(pageUrl);
}
// Intentar múltiples patrones de extracción
@@ -84,56 +99,56 @@ public final class StreamUrlResolver {
// 1. Patrón original: var playbackURL = "..."
streamUrl = extractWithPattern(html, PLAYBACK_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
return ResolvedStream.fromUrl(streamUrl);
}
// 2. Patrón: <source src="...">
streamUrl = extractWithPattern(html, VIDEO_SOURCE_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
return ResolvedStream.fromUrl(streamUrl);
}
// 3. Patrón: URLs M3U8 directas
streamUrl = extractWithPattern(html, M3U8_URL_PATTERN);
// 3. Patrón: URLs HLS/DASH directas
streamUrl = extractWithPattern(html, STREAM_MANIFEST_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
return ResolvedStream.fromUrl(streamUrl);
}
// 4. Patrón: URLs de stream en comillas
streamUrl = extractWithPattern(html, STREAM_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
return ResolvedStream.fromUrl(streamUrl);
}
// 5. Patrón: JavaScript file: / url: / stream:
streamUrl = extractWithPattern(html, JS_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
return ResolvedStream.fromUrl(streamUrl);
}
// 6. Patrón: JWPlayer "file": "url.m3u8" (para reproductores web y otros)
// 6. Patrón: JWPlayer "file": "url" (para reproductores web y otros)
streamUrl = extractWithPattern(html, JWPLAYER_FILE_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
return ResolvedStream.fromUrl(streamUrl);
}
// 7. Nuevo formato ofuscado: playbackURL generado con atob + fromCharCode
streamUrl = decodeObfuscatedPlaybackUrl(html);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
return ResolvedStream.fromUrl(streamUrl);
}
// Si no encontramos nada con patrones, intentar usar la URL original
// como stream directo (útil para URLs que ya son streams)
if (html.contains(".m3u8") || html.contains("stream") || html.contains("video")) {
return pageUrl;
// 8. Eventos "transmision*.php": DASH + ClearKey en variables ofuscadas.
ResolvedStream dashClearKeyStream = decodeDashClearKeyStream(html);
if (dashClearKeyStream != null) {
return dashClearKeyStream;
}
// Último recurso: si la URL viene de sudamericaplay.com o similares,
// intentar usarla directamente
if (pageUrl.contains("sudamericaplay.com") ||
pageUrl.contains("paramount")) {
return pageUrl;
return ResolvedStream.fromUrl(pageUrl);
}
// Si no encontramos la URL, mostrar un fragmento del HTML para debug
@@ -194,6 +209,64 @@ public final class StreamUrlResolver {
long k = valueA + valueB;
return decodePairs(extractEncodedPairs(script), k);
}
private static ResolvedStream decodeDashClearKeyStream(String html) {
if (html == null ||
!html.contains("\"type\": \"dash\"") ||
!html.contains("clearkey")) {
return null;
}
String dashUrl = decodeObfuscatedVariable(html, "_u");
String keyId = decodeObfuscatedVariable(html, "_ki");
String key = decodeObfuscatedVariable(html, "_k");
if (!isValidStreamUrl(dashUrl) ||
!isValidClearKeyHex(keyId) ||
!isValidClearKeyHex(key)) {
return null;
}
return ResolvedStream.dashClearKey(dashUrl, keyId, key);
}
private static String decodeObfuscatedVariable(String html, String variableName) {
String marker = "var " + variableName + "='';";
int start = html.indexOf(marker);
if (start < 0) {
return null;
}
int end = html.indexOf("var _", start + marker.length());
if (end < 0) {
end = html.indexOf("var data = jwplayer", start + marker.length());
}
if (end < 0) {
end = html.indexOf("</script>", start + marker.length());
}
if (end <= start) {
return null;
}
String block = html.substring(start, end);
Matcher kMatcher = INLINE_OBFUSCATED_K_PATTERN.matcher(block);
if (!kMatcher.find()) {
return null;
}
long k;
try {
k = Long.parseLong(kMatcher.group(1)) + Long.parseLong(kMatcher.group(2));
} catch (NumberFormatException e) {
return null;
}
return decodePairs(extractEncodedPairs(block), k);
}
private static List<EncodedPair> extractEncodedPairs(String script) {
List<EncodedPair> pairs = new ArrayList<>();
Matcher pairMatcher = OBFUSCATED_PAIR_PATTERN.matcher(script);
while (pairMatcher.find()) {
@@ -204,18 +277,21 @@ public final class StreamUrlResolver {
}
}
if (pairs.isEmpty()) {
return null;
}
Collections.sort(pairs, new Comparator<EncodedPair>() {
@Override
public int compare(EncodedPair left, EncodedPair right) {
return Integer.compare(left.index, right.index);
}
});
StringBuilder decoded = new StringBuilder(pairs.size());
return pairs;
}
private static String decodePairs(List<EncodedPair> pairs, long k) {
if (pairs.isEmpty()) {
return null;
}
StringBuilder decoded = new StringBuilder(pairs.size());
for (EncodedPair pair : pairs) {
byte[] decodedBytes;
try {
@@ -249,12 +325,23 @@ public final class StreamUrlResolver {
return null;
}
String url = decoded.toString().trim()
return decoded.toString().trim()
.replace("\\/", "/")
.replace("\\u0026", "&")
.replace("\\u002F", "/");
}
return isValidStreamUrl(url) ? url : null;
private static boolean isDashManifest(String body) {
if (body == null || body.isEmpty()) {
return false;
}
String lower = body.toLowerCase(Locale.ROOT);
return lower.startsWith("<mpd") ||
(lower.startsWith("<?xml") && lower.contains("<mpd"));
}
private static boolean isValidClearKeyHex(String value) {
return value != null && CLEAR_KEY_HEX_PATTERN.matcher(value).matches();
}
/**
@@ -327,4 +414,76 @@ public final class StreamUrlResolver {
this.encodedValue = encodedValue;
}
}
public static final class ResolvedStream {
private final String streamUrl;
private final String mimeType;
private final String clearKeyIdHex;
private final String clearKeyHex;
private ResolvedStream(String streamUrl,
String mimeType,
String clearKeyIdHex,
String clearKeyHex) {
this.streamUrl = streamUrl;
this.mimeType = mimeType;
this.clearKeyIdHex = clearKeyIdHex;
this.clearKeyHex = clearKeyHex;
}
public static ResolvedStream fromUrl(String streamUrl) {
String lower = streamUrl.toLowerCase(Locale.ROOT);
if (lower.contains(".mpd")) {
return dash(streamUrl);
}
if (lower.contains(".mp4")) {
return progressive(streamUrl, MimeTypes.VIDEO_MP4);
}
if (lower.contains(".ts")) {
return progressive(streamUrl, MimeTypes.VIDEO_MP2T);
}
return hls(streamUrl);
}
public static ResolvedStream hls(String streamUrl) {
return new ResolvedStream(streamUrl, MimeTypes.APPLICATION_M3U8, null, null);
}
public static ResolvedStream dash(String streamUrl) {
return new ResolvedStream(streamUrl, MimeTypes.APPLICATION_MPD, null, null);
}
public static ResolvedStream dashClearKey(String streamUrl,
String clearKeyIdHex,
String clearKeyHex) {
return new ResolvedStream(streamUrl,
MimeTypes.APPLICATION_MPD,
clearKeyIdHex,
clearKeyHex);
}
public static ResolvedStream progressive(String streamUrl, String mimeType) {
return new ResolvedStream(streamUrl, mimeType, null, null);
}
public String getStreamUrl() {
return streamUrl;
}
public String getMimeType() {
return mimeType;
}
public String getClearKeyIdHex() {
return clearKeyIdHex;
}
public String getClearKeyHex() {
return clearKeyHex;
}
public boolean hasClearKey() {
return clearKeyIdHex != null && clearKeyHex != null;
}
}
}