30 Commits

Author SHA1 Message Date
Renato
3ca31c70b3 fix: support streamtp event playback on tv 2026-03-11 15:58:03 -03:00
Renato
49ed737663 refactor: remove dashboard lock flow and clean non-critical files 2026-03-02 18:56:32 -03:00
Renato
7cdf5534b4 chore: limpiar archivos basura y dependencias versionadas 2026-03-02 18:48:36 -03:00
Renato
dfb9a3e1b0 refactor: optimizar app sin cambios funcionales y publicar v11.0.1 2026-03-02 18:44:42 -03:00
Renato
e14e454c5e chore: eliminar contenido y referencias de disney 2026-03-02 18:29:03 -03:00
Renato
9360294d22 feat: migrar player a media3 y mejoras de resolución 2026-03-02 18:25:49 -03:00
Renato
1526766630 v10.1.11: Aceptar todos los certificados SSL para streams con redirección 2026-02-19 16:59:53 -03:00
Renato
4e92ee6149 v10.1.10: Cambiar a HTTP para evitar errores de certificado 2026-02-19 16:54:04 -03:00
Renato
cff9658060 v10.1.12: Revertir a código v10.1.7 funcional con dominio streamtp10.com
- PlayerActivity: Referer vuelve a ser channelUrl (URL específica del canal)
- StreamUrlResolver: Vuelve a crear su propio cliente con Google DNS
- Eliminados cambios problemáticos de SSL trust-all y múltiples DNS
- Mantenidos solo los cambios necesarios: streamtp10.com en lugar de streamtpcloud.com

