feat: migrar player a media3 y mejoras de resolución

This commit is contained in:
Renato
2026-03-02 18:25:49 -03:00
parent 1526766630
commit 9360294d22
24 changed files with 1679 additions and 295 deletions

View File

@@ -8,8 +8,8 @@ android {
applicationId "com.streamplayer"
minSdk 21
targetSdk 35
versionCode 100111
versionName "10.1.11"
versionCode 100200
versionName "11.0.0"
buildConfigField "String", "DEVICE_REGISTRY_URL", '"http://194.163.191.200:4000"'
}
@@ -47,16 +47,14 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
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-ui:1.4.1'
// Media3 para reproduccion de video (Android TV optimizado)
implementation 'androidx.media3:media3-exoplayer:1.5.0'
implementation 'androidx.media3:media3-exoplayer-hls:1.5.0'
implementation 'androidx.media3:media3-datasource-okhttp:1.5.0'
implementation 'androidx.media3:media3-ui:1.5.0'
implementation 'androidx.media3:media3-ui-leanback:1.5.0'
implementation 'androidx.media3:media3-session:1.5.0'
// VLC Player para reproduccion de video (soporte DRM mejorado)
implementation 'org.videolan.android:libvlc-all:3.4.4'
// OkHttp con DNS over HTTPS
// OkHttp con DNS over HTTPS (para StreamUrlResolver)
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'

View File

@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature
android:name="android.software.leanback"

View File

