Compare commits
30 Commits
windows-on
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ca31c70b3 | ||
|
|
49ed737663 | ||
|
|
7cdf5534b4 | ||
|
|
dfb9a3e1b0 | ||
|
|
e14e454c5e | ||
|
|
9360294d22 | ||
|
|
1526766630 | ||
|
|
4e92ee6149 | ||
|
|
cff9658060 | ||
|
|
43439e0a88 | ||
|
|
98473e3b30 | ||
|
|
a4e8deb45a | ||
|
|
a9da5a3b8e | ||
|
|
ab69fd1aa4 | ||
|
|
907c97464b | ||
|
|
19c31ebf1b | ||
|
|
97adc46509 | ||
|
|
ec360cf303 | ||
|
|
3c1a323b35 | ||
|
|
e34323c2da | ||
|
|
dc5f6484b2 | ||
|
|
305e1362a6 | ||
|
|
e9773c1353 | ||
|
|
5bd1a2737d | ||
|
|
e3aafd3290 | ||
|
|
b6612c4544 | ||
|
|
df296d7172 | ||
|
|
bac564eb4f | ||
|
|
05625ffe50 | ||
|
|
c40448b997 |
2
.env
@@ -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
@@ -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
@@ -1 +0,0 @@
|
||||
StreamPlayer
|
||||
6
.idea/compiler.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/deploymentTargetSelector.xml
generated
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
[](https://android.com)
|
||||
[](https://www.oracle.com/java/)
|
||||
[](https://android-developers.blogspot.com/)
|
||||
[](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
|
||||
|
||||
0
.idea/.gitignore → app/.idea/.gitignore
generated
vendored
1017
app/.idea/caches/deviceStreaming.xml
generated
Normal file
8
.idea/gradle.xml → app/.idea/gradle.xml
generated
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.** { *; }
|
||||
59
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
92
app/src/main/java/com/streamplayer/ChannelAdapter.java
Normal 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));
|
||||
}
|
||||
}
|
||||
99
app/src/main/java/com/streamplayer/ChannelRepository.java
Normal 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;
|
||||
}
|
||||
}
|
||||
121
app/src/main/java/com/streamplayer/EventAdapter.java
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
49
app/src/main/java/com/streamplayer/EventItem.java
Normal 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;
|
||||
}
|
||||
}
|
||||
218
app/src/main/java/com/streamplayer/EventRepository.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
418
app/src/main/java/com/streamplayer/MainActivity.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
app/src/main/java/com/streamplayer/NetworkUtils.java
Normal 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;
|
||||
}
|
||||
}
|
||||
511
app/src/main/java/com/streamplayer/PlayerActivity.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
88
app/src/main/java/com/streamplayer/SectionAdapter.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/com/streamplayer/StreamChannel.java
Normal 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;
|
||||
}
|
||||
}
|
||||
489
app/src/main/java/com/streamplayer/StreamUrlResolver.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
549
app/src/main/java/com/streamplayer/UpdateManager.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
app/src/main/java/com/streamplayer/VlcPlayerConfig.java
Normal 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() {
|
||||
}
|
||||
}
|
||||
6
app/src/main/res/color/section_text_selector.xml
Normal 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>
|
||||
18
app/src/main/res/drawable/banner_streamplayer.xml
Normal 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>
|
||||
39
app/src/main/res/drawable/bg_channel_item_selector.xml
Normal 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>
|
||||
6
app/src/main/res/drawable/bg_section_indicator.xml
Normal 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>
|
||||
45
app/src/main/res/drawable/btn_refresh_selector.xml
Normal 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>
|
||||
13
app/src/main/res/drawable/ic_channel_default.xml
Normal 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>
|
||||
7
app/src/main/res/drawable/scrollbar_vertical.xml
Normal 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>
|
||||
143
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
75
app/src/main/res/layout/activity_player.xml
Normal 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>
|
||||
34
app/src/main/res/layout/item_channel.xml
Normal 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>
|
||||
57
app/src/main/res/layout/item_event.xml
Normal 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>
|
||||
23
app/src/main/res/layout/item_section.xml
Normal 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>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 585 B |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 585 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 809 B |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 809 B |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
4
app/src/main/res/values-sw720dp/integers.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<integer name="channel_grid_span">5</integer>
|
||||
</resources>
|
||||
16
app/src/main/res/values/colors.xml
Normal 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>
|
||||
4
app/src/main/res/values/integers.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<integer name="channel_grid_span">3</integer>
|
||||
</resources>
|
||||
43
app/src/main/res/values/strings.xml
Normal 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>
|
||||
9
app/src/main/res/values/themes.xml
Normal 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>
|
||||
6
app/src/main/res/xml/file_paths.xml
Normal 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
@@ -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
@@ -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
@@ -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
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
10
release/update-manifest.example.json
Normal 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
@@ -0,0 +1,9 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
rootProject.name = "StreamPlayer"
|
||||
include ':app'
|
||||
10
update-manifest.json
Normal 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"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
Before Width: | Height: | Size: 172 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace StreamPlayer.Desktop.Models;
|
||||
|
||||
public sealed record StreamChannel(string Name, string PageUrl);
|
||||
@@ -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]}";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace StreamPlayer.Desktop.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
@@ -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>
|
||||