Esto debería hacer que los streams vuelvan a funcionar como en v10.1.7
2026-02-15 19:18:01 -03:00
Renato
43439e0a88 v10.1.11: Fix mensajes de error - eliminar HTML crudo que mostraba símbolos extraños
- StreamUrlResolver ya no muestra HTML crudo en mensajes de error
- Agregada validación para detectar respuestas comprimidas/binarias
- Mejorados mensajes de error para ser más claros y sin caracteres raros
- Agregados más patrones de extracción de URLs
- Eliminado Accept-Encoding para evitar respuestas comprimidas
2026-02-15 19:08:52 -03:00
Renato
98473e3b30 v10.1.10: Fix reproducción de streams con SSL MITM y mejoras DNS
- Corregido PlayerActivity para usar NetworkUtils con 4 servidores DNS
- Agregado soporte para certificados SSL no válidos (evita MITM de ISP)
- Actualizados headers Referer y Origin a streamtp10.com
- Mejorado StreamUrlResolver con múltiples patrones de extracción
- Aumentados timeouts de red a 20-30 segundos
- Agregado manejo de errores 401/403/404 específicos
2026-02-15 18:57:54 -03:00
Renato
a4e8deb45a Fix DNS issues: Add 4 DoH servers with fallback, remove ineffective DNSSetter
- Remove DNSSetter.java (System.setProperty doesn't affect Android DNS)
- Update NetworkUtils with 4 DNS over HTTPS providers:
  * Google DNS (8.8.8.8) - Primary
  * Cloudflare (1.1.1.1) - Secondary
  * AdGuard (94.140.14.14) - Tertiary
  * Quad9 (9.9.9.9) - Quaternary
- Update DeviceRegistry to use NetworkUtils client
- Update UpdateManager to use NetworkUtils client
- Remove DNSSetter call from PlayerActivity

This ensures the app works even when ISPs block specific DNS servers.
2026-02-12 21:53:44 -03:00
StreamPlayer Bot
a9da5a3b8e fix: Update domains to streamtp10.com and implement robust DNS fallback
- Update all channel URLs and event endpoint to streamtp10.com
- Create NetworkUtils for centralized OkHttpClient configuration
- Implement DNS fallback: Google (Primary) -> AdGuard (Secondary) -> System (Tertiary)
- Migrate EventRepository to use NetworkUtils client instead of HttpURLConnection
- Fix Referer header in StreamUrlResolver
2026-02-09 22:37:20 -03:00
StreamPlayer Bot
ab69fd1aa4 feat: Add persistent scrollbar to events list
- Enable fadeScrollbars=false in RecyclerView
- Improve visibility of scrollbar

fix: Prevent navigation focus escape at end of list

- Implement custom LinearLayoutManager to intercept focus search
- Block FOCUS_DOWN action at the last item
- Remove legacy OnKeyListener and OnScrollListener
2026-02-09 22:05:54 -03:00
Apple
907c97464b fix: v10.1.6 - control remoto DPAD_DOWN y barra scroll visible
Problemas corregidos:

1. Control Remoto - Navegación fuera de eventos
   - Problema: Botón abajo del control remoto iba a canales en último evento
   - Solución: Agregado setOnKeyListener interceptando KEYCODE_DPAD_DOWN
   - Combina scroll listener táctil + manejo de teclas de control remoto
   - Import agregado: android.view.KeyEvent

2. Barra de Scroll Más Visible
   - Thumb: Blanco sólido #FFFFFFFF (antes 80% opacidad)
   - Ancho: 12dp (antes 8dp)
   - Radio: 6dp (antes 4dp)
   - Track oscuro agregado: #1A1A1A
   - scrollbarAlwaysDrawVerticalTrack="true"

Archivos modificados:
- MainActivity.java (OnKeyListener + import KeyEvent)
- scrollbar_vertical.xml (blanco sólido, 12dp)
- activity_main.xml (scrollbarSize, track, alwaysDraw)
- colors.xml (scrollbar_track)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:53:23 -03:00
Apple
19c31ebf1b fix: v10.1.5 - corregir scroll listener, barra visible y URL única
Problemas corregidos:

1. Scroll Listener Corregido (último evento cortado)
   - Cambiado de findFirstVisibleItemPosition() a findLastCompletelyVisibleItemPosition()
   - Ahora el scroll solo se detiene cuando el último elemento está completamente visible
   - Antes: el último evento aparecía solo a la mitad

2. Barra de Scroll Más Visible
   - Opacidad aumentada de #4DFFFFFF (30%) a #CCFFFFFF (80%)
   - Ancho de barra: 8dp (antes no definido)
   - Estilo cambiado de outsideOverlay a insideInset
   - scrollbarFadeDuration="0" para siempre visible
   - Radio de esquinas: 4dp (antes 2dp)

3. URL Única (eliminar bloqueos de ISP)
   - Eliminado sistema de fallback múltiples URLs
   - Ahora usa solo: https://streamtp10.com/eventos.json
   - Eliminado KEY_WORKING_URL y lógica de fallback
   - Código más simple y eficiente

Archivos modificados:
- app/src/main/java/com/streamplayer/EventRepository.java (simplificado)
- app/src/main/java/com/streamplayer/MainActivity.java (scroll fix)
- app/src/main/res/drawable/scrollbar_vertical.xml (más visible)
- app/src/main/res/layout/activity_main.xml (scrollbar config)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:45:49 -03:00
Apple
97adc46509 feat: mejoras de interfaz v10.1.4 - botón refresh visible, límite de scroll y barra indicadora
Cambios implementados:

1. Botón de Actualización Más Visible (para control remoto)
   - Nuevo archivo: btn_refresh_selector.xml con estados de foco
   - Color ámbar brillante (#FFC107) cuando está enfocado
   - Borde grueso (4dp) para mejor visibilidad

2. Prevención de Navegación Entre Secciones
   - Modificado: MainActivity.java showEvents()
   - Agregado OnScrollListener que detiene scroll al final de eventos
   - Previene paso accidental a sección de canales

3. Barra de Indicador de Scroll
   - Nuevo archivo: scrollbar_vertical.xml (drawable)
   - Modificado: activity_main.xml con atributos de scrollbar
   - Barra visual derecha como indicador de posición

Archivos modificados:
- app/src/main/java/com/streamplayer/MainActivity.java
- app/src/main/res/layout/activity_main.xml
- app/src/main/res/values/colors.xml

Archivos nuevos:
- app/src/main/res/drawable/btn_refresh_selector.xml
- app/src/main/res/drawable/scrollbar_vertical.xml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:33:09 -03:00
Apple
ec360cf303 fix: implementar sistema de fallback y redirecciones HTTP para carga de eventos
- Agregar sistema de fallback con múltiples URLs (streamtpcloud.com, streamtp10.com, streamtpmedia.com)
- Implementar seguimiento automático de redirecciones HTTP (301, 302, 303, 307, 308)
- Guardar última URL exitosa en SharedPreferences para optimizar futuras peticiones
- Corregir error "Unable to resolve host 'streamtpcloud.com'" cuando el dominio cambia

Resuelve issue donde los eventos no cargaban debido a cambios en el dominio del servidor.
La app ahora se adapta automáticamente sin necesidad de actualización.
2026-02-09 21:14:35 -03:00
renato97
3c1a323b35 🎉 v10.1.2: Reproductor robusto con calidad adaptativa
 Mejoras principales:
- Reproductor más robusto: reintenta automáticamente en errores 404
- Calidad adaptativa: eliminado forzamiento de 1080p
- Sistema de reintentos: 3 intentos automáticos con delays
- Timeouts mejorados: 20s connect, 30s read
- Manejo granular de errores recuperables

📱 Cambios técnicos:
- setForceHighestSupportedBitrate(false) - calidad adaptativa
- Configuración de reintentos (MAX_RETRIES = 3)
- Detección de errores 404, 403, timeout, network
- Feedback visual durante reintentos
- Timeouts incrementados para conexiones lentas

🔧 Build:
- Incrementado versionCode: 100101 → 100102
- Incrementado versionName: 10.1.1 → 10.1.2
- APK compilado y subido a Gitea

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 21:26:58 +01:00
renato97
e34323c2da Feature: boton para actualizar eventos manualmente (v10.1.1) 2026-01-27 02:50:03 +01:00
renato97
dc5f6484b2 Migración de ExoPlayer 2.x a Media3 1.5.0 (v10.1.0)
## Cambios realizados
- Migración completa de ExoPlayer 2.x a AndroidX Media3 1.5.0
- Actualización de dependencias: media3-exoplayer, media3-ui, media3-session, etc.
- Actualización de imports en PlayerActivity.java
- Actualización del namespace de PlayerView en activity_player.xml
- Incremento de versionCode a 100100 y versionName a 10.1.0
- Actualización de compileSdk y targetSdk a 35 para compatibilidad
- Soporte mejorado para Android TV/Leanback
- Preparación para MediaSession integrado

## Testing
- Compilación exitosa sin errores
- APK generado: StreamPlayer-v10.1.0-Media3-debug.apk

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 22:54:10 +01:00
renato97
305e1362a6 Feature: forzar máxima calidad de video (v10.0.7) 2026-01-26 22:25:00 +01:00
renato97
e9773c1353 Fix: ajuste de horarios +2 horas para Argentina (v10.0.6) 2026-01-26 22:20:25 +01:00
renato97
5bd1a2737d Feature: Enable in-app updates for private repository
- Added Gitea API token authentication to UpdateManager
- Now can check releases from private repository
- Bumped version to 10.0.5
2026-01-26 22:08:51 +01:00
renato97
e3aafd3290 Fix: Updated StreamUrlResolver for new page structure
- Page no longer uses obfuscated JavaScript
- playbackURL is now directly in HTML
- Simplified extraction using regex
- Bumped version to 10.0.4
2026-01-26 22:02:43 +01:00
renato97
b6612c4544 Fix: Bypass regional blocks using Google DNS (DoH)
- Updated StreamUrlResolver to use OkHttp with Google DoH
- Updated PlayerActivity to use Google DoH (8.8.8.8)
- Bumped version to 10.0.3
2026-01-26 21:59:05 +01:00
renato97
df296d7172 Update: Use new domain streamtpcloud.com for events and streams
- Updated EventRepository to point to streamtpcloud.com/eventos.json
- Updated ChannelRepository URLs to streamtpcloud.com
- Updated PlayerActivity Origin header
- Bumped version to 10.0.2
2026-01-26 21:53:56 +01:00
renato97
bac564eb4f Fix: Crash on HTML response in EventRepository and others
- Fixed: Value <! DOCTYPE cannot be converted to JSONArray in EventRepository
- Fixed: Added HTML validation in UpdateManager and DeviceRegistry
- Fixed: Improved HTTP error handling in StreamUrlResolver
- Improved: Error messages in PlayerActivity
- Bumped version to 9.4.3
2026-01-26 21:48:02 +01:00
ren
05625ffe50 Update manifest with v10.0 download URL 2026-01-12 00:36:26 +01:00
ren
c40448b997 Update version to v10.0 2026-01-12 00:34:59 +01:00
108 changed files with 5329 additions and 2842 deletions

2
.env
View File

@@ -1,4 +1,4 @@
GITEA_TOKEN=7921aa22187b39125d29399d26f527ba26a2fb5b
GITEA_TOKEN=efeed2af00597883adb04da70bd6a7c2993ae92d
GEMINI_API_KEY=AIzaSyDWOgyAJqscuPU6iSpS6gxupWBm4soNw5o
TELEGRAM_BOT_TOKEN=8593525164:AAGCX9B_RJGN35_F7tSB72rEZhS_4Zpcszs
TELEGRAM_CHAT_ID=692714536

11
.gitignore vendored
View File

@@ -129,14 +129,3 @@ lint/tmp/
app/release/
app/debug/
*.apk
# Dashboard local files
dashboard/node_modules/
dashboard/server.log
dashboard/config.json
# Windows desktop project artifacts
windows/StreamPlayer.Desktop/.vs/
windows/StreamPlayer.Desktop/bin/
windows/StreamPlayer.Desktop/obj/
windows/StreamPlayer.Desktop/ResolverTest/

1
.idea/.name generated
View File

@@ -1 +0,0 @@
StreamPlayer

6
.idea/compiler.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
</selectionStates>
</component>
</project>

7
.idea/misc.xml generated
View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

6
.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

57
Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
FROM eclipse-temurin:17-jdk
# Evitar interactividad durante la instalación
ENV DEBIAN_FRONTEND=noninteractive
# Instalar dependencias necesarias para Android SDK
RUN apt-get update && apt-get install -y \
wget \
unzip \
git \
python3 \
python3-pip \
ncurses-bin \
build-essential \
lib32z1 \
lib32ncurses6 \
lib32stdc++6 \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Instalar Android SDK
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV SDKMANAGER="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
RUN mkdir -p $ANDROID_SDK_ROOT/cmdline-tools && \
wget -q https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -O tools.zip && \
unzip -q tools.zip && \
mv cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools/latest && \
rm tools.zip
# Aceptar licencias
RUN yes | $SDKMANAGER --licenses
# Instalar componentes necesarios
RUN $SDKMANAGER "platform-tools" "platforms;android-33" "build-tools;33.0.2" "platforms;android-31"
# Instalar Gradle
ENV GRADLE_HOME=/opt/gradle
RUN wget -q https://services.gradle.org/distributions/gradle-8.2-bin.zip -O gradle.zip && \
unzip -q gradle.zip && \
mv gradle-8.2 $GRADLE_HOME && \
rm gradle.zip
ENV PATH=$PATH:$GRADLE_HOME/bin:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools
# Copiar proyecto
COPY . /app
WORKDIR /app
# Dar permisos de ejecución a gradlew
RUN chmod +x ./gradlew
# Construir APK
RUN ./gradlew assembleRelease
# Comando para copiar APK a un volumen montado
CMD ["cp", "/app/app/build/outputs/apk/release/app-release.apk", "/output/StreamPlayer-v10.0.apk"]

282
README.md
View File

@@ -1,54 +1,258 @@
## StreamPlayer Desktop (Windows)
# 📺 StreamPlayer
Este branch contiene únicamente la versión de escritorio desarrollada con **.NET 8 + Avalonia**. Replica todas las funciones del APK Android original (resolución de streams ofuscados, verificación remota de dispositivos, control de actualizaciones y reproducción protegida) pero genera un `.exe` listo para Windows.
[![Android](https://img.shields.io/badge/Platform-Android-green.svg)](https://android.com)
[![Java](https://img.shields.io/badge/Language-Java-orange.svg)](https://www.oracle.com/java/)
[![API](https://img.shields.io/badge/Min%20SDK-21%2B-brightgreen.svg)](https://android-developers.blogspot.com/)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
### Estructura
Aplicación Android reproductora de streaming con optimización DNS para mejor rendimiento y acceso a contenido multimedia.
```
windows/StreamPlayer.Desktop/
├── App.axaml / App.axaml.cs
├── Program.cs
├── AppVersion.cs
├── Models/ # ChannelSection, LiveEvent, UpdateInfo, etc.
├── Services/ # StreamUrlResolver, UpdateService, DeviceRegistryService, WindowsDnsService…
├── ViewModels/ # MainWindowViewModel con bloqueo previo y refrescos
├── Views/ # MainWindow, PlayerWindow (LibVLC), diálogos de update/bloqueo
└── StreamPlayer.Desktop.csproj
```
## 🌟 Características
### Requisitos
- **▶️ Reproducción Streaming**: Reproductor de video streaming optimizado con ExoPlayer
- **🌐 Optimización DNS**: Configuración automática de DNS de Google (8.8.8.8, 8.8.4.4) para mejor conectividad
- **🔍 Resolución de URL**: Sistema avanzado que resuelve URLs ofuscadas de streaming
- **📱 Orientación Landscape**: Diseño optimizado para experiencia multimedia inmersiva
- **⚡ Alto Rendimiento**: Implementación asíncrona para respuesta rápida
- **🛡️ Manejo de Errores**: Sistema robusto de gestión de errores y estados
- .NET SDK 8.0
- Windows 10/11 x64
- Visual Studio 2022 (o `dotnet` CLI) con workloads “.NET desktop”.
- VLC runtimes incluidos vía `VideoLAN.LibVLC.Windows`.
## 📋 Requisitos
### Cómo compilar
- **Android SDK**: API 21 (Android 5.0) o superior
- **Target SDK**: API 33 (Android 13)
- **Permisos**:
- `INTERNET` - Acceso a streaming
- `ACCESS_NETWORK_STATE` - Verificación de conectividad
- `CHANGE_NETWORK_STATE` - Configuración de red
## 🏗️ Arquitectura
### Componentes Principales
- **MainActivity.java** (`/app/src/main/java/com/streamplayer/MainActivity.java`)
- Gestión del ciclo de vida del reproductor
- Configuración de ExoPlayer
- Manejo de estados (loading, error, reproducción)
- **StreamUrlResolver.java** (`/app/src/main/java/com/streamplayer/StreamUrlResolver.java`)
- Resolución de URLs ofuscadas
- Decodificación Base64
- Extracción de claves de JavaScript
- **DNSSetter.java** (`/app/src/main/java/com/streamplayer/DNSSetter.java`)
- Configuración de DNS de Google
- Optimización de red para streaming
- Pre-resolución de dominios
## 🚀 Instalación y Build
### Prerequisites
```bash
git checkout windows-only
cd windows/StreamPlayer.Desktop
dotnet restore
dotnet build -c Release
# Para distribuir:
dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true
# Android SDK
# Java 8+
# Gradle 8.2+
```
El `.exe` resultante queda en `windows/StreamPlayer.Desktop/bin/Release/net8.0/win-x64/publish/`.
### Build con Gradle
```bash
# Clone el repositorio
git clone https://gitea.cbcren.online/renato97/app.git
cd app
### Características clave
# Build APK debug
./gradlew assembleDebug
- **Resolución de stream**: `Services/StreamUrlResolver.cs` analiza el JavaScript ofuscado y reconstruye el HLS real (idéntico al app móvil).
- **Reproducción**: `Views/PlayerWindow` usa LibVLC con los mismos headers/User-Agent del APK para evitar bloqueos de origen.
- **Verificación remota**: `DeviceRegistryService` sincroniza con tu dashboard y bloquea toda la UI hasta que el servidor permita el dispositivo.
- **Actualizaciones forzadas**: `UpdateService` consulta las releases de Gitea y puede abrir el browser o descargar la nueva versión.
- **DNS de Google**: `WindowsDnsService` fuerza 8.8.8.8 / 8.8.4.4 solicitando elevación (UAC); si el usuario rechaza, se muestra el mensaje para configurar manualmente antes de iniciar el player.
# Build APK release
./gradlew assembleRelease
```
### Uso
### Build con Docker
```bash
# Construir imagen
docker build -t streamplayer .
1. Compila o publica el `.exe`.
2. Ejecuta una vez como administrador para que el cambio de DNS quede aplicado.
3. Configura tus endpoints (`AppVersion.DeviceRegistryUrl`, `LatestReleaseApi`) si necesitas apuntar a otros servicios.
4. Distribuye el `.exe` y sube releases/manifest igual que con el APK.
# Ejecutar build
docker run --rm -v $(pwd)/output:/output streamplayer
```
Este branch **no** incluye ningún archivo Android; es solo el código fuente de la versión Windows. Para la versión móvil sigue usando `main`.
### Build Script Alternativo
```bash
# Usar script de build
chmod +x build_apk.sh
./build_apk.sh
```
## 🔄 Control de Instalaciones y Actualizaciones
StreamPlayer ahora consulta automáticamente las releases públicas del repositorio Gitea y puede forzar o sugerir actualizaciones directamente desde la app. Para aprovecharlo:
1. Ejecuta un build nuevo incrementando `versionCode`/`versionName` en `app/build.gradle`.
2. Crea una release en Gitea asignando un tag como `v9.1` y sube el APK.
3. Adjunta un archivo `update-manifest.json` en la misma release (puedes partir de `release/update-manifest.example.json`).
### Formato de `update-manifest.json`
```json
{
"versionCode": 91000,
"versionName": "9.1.0",
"minSupportedVersionCode": 90000,
"forceUpdate": false,
"downloadUrl": "https://gitea.cbcren.online/renato97/app/releases/download/v9.1/StreamPlayer-v9.1.apk",
"fileName": "StreamPlayer-v9.1.apk",
"sizeBytes": 12345678,
"notes": "Novedades destacadas que aparecerán en el diálogo dentro de la app"
}
```
- `versionCode` / `versionName`: deben coincidir con el APK publicado.
- `minSupportedVersionCode`: define desde qué versión mínima se permite seguir usando la app (ideal para bloquear instalaciones antiguas).
- `forceUpdate`: si es `true` la app mostrará un diálogo sin opción de omitir.
- `downloadUrl` / `fileName`: apuntan al asset `.apk` publicado en Gitea (puede ser el enlace de descarga directo).
- `notes`: texto libre mostrado en el diálogo dentro de la app; si lo omitís se usará el cuerpo de la release.
Si por algún motivo olvidas subir el manifiesto, la app igualmente tomará el primer asset `.apk` de la release, pero no podrá forzar versiones mínimas.
### Flujo dentro de la app
- Cada vez que se abre `MainActivity` se consulta `https://gitea.cbcren.online/api/v1/repos/renato97/app/releases/latest`.
- Si `versionCode` del servidor es mayor al instalado se muestra un diálogo para actualizar; el usuario puede abrir la release o descargarla directamente.
- Si `minSupportedVersionCode` es mayor al instalado la app bloqueará el uso hasta actualizar, cumpliendo con el requerimiento de controlar instalaciones.
- La descarga se gestiona con `DownloadManager` y, una vez completada, se lanza el instalador usando FileProvider.
## 📱 Estructura del Proyecto
```
app/
├── src/main/
│ ├── java/com/streamplayer/
│ │ ├── MainActivity.java # Actividad principal
│ │ ├── StreamUrlResolver.java # Resolvedor de URLs
│ │ └── DNSSetter.java # Configuración DNS
│ ├── res/
│ │ ├── layout/
│ │ │ └── activity_main.xml # UI principal
│ │ ├── mipmap-*/ # Íconos de la app
│ │ ├── values/
│ │ │ ├── strings.xml # Cadenas de texto
│ │ │ ├── colors.xml # Colores
│ │ │ └── themes.xml # Temas
│ │ └── xml/ # Configuraciones
│ └── AndroidManifest.xml # Manifiesto Android
├── build.gradle # Configuración Gradle
└── proguard-rules.pro # Reglas ProGuard
```
## ⚙️ Configuración
### URL de Streaming
La aplicación está configurada por defecto para:
```
https://streamtpmedia.com/global2.php?stream=espn
```
### Configuración DNS
```java
// DNS configurados automáticamente
String[] GOOGLE_DNS = {"8.8.8.8", "8.8.4.4"};
```
## 🔧 Dependencias Principales
- **ExoPlayer 2.18.7**: Motor de reproducción multimedia
- **AndroidX AppCompat 1.6.1**: Compatibilidad hacia atrás
- **ConstraintLayout 2.1.4**: Layout moderno y flexible
## 🛠️ Desarrollo
### Flujo de Reproducción
1. **MainActivity** inicializa y configura DNS de Google
2. **StreamUrlResolver** obtiene y decodifica la URL real del stream
3. **ExoPlayer** inicia la reproducción con la URL resuelta
4. UI actualiza estados (loading, playing, error)
### Características Técnicas
- **Threading**: Operaciones de red en background thread
- **Memory Management**: Proper lifecycle management de ExoPlayer
- **Error Handling**: Captura y display de errores al usuario
- **Network Optimization**: Configuración DNS específica para streaming
## 📊 Build Configuration
| Atributo | Valor |
|----------|-------|
| `applicationId` | `com.streamplayer` |
| `minSdk` | 21 |
| `targetSdk` | 33 |
| `versionCode` | 90000 |
| `versionName` | "9.0.0" |
| `compileSdk` | 33 |
## 🔐 Permisos y Seguridad
La aplicación requiere los siguientes permisos:
-`INTERNET` - Para streaming de contenido
-`ACCESS_NETWORK_STATE` - Para verificar conectividad
-`CHANGE_NETWORK_STATE` - Para optimización de red
## 🐛 Troubleshooting
### Problemas Comunes
**Error de Conexión**
- Verificar conexión a internet
- Confirmar configuración DNS
- Revisar disponibilidad del servicio de streaming
**Error de Reproducción**
- Validar formato de URL
- Verificar permisos de red
- Revisar logs de ExoPlayer
**Build Fail**
```bash
# Limpiar proyecto
./gradlew clean
# Rebuild
./gradlew build
```
## 📝 Logs y Debug
La aplicación incluye console logging para:
- Configuración DNS
- Resolución de URLs
- Estados del reproductor
- Errores de red
## 🤝 Contribución
1. Fork del repositorio
2. Feature branch (`git checkout -b feature/NuevaCaracteristica`)
3. Commit cambios (`git commit -m 'Add feature'`)
4. Push al branch (`git push origin feature/NuevaCaracteristica`)
5. Pull Request
## 📄 Licencia
Este proyecto está licenciado bajo la Licencia MIT - ver archivo [LICENSE](LICENSE) para detalles.
## 👨‍💻 Autor
**renato97** - [Gitea Profile](https://gitea.cbcren.online/renato97)
---
⚠️ **Disclaimer**: Esta aplicación es para fines educativos y de demostración. El usuario es responsable de cumplir con los términos de servicio de las plataformas de streaming utilizadas.
## 📞 Soporte
Para soporte y preguntas:
- 📧 Crear un issue en el repositorio
- 💬 Comentarios en el código
- 📱 Testing en dispositivos reales recomendado
---
**🔗 Repositorio**: https://gitea.cbcren.online/renato97/app

View File

1017
app/.idea/caches/deviceStreaming.xml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,7 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="gradleJvm" value="ms-11" />
</GradleProjectSettings>
</option>
</component>

10
app/.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

62
app/build.gradle Normal file
View File

@@ -0,0 +1,62 @@
apply plugin: 'com.android.application'
android {
namespace "com.streamplayer"
compileSdk 35
defaultConfig {
applicationId "com.streamplayer"
minSdk 21
targetSdk 35
versionCode 100201
versionName "11.0.1"
}
buildTypes {
release {
minifyEnabled false
signingConfig signingConfigs.debug
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
lint {
checkReleaseBuilds = false
abortOnError = false
}
buildFeatures {
buildConfig = true
}
packaging {
resources {
excludes += 'META-INF/com.android.tools/proguard/coroutines.pro'
}
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
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-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'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}

31
app/build_simple.gradle Normal file
View File

@@ -0,0 +1,31 @@
apply plugin: 'com.android.application'
android {
compileSdkVersion 28
buildToolsVersion "28.0.3"
defaultConfig {
applicationId "com.streamplayer"
minSdkVersion 21
targetSdkVersion 28
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation 'com.google.android.exoplayer:exoplayer:2.8.4'
implementation 'androidx.appcompat:appcompat:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.2'
}

24
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,24 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class com.google.android.exoplayer2.** { *; }
-keep class com.streamplayer.** { *; }

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permisos necesarios -->
<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"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:allowBackup="true"
android:banner="@drawable/banner_streamplayer"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.StreamPlayer"
android:usesCleartextTraffic="true">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".PlayerActivity"
android:exported="false"
android:screenOrientation="landscape" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,92 @@
package com.streamplayer;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
public class ChannelAdapter extends ListAdapter<StreamChannel, ChannelAdapter.ChannelViewHolder> {
public interface OnChannelClickListener {
void onChannelClick(StreamChannel channel);
}
private static final DiffUtil.ItemCallback<StreamChannel> DIFF_CALLBACK =
new DiffUtil.ItemCallback<StreamChannel>() {
@Override
public boolean areItemsTheSame(@NonNull StreamChannel oldItem, @NonNull StreamChannel newItem) {
return oldItem.getPageUrl().equals(newItem.getPageUrl());
}
@Override
public boolean areContentsTheSame(@NonNull StreamChannel oldItem, @NonNull StreamChannel newItem) {
return oldItem.getName().equals(newItem.getName())
&& oldItem.getPageUrl().equals(newItem.getPageUrl());
}
};
private final OnChannelClickListener listener;
public ChannelAdapter(OnChannelClickListener listener) {
super(DIFF_CALLBACK);
this.listener = listener;
}
@NonNull
@Override
public ChannelViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_channel, parent, false);
return new ChannelViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ChannelViewHolder holder, int position) {
StreamChannel channel = getItem(position);
holder.name.setText(channel.getName());
holder.icon.setImageResource(R.drawable.ic_channel_default);
holder.itemView.setOnClickListener(v -> {
if (listener != null) {
listener.onChannelClick(channel);
}
});
holder.itemView.setOnFocusChangeListener((v, hasFocus) -> {
float scale = hasFocus ? 1.08f : 1f;
v.animate().scaleX(scale).scaleY(scale).setDuration(120).start();
v.setSelected(hasFocus);
});
}
@Override
public int getItemCount() {
return super.getItemCount();
}
static class ChannelViewHolder extends RecyclerView.ViewHolder {
final ImageView icon;
final TextView name;
ChannelViewHolder(@NonNull View itemView) {
super(itemView);
icon = itemView.findViewById(R.id.channel_icon);
name = itemView.findViewById(R.id.channel_name);
}
}
public void submitList(List<StreamChannel> newChannels) {
if (newChannels == null) {
super.submitList(null);
return;
}
super.submitList(new ArrayList<>(newChannels));
}
}

View File

@@ -0,0 +1,99 @@
package com.streamplayer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public final class ChannelRepository {
private static final Comparator<StreamChannel> CHANNEL_NAME_COMPARATOR =
new Comparator<StreamChannel>() {
@Override
public int compare(StreamChannel left, StreamChannel right) {
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(
new StreamChannel("ESPN", "http://streamtp10.com/global2.php?stream=espn"),
new StreamChannel("ESPN 2", "http://streamtp10.com/global2.php?stream=espn2"),
new StreamChannel("ESPN 3", "http://streamtp10.com/global2.php?stream=espn3"),
new StreamChannel("ESPN 4", "http://streamtp10.com/global2.php?stream=espn4"),
new StreamChannel("ESPN 3 MX", "http://streamtp10.com/global2.php?stream=espn3mx"),
new StreamChannel("ESPN 5", "http://streamtp10.com/global2.php?stream=espn5"),
new StreamChannel("Fox Sports 3 MX", "http://streamtp10.com/global2.php?stream=foxsports3mx"),
new StreamChannel("ESPN 6", "http://streamtp10.com/global2.php?stream=espn6"),
new StreamChannel("Fox Sports MX", "http://streamtp10.com/global2.php?stream=foxsportsmx"),
new StreamChannel("ESPN 7", "http://streamtp10.com/global2.php?stream=espn7"),
new StreamChannel("Azteca Deportes", "http://streamtp10.com/global2.php?stream=azteca_deportes"),
new StreamChannel("Win Plus", "http://streamtp10.com/global2.php?stream=winplus"),
new StreamChannel("DAZN 1", "http://streamtp10.com/global2.php?stream=dazn1"),
new StreamChannel("Win Plus 2", "http://streamtp10.com/global2.php?stream=winplus2"),
new StreamChannel("DAZN 2", "http://streamtp10.com/global2.php?stream=dazn2"),
new StreamChannel("Win Sports", "http://streamtp10.com/global2.php?stream=winsports"),
new StreamChannel("DAZN LaLiga", "http://streamtp10.com/global2.php?stream=dazn_laliga"),
new StreamChannel("Win Plus Online 1", "http://streamtp10.com/global2.php?stream=winplusonline1"),
new StreamChannel("Caracol TV", "http://streamtp10.com/global2.php?stream=caracoltv"),
new StreamChannel("Fox 1 AR", "http://streamtp10.com/global2.php?stream=fox1ar"),
new StreamChannel("Fox 2 USA", "http://streamtp10.com/global2.php?stream=fox_2_usa"),
new StreamChannel("Fox 2 AR", "http://streamtp10.com/global2.php?stream=fox2ar"),
new StreamChannel("TNT 1 GB", "http://streamtp10.com/global2.php?stream=tnt_1_gb"),
new StreamChannel("TNT 2 GB", "http://streamtp10.com/global2.php?stream=tnt_2_gb"),
new StreamChannel("Fox 3 AR", "http://streamtp10.com/global2.php?stream=fox3ar"),
new StreamChannel("Universo USA", "http://streamtp10.com/global2.php?stream=universo_usa"),
new StreamChannel("DSports", "http://streamtp10.com/global2.php?stream=dsports"),
new StreamChannel("Univision USA", "http://streamtp10.com/global2.php?stream=univision_usa"),
new StreamChannel("DSports 2", "http://streamtp10.com/global2.php?stream=dsports2"),
new StreamChannel("Fox Deportes USA", "http://streamtp10.com/global2.php?stream=fox_deportes_usa"),
new StreamChannel("DSports Plus", "http://streamtp10.com/global2.php?stream=dsportsplus"),
new StreamChannel("Fox Sports 2 MX", "http://streamtp10.com/global2.php?stream=foxsports2mx"),
new StreamChannel("TNT Sports Chile", "http://streamtp10.com/global2.php?stream=tntsportschile"),
new StreamChannel("Fox Sports Premium", "http://streamtp10.com/global2.php?stream=foxsportspremium"),
new StreamChannel("TNT Sports", "http://streamtp10.com/global2.php?stream=tntsports"),
new StreamChannel("ESPN MX", "http://streamtp10.com/global2.php?stream=espnmx"),
new StreamChannel("ESPN Premium", "http://streamtp10.com/global2.php?stream=espnpremium"),
new StreamChannel("ESPN 2 MX", "http://streamtp10.com/global2.php?stream=espn2mx"),
new StreamChannel("TyC Sports", "http://streamtp10.com/global2.php?stream=tycsports"),
new StreamChannel("TUDN USA", "http://streamtp10.com/global2.php?stream=tudn_usa"),
new StreamChannel("Telefe", "http://streamtp10.com/global2.php?stream=telefe"),
new StreamChannel("TNT 3 GB", "http://streamtp10.com/global2.php?stream=tnt_3_gb"),
new StreamChannel("TV Pública", "http://streamtp10.com/global2.php?stream=tv_publica"),
new StreamChannel("Fox 1 USA", "http://streamtp10.com/global2.php?stream=fox_1_usa"),
new StreamChannel("Liga 1 Max", "http://streamtp10.com/global2.php?stream=liga1max"),
new StreamChannel("Gol TV", "http://streamtp10.com/global2.php?stream=goltv"),
new StreamChannel("VTV Plus", "http://streamtp10.com/global2.php?stream=vtvplus"),
new StreamChannel("ESPN Deportes", "http://streamtp10.com/global2.php?stream=espndeportes"),
new StreamChannel("Gol Perú", "http://streamtp10.com/global2.php?stream=golperu"),
new StreamChannel("TNT 4 GB", "http://streamtp10.com/global2.php?stream=tnt_4_gb"),
new StreamChannel("SportTV BR 1", "http://streamtp10.com/global2.php?stream=sporttvbr1"),
new StreamChannel("SportTV BR 2", "http://streamtp10.com/global2.php?stream=sporttvbr2"),
new StreamChannel("SportTV BR 3", "http://streamtp10.com/global2.php?stream=sporttvbr3"),
new StreamChannel("Premiere 1", "http://streamtp10.com/global2.php?stream=premiere1"),
new StreamChannel("Premiere 2", "http://streamtp10.com/global2.php?stream=premiere2"),
new StreamChannel("Premiere 3", "http://streamtp10.com/global2.php?stream=premiere3"),
new StreamChannel("ESPN NL 1", "http://streamtp10.com/global2.php?stream=espn_nl1"),
new StreamChannel("ESPN NL 2", "http://streamtp10.com/global2.php?stream=espn_nl2"),
new StreamChannel("ESPN NL 3", "http://streamtp10.com/global2.php?stream=espn_nl3"),
new StreamChannel("Caliente TV MX", "http://streamtp10.com/global2.php?stream=calientetvmx"),
new StreamChannel("USA Network", "http://streamtp10.com/global2.php?stream=usa_network"),
new StreamChannel("TyC Internacional", "http://streamtp10.com/global2.php?stream=tycinternacional"),
new StreamChannel("Canal 5 MX", "http://streamtp10.com/global2.php?stream=canal5mx"),
new StreamChannel("TUDN MX", "http://streamtp10.com/global2.php?stream=TUDNMX"),
new StreamChannel("FUTV", "http://streamtp10.com/global2.php?stream=futv"),
new StreamChannel("LaLiga Hypermotion", "http://streamtp10.com/global2.php?stream=laligahypermotion")
));
Collections.sort(channels, CHANNEL_NAME_COMPARATOR);
return Collections.unmodifiableList(channels);
}
private ChannelRepository() {
}
public static List<StreamChannel> getChannels() {
return CHANNELS;
}
}

View File

@@ -0,0 +1,121 @@
package com.streamplayer;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class EventAdapter extends ListAdapter<EventItem, EventAdapter.EventViewHolder> {
public interface OnEventClickListener {
void onEventClick(EventItem event);
}
private static final DiffUtil.ItemCallback<EventItem> DIFF_CALLBACK =
new DiffUtil.ItemCallback<EventItem>() {
@Override
public boolean areItemsTheSame(@NonNull EventItem oldItem, @NonNull EventItem newItem) {
return oldItem.getPageUrl().equals(newItem.getPageUrl())
&& oldItem.getStartMillis() == newItem.getStartMillis();
}
@Override
public boolean areContentsTheSame(@NonNull EventItem oldItem, @NonNull EventItem newItem) {
return oldItem.getTitle().equals(newItem.getTitle())
&& oldItem.getTime().equals(newItem.getTime())
&& oldItem.getCategory().equals(newItem.getCategory())
&& oldItem.getStatus().equals(newItem.getStatus())
&& oldItem.getPageUrl().equals(newItem.getPageUrl())
&& oldItem.getChannelName().equals(newItem.getChannelName())
&& oldItem.getStartMillis() == newItem.getStartMillis();
}
};
private final OnEventClickListener listener;
public EventAdapter(OnEventClickListener listener) {
super(DIFF_CALLBACK);
this.listener = listener;
}
public void submitList(List<EventItem> newEvents) {
if (newEvents == null) {
super.submitList(null);
return;
}
super.submitList(new ArrayList<>(newEvents));
}
@NonNull
@Override
public EventViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_event, parent, false);
return new EventViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull EventViewHolder holder, int position) {
EventItem event = getItem(position);
holder.title.setText(event.getTitle());
holder.time.setText(event.getTime());
holder.channel.setText(event.getChannelName());
holder.status.setText(buildStatusText(event));
holder.itemView.setOnClickListener(v -> {
if (listener != null) {
listener.onEventClick(event);
}
});
}
@Override
public int getItemCount() {
return super.getItemCount();
}
static class EventViewHolder extends RecyclerView.ViewHolder {
final TextView title;
final TextView time;
final TextView channel;
final TextView status;
EventViewHolder(@NonNull View itemView) {
super(itemView);
title = itemView.findViewById(R.id.event_title);
time = itemView.findViewById(R.id.event_time);
channel = itemView.findViewById(R.id.event_channel);
status = itemView.findViewById(R.id.event_status);
}
}
private String buildStatusText(EventItem event) {
long start = event.getStartMillis();
if (start <= 0) {
return event.getStatus();
}
long now = System.currentTimeMillis();
long diff = start - now;
if (diff > 0) {
long hours = diff / 3600000;
long minutes = (diff % 3600000) / 60000;
if (hours > 0) {
return String.format(Locale.getDefault(), "En %dh %02dm", hours, minutes);
} else {
return String.format(Locale.getDefault(), "En %d min", Math.max(1, minutes));
}
} else if (Math.abs(diff) <= 2 * 3600000L) {
return "En vivo";
} else {
return "Finalizado";
}
}
}

View File

@@ -0,0 +1,49 @@
package com.streamplayer;
public class EventItem {
private final String title;
private final String time;
private final String category;
private final String status;
private final String pageUrl;
private final String channelName;
private final long startMillis;
public EventItem(String title, String time, String category, String status, String pageUrl, String channelName, long startMillis) {
this.title = title;
this.time = time;
this.category = category;
this.status = status;
this.pageUrl = pageUrl;
this.channelName = channelName;
this.startMillis = startMillis;
}
public String getTitle() {
return title;
}
public String getTime() {
return time;
}
public String getCategory() {
return category;
}
public String getStatus() {
return status;
}
public String getPageUrl() {
return pageUrl;
}
public String getChannelName() {
return channelName;
}
public long getStartMillis() {
return startMillis;
}
}

View File

@@ -0,0 +1,218 @@
package com.streamplayer;
import android.content.Context;
import android.content.SharedPreferences;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import okhttp3.Request;
import okhttp3.Response;
public class EventRepository {
private static final String PREFS_NAME = "events_cache";
private static final String KEY_JSON = "json";
private static final String KEY_TIMESTAMP = "timestamp";
private static final long CACHE_DURATION = 24L * 60 * 60 * 1000; // 24 horas
private static final String ARGENTINA_TIMEZONE_ID = "America/Argentina/Buenos_Aires";
private static final TimeZone ARGENTINA_TIMEZONE = TimeZone.getTimeZone(ARGENTINA_TIMEZONE_ID);
private static final int ARGENTINA_OFFSET_HOURS = 2;
private static final long EVENT_ROLLOVER_WINDOW_MS = 12L * 60 * 60 * 1000;
// URL única para eventos (actualizado para evitar bloqueos de ISP)
private static final String EVENTS_URL = "http://streamtp10.com/eventos.json";
public interface Callback {
void onSuccess(List<EventItem> events);
void onError(String message);
}
public void loadEvents(Context context, boolean forceRefresh, Callback callback) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
long last = prefs.getLong(KEY_TIMESTAMP, 0);
long now = System.currentTimeMillis();
if (!forceRefresh && now - last < CACHE_DURATION) {
String cachedJson = prefs.getString(KEY_JSON, null);
if (cachedJson != null) {
try {
callback.onSuccess(parseEvents(cachedJson));
return;
} catch (JSONException ignored) {
}
}
}
new Thread(() -> {
try {
String json = downloadJson(context);
List<EventItem> events = parseEvents(json);
prefs.edit().putString(KEY_JSON, json).putLong(KEY_TIMESTAMP, System.currentTimeMillis()).apply();
callback.onSuccess(events);
} catch (IOException | JSONException e) {
String cachedJson = prefs.getString(KEY_JSON, null);
if (cachedJson != null) {
try {
callback.onSuccess(parseEvents(cachedJson));
return;
} catch (JSONException ignored) {
}
}
callback.onError(e.getMessage() != null ? e.getMessage() : "Error desconocido");
}
}).start();
}
private String downloadJson(Context context) throws IOException {
Request request = new Request.Builder()
.url(EVENTS_URL)
.header("User-Agent", NetworkUtils.getUserAgent())
.header("Accept", "application/json")
.build();
try (Response response = NetworkUtils.getClient().newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("Error HTTP " + response.code() + ": " + response.message());
}
if (response.body() == null) {
throw new IOException("Respuesta vacía del servidor");
}
String responseBody = response.body().string();
// Validar que no sea HTML
if (responseBody.trim().startsWith("<!") || responseBody.trim().startsWith("<html")) {
throw new IOException("El servidor devolvió HTML en lugar de JSON. La URL del endpoint puede estar incorrecta o el servidor tiene problemas.");
}
return responseBody;
}
}
private List<EventItem> parseEvents(String json) throws JSONException {
if (json == null || json.trim().isEmpty()) {
throw new JSONException("La respuesta está vacía");
}
// Validar que no sea HTML antes de parsear
String trimmed = json.trim();
if (trimmed.startsWith("<!") || trimmed.startsWith("<html")) {
throw new JSONException("Se recibió HTML en lugar de JSON");
}
JSONArray array = new JSONArray(json);
List<EventItem> events = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject obj = array.getJSONObject(i);
String title = obj.optString("title");
String time = obj.optString("time");
String category = obj.optString("category");
String status = obj.optString("status");
String link = obj.optString("link");
String normalized = normalizeLink(link);
EventSchedule schedule = computeEventSchedule(time);
String displayTime = schedule.displayTime;
long startMillis = schedule.startMillis;
events.add(new EventItem(title, displayTime, category, status, normalized, extractChannelName(link), startMillis));
}
return Collections.unmodifiableList(events);
}
private String normalizeLink(String link) {
if (link == null) {
return "";
}
// 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) {
if (link == null) {
return "";
}
int idx = link.indexOf("stream=");
if (idx == -1) {
return "";
}
return link.substring(idx + 7).replace("_", " ").toUpperCase(Locale.ROOT);
}
private EventSchedule computeEventSchedule(String time) {
if (time == null || time.trim().isEmpty()) {
return new EventSchedule(time == null ? "" : time, -1L);
}
try {
Calendar adjustedTime = parseAdjustedTime(time);
String displayTime = formatTime(adjustedTime);
Calendar now = Calendar.getInstance(ARGENTINA_TIMEZONE, Locale.US);
Calendar start = Calendar.getInstance(ARGENTINA_TIMEZONE, Locale.US);
start.set(Calendar.YEAR, now.get(Calendar.YEAR));
start.set(Calendar.MONTH, now.get(Calendar.MONTH));
start.set(Calendar.DAY_OF_MONTH, now.get(Calendar.DAY_OF_MONTH));
start.set(Calendar.HOUR_OF_DAY, adjustedTime.get(Calendar.HOUR_OF_DAY));
start.set(Calendar.MINUTE, adjustedTime.get(Calendar.MINUTE));
start.set(Calendar.SECOND, 0);
start.set(Calendar.MILLISECOND, 0);
long nowMillis = now.getTimeInMillis();
long startMillis = start.getTimeInMillis();
if (startMillis < nowMillis - EVENT_ROLLOVER_WINDOW_MS) {
start.add(Calendar.DAY_OF_MONTH, 1);
startMillis = start.getTimeInMillis();
}
return new EventSchedule(displayTime, startMillis);
} catch (ParseException ignored) {
return new EventSchedule(time, -1L);
}
}
private Calendar parseAdjustedTime(String time) throws ParseException {
SimpleDateFormat parser = new SimpleDateFormat("HH:mm", Locale.US);
parser.setLenient(false);
parser.setTimeZone(ARGENTINA_TIMEZONE);
java.util.Date parsedDate = parser.parse(time.trim());
if (parsedDate == null) {
throw new ParseException("Hora inválida: " + time, 0);
}
Calendar adjusted = Calendar.getInstance(ARGENTINA_TIMEZONE, Locale.US);
adjusted.setTime(parsedDate);
adjusted.add(Calendar.HOUR_OF_DAY, ARGENTINA_OFFSET_HOURS);
return adjusted;
}
private String formatTime(Calendar calendar) {
SimpleDateFormat formatter = new SimpleDateFormat("HH:mm", Locale.US);
formatter.setTimeZone(ARGENTINA_TIMEZONE);
return formatter.format(calendar.getTime());
}
private static final class EventSchedule {
final String displayTime;
final long startMillis;
EventSchedule(String displayTime, long startMillis) {
this.displayTime = displayTime;
this.startMillis = startMillis;
}
}
}

View File

@@ -0,0 +1,418 @@
package com.streamplayer;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public class MainActivity extends AppCompatActivity {
private RecyclerView sectionList;
private RecyclerView contentList;
private ProgressBar loadingIndicator;
private TextView messageView;
private TextView contentTitle;
private Button refreshButton;
private ChannelAdapter channelAdapter;
private EventAdapter eventAdapter;
private EventRepository eventRepository;
private SectionAdapter sectionAdapter;
private GridLayoutManager channelLayoutManager;
private LinearLayoutManager eventLayoutManager;
private final List<EventItem> cachedEvents = new ArrayList<>();
private List<SectionEntry> sections;
private SectionEntry currentSection;
private UpdateManager updateManager;
private AlertDialog updateDialog;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
sectionList = findViewById(R.id.section_list);
contentList = findViewById(R.id.content_list);
loadingIndicator = findViewById(R.id.loading_indicator);
messageView = findViewById(R.id.message_view);
contentTitle = findViewById(R.id.content_title);
refreshButton = findViewById(R.id.refresh_button);
refreshButton.setOnClickListener(v -> {
loadEvents(true);
Toast.makeText(this, "Actualizando eventos...", Toast.LENGTH_SHORT).show();
});
channelAdapter = new ChannelAdapter(
channel -> openPlayer(channel.getName(), channel.getPageUrl()));
eventAdapter = new EventAdapter(event -> openPlayer(event.getTitle(), event.getPageUrl()));
eventRepository = new EventRepository();
channelLayoutManager = new GridLayoutManager(this, getSpanCount());
eventLayoutManager = new LinearLayoutManager(this) {
@Override
public View onInterceptFocusSearch(View focused, int direction) {
if (direction == View.FOCUS_DOWN) {
int pos = getPosition(focused);
if (pos == getItemCount() - 1) {
return focused;
}
}
return super.onInterceptFocusSearch(focused, direction);
}
};
sections = buildSections();
sectionList.setLayoutManager(new LinearLayoutManager(this));
sectionAdapter = new SectionAdapter(getSectionTitles(), this::selectSection);
sectionList.setAdapter(sectionAdapter);
selectSection(0);
updateManager = new UpdateManager(this);
updateManager.checkForUpdates(new UpdateManager.UpdateCallback() {
@Override
public void onUpdateAvailable(UpdateManager.UpdateInfo info) {
handleUpdateInfo(info);
}
@Override
public void onUpToDate() {
// Nothing to do.
}
@Override
public void onError(String message) {
Toast.makeText(MainActivity.this,
getString(R.string.update_error_checking, message),
Toast.LENGTH_SHORT).show();
}
});
}
@Override
protected void onResume() {
super.onResume();
if (updateManager != null) {
updateManager.resumePendingInstall(this);
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (updateDialog != null && updateDialog.isShowing()) {
updateDialog.dismiss();
}
if (updateManager != null) {
updateManager.release();
}
}
private void selectSection(int index) {
if (sections == null || sections.isEmpty()) {
return;
}
if (index < 0 || index >= sections.size()) {
index = 0;
}
sectionAdapter.setSelectedIndex(index);
currentSection = sections.get(index);
if (currentSection.type == SectionEntry.Type.EVENTS) {
showEvents();
} else {
showChannels(currentSection);
}
}
private void showChannels(SectionEntry section) {
contentTitle.setText(section.title);
refreshButton.setVisibility(View.GONE);
contentList.setLayoutManager(channelLayoutManager);
contentList.setAdapter(channelAdapter);
// Clear any scroll listeners from Events section
contentList.clearOnScrollListeners();
loadingIndicator.setVisibility(View.GONE);
channelAdapter.submitList(section.channels);
if (section.channels.isEmpty()) {
messageView.setVisibility(View.VISIBLE);
messageView.setText(R.string.message_no_channels);
} else {
messageView.setVisibility(View.GONE);
contentList.post(() -> contentList.scrollToPosition(0));
}
}
private void showEvents() {
contentTitle.setText(currentSection != null ? currentSection.title : getString(R.string.section_events));
refreshButton.setVisibility(View.VISIBLE);
contentList.setLayoutManager(eventLayoutManager);
contentList.setAdapter(eventAdapter);
// Clear existing listeners
contentList.clearOnScrollListeners();
if (cachedEvents.isEmpty()) {
loadEvents(false);
} else {
displayEvents();
}
}
private void loadEvents(boolean forceRefresh) {
loadingIndicator.setVisibility(View.VISIBLE);
messageView.setVisibility(View.GONE);
eventRepository.loadEvents(this, forceRefresh, new EventRepository.Callback() {
@Override
public void onSuccess(List<EventItem> events) {
runOnUiThread(() -> {
cachedEvents.clear();
cachedEvents.addAll(events);
if (currentSection != null && currentSection.type == SectionEntry.Type.EVENTS) {
displayEvents();
} else {
loadingIndicator.setVisibility(View.GONE);
}
});
}
@Override
public void onError(String message) {
runOnUiThread(() -> {
loadingIndicator.setVisibility(View.GONE);
messageView.setVisibility(View.VISIBLE);
messageView.setText(getString(R.string.message_events_error, message));
});
}
});
}
private void displayEvents() {
loadingIndicator.setVisibility(View.GONE);
if (cachedEvents.isEmpty()) {
messageView.setVisibility(View.VISIBLE);
messageView.setText(R.string.message_no_events);
eventAdapter.submitList(new ArrayList<>());
} else {
messageView.setVisibility(View.GONE);
eventAdapter.submitList(new ArrayList<>(cachedEvents));
}
}
private void openPlayer(String name, String pageUrl) {
Intent intent = new Intent(MainActivity.this, PlayerActivity.class);
intent.putExtra(PlayerActivity.EXTRA_CHANNEL_NAME, name);
intent.putExtra(PlayerActivity.EXTRA_CHANNEL_URL, pageUrl);
startActivity(intent);
}
private void handleUpdateInfo(UpdateManager.UpdateInfo info) {
if (info == null) {
return;
}
boolean forceUpdate = info.isMandatory(BuildConfig.VERSION_CODE);
showUpdateDialog(info, forceUpdate);
}
private void showUpdateDialog(UpdateManager.UpdateInfo info, boolean mandatory) {
if (isFinishing()) {
return;
}
if (updateDialog != null && updateDialog.isShowing()) {
updateDialog.dismiss();
}
AlertDialog.Builder builder = new AlertDialog.Builder(this)
.setTitle(mandatory ? R.string.update_required_title : R.string.update_available_title)
.setMessage(buildUpdateMessage(info))
.setPositiveButton(R.string.update_action_download,
(dialog, which) -> updateManager.downloadUpdate(MainActivity.this, info))
.setNeutralButton(R.string.update_action_view_release,
(dialog, which) -> openReleasePage(info));
if (mandatory) {
builder.setCancelable(false);
builder.setNegativeButton(R.string.update_action_close_app,
(dialog, which) -> finish());
} else {
builder.setNegativeButton(R.string.update_action_later, null);
}
updateDialog = builder.create();
updateDialog.setOnShowListener(dialog -> {
int actionColor = ContextCompat.getColor(this, R.color.refresh_button_focused);
Button positive = updateDialog.getButton(AlertDialog.BUTTON_POSITIVE);
Button neutral = updateDialog.getButton(AlertDialog.BUTTON_NEUTRAL);
Button negative = updateDialog.getButton(AlertDialog.BUTTON_NEGATIVE);
if (positive != null) {
positive.setTextColor(actionColor);
}
if (neutral != null) {
neutral.setTextColor(actionColor);
}
if (negative != null) {
negative.setTextColor(actionColor);
}
});
updateDialog.show();
}
private CharSequence buildUpdateMessage(UpdateManager.UpdateInfo info) {
StringBuilder builder = new StringBuilder();
builder.append(getString(R.string.update_current_version,
BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE));
builder.append('\n');
builder.append(getString(R.string.update_latest_version,
info.versionName, info.versionCode));
if (info.minSupportedVersionCode > 0) {
builder.append('\n').append(getString(R.string.update_min_supported,
info.minSupportedVersionCode));
}
String size = info.formatSize(this);
if (!size.isEmpty()) {
builder.append('\n').append(getString(R.string.update_download_size, size));
}
if (info.downloadCount > 0) {
builder.append('\n').append(getString(R.string.update_downloads,
info.downloadCount));
}
if (!info.releaseNotes.isEmpty()) {
builder.append("\n\n");
builder.append(getString(R.string.update_release_notes_title));
builder.append('\n');
builder.append(info.getReleaseNotesPreview());
}
if (!info.isMandatory(BuildConfig.VERSION_CODE)) {
builder.append("\n\n");
builder.append(getString(R.string.update_optional_hint));
}
return builder.toString();
}
private void openReleasePage(UpdateManager.UpdateInfo info) {
String url = info.releasePageUrl;
if (url == null || url.isEmpty()) {
url = info.downloadUrl;
}
if (url == null || url.isEmpty()) {
Toast.makeText(this, R.string.update_error_missing_url, Toast.LENGTH_SHORT).show();
return;
}
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
try {
startActivity(intent);
} catch (ActivityNotFoundException e) {
Toast.makeText(this, R.string.update_error_open_release, Toast.LENGTH_SHORT).show();
}
}
private int getSpanCount() {
return getResources().getInteger(R.integer.channel_grid_span);
}
private List<SectionEntry> buildSections() {
List<SectionEntry> list = new ArrayList<>();
list.add(SectionEntry.events(getString(R.string.section_events)));
Map<String, List<StreamChannel>> grouped = new HashMap<>();
List<StreamChannel> allChannels = ChannelRepository.getChannels();
for (StreamChannel channel : allChannels) {
String key = deriveGroupName(channel.getName());
List<StreamChannel> group = grouped.get(key);
if (group == null) {
group = new ArrayList<>();
grouped.put(key, group);
}
group.add(channel);
}
List<StreamChannel> espnChannels = grouped.remove("ESPN");
if (espnChannels != null && !espnChannels.isEmpty()) {
list.add(SectionEntry.channels("ESPN", espnChannels));
}
List<String> remaining = new ArrayList<>(grouped.keySet());
Collections.sort(remaining, String.CASE_INSENSITIVE_ORDER);
for (String key : remaining) {
List<StreamChannel> channels = grouped.get(key);
if (channels == null || channels.isEmpty()) {
continue;
}
list.add(SectionEntry.channels(key, channels));
}
list.add(SectionEntry.channels(getString(R.string.section_all_channels), allChannels));
return list;
}
private List<String> getSectionTitles() {
List<String> titles = new ArrayList<>();
for (SectionEntry entry : sections) {
titles.add(entry.title);
}
return titles;
}
private String deriveGroupName(String name) {
if (name == null) {
return getString(R.string.section_all_channels);
}
String upper = name.toUpperCase(Locale.US);
if (upper.startsWith("ESPN")) {
return "ESPN";
} else if (upper.contains("FOX SPORTS")) {
return "Fox Sports";
} else if (upper.contains("FOX")) {
return "Fox";
} else if (upper.contains("TNT")) {
return "TNT";
} else if (upper.contains("DAZN")) {
return "DAZN";
} else if (upper.contains("TUDN")) {
return "TUDN";
} else if (upper.contains("TYC")) {
return "TyC";
} else if (upper.contains("GOL")) {
return "Gol";
}
int spaceIndex = upper.indexOf(' ');
return spaceIndex > 0 ? upper.substring(0, spaceIndex) : upper;
}
private static class SectionEntry {
enum Type { EVENTS, CHANNELS }
final String title;
final Type type;
final List<StreamChannel> channels;
private SectionEntry(String title, Type type, List<StreamChannel> channels) {
this.title = title;
this.type = type;
this.channels = channels == null ? new ArrayList<>() : new ArrayList<>(channels);
}
static SectionEntry events(String title) {
return new SectionEntry(title, Type.EVENTS, null);
}
static SectionEntry channels(String title, List<StreamChannel> channels) {
return new SectionEntry(title, Type.CHANNELS, channels);
}
}
}

View File

@@ -0,0 +1,126 @@
package com.streamplayer;
import android.util.Log;
import java.net.InetAddress;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.concurrent.TimeUnit;
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.dnsoverhttps.DnsOverHttps;
/**
* Utilidad centralizada para configuración de red.
* Fuerza DNS over HTTPS con fallback Google -> Cloudflare -> DNS del sistema.
*/
public final class NetworkUtils {
private static final String TAG = "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";
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";
static {
OkHttpClient.Builder builder = new OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.retryOnConnectionFailure(true);
try {
// Configurar para aceptar todos los certificados SSL (útil para diagnosticar problemas de ISP)
// NOTA: Esto es temporal para diagnosticar si hay problemas de certificados MITM
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("TLS");
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
builder.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0]);
builder.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)
.retryOnConnectionFailure(true)
.build();
final DnsOverHttps googleDns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get(GOOGLE_DOH_URL))
.bootstrapDnsHosts(
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("8.8.4.4"))
.includeIPv6(false)
.build();
final DnsOverHttps cloudflareDns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get(CLOUDFLARE_DOH_URL))
.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);
});
} catch (Exception e) {
builder.dns(Dns.SYSTEM);
Log.w(TAG, "Error configurando DNS over HTTPS", e);
}
CLIENT = builder.build();
}
private NetworkUtils() {
}
public static OkHttpClient getClient() {
return CLIENT;
}
public static String getUserAgent() {
return USER_AGENT;
}
}