@@ -151,10 +151,9 @@ public class EventRepository {
if (link == null) {
return "";
}
// Actualizado a streamtp10.com
String updated = link.replace("streamtpmedia.com", "streamtp10.com")
.replace("streamtpcloud.com", "streamtp10.com");
return updated.replace("global1.php", "global2.php");
// Mantener el endpoint original (global1/global2) que entregue el proveedor.
return link.replace("streamtpmedia.com", "streamtp10.com")
.replace("streamtpcloud.com", "streamtp10.com");
}
private String extractChannelName(String link) {

View File

@@ -1,8 +1,8 @@
package com.streamplayer;
import java.security.cert.X509Certificate;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -16,19 +16,15 @@ import okhttp3.OkHttpClient;
import okhttp3.dnsoverhttps.DnsOverHttps;
/**
* Utilidad centralizada para configuración de red con múltiples servidores DNS over HTTPS.
* Implementa fallback progresivo: Google -> Cloudflare -> AdGuard -> Quad9 -> Sistema
* Utilidad centralizada para configuración de red.
* Fuerza DNS over HTTPS con fallback Google -> Cloudflare -> DNS del sistema.
*/
public class NetworkUtils {
private static final OkHttpClient CLIENT;
private static final String USER_AGENT = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
// URLs de servidores DNS over HTTPS
private static final String GOOGLE_DOH_URL = "https://dns.google/dns-query";
private static final String CLOUDFLARE_DOH_URL = "https://cloudflare-dns.com/dns-query";
private static final String ADGUARD_DOH_URL = "https://dns.adguard-dns.com/dns-query";
private static final String QUAD9_DOH_URL = "https://dns.quad9.net/dns-query";
static {
OkHttpClient.Builder builder = new OkHttpClient.Builder()
@@ -61,109 +57,60 @@ public class NetworkUtils {
builder.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0]);
builder.hostnameVerifier((hostname, session) -> true);
// Cliente bootstrap para resolver los dominios de DNS
OkHttpClient bootstrap = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0])
.hostnameVerifier((hostname, session) -> true)
.retryOnConnectionFailure(true)
.build();
// 1. Google DNS over HTTPS (Primario)
final DnsOverHttps googleDns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get(GOOGLE_DOH_URL))
.bootstrapDnsHosts(
getByIp("8.8.8.8"),
getByIp("8.8.4.4"))
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("8.8.4.4"))
.includeIPv6(false)
.build();
// 2. Cloudflare DNS over HTTPS (Secundario)
final DnsOverHttps cloudflareDns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get(CLOUDFLARE_DOH_URL))
.bootstrapDnsHosts(
getByIp("1.1.1.1"),
getByIp("1.0.0.1"))
InetAddress.getByName("1.1.1.1"),
InetAddress.getByName("1.0.0.1"))
.includeIPv6(false)
.build();
// 3. AdGuard DNS over HTTPS (Terciario)
final DnsOverHttps adGuardDns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get(ADGUARD_DOH_URL))
.bootstrapDnsHosts(
getByIp("94.140.14.14"),
getByIp("94.140.15.15"))
.includeIPv6(false)
.build();
// 4. Quad9 DNS over HTTPS (Cuaternario)
final DnsOverHttps quad9Dns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get(QUAD9_DOH_URL))
.bootstrapDnsHosts(
getByIp("9.9.9.9"),
getByIp("149.112.112.112"))
.includeIPv6(false)
.build();
// Configurar DNS con fallback: Google -> Cloudflare -> AdGuard -> Quad9 -> Sistema
builder.dns(new Dns() {
@Override
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
// Intento 1: Google DNS
try {
List<InetAddress> result = googleDns.lookup(hostname);
if (result != null && !result.isEmpty()) return result;
} catch (Exception ignored) {
// Falló Google, continuar
}
// Intento 2: Cloudflare DNS
try {
List<InetAddress> result = cloudflareDns.lookup(hostname);
if (result != null && !result.isEmpty()) return result;
} catch (Exception ignored) {
// Falló Cloudflare, continuar
}
// Intento 3: AdGuard DNS
try {
List<InetAddress> result = adGuardDns.lookup(hostname);
if (result != null && !result.isEmpty()) return result;
} catch (Exception ignored) {
// Falló AdGuard, continuar
}
// Intento 4: Quad9 DNS
try {
List<InetAddress> result = quad9Dns.lookup(hostname);
if (result != null && !result.isEmpty()) return result;
} catch (Exception ignored) {
// Falló Quad9, continuar
}
// Intento 5: DNS del Sistema (Fallback final)
try {
return Dns.SYSTEM.lookup(hostname);
} catch (UnknownHostException e) {
throw e;
builder.dns(hostname -> {
try {
List<InetAddress> result = googleDns.lookup(hostname);
if (result != null && !result.isEmpty()) {
return result;
}
} catch (Exception ignored) {
}
try {
List<InetAddress> result = cloudflareDns.lookup(hostname);
if (result != null && !result.isEmpty()) {
return result;
}
} catch (Exception ignored) {
}
return Dns.SYSTEM.lookup(hostname);
});
} catch (Exception e) {
// Si algo falla en la configuración DNS, usamos por defecto (implícito en el builder)
builder.dns(Dns.SYSTEM);
System.out.println("Error configurando DNS over HTTPS: " + e.getMessage());
}
CLIENT = builder.build();
}
private static InetAddress getByIp(String ip) throws UnknownHostException {
return InetAddress.getByName(ip);
}
public static OkHttpClient getClient() {
return CLIENT;
}

View File

@@ -1,8 +1,12 @@
package com.streamplayer;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.StrictMode;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;
@@ -10,42 +14,24 @@ import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.exoplayer.DefaultRenderersFactory;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.datasource.okhttp.OkHttpDataSource;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.datasource.DefaultHttpDataSource;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.hls.HlsMediaSource;
import androidx.media3.ui.PlayerView;
import androidx.media3.common.util.Util;
import androidx.annotation.OptIn;
import androidx.media3.common.util.UnstableApi;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.security.cert.X509Certificate;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.dnsoverhttps.DnsOverHttps;
@OptIn(markerClass = UnstableApi.class)
public class PlayerActivity extends AppCompatActivity {
public static final String EXTRA_CHANNEL_NAME = "extra_channel_name";
public static final String EXTRA_CHANNEL_URL = "extra_channel_url";
private static final String TAG = "PlayerActivity";
private static final long STARTUP_TIMEOUT_MS = 12000L;
private PlayerView playerView;
private ProgressBar loadingIndicator;
@@ -55,14 +41,18 @@ public class PlayerActivity extends AppCompatActivity {
private View playerToolbar;
private ExoPlayer player;
private DefaultTrackSelector trackSelector;
private String channelName;
private String channelUrl;
private boolean overlayVisible = true;
private OkHttpClient okHttpClient;
private int retryCount = 0;
private static final int MAX_RETRIES = 3;
private String lastStreamUrl;
private String currentChannelPageUrl;
private boolean playbackStarted = false;
private boolean alternateSourceAttempted = false;
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private Runnable startupTimeoutRunnable;
private final Object resolveLock = new Object();
private int resolveGeneration = 0;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -88,11 +78,11 @@ public class PlayerActivity extends AppCompatActivity {
finish();
return;
}
currentChannelPageUrl = channelUrl;
initViews();
channelLabel.setText(channelName);
// DNS configurado en StreamUrlResolver
loadChannel();
}
@@ -106,118 +96,261 @@ public class PlayerActivity extends AppCompatActivity {
closeButton.setOnClickListener(v -> finish());
playerView.setOnClickListener(v -> toggleOverlay());
playerView.setUseController(false);
}
private void loadChannel() {
showLoading(true);
retryCount = 0; // Resetear contador al cargar nuevo canal
retryCount = 0;
alternateSourceAttempted = false;
currentChannelPageUrl = channelUrl;
loadChannelFromPageUrl(channelUrl);
}
private void loadChannelFromPageUrl(String pageUrl) {
currentChannelPageUrl = pageUrl;
final int requestGeneration;
synchronized (resolveLock) {
resolveGeneration++;
requestGeneration = resolveGeneration;
}
Log.d(TAG, "Resolviendo stream desde: " + pageUrl + " (req=" + requestGeneration + ")");
new Thread(() -> {
try {
String resolvedUrl = StreamUrlResolver.resolve(channelUrl);
runOnUiThread(() -> startPlayback(resolvedUrl));
String resolvedUrl = StreamUrlResolver.resolve(pageUrl);
Log.d(TAG, "Stream resuelto: " + resolvedUrl + " (req=" + requestGeneration + ")");
runOnUiThread(() -> {
if (!isLatestResolveRequest(requestGeneration)) {
Log.d(TAG, "Ignorando resultado viejo (req=" + requestGeneration + ")");
return;
}
startPlayback(resolvedUrl);
});
} catch (IOException e) {
runOnUiThread(() -> showError("No se pudo conectar con el canal: " + e.getMessage()));
runOnUiThread(() -> {
if (!isLatestResolveRequest(requestGeneration)) {
return;
}
if (!tryAlternateSource("No se pudo conectar con el canal. " + e.getMessage())) {
showError("No se pudo conectar con el canal: " + e.getMessage());
}
});
} catch (Exception e) {
runOnUiThread(() -> showError("Error inesperado: " + e.getMessage()));
runOnUiThread(() -> {
if (!isLatestResolveRequest(requestGeneration)) {
return;
}
if (!tryAlternateSource("Error inesperado al resolver stream. " + e.getMessage())) {
showError("Error inesperado: " + e.getMessage());
}
});
}
}).start();
}
private boolean isLatestResolveRequest(int requestGeneration) {
synchronized (resolveLock) {
return requestGeneration == resolveGeneration;
}
}
private void startPlayback(String streamUrl) {
try {
releasePlayer();
lastStreamUrl = streamUrl; // Guardar URL para reintentos
retryCount = 0; // Resetear contador al iniciar nueva reproducción
DefaultRenderersFactory renderersFactory = new DefaultRenderersFactory(this)
.setEnableDecoderFallback(true)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON);
// Configurar track selector para calidad adaptativa (no forzar máxima calidad)
trackSelector = new DefaultTrackSelector(this);
DefaultTrackSelector.Parameters params = trackSelector.buildUponParameters()
.setForceHighestSupportedBitrate(false) // Permitir calidad adaptativa
.setMaxVideoBitrate(Integer.MAX_VALUE) // Sin límite máximo de bitrate
.build();
trackSelector.setParameters(params);
player = new ExoPlayer.Builder(this, renderersFactory)
.setTrackSelector(trackSelector)
.setSeekForwardIncrementMs(10_000)
.setSeekBackIncrementMs(10_000)
.build();
lastStreamUrl = streamUrl;
retryCount = 0;
playbackStarted = false;
scheduleStartupTimeout();
Log.d(TAG, "Iniciando reproducción: " + streamUrl);
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)));
player = new ExoPlayer.Builder(this).build();
playerView.setPlayer(player);
setupPlayerListener();
player.addListener(new Player.Listener() {
@Override
public void onPlaybackStateChanged(int playbackState) {
if (playbackState == Player.STATE_READY) {
showLoading(false);
retryCount = 0; // Resetear contador de reintentos al reproducir exitosamente
} else if (playbackState == Player.STATE_BUFFERING) {
showLoading(true);
}
}
player.setMediaSource(mediaSource);
player.prepare();
player.play();
setOverlayVisible(false);
} catch (Exception e) {
cancelStartupTimeout();
Log.e(TAG, "Error al iniciar reproducción", e);
if (!tryAlternateSource("Error al inicializar reproductor. Probando fuente alterna...")) {
showError("Error al inicializar reproductor: " + e.getMessage());
}
}
}
@Override
public void onPlayerError(PlaybackException error) {
String errorMsg = error.getMessage() != null ? error.getMessage() : "";
String detail = error.getCause() != null ?
error.getCause().getMessage() : "";
String fullError = errorMsg + " " + detail;
private void setupPlayerListener() {
if (player == null) {
return;
}
// Verificar si es un error que justifica reintento (404, conectividad, etc.)
boolean isRetryableError =
fullError.contains("404") ||
fullError.contains("403") ||
fullError.contains("timeout") ||
fullError.contains("Unable to connect") ||
fullError.contains("Network") ||
fullError.contains("source error") ||
error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ||
error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT ||
error.errorCode == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS;
if (isRetryableError && retryCount < MAX_RETRIES) {
retryCount++;
player.addListener(new Player.Listener() {
@Override
public void onPlaybackStateChanged(int playbackState) {
switch (playbackState) {
case Player.STATE_BUFFERING:
Log.d(TAG, "Exo Event: BUFFERING");
runOnUiThread(() -> showLoading(true));
break;
case Player.STATE_READY:
Log.d(TAG, "Exo Event: READY");
runOnUiThread(() -> {
showLoading(true);
showError("Error de conexión. Reintentando... (" + retryCount + "/" + MAX_RETRIES + ")");
playbackStarted = true;
cancelStartupTimeout();
showLoading(false);
retryCount = 0;
});
// Reintentar después de 2 segundos
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
if (lastStreamUrl != null) {
startPlayback(lastStreamUrl);
} else {
loadChannel();
}
}, 2000);
} else {
// Mostrar error final después de agotar reintentos
String finalMessage = "Error al reproducir: " + fullError;
if (retryCount >= MAX_RETRIES) {
finalMessage += "\n\nSe agotaron los reintentos (" + MAX_RETRIES + ").";
}
showError(finalMessage);
}
break;
case Player.STATE_ENDED:
Log.d(TAG, "Exo Event: ENDED");
runOnUiThread(() -> {
cancelStartupTimeout();
finish();
});
break;
default:
break;
}
}
@Override
public void onIsPlayingChanged(boolean isPlaying) {
Log.d(TAG, "Exo Event: isPlaying=" + isPlaying);
if (isPlaying) {
runOnUiThread(() -> {
playbackStarted = true;
cancelStartupTimeout();
showLoading(false);
});
}
}
@Override
public void onPlayerError(PlaybackException error) {
String message = error.getMessage() != null
? error.getMessage()
: "code=" + error.errorCode;
Log.e(TAG, "Exo Error: " + message, error);
runOnUiThread(() -> {
cancelStartupTimeout();
handlePlaybackError("Error de reproducción Exo: " + message);
});
}
});
}
private void handlePlaybackError(String errorMsg) {
if (tryAlternateSource("Falló la reproducción. " + errorMsg)) {
return;
}
String lower = errorMsg.toLowerCase();
boolean isRetryableError =
lower.contains("404") ||
lower.contains("403") ||
lower.contains("timeout") ||
lower.contains("network") ||
lower.contains("connection") ||
lower.contains("source");
if (isRetryableError && retryCount < VlcPlayerConfig.MAX_RETRIES) {
retryCount++;
runOnUiThread(() -> {
showLoading(true);
errorMessage.setVisibility(View.VISIBLE);
errorMessage.setText("Error de conexión. Reintentando... (" + retryCount + "/" + VlcPlayerConfig.MAX_RETRIES + ")");
});
MediaItem mediaItem = MediaItem.fromUri(streamUrl);
player.setMediaSource(buildMediaSource(mediaItem));
player.prepare();
player.setPlayWhenReady(true);
setOverlayVisible(false);
} catch (Exception e) {
showError("Error al inicializar reproductor: " + e.getMessage());
mainHandler.postDelayed(() -> {
if (lastStreamUrl != null) {
startPlayback(lastStreamUrl);
} else {
loadChannel();
}
}, 1500);
} else {
String finalMessage = "Error al reproducir: " + errorMsg;
if (retryCount >= VlcPlayerConfig.MAX_RETRIES) {
finalMessage += "\n\nSe agotaron los reintentos (" + VlcPlayerConfig.MAX_RETRIES + ").";
}
showError(finalMessage);
}
}
private void scheduleStartupTimeout() {
cancelStartupTimeout();
startupTimeoutRunnable = () -> {
if (!playbackStarted) {
Log.w(TAG, "Timeout de inicio de reproducción");
if (!tryAlternateSource("El canal no inició a tiempo. Probando fuente alterna...")) {
handlePlaybackError("Timeout al iniciar stream");
}
}
};
mainHandler.postDelayed(startupTimeoutRunnable, STARTUP_TIMEOUT_MS);
}
private void cancelStartupTimeout() {
if (startupTimeoutRunnable != null) {
mainHandler.removeCallbacks(startupTimeoutRunnable);
startupTimeoutRunnable = null;
}
}
private boolean tryAlternateSource(String reason) {
if (alternateSourceAttempted) {
return false;
}
String alternateUrl = buildAlternateGlobalUrl(currentChannelPageUrl);
if (alternateUrl == null || alternateUrl.equals(currentChannelPageUrl)) {
return false;
}
alternateSourceAttempted = true;
Log.w(TAG, "Probando fuente alterna: " + alternateUrl + " | motivo: " + reason);
showLoading(true);
errorMessage.setVisibility(View.VISIBLE);
errorMessage.setText("Problema con la fuente actual.\nProbando fuente alterna...");
loadChannelFromPageUrl(alternateUrl);
return true;
}
private String buildAlternateGlobalUrl(String url) {
if (url == null || url.isEmpty()) {
return null;
}
if (url.contains("global2.php")) {
return url.replace("global2.php", "global1.php");
}
if (url.contains("global1.php")) {
return url.replace("global1.php", "global2.php");
}
return null;
}
private void showLoading(boolean show) {
loadingIndicator.setVisibility(show ? View.VISIBLE : View.GONE);
errorMessage.setVisibility(View.GONE);
playerView.setVisibility(show ? View.GONE : View.VISIBLE);
playerView.setVisibility(View.VISIBLE);
if (show) {
setOverlayVisible(true);
}
@@ -232,85 +365,20 @@ public class PlayerActivity extends AppCompatActivity {
}
private void releasePlayer() {
cancelStartupTimeout();
playbackStarted = false;
if (player != null) {
player.release();
player = null;
}
}
private MediaSource buildMediaSource(MediaItem mediaItem) {
Map<String, String> headers = new HashMap<>();
headers.put("Referer", channelUrl);
headers.put("Origin", "http://streamtp10.com");
headers.put("Accept", "*/*");
headers.put("Connection", "keep-alive");
String userAgent = Util.getUserAgent(this, "StreamPlayer");
OkHttpDataSource.Factory factory = new OkHttpDataSource.Factory(provideOkHttpClient())
.setUserAgent(userAgent)
.setDefaultRequestProperties(headers);
return new HlsMediaSource.Factory(factory).createMediaSource(mediaItem);
}
private OkHttpClient provideOkHttpClient() {
if (okHttpClient != null) {
return okHttpClient;
}
try {
final TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[]{};
}
}
};
final SSLContext sslContext = SSLContext.getInstance("SSL");
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
OkHttpClient.Builder builder = new OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0])
.hostnameVerifier((hostname, session) -> true);
OkHttpClient bootstrap = builder.build();
DnsOverHttps dohDns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get("https://dns.google/dns-query"))
.bootstrapDnsHosts(
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("8.8.4.4"))
.build();
okHttpClient = builder.dns(dohDns).build();
} catch (Exception e) {
okHttpClient = new OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build();
}
return okHttpClient;
playerView.setPlayer(null);
}
@Override
protected void onStart() {
super.onStart();
if (player != null) {
playerView.onResume();
} else if (channelUrl != null) {
loadChannel();
if (player != null && !player.isPlaying()) {
player.play();
}
}
@@ -318,7 +386,7 @@ public class PlayerActivity extends AppCompatActivity {
protected void onResume() {
super.onResume();
if (player != null) {
playerView.onResume();
player.play();
}
}
@@ -326,14 +394,14 @@ public class PlayerActivity extends AppCompatActivity {
protected void onPause() {
super.onPause();
if (player != null) {
playerView.onPause();
player.pause();
}
}
@Override
protected void onStop() {
super.onStop();
releasePlayer();
// Keep player for quick resume.
}
@Override

View File

@@ -1,8 +1,17 @@
package com.streamplayer;
import android.util.Base64;
import java.io.IOException;
import java.net.InetAddress;
import java.security.cert.X509Certificate;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -11,6 +20,7 @@ import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import okhttp3.Dns;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
@@ -19,16 +29,52 @@ import okhttp3.dnsoverhttps.DnsOverHttps;
/**
* Resuelve la URL real del stream extrayendo playbackURL de la página.
* Utiliza DNS de Google para evitar bloqueos.
* Utiliza DNS over HTTPS (Google + Cloudflare) para evitar bloqueos.
* Soporta múltiples formatos de páginas y streams directos.
* Soporta JWPlayer con DRM ClearKey.
*/
public final class StreamUrlResolver {
// Patrón para extraer la URL del stream directamente
private static final Pattern PLAYBACK_URL_PATTERN =
// Patrón original para streamtp10.com
private static final Pattern PLAYBACK_URL_PATTERN =
Pattern.compile("var\\s+playbackURL\\s*=\\s*[\"']([^\"']+)[\"']");
// Patrón para source src en tags video
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 de stream en comillas dobles o simples
private static final Pattern STREAM_URL_PATTERN =
Pattern.compile("['\"](https?://[^'\"<>\\s]+?\\.(?:m3u8|mp4|ts)[^'\"<>\\s]*)['\"]");
// Patrón para file: o url: en JavaScript
private static final Pattern JS_URL_PATTERN =
Pattern.compile("(?:file|url|stream|source)\\s*[:=]\\s*[\"'](https?://[^\"']+)[\"']",
Pattern.CASE_INSENSITIVE);
// Patrón para JWPlayer sources con "file": "url"
private static final Pattern JWPLAYER_FILE_PATTERN =
Pattern.compile("\"file\"\\s*:\\s*\"([^\"]+\\.m3u8[^\"]*)\"");
// Patrón para pares [indice, "base64"] del nuevo playbackURL ofuscado
private static final Pattern OBFUSCATED_PAIR_PATTERN =
Pattern.compile("\\[(\\d+)\\s*,\\s*[\"']([^\"']+)[\"']\\]");
// Patrón para k = fn1() + fn2()
private static final Pattern OBFUSCATED_K_PATTERN =
Pattern.compile("var\\s+k\\s*=\\s*([A-Za-z_$][\\w$]*)\\(\\)\\s*\\+\\s*([A-Za-z_$][\\w$]*)\\(\\)");
// Patrón para function fn() { return 12345; }
private static final Pattern JS_RETURN_NUMBER_FUNCTION_PATTERN =
Pattern.compile("function\\s+([A-Za-z_$][\\w$]*)\\s*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*;?\\s*\\}",
Pattern.DOTALL);
private static final String USER_AGENT = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
private static final OkHttpClient CLIENT;
static {
@@ -58,18 +104,49 @@ public final class StreamUrlResolver {
.hostnameVerifier((hostname, session) -> true);
OkHttpClient bootstrap = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0])
.hostnameVerifier((hostname, session) -> true)
.build();
DnsOverHttps dns = new DnsOverHttps.Builder()
final DnsOverHttps googleDns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get("https://dns.google/dns-query"))
.bootstrapDnsHosts(
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("8.8.4.4"))
.includeIPv6(false)
.build();
builder.dns(dns);
final DnsOverHttps cloudflareDns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get("https://cloudflare-dns.com/dns-query"))
.bootstrapDnsHosts(
InetAddress.getByName("1.1.1.1"),
InetAddress.getByName("1.0.0.1"))
.includeIPv6(false)
.build();
builder.dns(hostname -> {
try {
List<InetAddress> result = googleDns.lookup(hostname);
if (result != null && !result.isEmpty()) {
return result;
}
} catch (Exception ignored) {
}
try {
List<InetAddress> result = cloudflareDns.lookup(hostname);
if (result != null && !result.isEmpty()) {
return result;
}
} catch (Exception ignored) {
}
return Dns.SYSTEM.lookup(hostname);
});
client = builder.build();
} catch (Exception e) {
@@ -77,6 +154,7 @@ public final class StreamUrlResolver {
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.followRedirects(true)
.dns(Dns.SYSTEM)
.build();
}
CLIENT = client;
@@ -86,20 +164,251 @@ public final class StreamUrlResolver {
}
public static String resolve(String pageUrl) throws IOException {
String html = downloadPage(pageUrl);
// Buscar playbackURL directamente en el HTML
Matcher matcher = PLAYBACK_URL_PATTERN.matcher(html);
if (matcher.find()) {
String url = matcher.group(1);
if (url != null && !url.isEmpty() && url.startsWith("http")) {
return url;
}
// Primero verificar si la URL ya parece ser un stream directo
if (isDirectStreamUrl(pageUrl)) {
return pageUrl;
}
String html = downloadPage(pageUrl);
// Si el contenido ya parece ser un stream M3U8, retornarlo directamente
if (html.startsWith("#EXTM3U") || html.startsWith("#EXT")) {
return pageUrl;
}
// Intentar múltiples patrones de extracción
String streamUrl = null;
// 1. Patrón original: var playbackURL = "..."
streamUrl = extractWithPattern(html, PLAYBACK_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 2. Patrón: <source src="...">
streamUrl = extractWithPattern(html, VIDEO_SOURCE_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 3. Patrón: URLs M3U8 directas
streamUrl = extractWithPattern(html, M3U8_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 4. Patrón: URLs de stream en comillas
streamUrl = extractWithPattern(html, STREAM_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 5. Patrón: JavaScript file: / url: / stream:
streamUrl = extractWithPattern(html, JS_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 6. Patrón: JWPlayer "file": "url.m3u8" (para Disney+ y otros)
streamUrl = extractWithPattern(html, JWPLAYER_FILE_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 7. Nuevo formato ofuscado: playbackURL generado con atob + fromCharCode
streamUrl = decodeObfuscatedPlaybackUrl(html);
if (isValidStreamUrl(streamUrl)) {
return 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;
}
// Último recurso: si la URL viene de sudamericaplay.com o similares,
// intentar usarla directamente
if (pageUrl.contains("sudamericaplay.com") ||
pageUrl.contains("disney") ||
pageUrl.contains("paramount")) {
return pageUrl;
}
// Si no encontramos la URL, mostrar un fragmento del HTML para debug
String preview = html.length() > 500 ? html.substring(0, 500) : html;
throw new IOException("No se encontró la URL del stream en la página. Vista previa: " + preview);
throw new IOException("No se encontró la URL del stream en la página. URL: " + pageUrl + ". Vista previa: " + preview);
}
/**
* Decodifica páginas donde playbackURL se arma carácter por carácter con:
* playbackURL += String.fromCharCode(parseInt(atob(v).replace(/\D/g,'')) - k)
*/
private static String decodeObfuscatedPlaybackUrl(String html) {
if (html == null ||
!html.contains("var playbackURL") ||
!html.contains("playbackURL+=") ||
!html.contains("String.fromCharCode") ||
!html.contains("atob(")) {
return null;
}
int scriptStart = html.indexOf("var playbackURL");
if (scriptStart < 0) {
return null;
}
int scriptEnd = html.indexOf("var p2pConfig", scriptStart);
if (scriptEnd < 0) {
scriptEnd = html.indexOf("</script>", scriptStart);
}
if (scriptEnd < 0 || scriptEnd <= scriptStart) {
scriptEnd = Math.min(html.length(), scriptStart + 20000);
}
String script = html.substring(scriptStart, scriptEnd);
Matcher kMatcher = OBFUSCATED_K_PATTERN.matcher(script);
if (!kMatcher.find()) {
return null;
}
String functionA = kMatcher.group(1);
String functionB = kMatcher.group(2);
Map<String, Long> functionValues = new HashMap<>();
Matcher functionMatcher = JS_RETURN_NUMBER_FUNCTION_PATTERN.matcher(script);
while (functionMatcher.find()) {
try {
functionValues.put(functionMatcher.group(1), Long.parseLong(functionMatcher.group(2)));
} catch (NumberFormatException ignored) {
}
}
Long valueA = functionValues.get(functionA);
Long valueB = functionValues.get(functionB);
if (valueA == null || valueB == null) {
return null;
}
long k = valueA + valueB;
List<EncodedPair> pairs = new ArrayList<>();
Matcher pairMatcher = OBFUSCATED_PAIR_PATTERN.matcher(script);
while (pairMatcher.find()) {
try {
int index = Integer.parseInt(pairMatcher.group(1));
pairs.add(new EncodedPair(index, pairMatcher.group(2)));
} catch (NumberFormatException ignored) {
}
}
if (pairs.isEmpty()) {
return null;
}
Collections.sort(pairs, Comparator.comparingInt(pair -> pair.index));
StringBuilder decoded = new StringBuilder(pairs.size());
for (EncodedPair pair : pairs) {
byte[] decodedBytes;
try {
decodedBytes = Base64.decode(pair.encodedValue, Base64.DEFAULT);
} catch (IllegalArgumentException e) {
continue;
}
String decodedText = new String(decodedBytes, StandardCharsets.UTF_8);
String digits = decodedText.replaceAll("\\D", "");
if (digits.isEmpty()) {
continue;
}
long numericValue;
try {
numericValue = Long.parseLong(digits);
} catch (NumberFormatException e) {
continue;
}
long charCode = numericValue - k;
if (charCode < 0 || charCode > Character.MAX_VALUE) {
return null;
}
decoded.append((char) charCode);
}
if (decoded.length() == 0) {
return null;
}
String url = decoded.toString().trim()
.replace("\\/", "/")
.replace("\\u0026", "&")
.replace("\\u002F", "/");
return isValidStreamUrl(url) ? url : null;
}
/**
* Verifica si una URL parece ser un stream directo (M3U8, MP4, etc.)
*/
private static boolean isDirectStreamUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
}
String lower = url.toLowerCase();
return lower.contains(".m3u8") ||
lower.contains(".mpd") ||
lower.contains("stream") && lower.contains(".php") == false ||
lower.endsWith(".mp4") ||
lower.endsWith(".ts");
}
/**
* Verifica si una URL extraída es válida
*/
private static boolean isValidStreamUrl(String url) {
return url != null && !url.isEmpty() && url.startsWith("http");
}
/**
* Extrae la primera coincidencia de un patrón regex
*/
private static String extractWithPattern(String html, Pattern pattern) {
Matcher matcher = pattern.matcher(html);
if (matcher.find()) {
String url = matcher.group(1);
// Limpiar URL de caracteres basura
if (url != null) {
url = url.trim();
// Remover caracteres especiales al final
url = url.replaceAll("[\"'<>\\s].*$", "");
}
return url;
}
return null;
}
/**
* Intenta determinar el tipo de contenido del HTML
*/
private static String getContentTypeFromHtml(String html) {
if (html == null || html.isEmpty()) {
return "unknown";
}
String trimmed = html.trim();
if (trimmed.startsWith("#EXTM3U") || trimmed.startsWith("#EXT")) {
return "m3u8";
}
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return "json";
}
if (trimmed.startsWith("<")) {
return "html";
}
return "text";
}
private static String downloadPage(String pageUrl) throws IOException {
@@ -122,4 +431,14 @@ public final class StreamUrlResolver {
return response.body().string();
}
}
private static final class EncodedPair {
final int index;
final String encodedValue;
EncodedPair(int index, String encodedValue) {
this.index = index;
this.encodedValue = encodedValue;
}
}
}

View File

@@ -0,0 +1,66 @@
package com.streamplayer;
import org.videolan.libvlc.Media;
import java.util.Map;
/**
* Handles DRM configuration for VLC Media Player
* Supports ClearKey DRM for Disney+ and other streaming services
*/
public class VlcDrmManager {
// ClearKey DRM configuration
private static final String CLEARKEY_KEY_SYSTEM = "org.w3.clearkey";
/**
* Configure ClearKey DRM for a Media object
* @param media The VLC Media object
* @param keyId The key ID (extracted from manifest or license server)
* @param key The content key
*/
public static void configureClearKey(Media media, String keyId, String key) {
if (media == null || keyId == null || key == null) {
return;
}
// VLC uses a specific format for ClearKey
// Format: keyid:key
String keyPair = keyId + ":" + key;
media.addOption(":demux=avformat");
media.addOption(":key-format=" + CLEARKEY_KEY_SYSTEM);
media.addOption(":key=" + keyPair);
}
/**
* Configure ClearKey DRM with multiple keys
* @param media The VLC Media object
* @param keys Map of keyId -> key pairs
*/
public static void configureClearKey(Media media, Map<String, String> keys) {
if (media == null || keys == null || keys.isEmpty()) {
return;
}
media.addOption(":demux=avformat");
media.addOption(":key-format=" + CLEARKEY_KEY_SYSTEM);
for (Map.Entry<String, String> entry : keys.entrySet()) {
String keyPair = entry.getKey() + ":" + entry.getValue();
media.addOption(":key=" + keyPair);
}
}
/**
* Configure Widevine DRM (for reference, if needed later)
* VLC supports Widevine via specific options
*/
public static void configureWidevine(Media media, String drmServerUrl) {
if (media == null || drmServerUrl == null) {
return;
}
media.addOption(":drm=widevine");
media.addOption(":aes-key=" + drmServerUrl);
}
}

View File

@@ -0,0 +1,29 @@
package com.streamplayer;
/**
* Configuration constants for VLC Player
*/
public class VlcPlayerConfig {
// Network caching (ms)
public static final int NETWORK_CACHING = 1500;
// Live streaming caching (ms)
public static final int LIVE_CACHING = 5000;
// User Agent
public static final String USER_AGENT =
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
// Hardware acceleration
public static final String HW_ACCELERATION = "automatic";
// Chroma format
public static final String CHROMA = "RV32";
// Audio output
public static final String AUDIO_OUTPUT = "opensles";
// Maximum retries for playback
public static final int MAX_RETRIES = 3;
}

View File

@@ -11,8 +11,10 @@
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:resize_mode="fill"
app:use_controller="true" />
android:keepScreenOn="true"
app:show_buffering="never"
app:surface_type="surface_view"
app:use_controller="false" />
<LinearLayout
android:id="@+id/player_toolbar"