View File

@@ -0,0 +1,511 @@
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.util.Base64;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.ProgressBar;
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.HttpDataSource;
import androidx.media3.datasource.okhttp.OkHttpDataSource;
import androidx.media3.exoplayer.ExoPlayer;
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;
@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;
private TextView errorMessage;
private TextView channelLabel;
private Button closeButton;
private View playerToolbar;
private ExoPlayer player;
private String channelName;
private String channelUrl;
private boolean overlayVisible = true;
private int retryCount = 0;
private StreamUrlResolver.ResolvedStream lastResolvedStream;
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) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_player);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
Intent intent = getIntent();
if (intent == null) {
finish();
return;
}
channelName = intent.getStringExtra(EXTRA_CHANNEL_NAME);
channelUrl = intent.getStringExtra(EXTRA_CHANNEL_URL);
if (channelName == null || channelUrl == null) {
finish();
return;
}
currentChannelPageUrl = channelUrl;
initViews();
channelLabel.setText(channelName);
loadChannel();
}
private void initViews() {
playerView = findViewById(R.id.player_view);
loadingIndicator = findViewById(R.id.loading_indicator);
errorMessage = findViewById(R.id.error_message);
channelLabel = findViewById(R.id.player_channel_label);
closeButton = findViewById(R.id.close_button);
playerToolbar = findViewById(R.id.player_toolbar);
closeButton.setOnClickListener(v -> finish());
playerView.setOnClickListener(v -> toggleOverlay());
playerView.setUseController(false);
}
private void loadChannel() {
showLoading(true);
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 {
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(resolvedStream);
});
} catch (IOException e) {
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(() -> {
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(StreamUrlResolver.ResolvedStream resolvedStream) {
try {
releasePlayer();
lastResolvedStream = resolvedStream;
retryCount = 0;
playbackStarted = false;
scheduleStartupTimeout();
Log.d(TAG, "Iniciando reproducción: " + resolvedStream.getStreamUrl()
+ " | mime=" + resolvedStream.getMimeType()
+ " | drm=" + resolvedStream.hasClearKey());
MediaSource mediaSource = buildMediaSource(resolvedStream);
player = new ExoPlayer.Builder(this).build();
playerView.setPlayer(player);
setupPlayerListener();
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());
}
}
}
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;
}
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(() -> {
playbackStarted = true;
cancelStartupTimeout();
showLoading(false);
retryCount = 0;
});
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(Locale.ROOT);
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(getString(R.string.player_retrying, retryCount, VlcPlayerConfig.MAX_RETRIES));
});
mainHandler.postDelayed(() -> {
if (lastResolvedStream != null) {
startPlayback(lastResolvedStream);
} 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(View.VISIBLE);
if (show) {
setOverlayVisible(true);
}
}
private void showError(String message) {
loadingIndicator.setVisibility(View.GONE);
playerView.setVisibility(View.GONE);
errorMessage.setVisibility(View.VISIBLE);
errorMessage.setText(message);
setOverlayVisible(true);
}
private void releasePlayer() {
cancelStartupTimeout();
playbackStarted = false;
if (player != null) {
player.release();
player = null;
}
playerView.setPlayer(null);
}
@Override
protected void onStart() {
super.onStart();
if (player != null && !player.isPlaying()) {
player.play();
}
}
@Override
protected void onResume() {
super.onResume();
if (player != null) {
player.play();
}
}
@Override
protected void onPause() {
super.onPause();
if (player != null) {
player.pause();
}
}
@Override
protected void onStop() {
super.onStop();
// Keep player for quick resume.
}
@Override
protected void onDestroy() {
mainHandler.removeCallbacksAndMessages(null);
super.onDestroy();
releasePlayer();
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
private void toggleOverlay() {
setOverlayVisible(!overlayVisible);
}
private void setOverlayVisible(boolean visible) {
overlayVisible = visible;
playerToolbar.setVisibility(visible ? View.VISIBLE : View.GONE);
}
@Override
public void onBackPressed() {
if (!overlayVisible) {
setOverlayVisible(true);
} else {
super.onBackPressed();
}
}
}

View File

@@ -0,0 +1,88 @@
package com.streamplayer;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
public class SectionAdapter extends RecyclerView.Adapter<SectionAdapter.SectionViewHolder> {
public interface OnSectionSelectedListener {
void onSectionSelected(int position);
}
private final List<String> sections;
private final OnSectionSelectedListener listener;
private int selectedIndex = 0;
public SectionAdapter(List<String> sections, OnSectionSelectedListener listener) {
this.sections = sections;
this.listener = listener;
}
@NonNull
@Override
public SectionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_section, parent, false);
return new SectionViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull SectionViewHolder holder, int position) {
holder.title.setText(sections.get(position));
holder.itemView.setSelected(position == selectedIndex);
holder.itemView.setOnClickListener(v -> notifySelection(holder));
holder.itemView.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus) {
notifySelection(holder);
}
});
}
@Override
public int getItemCount() {
return sections.size();
}
public void setSelectedIndex(int index) {
if (index < 0 || index >= sections.size()) {
return;
}
if (selectedIndex == index) {
return;
}
int previous = selectedIndex;
selectedIndex = index;
notifyItemChanged(previous);
notifyItemChanged(selectedIndex);
}
public int getSelectedIndex() {
return selectedIndex;
}
private void notifySelection(SectionViewHolder holder) {
int position = holder.getBindingAdapterPosition();
if (position == RecyclerView.NO_POSITION) {
return;
}
if (listener != null) {
listener.onSectionSelected(position);
}
}
static class SectionViewHolder extends RecyclerView.ViewHolder {
final TextView title;
SectionViewHolder(@NonNull View itemView) {
super(itemView);
title = itemView.findViewById(R.id.section_title);
}
}
}

View File

@@ -0,0 +1,19 @@
package com.streamplayer;
public class StreamChannel {
private final String name;
private final String pageUrl;
public StreamChannel(String name, String pageUrl) {
this.name = name;
this.pageUrl = pageUrl;
}
public String getName() {
return name;
}
public String getPageUrl() {
return pageUrl;
}
}

View File

@@ -0,0 +1,489 @@
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;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import okhttp3.Request;
import okhttp3.Response;
/**
* Resuelve la URL real del stream extrayendo playbackURL de la página.
* Utiliza DNS over HTTPS (Google + Cloudflare) para evitar bloqueos.
* Soporta múltiples formatos de páginas y streams directos.
* Incluye fallback para páginas con JWPlayer y formatos ofuscados.
*/
public final class StreamUrlResolver {
// 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 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|mpd|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|mpd)[^\"]*)\"",
Pattern.CASE_INSENSITIVE);
// 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);
// 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 ResolvedStream resolve(String pageUrl) throws IOException {
// Primero verificar si la URL ya parece ser un stream directo
if (isDirectStreamUrl(pageUrl)) {
return ResolvedStream.fromUrl(pageUrl);
}
String html = downloadPage(pageUrl);
String trimmedHtml = html.trim();
// 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
String streamUrl = null;
// 1. Patrón original: var playbackURL = "..."
streamUrl = extractWithPattern(html, PLAYBACK_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return ResolvedStream.fromUrl(streamUrl);
}
// 2. Patrón: <source src="...">
streamUrl = extractWithPattern(html, VIDEO_SOURCE_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return ResolvedStream.fromUrl(streamUrl);
}
// 3. Patrón: URLs HLS/DASH directas
streamUrl = extractWithPattern(html, STREAM_MANIFEST_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return ResolvedStream.fromUrl(streamUrl);
}
// 4. Patrón: URLs de stream en comillas
streamUrl = extractWithPattern(html, STREAM_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return ResolvedStream.fromUrl(streamUrl);
}
// 5. Patrón: JavaScript file: / url: / stream:
streamUrl = extractWithPattern(html, JS_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return ResolvedStream.fromUrl(streamUrl);
}
// 6. Patrón: JWPlayer "file": "url" (para reproductores web y otros)
streamUrl = extractWithPattern(html, JWPLAYER_FILE_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return ResolvedStream.fromUrl(streamUrl);
}
// 7. Nuevo formato ofuscado: playbackURL generado con atob + fromCharCode
streamUrl = decodeObfuscatedPlaybackUrl(html);
if (isValidStreamUrl(streamUrl)) {
return ResolvedStream.fromUrl(streamUrl);
}
// 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 ResolvedStream.fromUrl(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. 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;
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()) {
try {
int index = Integer.parseInt(pairMatcher.group(1));
pairs.add(new EncodedPair(index, pairMatcher.group(2)));
} catch (NumberFormatException ignored) {
}
}
Collections.sort(pairs, new Comparator<EncodedPair>() {
@Override
public int compare(EncodedPair left, EncodedPair right) {
return Integer.compare(left.index, right.index);
}
});
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 {
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;
}
return decoded.toString().trim()
.replace("\\/", "/")
.replace("\\u0026", "&")
.replace("\\u002F", "/");
}
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();
}
/**
* 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(Locale.ROOT);
return lower.contains(".m3u8") ||
lower.contains(".mpd") ||
(lower.contains("stream") && !lower.contains(".php")) ||
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;
}
private static String downloadPage(String pageUrl) throws IOException {
Request request = new Request.Builder()
.url(pageUrl)
.header("User-Agent", NetworkUtils.getUserAgent())
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "es-ES,es;q=0.9,en;q=0.8")
.header("Referer", "http://streamtp10.com/")
.build();
try (Response response = NetworkUtils.getClient().newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("Error HTTP " + response.code() + " al cargar la página del stream");
}
if (response.body() == null) {
throw new IOException("Respuesta vacía del servidor");
}
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;
}
}
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;
}
}
}

View File

@@ -0,0 +1,549 @@
package com.streamplayer;
import android.app.Activity;
import android.app.DownloadManager;
import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.provider.Settings;
import android.text.TextUtils;
import android.text.format.Formatter;
import android.util.Log;
import android.widget.Toast;
import androidx.core.content.FileProvider;
import androidx.core.content.ContextCompat;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
/**
* Encapsula toda la lógica para consultar releases de Gitea, descargar el APK y lanzarlo.
*/
public class UpdateManager {
private static final String TAG = "UpdateManager";
private static final String LATEST_RELEASE_URL =
"https://gitea.cbcren.online/api/v1/repos/renato97/app/releases/latest";
private static final String GITEA_TOKEN = "4b94b3610136529861af0821040a801906821a0f";
private final Context appContext;
private final Handler mainHandler;
private final ExecutorService networkExecutor;
private final OkHttpClient httpClient;
private WeakReference<Activity> activityRef;
private DownloadReceiver downloadReceiver;
private long currentDownloadId = -1L;
private File downloadingFile;
private File pendingInstallFile;
private UpdateInfo cachedUpdate;
public UpdateManager(Context context) {
this.appContext = context.getApplicationContext();
this.mainHandler = new Handler(Looper.getMainLooper());
this.networkExecutor = Executors.newSingleThreadExecutor();
// Usar NetworkUtils para obtener cliente con DNS over HTTPS configurado
this.httpClient = NetworkUtils.getClient();
}
public void checkForUpdates(UpdateCallback callback) {
networkExecutor.execute(() -> {
try {
Request request = new Request.Builder()
.url(LATEST_RELEASE_URL)
.header("Authorization", "token " + GITEA_TOKEN)
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (response.body() == null) {
postError(callback, appContext.getString(R.string.update_error_empty_response));
return;
}
if (!response.isSuccessful()) {
postError(callback, appContext.getString(R.string.update_error_http,
response.code()));
return;
}
String body = response.body().string();
UpdateInfo info = parseRelease(body);
cachedUpdate = info;
if (info != null && info.isUpdateAvailable(BuildConfig.VERSION_CODE)) {
postAvailable(callback, info);
} else {
postUpToDate(callback);
}
}
} catch (IOException | JSONException e) {
Log.w(TAG, "Error checking updates", e);
postError(callback, e.getMessage());
}
});
}
public UpdateInfo getCachedUpdate() {
return cachedUpdate;
}
public void downloadUpdate(Activity activity, UpdateInfo info) {
if (info == null || TextUtils.isEmpty(info.downloadUrl)) {
showToast(appContext.getString(R.string.update_error_missing_url));
return;
}
DownloadManager downloadManager =
(DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
if (downloadManager == null) {
showToast(appContext.getString(R.string.update_error_download_manager));
return;
}
File targetDir = appContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
if (targetDir == null) {
targetDir = appContext.getExternalFilesDir(null);
}
if (targetDir == null) {
showToast(appContext.getString(R.string.update_error_storage));
return;
}
if (!targetDir.exists() && !targetDir.mkdirs()) {
showToast(appContext.getString(R.string.update_error_storage));
return;
}
String fileName = info.getResolvedFileName();
File apkFile = new File(targetDir, fileName);
if (apkFile.exists() && !apkFile.delete()) {
showToast(appContext.getString(R.string.update_error_storage));
return;
}
Uri destination = Uri.fromFile(apkFile);
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(info.downloadUrl));
request.setTitle(appContext.getString(R.string.update_notification_title, info.versionName));
request.setDescription(appContext.getString(R.string.update_notification_description));
request.setAllowedOverMetered(true);
request.setAllowedOverRoaming(false);
request.setNotificationVisibility(
DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
request.setDestinationUri(destination);
try {
currentDownloadId = downloadManager.enqueue(request);
} catch (IllegalArgumentException e) {
showToast(appContext.getString(R.string.update_error_download, e.getMessage()));
return;
}
downloadingFile = apkFile;
activityRef = new WeakReference<>(activity);
registerDownloadReceiver();
showToast(appContext.getString(R.string.update_download_started));
}
public void resumePendingInstall(Activity activity) {
if (pendingInstallFile == null || !pendingInstallFile.exists()) {
return;
}
installDownloadedApk(activity, pendingInstallFile);
}
public void release() {
unregisterDownloadReceiver();
networkExecutor.shutdownNow();
activityRef = null;
}
private UpdateInfo parseRelease(String responseBody) throws JSONException, IOException {
if (responseBody == null || responseBody.trim().isEmpty()) {
throw new JSONException("La respuesta está vacía");
}
// Validar que no sea HTML antes de parsear
String trimmed = responseBody.trim();
if (trimmed.startsWith("<!") || trimmed.startsWith("<html")) {
throw new JSONException("Se recibió HTML en lugar de JSON");
}
JSONObject releaseJson = new JSONObject(responseBody);
String tagName = releaseJson.optString("tag_name", "");
String versionName = deriveVersionName(tagName, releaseJson.optString("name"));
int versionCode = parseVersionCode(versionName);
String releaseNotes = releaseJson.optString("body", "");
String releasePageUrl = releaseJson.optString("html_url", "");
JSONArray assets = releaseJson.optJSONArray("assets");
JSONObject apkAsset = findApkAsset(assets);
String downloadUrl = apkAsset != null ? apkAsset.optString("browser_download_url", "") : "";
String downloadFileName = apkAsset != null ? apkAsset.optString("name", "") : "";
long sizeBytes = apkAsset != null ? apkAsset.optLong("size", 0L) : 0L;
int downloadCount = apkAsset != null ? apkAsset.optInt("download_count", 0) : 0;
int minSupported = 0;
boolean forceUpdate = false;
JSONObject manifestJson = fetchManifest(assets);
if (manifestJson != null) {
versionCode = manifestJson.optInt("versionCode", versionCode);
versionName = manifestJson.optString("versionName", versionName);
minSupported = manifestJson.optInt("minSupportedVersionCode", 0);
forceUpdate = manifestJson.optBoolean("forceUpdate", false);
String manifestUrl = manifestJson.optString("downloadUrl", null);
if (!TextUtils.isEmpty(manifestUrl)) {
downloadUrl = manifestUrl;
}
if (manifestJson.has("fileName")) {
downloadFileName = manifestJson.optString("fileName", downloadFileName);
}
if (manifestJson.has("sizeBytes")) {
sizeBytes = manifestJson.optLong("sizeBytes", sizeBytes);
}
if (manifestJson.has("notes") && TextUtils.isEmpty(releaseNotes)) {
releaseNotes = manifestJson.optString("notes", releaseNotes);
}
}
if (TextUtils.isEmpty(downloadUrl)) {
return null;
}
return new UpdateInfo(versionCode, versionName, releaseNotes, downloadUrl,
downloadFileName, sizeBytes, downloadCount, releasePageUrl,
minSupported, forceUpdate);
}
private JSONObject fetchManifest(JSONArray assets) throws IOException, JSONException {
if (assets == null) {
return null;
}
for (int i = 0; i < assets.length(); i++) {
JSONObject asset = assets.optJSONObject(i);
if (asset == null) {
continue;
}
String name = asset.optString("name", "").toLowerCase(Locale.US);
if (TextUtils.isEmpty(name) || !name.endsWith(".json")) {
continue;
}
if (!(name.contains("update") || name.contains("manifest"))) {
continue;
}
String url = asset.optString("browser_download_url", "");
if (TextUtils.isEmpty(url)) {
continue;
}
Request request = new Request.Builder()
.url(url)
.header("Authorization", "token " + GITEA_TOKEN)
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
continue;
}
String json = response.body().string();
if (!TextUtils.isEmpty(json)) {
// Validar que no sea HTML antes de parsear
String trimmed = json.trim();
if (trimmed.startsWith("<!") || trimmed.startsWith("<html")) {
continue;
}
return new JSONObject(json);
}
}
}
return null;
}
private JSONObject findApkAsset(JSONArray assets) {
if (assets == null) {
return null;
}
JSONObject firstApk = null;
JSONObject debugApk = null;
for (int i = 0; i < assets.length(); i++) {
JSONObject asset = assets.optJSONObject(i);
if (asset == null) {
continue;
}
String name = asset.optString("name", "").toLowerCase(Locale.US);
if (name.endsWith(".apk")) {
if (name.contains("release")) {
return asset;
}
if (firstApk == null) {
firstApk = asset;
}
if (name.contains("debug") && debugApk == null) {
debugApk = asset;
}
}
}
return firstApk != null ? firstApk : debugApk;
}
private String deriveVersionName(String tagName, String fallback) {
String base = !TextUtils.isEmpty(tagName) ? tagName : fallback;
if (TextUtils.isEmpty(base)) {
return "";
}
return base.replaceFirst("^[Vv]", "").trim();
}
private int parseVersionCode(String versionName) {
if (TextUtils.isEmpty(versionName)) {
return -1;
}
String normalized = versionName.replaceAll("[^0-9\\.]", "");
if (TextUtils.isEmpty(normalized)) {
return -1;
}
String[] parts = normalized.split("\\.");
int major = parsePart(parts, 0);
int minor = parsePart(parts, 1);
int patch = parsePart(parts, 2);
return major * 10000 + minor * 100 + patch;
}
private int parsePart(String[] parts, int index) {
if (parts.length <= index) {
return 0;
}
try {
return Integer.parseInt(parts[index]);
} catch (NumberFormatException e) {
return 0;
}
}
private void installDownloadedApk(Activity activity, File apkFile) {
if (activity == null || apkFile == null || !apkFile.exists()) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
boolean canInstall = appContext.getPackageManager().canRequestPackageInstalls();
if (!canInstall) {
pendingInstallFile = apkFile;
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:" + appContext.getPackageName()));
try {
activity.startActivity(intent);
} catch (ActivityNotFoundException ignored) {
showToast(appContext.getString(R.string.update_error_install_permissions));
}
showToast(appContext.getString(R.string.update_permission_request));
return;
}
}
Uri uri = FileProvider.getUriForFile(appContext,
appContext.getPackageName() + ".fileprovider", apkFile);
Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.setDataAndType(uri, "application/vnd.android.package-archive");
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
activity.startActivity(installIntent);
pendingInstallFile = null;
} catch (ActivityNotFoundException e) {
showToast(appContext.getString(R.string.update_error_install_intent));
}
}
private void registerDownloadReceiver() {
if (downloadReceiver != null) {
return;
}
downloadReceiver = new DownloadReceiver();
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
ContextCompat.registerReceiver(
appContext,
downloadReceiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED
);
}
private void unregisterDownloadReceiver() {
if (downloadReceiver != null) {
try {
appContext.unregisterReceiver(downloadReceiver);
} catch (IllegalArgumentException ignored) {
}
downloadReceiver = null;
}
}
private void handleDownloadComplete(long downloadId) {
if (downloadId != currentDownloadId) {
return;
}
DownloadManager downloadManager =
(DownloadManager) appContext.getSystemService(Context.DOWNLOAD_SERVICE);
if (downloadManager == null) {
showToast(appContext.getString(R.string.update_error_download_manager));
cleanupDownloadState();
return;
}
DownloadManager.Query query = new DownloadManager.Query();
query.setFilterById(downloadId);
Cursor cursor = downloadManager.query(query);
if (cursor != null) {
try {
if (cursor.moveToFirst()) {
int status = cursor.getInt(cursor.getColumnIndexOrThrow(
DownloadManager.COLUMN_STATUS));
if (status == DownloadManager.STATUS_SUCCESSFUL && downloadingFile != null) {
pendingInstallFile = downloadingFile;
Activity activity = activityRef != null ? activityRef.get() : null;
mainHandler.post(() -> {
showToast(appContext.getString(R.string.update_download_complete));
installDownloadedApk(activity, pendingInstallFile);
});
} else {
int reason = cursor.getInt(cursor.getColumnIndexOrThrow(
DownloadManager.COLUMN_REASON));
mainHandler.post(() -> showToast(appContext.getString(
R.string.update_error_download_failed, reason)));
}
}
} finally {
cursor.close();
}
}
cleanupDownloadState();
}
private void cleanupDownloadState() {
unregisterDownloadReceiver();
currentDownloadId = -1L;
downloadingFile = null;
}
private void postAvailable(UpdateCallback callback, UpdateInfo info) {
if (callback == null) {
return;
}
mainHandler.post(() -> callback.onUpdateAvailable(info));
}
private void postUpToDate(UpdateCallback callback) {
if (callback == null) {
return;
}
mainHandler.post(callback::onUpToDate);
}
private void postError(UpdateCallback callback, String message) {
if (callback == null) {
return;
}
String safeMessage = TextUtils.isEmpty(message)
? appContext.getString(R.string.update_error_unknown)
: message;
mainHandler.post(() -> callback.onError(safeMessage));
}
private void showToast(String message) {
mainHandler.post(() -> Toast.makeText(appContext, message, Toast.LENGTH_LONG).show());
}
private class DownloadReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L);
handleDownloadComplete(id);
}
}
public interface UpdateCallback {
void onUpdateAvailable(UpdateInfo info);
void onUpToDate();
void onError(String message);
}
public static class UpdateInfo {
public final int versionCode;
public final String versionName;
public final String releaseNotes;
public final String downloadUrl;
public final String downloadFileName;
public final long downloadSizeBytes;
public final int downloadCount;
public final String releasePageUrl;
public final int minSupportedVersionCode;
public final boolean forceUpdate;
UpdateInfo(int versionCode,
String versionName,
String releaseNotes,
String downloadUrl,
String downloadFileName,
long downloadSizeBytes,
int downloadCount,
String releasePageUrl,
int minSupportedVersionCode,
boolean forceUpdate) {
this.versionCode = versionCode;
this.versionName = versionName;
this.releaseNotes = releaseNotes == null ? "" : releaseNotes.trim();
this.downloadUrl = downloadUrl;
this.downloadFileName = downloadFileName;
this.downloadSizeBytes = downloadSizeBytes;
this.downloadCount = downloadCount;
this.releasePageUrl = releasePageUrl;
this.minSupportedVersionCode = minSupportedVersionCode;
this.forceUpdate = forceUpdate;
}
public boolean isUpdateAvailable(int currentVersionCode) {
return versionCode > currentVersionCode;
}
public boolean isMandatory(int currentVersionCode) {
return forceUpdate || currentVersionCode < minSupportedVersionCode;
}
public String getReleaseNotesPreview() {
if (TextUtils.isEmpty(releaseNotes)) {
return "";
}
final int limit = 900;
if (releaseNotes.length() <= limit) {
return releaseNotes;
}
return releaseNotes.substring(0, limit) + "\n…";
}
public String getResolvedFileName() {
if (!TextUtils.isEmpty(downloadFileName)) {
return downloadFileName;
}
String safeVersion = TextUtils.isEmpty(versionName) ? String.valueOf(versionCode)
: versionName.replaceAll("[^0-9a-zA-Z._-]", "");
return "StreamPlayer-" + safeVersion + ".apk";
}
public String formatSize(Context context) {
if (downloadSizeBytes <= 0) {
return "";
}
return Formatter.formatShortFileSize(context, downloadSizeBytes);
}
}
}

View File

@@ -0,0 +1,14 @@
package com.streamplayer;
public final class VlcPlayerConfig {
// 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";
// Maximum retries for playback
public static final int MAX_RETRIES = 3;
private VlcPlayerConfig() {
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true" android:color="@color/white" />
<item android:state_focused="true" android:color="@color/white" />
<item android:color="@color/text_secondary" />
</selector>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#FF002766"
android:startColor="#FF0F4C81" />
<corners android:radius="12dp" />
</shape>
</item>
<item>
<bitmap
android:antialias="true"
android:gravity="center"
android:src="@mipmap/ic_launcher" />
</item>
</layer-list>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="#88003C8F" />
<corners android:radius="18dp" />
<stroke
android:width="3dp"
android:color="#FFFFFFFF" />
</shape>
</item>
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#55003C8F" />
<corners android:radius="18dp" />
<stroke
android:width="3dp"
android:color="#FFFFFFFF" />
</shape>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#55003C8F" />
<corners android:radius="18dp" />
<stroke
android:width="3dp"
android:color="#FFFFFFFF" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#33212121" />
<corners android:radius="18dp" />
<stroke
android:width="2dp"
android:color="#33FFFFFF" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="true"><shape android:shape="rectangle"><solid android:color="#202020"/></shape></item>
<item android:state_focused="true"><shape android:shape="rectangle"><solid android:color="#303030"/></shape></item>
<item><shape android:shape="rectangle"><solid android:color="@android:color/transparent"/></shape></item>
</selector>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Focused state - bright amber with thick border -->
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="@color/refresh_button_focused" />
<corners android:radius="8dp" />
<stroke
android:width="4dp"
android:color="@color/refresh_button_focused_border" />
<padding
android:left="12dp"
android:top="8dp"
android:right="12dp"
android:bottom="8dp" />
</shape>
</item>
<!-- Pressed state -->
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="@color/refresh_button_pressed" />
<corners android:radius="8dp" />
<stroke
android:width="2dp"
android:color="@color/refresh_button_border" />
</shape>
</item>
<!-- Default state - darker background with subtle border -->
<item>
<shape android:shape="rectangle">
<solid android:color="@color/refresh_button_default" />
<corners android:radius="8dp" />
<stroke
android:width="2dp"
android:color="@color/refresh_button_border" />
<padding
android:left="12dp"
android:top="8dp"
android:right="12dp"
android:bottom="8dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M21,6h-7.59l2.3,-2.29c0.63,-0.63 0.19,-1.71 -0.7,-1.71H8.99c-0.89,0 -1.33,1.08 -0.7,1.71L10.59,6H3c-1.11,0 -2,0.9 -2,2v12c0,1.1 0.89,2 2,2h18c1.11,0 2,-0.9 2,-2L23,8c0,-1.1 -0.89,-2 -2,-2zM21,18H3L3,8h18v10z" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,10h2v6L9,16zM13,10h2v6h-2z" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFFFF" />
<corners android:radius="6dp" />
<size android:width="12dp" />
</shape>

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
tools:context=".MainActivity">
<LinearLayout
android:id="@+id/nav_panel"
android:layout_width="180dp"
android:layout_height="0dp"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="32dp"
android:paddingEnd="16dp"
android:paddingBottom="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/app_brand"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textAllCaps="true"
android:textColor="@color/white"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/app_tagline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/home_tagline"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/section_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:layout_weight="1"
android:overScrollMode="never"
tools:listitem="@layout/item_section" />
</LinearLayout>
<View
android:id="@+id/divider"
android:layout_width="1dp"
android:layout_height="0dp"
android:background="#33FFFFFF"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/nav_panel"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/content_panel"
android:layout_width="0dp"
android:layout_height="0dp"
android:orientation="vertical"
android:paddingStart="24dp"
android:paddingTop="32dp"
android:paddingEnd="24dp"
android:paddingBottom="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/divider"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/content_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/white"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Canales" />
<Button
android:id="@+id/refresh_button"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="@string/action_refresh"
android:textAllCaps="false"
android:textSize="12sp"
android:visibility="gone"
android:focusable="true"
android:focusableInTouchMode="true"
android:background="@drawable/btn_refresh_selector"
android:textColor="@color/white"
android:elevation="2dp" />
</LinearLayout>
<ProgressBar
android:id="@+id/loading_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone" />
<TextView
android:id="@+id/message_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:visibility="gone"
tools:text="Mensaje" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/content_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_weight="1"
android:overScrollMode="never"
android:scrollbars="vertical"
android:fadeScrollbars="false"
android:scrollbarStyle="insideInset"
android:scrollbarThumbVertical="@drawable/scrollbar_vertical"
android:scrollbarTrackVertical="@color/scrollbar_track"
android:scrollbarSize="12dp"
android:scrollbarAlwaysDrawVerticalTrack="true"
android:scrollbarFadeDuration="0"
android:nextFocusLeft="@id/section_list"
tools:listitem="@layout/item_channel" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
tools:context=".PlayerActivity">
<androidx.media3.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:keepScreenOn="true"
app:show_buffering="never"
app:surface_type="surface_view"
app:use_controller="false" />
<LinearLayout
android:id="@+id/player_toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:background="#66000000"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/player_channel_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/player_channel_default"
android:textColor="@color/white"
android:textSize="18sp"
android:textStyle="bold" />
<Button
android:id="@+id/close_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/player_action_choose_other"
android:textAllCaps="false" />
</LinearLayout>
<ProgressBar
android:id="@+id/loading_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminateTint="@color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="16dp"
android:textColor="@color/white"
android:textSize="16sp"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Error al reproducir" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="6dp"
android:background="@drawable/bg_channel_item_selector"
android:focusable="true"
android:focusableInTouchMode="true"
android:defaultFocusHighlightEnabled="true"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:id="@+id/channel_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/app_name"
android:src="@drawable/ic_channel_default"
app:tint="@color/white" />
<TextView
android:id="@+id/channel_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:maxLines="2"
android:textColor="@color/white"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:background="@drawable/bg_channel_item_selector"
android:gravity="center_vertical"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/event_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="16sp"
android:textStyle="bold"
tools:text="Partido" />
<TextView
android:id="@+id/event_time"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/white"
android:textSize="14sp"
tools:text="20:00" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/event_channel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/white"
android:textSize="14sp"
tools:text="ESPN" />
<TextView
android:id="@+id/event_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingEnd="8dp"
android:paddingStart="8dp"
android:textColor="#18d763"
android:textSize="14sp"
tools:text="En vivo" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:focusable="true"
android:focusableInTouchMode="true"
android:background="@drawable/bg_section_indicator"
android:padding="8dp">
<TextView
android:id="@+id/section_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/section_text_selector"
android:textSize="14sp"
android:textStyle="bold"
tools:text="Canales" />
</LinearLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="channel_grid_span">5</integer>
</resources>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="text_secondary">#B3FFFFFF</color>
<!-- Refresh button colors -->
<color name="refresh_button_default">#2A2A2A</color>
<color name="refresh_button_border">#4A4A4A</color>
<color name="refresh_button_focused">#FFC107</color>
<color name="refresh_button_focused_border">#FFD54F</color>
<color name="refresh_button_pressed">#FF9800</color>
<!-- Scrollbar -->
<color name="scrollbar_track">#1A1A1A</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="channel_grid_span">3</integer>
</resources>

View File

@@ -0,0 +1,43 @@
<resources>
<string name="app_name">StreamPlayer</string>
<string name="home_tagline">Todo el deporte en un solo lugar</string>
<string name="section_events">Eventos</string>
<string name="section_all_channels">Todos los canales</string>
<string name="message_no_channels">No hay canales disponibles</string>
<string name="message_no_events">No hay eventos disponibles</string>
<string name="action_refresh">Actualizar</string>
<string name="message_events_error">No se pudieron cargar los eventos: %1$s</string>
<string name="player_channel_default">Canal</string>
<string name="player_action_choose_other">Elegir otro</string>
<string name="player_retrying">Error de conexión. Reintentando... (%1$d/%2$d)</string>
<string name="update_required_title">Actualización obligatoria</string>
<string name="update_available_title">Actualización disponible</string>
<string name="update_action_download">Actualizar</string>
<string name="update_action_view_release">Ver detalles</string>
<string name="update_action_close_app">Salir</string>
<string name="update_action_later">Más tarde</string>
<string name="update_current_version">Versión instalada: %1$s (%2$d)</string>
<string name="update_latest_version">Última versión publicada: %1$s (%2$d)</string>
<string name="update_min_supported">Versiones anteriores a %1$d ya no están permitidas.</string>
<string name="update_download_size">Tamaño aproximado: %1$s</string>
<string name="update_downloads">Descargas registradas: %1$d</string>
<string name="update_release_notes_title">Novedades</string>
<string name="update_optional_hint">Puedes continuar usando la app, pero recomendamos instalar la actualización para obtener el mejor rendimiento.</string>
<string name="update_error_checking">No se pudo verificar actualizaciones (%1$s)</string>
<string name="update_error_unknown">Error desconocido</string>
<string name="update_error_open_release">No se pudo abrir el detalle de la versión</string>
<string name="update_error_empty_response">Respuesta vacía del servidor de releases</string>
<string name="update_error_http">Error de red (%1$d)</string>
<string name="update_error_missing_url">No se encontró URL de descarga</string>
<string name="update_error_download_manager">DownloadManager no está disponible en este dispositivo</string>
<string name="update_error_storage">No se pudo preparar el almacenamiento para la actualización</string>
<string name="update_error_download">Error al iniciar la descarga: %1$s</string>
<string name="update_download_started">Descarga iniciada, revisa la notificación para ver el progreso</string>
<string name="update_download_complete">Descarga finalizada, preparando instalación…</string>
<string name="update_error_download_failed">La descarga falló (código %1$d)</string>
<string name="update_error_install_permissions">No se pudo abrir la configuración de instalación desconocida</string>
<string name="update_permission_request">Habilita "Instalar apps desconocidas" para StreamPlayer y regresa para continuar.</string>
<string name="update_error_install_intent">No se pudo abrir el instalador de paquetes</string>
<string name="update_notification_title">StreamPlayer %1$s</string>
<string name="update_notification_description">Descargando nueva versión</string>
</resources>

View File

@@ -0,0 +1,9 @@
<resources>
<style name="Theme.StreamPlayer" parent="Theme.AppCompat.Light.NoActionBar">
<item name="colorPrimary">@color/black</item>
<item name="colorPrimaryDark">@color/black</item>
<item name="colorAccent">@color/white</item>
<item name="android:statusBarColor">@color/black</item>
<item name="android:navigationBarColor">@color/black</item>
</style>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path
name="updates"
path="." />
</paths>

20
build.gradle Normal file
View File

@@ -0,0 +1,20 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.5.1'
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

77
create_release.py Normal file
View File

@@ -0,0 +1,77 @@
import json
import urllib.request
import urllib.parse
import urllib.error
import os
import sys
# Configuration
GITEA_URL = "https://gitea.cbcren.online/api/v1"
REPO_OWNER = "renato97"
REPO_NAME = "app"
TOKEN = "efeed2af00597883adb04da70bd6a7c2993ae92d"
TAG_NAME = "v10.1.7"
RELEASE_NAME = "StreamPlayer v10.1.7"
CHANGELOG_FILE = "CHANGELOG-v10.1.7.md"
APK_FILE = "StreamPlayer-10.1.7-debug.apk"
def create_release():
try:
with open(CHANGELOG_FILE, 'r') as f:
body = f.read()
except FileNotFoundError:
print(f"Error: {CHANGELOG_FILE} not found.")
sys.exit(1)
url = f"{GITEA_URL}/repos/{REPO_OWNER}/{REPO_NAME}/releases"
headers = {
"Authorization": f"token {TOKEN}",
"Content-Type": "application/json",
"Accept": "application/json"
}
data = {
"tag_name": TAG_NAME,
"target_commitish": "main",
"name": RELEASE_NAME,
"body": body,
"draft": False,
"prerelease": False
}
req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers=headers, method='POST')
try:
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode('utf-8'))
print(f"Release created successfully. ID: {result['id']}")
return result['id']
except urllib.error.HTTPError as e:
print(f"HTTP Error creating release: {e.code} {e.reason}")
print(e.read().decode('utf-8'))
sys.exit(1)
except Exception as e:
print(f"Error creating release: {e}")
sys.exit(1)
def upload_asset(release_id):
if not os.path.exists(APK_FILE):
print(f"Error: APK file {APK_FILE} not found.")
sys.exit(1)
url = f"{GITEA_URL}/repos/{REPO_OWNER}/{REPO_NAME}/releases/{release_id}/assets"
# Simple multipart upload via python is tricky without requests library.
# However, Gitea API usually accepts raw binary in body if Content-Type is set,
# but Gitea's API for assets usually requires multipart/form-data.
# Let's check Gitea API docs...
# The standard Gitea API uses POST /repos/{owner}/{repo}/releases/{id}/assets with name query parameter and file content in body
# Wait, looking at Gitea API docs (swagger usually available at /api/swagger),
# POST /repos/{owner}/{repo}/releases/{id}/assets takes 'attachment' as form-data.
# Implementing multipart/form-data with urllib is painful.
# Instead, I will use curl to upload the asset, using the release ID obtained from Python.
return release_id
if __name__ == "__main__":
release_id = create_release()
print(f"RELEASE_ID={release_id}")

3
gradle.properties Normal file
View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=false

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

7
local.properties.example Normal file
View File

@@ -0,0 +1,7 @@
# Copy this file to `local.properties` and adjust `sdk.dir` to your SDK path.
# On Windows (Android Studio default):
# sdk.dir=C:\\Users\\Administrator\\AppData\\Local\\Android\\Sdk
# On WSL/Linux (tooling scripts in this repo expect this path):
# sdk.dir=/opt/android-sdk

View File

@@ -0,0 +1,10 @@
{
"versionCode": 90000,
"versionName": "9.0.0",
"minSupportedVersionCode": 80000,
"forceUpdate": false,
"downloadUrl": "https://gitea.cbcren.online/renato97/app/releases/download/v9.0/StreamPlayer-v9.0-DefinitiveEdition.apk",
"fileName": "StreamPlayer-v9.0-DefinitiveEdition.apk",
"sizeBytes": 12000000,
"notes": "Texto opcional si necesitas personalizar las notas que verá el usuario"
}

9
settings.gradle Normal file
View File

@@ -0,0 +1,9 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "StreamPlayer"
include ':app'

10
update-manifest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"versionCode": 100110,
"versionName": "10.1.10",
"minSupportedVersionCode": 0,
"forceUpdate": false,
"downloadUrl": "http://gitea.cbcren.online/renato97/app/releases/download/v10.1.10/StreamPlayer-v10.1.10-debug.apk",
"fileName": "StreamPlayer-v10.1.10-debug.apk",
"sizeBytes": 9113609,
"notes": "Cambiar a HTTP para evitar errores de certificado"
}

View File

@@ -1,23 +0,0 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="StreamPlayer.Desktop.App"
xmlns:local="using:StreamPlayer.Desktop"
xmlns:converters="using:StreamPlayer.Desktop.Converters"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Resources>
<converters:InverseBooleanConverter x:Key="InverseBooleanConverter" />
<converters:BooleanToBrushConverter x:Key="LiveStatusBrushConverter"
TrueBrush="#27AE60"
FalseBrush="#444" />
</Application.Resources>
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View File

@@ -1,49 +0,0 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using Avalonia.Markup.Xaml;
using LibVLCSharp.Shared;
using StreamPlayer.Desktop.ViewModels;
using StreamPlayer.Desktop.Views;
namespace StreamPlayer.Desktop;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
Core.Initialize();
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
}
base.OnFrameworkInitializationCompleted();
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
// remove each entry found
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
}

View File

@@ -1,14 +0,0 @@
namespace StreamPlayer.Desktop;
/// <summary>
/// Centraliza los metadatos de versión y endpoints compartidos con la app Android original.
/// </summary>
public static class AppVersion
{
public const string VersionName = "9.4.6";
public const int VersionCode = 94600;
public const string DeviceRegistryUrl = "http://194.163.191.200:4000";
public const string LatestReleaseApi =
"https://gitea.cbcren.online/api/v1/repos/renato97/app/releases/latest";
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

View File

@@ -1,23 +0,0 @@
using System;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace StreamPlayer.Desktop.Converters;
public sealed class BooleanToBrushConverter : IValueConverter
{
public IBrush TrueBrush { get; set; } = Brushes.White;
public IBrush FalseBrush { get; set; } = Brushes.Gray;
public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
{
if (value is bool boolean)
{
return boolean ? TrueBrush : FalseBrush;
}
return Avalonia.AvaloniaProperty.UnsetValue;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) =>
Avalonia.AvaloniaProperty.UnsetValue;
}

View File

@@ -1,27 +0,0 @@
using System;
using Avalonia.Data.Converters;
namespace StreamPlayer.Desktop.Converters;
public sealed class InverseBooleanConverter : IValueConverter
{
public static readonly InverseBooleanConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
{
if (value is bool boolean)
{
return !boolean;
}
return Avalonia.AvaloniaProperty.UnsetValue;
}
public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
{
if (value is bool boolean)
{
return !boolean;
}
return Avalonia.AvaloniaProperty.UnsetValue;
}
}

View File

@@ -1,63 +0,0 @@
using System.Text.Json;
namespace StreamPlayer.Desktop;
public static class JsonExtensions
{
public static string GetPropertyOrDefault(this JsonElement element, string propertyName)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property))
{
return property.ValueKind switch
{
JsonValueKind.String => property.GetString() ?? string.Empty,
JsonValueKind.Number => property.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => property.GetRawText()
};
}
return string.Empty;
}
public static int GetPropertyOrDefaultInt(this JsonElement element, string propertyName, int fallback = 0)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property))
{
if (property.TryGetInt32(out var value))
{
return value;
}
}
return fallback;
}
public static long GetPropertyOrDefaultLong(this JsonElement element, string propertyName, long fallback = 0)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property))
{
if (property.TryGetInt64(out var value))
{
return value;
}
}
return fallback;
}
public static bool GetPropertyOrDefaultBool(this JsonElement element, string propertyName, bool fallback = false)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property))
{
if (property.ValueKind == JsonValueKind.True)
{
return true;
}
if (property.ValueKind == JsonValueKind.False)
{
return false;
}
}
return fallback;
}
}

View File

@@ -1,14 +0,0 @@
using System.Collections.Generic;
namespace StreamPlayer.Desktop.Models;
public enum SectionType
{
Events,
Channels
}
public sealed record ChannelSection(string Title, SectionType Type, IReadOnlyList<StreamChannel> Channels)
{
public bool IsEvents => Type == SectionType.Events;
}

View File

@@ -1,6 +0,0 @@
namespace StreamPlayer.Desktop.Models;
public sealed record DeviceStatus(bool IsBlocked, string Reason, string TokenPart)
{
public static DeviceStatus Allowed() => new(false, string.Empty, string.Empty);
}

View File

@@ -1,37 +0,0 @@
using System;
namespace StreamPlayer.Desktop.Models;
public sealed record LiveEvent(
string Title,
string DisplayTime,
string Category,
string Status,
string PageUrl,
string ChannelName,
long StartTimestamp)
{
public bool IsLive =>
!string.IsNullOrWhiteSpace(Status) &&
Status.Contains("live", StringComparison.OrdinalIgnoreCase);
public string Subtitle
{
get
{
if (string.IsNullOrWhiteSpace(DisplayTime) && string.IsNullOrWhiteSpace(Category))
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(Category))
{
return DisplayTime;
}
if (string.IsNullOrWhiteSpace(DisplayTime))
{
return Category;
}
return $"{DisplayTime} · {Category}";
}
}
}

View File

@@ -1,3 +0,0 @@
namespace StreamPlayer.Desktop.Models;
public sealed record StreamChannel(string Name, string PageUrl);

View File

@@ -1,52 +0,0 @@
using System;
using System.Globalization;
namespace StreamPlayer.Desktop.Models;
public sealed record UpdateInfo(
int VersionCode,
string VersionName,
string ReleaseNotes,
string DownloadUrl,
string DownloadFileName,
long DownloadSizeBytes,
int DownloadCount,
string ReleasePageUrl,
int MinSupportedVersionCode,
bool ForceUpdate)
{
public bool IsUpdateAvailable(int currentVersionCode) => VersionCode > currentVersionCode;
public bool IsMandatory(int currentVersionCode) =>
ForceUpdate || (MinSupportedVersionCode > 0 && currentVersionCode < MinSupportedVersionCode);
public string GetReleaseNotesPreview(int maxLength = 900)
{
if (string.IsNullOrWhiteSpace(ReleaseNotes))
{
return string.Empty;
}
if (ReleaseNotes.Length <= maxLength)
{
return ReleaseNotes.Trim();
}
return ReleaseNotes[..maxLength].TrimEnd() + Environment.NewLine + "…";
}
public string FormatSize()
{
if (DownloadSizeBytes <= 0)
{
return string.Empty;
}
string[] suffixes = { "B", "KB", "MB", "GB" };
double size = DownloadSizeBytes;
int index = 0;
while (size >= 1024 && index < suffixes.Length - 1)
{
size /= 1024;
index++;
}
return $"{size:0.##} {suffixes[index]}";
}
}

View File

@@ -1,21 +0,0 @@
using Avalonia;
using System;
namespace StreamPlayer.Desktop;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View File

@@ -1,90 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StreamPlayer.Desktop.Models;
namespace StreamPlayer.Desktop.Services;
public static class ChannelRepository
{
private static readonly IReadOnlyList<StreamChannel> Channels = BuildChannels();
public static IReadOnlyList<StreamChannel> GetChannels() => Channels;
private static IReadOnlyList<StreamChannel> BuildChannels()
{
var list = new List<StreamChannel>
{
new("ESPN", "https://streamtpmedia.com/global2.php?stream=espn"),
new("ESPN 2", "https://streamtpmedia.com/global2.php?stream=espn2"),
new("ESPN 3", "https://streamtpmedia.com/global2.php?stream=espn3"),
new("ESPN 4", "https://streamtpmedia.com/global2.php?stream=espn4"),
new("ESPN 3 MX", "https://streamtpmedia.com/global2.php?stream=espn3mx"),
new("ESPN 5", "https://streamtpmedia.com/global2.php?stream=espn5"),
new("Fox Sports 3 MX", "https://streamtpmedia.com/global2.php?stream=foxsports3mx"),
new("ESPN 6", "https://streamtpmedia.com/global2.php?stream=espn6"),
new("Fox Sports MX", "https://streamtpmedia.com/global2.php?stream=foxsportsmx"),
new("ESPN 7", "https://streamtpmedia.com/global2.php?stream=espn7"),
new("Azteca Deportes", "https://streamtpmedia.com/global2.php?stream=azteca_deportes"),
new("Win Plus", "https://streamtpmedia.com/global2.php?stream=winplus"),
new("DAZN 1", "https://streamtpmedia.com/global2.php?stream=dazn1"),
new("Win Plus 2", "https://streamtpmedia.com/global2.php?stream=winplus2"),
new("DAZN 2", "https://streamtpmedia.com/global2.php?stream=dazn2"),
new("Win Sports", "https://streamtpmedia.com/global2.php?stream=winsports"),
new("DAZN LaLiga", "https://streamtpmedia.com/global2.php?stream=dazn_laliga"),
new("Win Plus Online 1", "https://streamtpmedia.com/global2.php?stream=winplusonline1"),
new("Caracol TV", "https://streamtpmedia.com/global2.php?stream=caracoltv"),
new("Fox 1 AR", "https://streamtpmedia.com/global2.php?stream=fox1ar"),
new("Fox 2 USA", "https://streamtpmedia.com/global2.php?stream=fox_2_usa"),
new("Fox 2 AR", "https://streamtpmedia.com/global2.php?stream=fox2ar"),
new("TNT 1 GB", "https://streamtpmedia.com/global2.php?stream=tnt_1_gb"),
new("TNT 2 GB", "https://streamtpmedia.com/global2.php?stream=tnt_2_gb"),
new("Fox 3 AR", "https://streamtpmedia.com/global2.php?stream=fox3ar"),
new("Universo USA", "https://streamtpmedia.com/global2.php?stream=universo_usa"),
new("DSports", "https://streamtpmedia.com/global2.php?stream=dsports"),
new("Univision USA", "https://streamtpmedia.com/global2.php?stream=univision_usa"),
new("DSports 2", "https://streamtpmedia.com/global2.php?stream=dsports2"),
new("Fox Deportes USA", "https://streamtpmedia.com/global2.php?stream=fox_deportes_usa"),
new("DSports Plus", "https://streamtpmedia.com/global2.php?stream=dsportsplus"),
new("Fox Sports 2 MX", "https://streamtpmedia.com/global2.php?stream=foxsports2mx"),
new("TNT Sports Chile", "https://streamtpmedia.com/global2.php?stream=tntsportschile"),
new("Fox Sports Premium", "https://streamtpmedia.com/global2.php?stream=foxsportspremium"),
new("TNT Sports", "https://streamtpmedia.com/global2.php?stream=tntsports"),
new("ESPN MX", "https://streamtpmedia.com/global2.php?stream=espnmx"),
new("ESPN Premium", "https://streamtpmedia.com/global2.php?stream=espnpremium"),
new("ESPN 2 MX", "https://streamtpmedia.com/global2.php?stream=espn2mx"),
new("TyC Sports", "https://streamtpmedia.com/global2.php?stream=tycsports"),
new("TUDN USA", "https://streamtpmedia.com/global2.php?stream=tudn_usa"),
new("Telefe", "https://streamtpmedia.com/global2.php?stream=telefe"),
new("TNT 3 GB", "https://streamtpmedia.com/global2.php?stream=tnt_3_gb"),
new("TV Pública", "https://streamtpmedia.com/global2.php?stream=tv_publica"),
new("Fox 1 USA", "https://streamtpmedia.com/global2.php?stream=fox_1_usa"),
new("Liga 1 Max", "https://streamtpmedia.com/global2.php?stream=liga1max"),
new("Gol TV", "https://streamtpmedia.com/global2.php?stream=goltv"),
new("VTV Plus", "https://streamtpmedia.com/global2.php?stream=vtvplus"),
new("ESPN Deportes", "https://streamtpmedia.com/global2.php?stream=espndeportes"),
new("Gol Perú", "https://streamtpmedia.com/global2.php?stream=golperu"),
new("TNT 4 GB", "https://streamtpmedia.com/global2.php?stream=tnt_4_gb"),
new("SportTV BR 1", "https://streamtpmedia.com/global2.php?stream=sporttvbr1"),
new("SportTV BR 2", "https://streamtpmedia.com/global2.php?stream=sporttvbr2"),
new("SportTV BR 3", "https://streamtpmedia.com/global2.php?stream=sporttvbr3"),
new("Premiere 1", "https://streamtpmedia.com/global2.php?stream=premiere1"),
new("Premiere 2", "https://streamtpmedia.com/global2.php?stream=premiere2"),
new("Premiere 3", "https://streamtpmedia.com/global2.php?stream=premiere3"),
new("ESPN NL 1", "https://streamtpmedia.com/global2.php?stream=espn_nl1"),
new("ESPN NL 2", "https://streamtpmedia.com/global2.php?stream=espn_nl2"),
new("ESPN NL 3", "https://streamtpmedia.com/global2.php?stream=espn_nl3"),
new("Caliente TV MX", "https://streamtpmedia.com/global2.php?stream=calientetvmx"),
new("USA Network", "https://streamtpmedia.com/global2.php?stream=usa_network"),
new("TyC Internacional", "https://streamtpmedia.com/global2.php?stream=tycinternacional"),
new("Canal 5 MX", "https://streamtpmedia.com/global2.php?stream=canal5mx"),
new("TUDN MX", "https://streamtpmedia.com/global2.php?stream=TUDNMX"),
new("FUTV", "https://streamtpmedia.com/global2.php?stream=futv"),
new("LaLiga Hypermotion", "https://streamtpmedia.com/global2.php?stream=laligahypermotion")
};
return list
.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}

View File

@@ -1,91 +0,0 @@
using System;
using System.Net;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StreamPlayer.Desktop.Models;
namespace StreamPlayer.Desktop.Services;
public sealed class DeviceRegistryService
{
private static readonly HttpClient HttpClient = new(new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All
})
{
Timeout = TimeSpan.FromSeconds(20)
};
private readonly string _deviceId = CreateDeviceId();
public async Task<DeviceStatus> SyncAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(AppVersion.DeviceRegistryUrl))
{
return DeviceStatus.Allowed();
}
var payload = new
{
deviceId = _deviceId,
deviceName = Environment.MachineName,
model = RuntimeInformation.OSDescription,
manufacturer = "Microsoft",
osVersion = Environment.OSVersion.VersionString,
appVersionName = AppVersion.VersionName,
appVersionCode = AppVersion.VersionCode
};
string endpoint = $"{SanitizeBaseUrl(AppVersion.DeviceRegistryUrl)}/api/devices/register";
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = content
};
using var response = await HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
bool blocked = root.GetPropertyOrDefaultBool("blocked", false);
string reason = root.GetPropertyOrDefault("message");
if (root.TryGetProperty("device", out var deviceElement))
{
if (string.IsNullOrWhiteSpace(reason))
{
reason = deviceElement.GetPropertyOrDefault("notes");
}
}
string tokenPart = string.Empty;
if (root.TryGetProperty("verification", out var verificationElement))
{
bool verificationRequired = verificationElement.GetPropertyOrDefaultBool("required", false);
blocked = blocked || verificationRequired;
tokenPart = verificationElement.GetPropertyOrDefault("clientTokenPart");
}
return new DeviceStatus(blocked, reason, tokenPart);
}
private static string SanitizeBaseUrl(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value.EndsWith("/", StringComparison.Ordinal) ? value.TrimEnd('/') : value;
}
private static string CreateDeviceId()
{
string raw = $"{Environment.MachineName}|{Environment.UserName}|{RuntimeInformation.OSDescription}";
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(hash)[..24].ToLowerInvariant();
}
}

View File

@@ -1,34 +0,0 @@
using System;
using System.Net;
using System.Threading.Tasks;
namespace StreamPlayer.Desktop.Services;
public static class DnsHelper
{
private static readonly string[] DomainsToPrefetch =
{
"streamtpmedia.com",
"google.com",
"doubleclick.net"
};
public static void WarmUp()
{
ServicePointManager.DnsRefreshTimeout = (int)TimeSpan.FromMinutes(5).TotalMilliseconds;
_ = Task.Run(async () =>
{
foreach (var domain in DomainsToPrefetch)
{
try
{
await Dns.GetHostAddressesAsync(domain).ConfigureAwait(false);
}
catch
{
// Ignore individual failures, this is best-effort caching.
}
}
});
}
}

View File

@@ -1,198 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StreamPlayer.Desktop.Models;
namespace StreamPlayer.Desktop.Services;
public sealed class EventService
{
private static readonly Uri EventsUri = new("https://streamtpmedia.com/eventos.json");
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(24);
private static readonly string CachePath = Path.Combine(GetDataDirectory(), "events-cache.json");
private static readonly TimeZoneInfo EventZone = ResolveEventZone();
private static readonly HttpClient HttpClient = CreateHttpClient();
public async Task<IReadOnlyList<LiveEvent>> GetEventsAsync(bool forceRefresh, CancellationToken cancellationToken)
{
if (!forceRefresh && TryLoadFromCache(out var cached))
{
return cached;
}
try
{
string json = await DownloadJsonAsync(cancellationToken).ConfigureAwait(false);
var events = ParseEvents(json);
SaveCache(json);
return events;
}
catch
{
if (TryLoadFromCache(out var cachedEvents))
{
return cachedEvents;
}
throw;
}
}
private static bool TryLoadFromCache(out IReadOnlyList<LiveEvent> events)
{
events = Array.Empty<LiveEvent>();
if (!File.Exists(CachePath))
{
return false;
}
var age = DateTimeOffset.UtcNow - File.GetLastWriteTimeUtc(CachePath);
if (age > CacheDuration)
{
return false;
}
try
{
string json = File.ReadAllText(CachePath, Encoding.UTF8);
events = ParseEvents(json);
return true;
}
catch
{
return false;
}
}
private static void SaveCache(string json)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(CachePath)!);
File.WriteAllText(CachePath, json, Encoding.UTF8);
}
catch
{
// Not critical if the cache cannot be persisted.
}
}
private static async Task<string> DownloadJsonAsync(CancellationToken cancellationToken)
{
using var response = await HttpClient.GetAsync(EventsUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
private static IReadOnlyList<LiveEvent> ParseEvents(string json)
{
using var document = JsonDocument.Parse(json);
var results = new List<LiveEvent>();
foreach (var item in document.RootElement.EnumerateArray())
{
string title = item.GetPropertyOrDefault("title");
string time = item.GetPropertyOrDefault("time");
string category = item.GetPropertyOrDefault("category");
string status = item.GetPropertyOrDefault("status");
string link = NormalizeLink(item.GetPropertyOrDefault("link"));
string channelName = ExtractChannelName(link);
long startMillis = ParseEventTime(time);
results.Add(new LiveEvent(title, time, category, status, link, channelName, startMillis));
}
return results;
}
private static string NormalizeLink(string link)
{
if (string.IsNullOrWhiteSpace(link))
{
return string.Empty;
}
return link.Replace("global1.php", "global2.php", StringComparison.OrdinalIgnoreCase);
}
private static string ExtractChannelName(string link)
{
if (string.IsNullOrWhiteSpace(link))
{
return string.Empty;
}
int index = link.IndexOf("stream=", StringComparison.OrdinalIgnoreCase);
if (index < 0 || index + 7 >= link.Length)
{
return string.Empty;
}
return link[(index + 7)..].Replace("_", " ").ToUpperInvariant();
}
private static long ParseEventTime(string time)
{
if (string.IsNullOrWhiteSpace(time))
{
return -1;
}
try
{
var parsed = DateTime.ParseExact(time.Trim(), "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None);
var today = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, EventZone).Date;
var localCandidate = new DateTime(today.Year, today.Month, today.Day, parsed.Hour, parsed.Minute, 0, DateTimeKind.Unspecified);
var utcDateTime = TimeZoneInfo.ConvertTimeToUtc(localCandidate, EventZone);
var start = new DateTimeOffset(utcDateTime, TimeSpan.Zero);
if (start < DateTimeOffset.UtcNow.AddHours(-12))
{
start = start.AddDays(1);
}
return start.ToUnixTimeMilliseconds();
}
catch
{
return -1;
}
}
private static string GetDataDirectory()
{
var folder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"StreamPlayerDesktop");
Directory.CreateDirectory(folder);
return folder;
}
private static HttpClient CreateHttpClient()
{
var handler = new HttpClientHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.All
};
var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(15)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd("StreamPlayerDesktop/1.0");
client.DefaultRequestHeaders.ConnectionClose = false;
return client;
}
private static TimeZoneInfo ResolveEventZone()
{
string[] candidates = { "America/Argentina/Buenos_Aires", "Argentina Standard Time" };
foreach (var id in candidates)
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById(id);
}
catch
{
// try next
}
}
return TimeZoneInfo.Local;
}
}

View File

@@ -1,83 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StreamPlayer.Desktop.Models;
namespace StreamPlayer.Desktop.Services;
public static class SectionBuilder
{
public static IReadOnlyList<ChannelSection> BuildSections()
{
var sections = new List<ChannelSection>
{
new("Eventos en vivo", SectionType.Events, Array.Empty<StreamChannel>())
};
var grouped = ChannelRepository.GetChannels()
.GroupBy(channel => DeriveGroupName(channel.Name))
.ToDictionary(group => group.Key, group => (IReadOnlyList<StreamChannel>)group.ToList());
if (grouped.TryGetValue("ESPN", out var espnGroup))
{
sections.Add(new ChannelSection("ESPN", SectionType.Channels, espnGroup));
grouped.Remove("ESPN");
}
foreach (var key in grouped.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase))
{
var channels = grouped[key];
if (channels.Count == 0)
{
continue;
}
sections.Add(new ChannelSection(key, SectionType.Channels, channels));
}
sections.Add(new ChannelSection("Todos los canales", SectionType.Channels, ChannelRepository.GetChannels()));
return sections;
}
private static string DeriveGroupName(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return "General";
}
string upper = name.ToUpperInvariant();
if (upper.StartsWith("ESPN", StringComparison.Ordinal))
{
return "ESPN";
}
if (upper.Contains("FOX SPORTS", StringComparison.Ordinal))
{
return "Fox Sports";
}
if (upper.Contains("FOX", StringComparison.Ordinal))
{
return "Fox";
}
if (upper.Contains("TNT", StringComparison.Ordinal))
{
return "TNT";
}
if (upper.Contains("DAZN", StringComparison.Ordinal))
{
return "DAZN";
}
if (upper.Contains("TUDN", StringComparison.Ordinal))
{
return "TUDN";
}
if (upper.Contains("TYC", StringComparison.Ordinal))
{
return "TyC";
}
if (upper.Contains("GOL", StringComparison.Ordinal))
{
return "Gol";
}
int spaceIndex = upper.IndexOf(' ');
return spaceIndex > 0 ? upper[..spaceIndex] : upper;
}
}

View File

@@ -1,149 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace StreamPlayer.Desktop.Services;
/// <summary>
/// Replica el resolvedor ofuscado que utiliza la app Android para reconstruir la URL real del stream.
/// </summary>
public sealed class StreamUrlResolver
{
private static readonly Regex ArrayNameRegex =
new(@"var\s+playbackURL\s*=\s*""""\s*,\s*([A-Za-z0-9]+)\s*=\s*\[\]", RegexOptions.Compiled);
private static readonly Regex EntryRegex =
new(@"\[(\d+),""([A-Za-z0-9+/=]+)""\]", RegexOptions.Compiled);
private static readonly Regex KeyFunctionsRegex =
new(@"var\s+k=(\w+)\(\)\+(\w+)\(\);", RegexOptions.Compiled);
private const string FunctionTemplate = @"function\s+{0}\(\)\s*\{{\s*return\s+(\d+);\s*\}}";
private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) StreamPlayerResolver/1.0";
private static readonly HttpClient HttpClient = CreateHttpClient();
public async Task<string> ResolveAsync(string pageUrl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(pageUrl))
{
throw new ArgumentException("URL inválida", nameof(pageUrl));
}
string html = await DownloadPageAsync(pageUrl, cancellationToken).ConfigureAwait(false);
long keyOffset = ExtractKeyOffset(html);
var entries = ExtractEntries(html);
if (entries.Count == 0)
{
throw new InvalidOperationException("No se pudieron obtener los fragmentos del stream.");
}
var builder = new StringBuilder();
foreach (var entry in entries)
{
string decoded = Encoding.UTF8.GetString(Convert.FromBase64String(entry.Encoded));
string numeric = new string(decoded.Where(char.IsDigit).ToArray());
if (string.IsNullOrEmpty(numeric))
{
continue;
}
if (!long.TryParse(numeric, out long value))
{
continue;
}
builder.Append((char)(value - keyOffset));
}
string url = builder.ToString();
if (string.IsNullOrWhiteSpace(url))
{
throw new InvalidOperationException("No se pudo reconstruir la URL de reproducción.");
}
return url.Trim();
}
private static async Task<string> DownloadPageAsync(string url, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd(UserAgent);
request.Headers.Referrer = new Uri("https://streamtpmedia.com/");
using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
private static long ExtractKeyOffset(string html)
{
var match = KeyFunctionsRegex.Match(html);
if (!match.Success)
{
throw new InvalidOperationException("No se encontró la clave para el stream.");
}
string first = match.Groups[1].Value;
string second = match.Groups[2].Value;
long firstVal = ExtractReturnValue(html, first);
long secondVal = ExtractReturnValue(html, second);
return firstVal + secondVal;
}
private static long ExtractReturnValue(string html, string functionName)
{
var pattern = string.Format(CultureInfo.InvariantCulture, FunctionTemplate, Regex.Escape(functionName));
var regex = new Regex(pattern);
var match = regex.Match(html);
if (!match.Success)
{
throw new InvalidOperationException($"No se encontró el valor de la función {functionName}.");
}
return long.Parse(match.Groups[1].Value);
}
private static List<Entry> ExtractEntries(string html)
{
var matcher = ArrayNameRegex.Match(html);
if (!matcher.Success)
{
throw new InvalidOperationException("No se detectó la variable de fragmentos.");
}
string arrayName = matcher.Groups[1].Value;
var arrayRegex = new Regex($"{Regex.Escape(arrayName)}=\\[(.*?)\\];", RegexOptions.Singleline);
var arrayMatch = arrayRegex.Match(html);
if (!arrayMatch.Success)
{
throw new InvalidOperationException("No se encontró el arreglo de fragmentos.");
}
string rawEntries = arrayMatch.Groups[1].Value;
var entries = new List<Entry>();
foreach (Match match in EntryRegex.Matches(rawEntries))
{
if (int.TryParse(match.Groups[1].Value, out int index))
{
entries.Add(new Entry(index, match.Groups[2].Value));
}
}
return entries.OrderBy(e => e.Index).ToList();
}
private static HttpClient CreateHttpClient()
{
var handler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
AllowAutoRedirect = true
};
return new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(20)
};
}
private sealed record Entry(int Index, string Encoded);
}

View File

@@ -1,236 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using StreamPlayer.Desktop.Models;
namespace StreamPlayer.Desktop.Services;
public sealed class UpdateService
{
private static readonly HttpClient HttpClient = new(new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All
})
{
Timeout = TimeSpan.FromSeconds(20)
};
public async Task<UpdateInfo?> CheckForUpdatesAsync(CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, AppVersion.LatestReleaseApi);
using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
string tagName = root.GetPropertyOrDefault("tag_name");
string versionName = DeriveVersionName(tagName, root.GetPropertyOrDefault("name"));
int versionCode = ParseVersionCode(versionName);
string releaseNotes = root.GetPropertyOrDefault("body");
string releasePageUrl = root.GetPropertyOrDefault("html_url");
string downloadUrl = string.Empty;
string downloadFileName = string.Empty;
long sizeBytes = 0;
int downloadCount = 0;
JsonElement assetsElement = default;
bool hasAssets = root.TryGetProperty("assets", out assetsElement) && assetsElement.ValueKind == JsonValueKind.Array;
if (hasAssets && (TryFindAsset(assetsElement, IsApkAsset, out var apkAsset) ||
TryGetFirstAsset(assetsElement, out apkAsset)))
{
downloadUrl = apkAsset.GetPropertyOrDefault("browser_download_url");
downloadFileName = apkAsset.GetPropertyOrDefault("name");
long.TryParse(apkAsset.GetPropertyOrDefault("size"), out sizeBytes);
int.TryParse(apkAsset.GetPropertyOrDefault("download_count"), out downloadCount);
}
var manifest = hasAssets
? await TryFetchManifestAsync(assetsElement, cancellationToken).ConfigureAwait(false)
: null;
if (manifest is not null)
{
versionCode = manifest.Value.GetPropertyOrDefaultInt("versionCode", versionCode);
var manifestVersionName = manifest.Value.GetPropertyOrDefault("versionName");
if (!string.IsNullOrWhiteSpace(manifestVersionName))
{
versionName = manifestVersionName;
}
int minSupported = manifest.Value.GetPropertyOrDefaultInt("minSupportedVersionCode", 0);
bool forceUpdate = manifest.Value.GetPropertyOrDefaultBool("forceUpdate", false);
string manifestUrl = manifest.Value.GetPropertyOrDefault("downloadUrl");
if (!string.IsNullOrWhiteSpace(manifestUrl))
{
downloadUrl = manifestUrl;
}
string manifestFileName = manifest.Value.GetPropertyOrDefault("fileName");
if (!string.IsNullOrWhiteSpace(manifestFileName))
{
downloadFileName = manifestFileName;
}
long manifestSize = manifest.Value.GetPropertyOrDefaultLong("sizeBytes", sizeBytes);
if (manifestSize > 0)
{
sizeBytes = manifestSize;
}
string manifestNotes = manifest.Value.GetPropertyOrDefault("notes");
if (!string.IsNullOrWhiteSpace(manifestNotes) && string.IsNullOrWhiteSpace(releaseNotes))
{
releaseNotes = manifestNotes;
}
return BuildInfo(versionCode, versionName, releaseNotes, downloadUrl, downloadFileName,
sizeBytes, downloadCount, releasePageUrl, minSupported, forceUpdate);
}
return BuildInfo(versionCode, versionName, releaseNotes, downloadUrl, downloadFileName,
sizeBytes, downloadCount, releasePageUrl, 0, false);
}
private static UpdateInfo? BuildInfo(
int versionCode,
string versionName,
string releaseNotes,
string downloadUrl,
string downloadFileName,
long sizeBytes,
int downloadCount,
string releasePageUrl,
int minSupported,
bool forceUpdate)
{
if (string.IsNullOrWhiteSpace(downloadUrl))
{
return null;
}
return new UpdateInfo(
versionCode,
versionName,
releaseNotes,
downloadUrl,
downloadFileName,
sizeBytes,
downloadCount,
releasePageUrl,
minSupported,
forceUpdate);
}
private static async Task<JsonElement?> TryFetchManifestAsync(JsonElement assets, CancellationToken cancellationToken)
{
foreach (var asset in assets.EnumerateArray())
{
string name = asset.GetPropertyOrDefault("name").ToLowerInvariant();
if (!name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!name.Contains("update", StringComparison.OrdinalIgnoreCase) &&
!name.Contains("manifest", StringComparison.OrdinalIgnoreCase))
{
continue;
}
string url = asset.GetPropertyOrDefault("browser_download_url");
if (string.IsNullOrWhiteSpace(url))
{
continue;
}
try
{
using var manifestResponse = await HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
manifestResponse.EnsureSuccessStatusCode();
await using var stream = await manifestResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return document.RootElement.Clone();
}
catch
{
// Try next manifest candidate.
}
}
return null;
}
private static bool TryFindAsset(JsonElement assets, Func<JsonElement, bool> predicate, out JsonElement asset)
{
if (assets.ValueKind != JsonValueKind.Array)
{
asset = default;
return false;
}
foreach (var candidate in assets.EnumerateArray())
{
if (predicate(candidate))
{
asset = candidate;
return true;
}
}
asset = default;
return false;
}
private static bool IsApkAsset(JsonElement asset)
{
string name = asset.GetPropertyOrDefault("name").ToLowerInvariant();
return name.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase);
}
private static bool TryGetFirstAsset(JsonElement assets, out JsonElement asset)
{
if (assets.ValueKind == JsonValueKind.Array)
{
using var enumerator = assets.EnumerateArray().GetEnumerator();
if (enumerator.MoveNext())
{
asset = enumerator.Current;
return true;
}
}
asset = default;
return false;
}
private static string DeriveVersionName(string tagName, string fallback)
{
string baseName = string.IsNullOrWhiteSpace(tagName) ? fallback : tagName;
if (string.IsNullOrWhiteSpace(baseName))
{
return string.Empty;
}
return Regex.Replace(baseName, @"^[Vv]", string.Empty).Trim();
}
private static int ParseVersionCode(string versionName)
{
if (string.IsNullOrWhiteSpace(versionName))
{
return -1;
}
var parts = versionName.Split('.', StringSplitOptions.RemoveEmptyEntries);
int major = ParsePart(parts, 0);
int minor = ParsePart(parts, 1);
int patch = ParsePart(parts, 2);
return major * 10000 + minor * 100 + patch;
}
private static int ParsePart(IReadOnlyList<string> parts, int index)
{
if (index >= parts.Count)
{
return 0;
}
if (int.TryParse(Regex.Replace(parts[index], @"[^\d]", string.Empty), out int value))
{
return value;
}
return 0;
}
}

View File

@@ -1,169 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
namespace StreamPlayer.Desktop.Services;
public sealed class WindowsDnsService
{
private static readonly string[] PreferredDns = { "8.8.8.8", "8.8.4.4" };
private bool _attempted;
public async Task<DnsSetupResult> EnsureGoogleDnsAsync(CancellationToken cancellationToken)
{
if (!OperatingSystem.IsWindows())
{
return DnsSetupResult.CreateSuccess();
}
if (_attempted)
{
return DnsSetupResult.CreateSuccess();
}
_attempted = true;
bool needsElevation = !IsRunningAsAdministrator();
if (needsElevation)
{
var consent = PromptForElevation();
if (!consent)
{
return DnsSetupResult.CreateFailure("Se canceló la solicitud de permisos. Ejecuta la app como administrador o configura los DNS manualmente (8.8.8.8 y 8.8.4.4).");
}
}
var interfaces = GetEligibleInterfaces().ToList();
if (interfaces.Count == 0)
{
return DnsSetupResult.CreateSuccess("No se detectaron adaptadores de red activos para forzar DNS.");
}
foreach (var adapter in interfaces)
{
bool primary = await RunNetshAsync(
$"interface ipv4 set dns name=\"{adapter}\" static {PreferredDns[0]} primary",
cancellationToken,
needsElevation);
bool secondary = await RunNetshAsync(
$"interface ipv4 add dns name=\"{adapter}\" {PreferredDns[1]} index=2",
cancellationToken,
needsElevation);
if (!primary || !secondary)
{
return DnsSetupResult.CreateFailure($"No se pudo configurar DNS para el adaptador \"{adapter}\". Verifica permisos de administrador o configura manualmente los DNS de Google.");
}
}
return DnsSetupResult.CreateSuccess("DNS de Google aplicados correctamente a los adaptadores de red activos.");
}
private static bool IsRunningAsAdministrator()
{
if (!OperatingSystem.IsWindows())
{
return false;
}
try
{
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
catch
{
return false;
}
}
private static IEnumerable<string> GetEligibleInterfaces()
{
return NetworkInterface.GetAllNetworkInterfaces()
.Where(ni =>
ni.OperationalStatus == OperationalStatus.Up &&
ni.NetworkInterfaceType != NetworkInterfaceType.Loopback &&
ni.NetworkInterfaceType != NetworkInterfaceType.Tunnel &&
ni.Supports(NetworkInterfaceComponent.IPv4))
.Select(ni => ni.Name);
}
private static bool PromptForElevation()
{
try
{
var psi = new ProcessStartInfo
{
FileName = "netsh",
Arguments = "advfirewall show currentprofile",
UseShellExecute = true,
Verb = "runas",
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null)
{
return false;
}
process.WaitForExit();
return process.ExitCode == 0;
}
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
// User cancelled UAC prompt.
return false;
}
catch
{
return false;
}
}
private static async Task<bool> RunNetshAsync(string arguments, CancellationToken cancellationToken, bool elevate)
{
try
{
var psi = new ProcessStartInfo
{
FileName = "netsh",
Arguments = arguments,
UseShellExecute = elevate,
RedirectStandardOutput = !elevate,
RedirectStandardError = !elevate,
CreateNoWindow = true
};
if (elevate)
{
psi.Verb = "runas";
}
using var process = Process.Start(psi);
if (process == null)
{
return false;
}
await process.WaitForExitAsync(cancellationToken);
return process.ExitCode == 0;
}
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
{
return false;
}
catch
{
return false;
}
}
}
public sealed record DnsSetupResult(bool Success, string Message)
{
public static DnsSetupResult CreateSuccess(string message = "") => new(true, message);
public static DnsSetupResult CreateFailure(string message) => new(false, message);
}

View File

@@ -1,36 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Version>9.4.6</Version>
<AssemblyVersion>9.4.6.0</AssemblyVersion>
<FileVersion>9.4.6.0</FileVersion>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<Folder Include="Models\" />
<Folder Include="Services\" />
<Folder Include="Converters\" />
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.9" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.9" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.9" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.9" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.9">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.21" />
<PackageReference Include="System.Security.Principal.Windows" Version="5.0.0" />
</ItemGroup>
</Project>

View File

@@ -1,34 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.1.11312.151 d18.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StreamPlayer.Desktop", "StreamPlayer.Desktop.csproj", "{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|x64.ActiveCfg = Debug|x64
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|x64.Build.0 = Debug|x64
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|x86.ActiveCfg = Debug|x86
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|Any CPU.Build.0 = Release|Any CPU
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|x64.ActiveCfg = Release|x64
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|x64.Build.0 = Release|x64
{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|x86.ActiveCfg = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {78A0B5E4-153D-4093-85ED-127C1CDFB1B1}
EndGlobalSection
EndGlobal

View File

@@ -1,37 +0,0 @@
using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using StreamPlayer.Desktop.ViewModels;
namespace StreamPlayer.Desktop;
/// <summary>
/// Given a view model, returns the corresponding view if possible.
/// </summary>
[RequiresUnreferencedCode(
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
public class ViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
if (param is null)
return null;
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}

View File

@@ -1,301 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using StreamPlayer.Desktop.Models;
using StreamPlayer.Desktop.Services;
namespace StreamPlayer.Desktop.ViewModels;
public partial class MainWindowViewModel : ViewModelBase
{
private readonly EventService _eventService = new();
private readonly UpdateService _updateService = new();
private readonly DeviceRegistryService _deviceRegistryService = new();
private readonly WindowsDnsService _dnsService = new();
private readonly ReadOnlyCollection<ChannelSection> _sections;
private readonly AsyncRelayCommand _refreshEventsCommand;
private readonly RelayCommand<StreamChannel> _openChannelCommand;
private readonly RelayCommand<LiveEvent> _openEventCommand;
private bool _isInitialized;
private CancellationTokenSource? _eventsCts;
public ObservableCollection<StreamChannel> VisibleChannels { get; } = new();
public ObservableCollection<LiveEvent> VisibleEvents { get; } = new();
[ObservableProperty]
private ChannelSection? selectedSection;
[ObservableProperty]
private bool isLoading;
[ObservableProperty]
private bool isShowingEvents;
[ObservableProperty]
private bool isRefreshingEvents;
[ObservableProperty]
private bool isDeviceCheckInProgress = true;
[ObservableProperty]
private bool isDeviceAllowed;
[ObservableProperty]
private string statusMessage = string.Empty;
[ObservableProperty]
private string deviceStatusMessage = "Verificando dispositivo…";
public ReadOnlyCollection<ChannelSection> Sections => _sections;
public string RefreshButtonLabel => IsRefreshingEvents
? "Actualizando..."
: "Actualizar eventos";
public bool IsInteractionLocked => IsDeviceCheckInProgress || !IsDeviceAllowed;
public IAsyncRelayCommand RefreshEventsCommand => _refreshEventsCommand;
public IRelayCommand<StreamChannel> OpenChannelCommand => _openChannelCommand;
public IRelayCommand<LiveEvent> OpenEventCommand => _openEventCommand;
public event EventHandler<StreamChannel>? ChannelRequested;
public event EventHandler<string>? ErrorRaised;
public event EventHandler<UpdateInfo>? UpdateAvailable;
public event EventHandler<DeviceStatus>? DeviceStatusEvaluated;
public MainWindowViewModel()
{
var sections = SectionBuilder.BuildSections().ToList();
_sections = new ReadOnlyCollection<ChannelSection>(sections);
_refreshEventsCommand = new AsyncRelayCommand(
() => LoadEventsAsync(forceRefresh: true, CancellationToken.None),
() => IsShowingEvents && !IsRefreshingEvents && CanInteract());
_openChannelCommand = new RelayCommand<StreamChannel>(
channel =>
{
if (channel != null)
{
ChannelRequested?.Invoke(this, channel);
}
},
_ => CanInteract());
_openEventCommand = new RelayCommand<LiveEvent>(
evt =>
{
if (evt == null || string.IsNullOrWhiteSpace(evt.PageUrl))
{
return;
}
ChannelRequested?.Invoke(this, new StreamChannel(evt.Title, evt.PageUrl));
},
_ => CanInteract());
SelectedSection = _sections.FirstOrDefault();
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
if (_isInitialized)
{
return;
}
var dnsResult = await _dnsService.EnsureGoogleDnsAsync(cancellationToken);
if (!dnsResult.Success)
{
ErrorRaised?.Invoke(this, dnsResult.Message);
}
else if (!string.IsNullOrWhiteSpace(dnsResult.Message))
{
StatusMessage = dnsResult.Message;
}
DnsHelper.WarmUp();
_isInitialized = true;
if (SelectedSection != null)
{
await LoadSectionAsync(SelectedSection, cancellationToken);
}
}
public async Task CheckForUpdatesAsync(CancellationToken cancellationToken)
{
try
{
var info = await _updateService.CheckForUpdatesAsync(cancellationToken);
if (info != null && info.IsUpdateAvailable(AppVersion.VersionCode))
{
UpdateAvailable?.Invoke(this, info);
}
}
catch (Exception ex)
{
ErrorRaised?.Invoke(this, $"No se pudo verificar actualizaciones: {ex.Message}");
}
}
public async Task VerifyDeviceAsync(CancellationToken cancellationToken)
{
IsDeviceCheckInProgress = true;
DeviceStatusMessage = "Verificando dispositivo…";
try
{
var status = await _deviceRegistryService.SyncAsync(cancellationToken);
IsDeviceAllowed = !status.IsBlocked;
DeviceStatusMessage = status.IsBlocked ? "Dispositivo bloqueado" : string.Empty;
DeviceStatusEvaluated?.Invoke(this, status);
}
catch (Exception ex)
{
ErrorRaised?.Invoke(this, $"Error sincronizando dispositivo: {ex.Message}");
IsDeviceAllowed = true;
DeviceStatusMessage = string.Empty;
}
finally
{
IsDeviceCheckInProgress = false;
}
}
partial void OnSelectedSectionChanged(ChannelSection? value)
{
IsShowingEvents = value?.IsEvents == true;
_refreshEventsCommand.NotifyCanExecuteChanged();
if (!_isInitialized || value == null)
{
return;
}
_ = LoadSectionSafeAsync(value);
}
private async Task LoadSectionAsync(ChannelSection section, CancellationToken cancellationToken)
{
if (section.IsEvents)
{
await LoadEventsAsync(false, cancellationToken);
return;
}
CancelPendingEvents();
SetLoading(false);
VisibleEvents.Clear();
VisibleChannels.Clear();
foreach (var channel in section.Channels)
{
VisibleChannels.Add(channel);
}
StatusMessage = VisibleChannels.Count == 0
? "No hay canales en esta sección."
: string.Empty;
}
private async Task LoadEventsAsync(bool forceRefresh, CancellationToken cancellationToken)
{
if (SelectedSection?.IsEvents != true)
{
return;
}
CancelPendingEvents();
_eventsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var token = _eventsCts.Token;
try
{
SetLoading(true);
IsRefreshingEvents = true;
StatusMessage = string.Empty;
var events = await _eventService.GetEventsAsync(forceRefresh, token);
if (token.IsCancellationRequested)
{
return;
}
VisibleChannels.Clear();
VisibleEvents.Clear();
foreach (var evt in events.OrderBy(e => e.StartTimestamp <= 0 ? long.MaxValue : e.StartTimestamp))
{
VisibleEvents.Add(evt);
}
if (VisibleEvents.Count == 0)
{
StatusMessage = "No hay eventos próximos.";
}
}
catch (OperationCanceledException)
{
// Ignore cancellation.
}
catch (Exception ex)
{
StatusMessage = "No se pudieron cargar los eventos.";
ErrorRaised?.Invoke(this, $"No se pudieron cargar los eventos: {ex.Message}");
}
finally
{
if (!cancellationToken.IsCancellationRequested)
{
SetLoading(false);
}
IsRefreshingEvents = false;
_refreshEventsCommand.NotifyCanExecuteChanged();
}
}
private async Task LoadSectionSafeAsync(ChannelSection section)
{
try
{
await LoadSectionAsync(section, CancellationToken.None);
}
catch (Exception ex)
{
ErrorRaised?.Invoke(this, $"Error al actualizar la sección: {ex.Message}");
}
}
private void SetLoading(bool value)
{
IsLoading = value;
}
private void CancelPendingEvents()
{
if (_eventsCts != null)
{
_eventsCts.Cancel();
_eventsCts.Dispose();
_eventsCts = null;
}
}
private bool CanInteract() => !IsInteractionLocked;
private void NotifyInteractionChanged()
{
_openChannelCommand.NotifyCanExecuteChanged();
_openEventCommand.NotifyCanExecuteChanged();
_refreshEventsCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsInteractionLocked));
}
partial void OnIsDeviceCheckInProgressChanged(bool value)
{
NotifyInteractionChanged();
}
partial void OnIsDeviceAllowedChanged(bool value)
{
NotifyInteractionChanged();
}
partial void OnIsRefreshingEventsChanged(bool value)
{
_refreshEventsCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(RefreshButtonLabel));
}
}

View File

@@ -1,7 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace StreamPlayer.Desktop.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}

View File

@@ -1,35 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="StreamPlayer.Desktop.Views.BlockedDialog"
Width="420"
SizeToContent="Height"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="Dispositivo bloqueado">
<Border Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="Acceso bloqueado"
FontSize="20"
FontWeight="SemiBold"/>
<TextBlock x:Name="ReasonText"
TextWrapping="Wrap"/>
<StackPanel x:Name="TokenPanel"
Spacing="6"
IsVisible="False">
<TextBlock Text="Token para soporte:"/>
<TextBox x:Name="TokenText"
IsReadOnly="True"
Background="#111"
Foreground="White"
BorderBrush="#555"/>
<Button Content="Copiar token"
HorizontalAlignment="Left"
Click="OnCopyClicked"/>
</StackPanel>
<Button Content="Cerrar aplicación"
HorizontalAlignment="Right"
Width="180"
Click="OnCloseClicked"/>
</StackPanel>
</Border>
</Window>

Some files were not shown because too many files have changed in this diff Show More