28 Commits
Author SHA1 Message Date
renato97 f247f9ddee Keep only Windows desktop source 2025-12-17 19:31:05 +00:00
renato97 8921d7f2a6 Add Windows desktop version 2025-12-17 19:20:55 +00:00
renato97 93dbe0941e Update v9.4.6: Custom Update Dialog & Focus States 2025-11-25 19:23:17 +00:00
renato97 22d2cf9eda Update v9.4.5: Forced Update Flow & Focus Highlight 2025-11-25 19:16:12 +00:00
renato97 d2c3041b0a Update v9.4.4: Dialog Contrast & Event Refresh Button 2025-11-25 19:10:08 +00:00
renato97 77c417117a Update v9.4.3: Manual Events Refresh & Background Sync 2025-11-25 19:01:21 +00:00
renato97andClaude 2c65578bdd Update v9.4.2: Enhanced UI Theme & Visual Consistency
- Incremented version to 9.4.2 (versionCode: 94200)
- Added custom AlertDialog theme with white text styling
- Enhanced visual consistency for all dialog components
- Improved theme overlay for better readability
- Applied custom styling to update and blocked dialogs
- Better contrast and visual hierarchy in dialogs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 00:13:29 +01:00
renato97andClaude 6aef195f30 Update v9.4.1: Enhanced Playback & Device Management
- Incremented version to 9.4.1 (versionCode: 94100)
- Added keep screen on functionality during video playback
- Implemented device deletion in dashboard with confirmation
- Enhanced device management with delete capability
- Improved user experience during media playback
- Better device lifecycle management in dashboard
- Added confirmation dialog for device deletion

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 00:07:04 +01:00
renato97andClaude cc4696dec2 Update v9.4.0: Advanced Telegram Integration & Custom UI
- Incremented version to 9.4.0 (versionCode: 94000)
- Added custom blocked dialog layout with improved UX
- Implemented Telegram commands: /allow, /deny, /pending
- Enhanced Telegram polling for real-time device management
- Custom token display with click-to-copy functionality
- Improved device verification workflow via Telegram
- Better error handling and user feedback
- Enhanced documentation for Telegram integration
- Improved token validation and management system

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 23:15:37 +01:00
renato97andClaude 73a4f81341 Update v9.3.1: Enhanced UX and Environment Configuration
- Incremented version to 9.3.1 (versionCode: 93100)
- Added copy token button in blocked dialog for better UX
- Fixed environment variable configuration for Telegram integration
- Improved clipboard functionality for token sharing
- Enhanced dashboard environment handling with dotenv
- Corrected variable names for Telegram configuration
- Improved error handling for token copy operations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 22:18:31 +01:00
renato97andClaude adadb89232 Update v9.3.0: Enhanced Security with Telegram Integration
- Incremented version to 9.3.0 (versionCode: 93000)
- Added Telegram integration for device notifications
- Implemented token-based verification system
- Enhanced device registry with IP/country detection
- Added split token verification for admin/user validation
- Improved dashboard with real-time notifications
- Enhanced blocking system with token verification
- Added geo-location tracking for devices
- Improved device management interface
- Enhanced security controls and monitoring

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 22:10:21 +01:00
renato97andClaude d326f4c549 Update v9.2.0: Enhanced UI and generic update messages
- Incremented version to 9.2.0 (versionCode: 92000)
- Simplified update dialog buttons: "Actualizar ahora" → "Actualizar"
- Changed "Cerrar aplicación" to "Salir" for better UX
- Maintained security and device management features
- Improved user interface text for better user experience

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:52:11 +01:00
renato97andClaude 84f19449b5 Update version to v9.1.2 with IP fix
- Incremented versionCode to 91020 and versionName to 9.1.2
- APK built with Device Registry URL pointing to public IP
- Fixed localhost issue for remote device registration

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:43:15 +01:00
renato97andClaude 60f5830ac3 Fix Device Registry URL: Update localhost to public IP
- Changed DEVICE_REGISTRY_URL from localhost to 194.163.191.200:4000
- Allows remote device registration with dashboard
- Enables proper Device Registry functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:42:36 +01:00
renato97andClaude 8fc8991efa Add v9.1.1: Device registry and remote blocking system
- Implement DeviceRegistry for remote device management
- Add dashboard for device tracking and blocking
- Remote device blocking capability with admin control
- Support for device aliasing and notes
- Enhanced device management interface
- Dashboard with real-time device status
- Configurable registry URL in build config

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:37:11 +01:00
renato97andClaude cf11aa04bc Add v9.0: Auto-update system with Gitea integration
- Implement UpdateManager to check Gitea releases for new versions
- Add update dialogs with mandatory/optional update support
- Integrate DownloadManager for APK downloads
- Add FileProvider configuration for app installation
- Support update-manifest.json for version control enforcement
- Add comprehensive update strings and error handling
- Include gradlew executable permissions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 21:14:14 +01:00
renato97andClaude d2d66a7906 Android TV v9.0 Final - Enhanced Section-Based Layout
Android TV Enhancements:
- SectionAdapter.java: New section-based content organization
- Enhanced MainActivity with improved section management
- Optimized ChannelAdapter for better TV navigation
- Modernized activity_main.xml with section layout
- Professional color scheme for Android TV
- Updated strings for better TV experience

New UI Components:
- bg_section_indicator.xml: Visual section indicators
- item_section.xml: Section-based layout structure
- color/**: State-aware colors for TV focus states
- Enhanced focus management for D-pad navigation

Technical Improvements:
- Better memory management with section-based loading
- Improved RecyclerView performance
- Enhanced visual feedback for TV remote control
- Professional color palette optimized for TV screens
- Consistent design language throughout app

All v8.0 features maintained:
- Audio background fix (onStop() lifecycle)
- Real-time events with Argentina timezone
- Alphabetical channel sorting
- DNS bypass for global access
- Tab navigation (Channels/Events)
- Complete Android TV optimization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 03:25:52 +00:00
renato97andClaude 5ade6350eb Fix v8.0: Audio Background Playback Issue
Critical Fix:
- Fixed audio continuing to play when leaving the app
- PlayerActivity now properly manages ExoPlayer lifecycle
- No more background audio when switching to other apps

Lifecycle Management:
- onStop(): Releases ExoPlayer when app goes to background
- onStart(): Reloads stream only if player is null (prevents duplicates)
- onDestroy(): Final cleanup of player resources
- Proper memory management prevents leaks

Android TV Integration:
- Perfect multitasking behavior for TV environment
- Clean switching between streaming apps (YouTube, Netflix, etc.)
- No audio interference when navigating Android TV interface
- Stream resumes automatically when returning to app

Technical Details:
- Dual release points: onStop() and onDestroy() for safety
- Smart reconstruction: Only reload when necessary
- Memory safe: No ExoPlayer leaks or resource issues
- TV optimized: Seamless integration with Android TV ecosystem

User Experience:
- Background audio completely stopped when leaving app
- Clean transitions between different streaming services
- Automatic stream resumption when returning to StreamPlayer
- Professional Android TV behavior

All existing features maintained:
- Tab interface with visible focus (Channels/Events)
- Real-time events with Argentina timezone
- Alphabetical channel sorting
- DNS bypass for global access
- TV-optimized navigation and UI

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 22:11:05 +00:00
renato97andClaude 96c4c360ee Add v7.0: Tabs UI and Real-Time Events
Major Features:
- Dual tab interface (Channels and Events) with visible focus
- Real-time event status calculation (Live, Upcoming, Finished)
- Smart caching system for events (24-hour cache)
- Argentina timezone support (America/Argentina/Buenos_Aires)

UI/TV Improvements:
- Focusable tabs with bg_tab_selector for D-pad navigation
- Visual feedback with highlighted borders on focused tabs
- Consistent design between tabs and content cards
- Enhanced TV navigation experience

Real-Time Event System:
- EventRepository: Centralized event management with 24h cache
- EventAdapter: Optimized RecyclerView for event listings
- EventItem: Structured data model for events
- Dynamic status calculation (remaining time, live duration, completion)
- Automatic link normalization to global2.php

Technical Implementation:
- activity_main.xml: Complete dual-tab layout
- item_event.xml: Dedicated event item layout with RecyclerView
- bg_tab_selector.xml: Tab states (selected, focused, pressed)
- MainActivity.java: Tab switching and event management
- Automatic URL processing for seamless PlayerActivity integration

Time Zone Features:
- Argentina local time (America/Argentina/Buenos_Aires)
- Real-time status updates without page refresh
- "En Xh Ym" for upcoming events
- "En vivo durante 2h" status for live events
- "Finalizado" status for completed events

Solutions:
- Fixed web page "En vivo" not updating issue
- Provides always-current event status in app
- Direct event-to-player navigation without manual intervention
- Improved TV navigation with clear visual feedback

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 21:44:04 +00:00
renato97andClaude ab43ce7794 Add alphabetical sorting for Android TV channel list
Alphabetical Sorting Features:
- ChannelRepository updated with alphabetical sorting
- ChannelAdapter optimized for sorted display
- Channel focus and selection improvements
- Added arrays.xml for channel categories and sorting
- Enhanced UI components for TV navigation

Code Changes:
- ChannelRepository: addSortChannels() method
- ChannelAdapter: optimized for alphabetical display
- AndroidManifest.xml: updated for sorting features
- item_channel.xml: improved focus states
- bg_channel_item_selector.xml: enhanced visual feedback

TV Navigation Improvements:
- Consistent alphabetical order (A-Z)
- Better focus management for D-Pad navigation
- Enhanced visual indicators for selected channels
- Improved readability on large screens
- Quick channel location with remote control

This improves the Android TV user experience by making channel discovery faster and more intuitive.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 21:06:09 +00:00
renato97andClaude 24a4c93fb5 Merge DNS bypass features into Android TV edition
DNS Bypass Features:
- Multiple DNS providers (Google, Cloudflare, OpenDNS, Quad9)
- Geographic restriction bypass
- Smart DNS rotation and fallback
- Enhanced build.gradle with DNS libraries
- DNS permissions in AndroidManifest.xml
- TV-optimized DNS configuration
- Streaming geo-block circumvention
- Regional content access

Resolved merge conflicts:
- Combined banner design features (antialias + 12dp corners)
- Merged TV and mobile DNS configurations
- Unified streaming capabilities

This merges the DNS bypass functionality from mobile development into the Android TV main branch for v5.0 release.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:55:18 +00:00
renato97andClaude eba119493c Add DNS bypass functionality for geographic restrictions
DNS Features:
- Multiple DNS providers (Google, Cloudflare, OpenDNS)
- Automatic DNS rotation for bypassing geo-blocks
- Enhanced DNS caching and resolution
- Fallback DNS mechanisms
- Smart DNS switching based on streaming success

Code Changes:
- Update build.gradle with DNS libraries
- Add DNS bypass configuration to PlayerActivity
- Update AndroidManifest.xml for DNS permissions
- Add TV-specific DNS resources

Bypass Features:
- Geo-block circumvention
- Regional content access
- Alternative DNS routes
- Connection optimization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:53:09 +00:00
renato97 87780cddee Merge mobile branch with Android TV changes 2025-11-14 19:00:27 +00:00
renato97andClaude 672774e216 Add Android TV Edition support
- Update AndroidManifest.xml for TV compatibility
- Add ChannelAdapter with D-pad navigation support
- Update MainActivity for TV UI optimization
- Add Android TV specific resources:
  - banner_streamplayer.xml for TV launcher
  - bg_channel_item_selector for focus states
  - values-sw720dp for large screens
  - integers.xml for TV configurations
- Update item_channel.xml for TV navigation
- Remove unused mobile-specific drawable

Features:
- Android TV Leanback support
- D-pad navigation optimization
- TV-optimized layouts and focus management
- Large screen resources for TV displays

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:00:17 +00:00
renato97andClaude e6b4d0825b Update source code with grid layout and channel management
Major changes:
- Add ChannelAdapter for grid display functionality
- Add ChannelRepository for data management
- Add PlayerActivity for dedicated video playback
- Add StreamChannel model for channel representation
- Update MainActivity to support grid layout
- Update activity_main.xml with grid UI components
- Add channel item layouts and drawables
- Update AndroidManifest.xml with new activity
- Update build.gradle dependencies

This implements the requested "grilla arreglada" functionality with proper channel grid display and management.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 18:50:54 +00:00
renato97andClaude d861eca52f Add release APK build
- Copy debug APK as release version
- APK location: app/build/outputs/apk/release/app-release.apk
- File size: 6.8MB
- Ready for distribution

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 18:34:14 +00:00
renato97andClaude 701b710acc Add comprehensive README.md and improve .gitignore
- Add professional README with complete project documentation
- Include architecture overview, installation guides, and troubleshooting
- Improve .gitignore with comprehensive Android development exclusions
- Add badges, project structure, and contribution guidelines

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 18:21:45 +00:00
renato97andClaude 81da7510ef Initial commit: Complete project setup
Add all project files and configuration

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 18:19:18 +00:00
106 changed files with 2847 additions and 4521 deletions
+4
View File
@@ -0,0 +1,4 @@
GITEA_TOKEN=7921aa22187b39125d29399d26f527ba26a2fb5b
GEMINI_API_KEY=AIzaSyDWOgyAJqscuPU6iSpS6gxupWBm4soNw5o
TELEGRAM_BOT_TOKEN=8593525164:AAGCX9B_RJGN35_F7tSB72rEZhS_4Zpcszs
TELEGRAM_CHAT_ID=692714536
+11
View File
@@ -129,3 +129,14 @@ 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/
View File
Generated
+1
View File
@@ -0,0 +1 @@
StreamPlayer
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?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 -1
View File
@@ -5,7 +5,13 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="ms-11" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
View File
+7
View File
@@ -0,0 +1,7 @@
<?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>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
-57
View File
@@ -1,57 +0,0 @@
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"]
+41 -62
View File
@@ -1,75 +1,54 @@
# 📺 StreamPlayer - Android IPTV Application
## StreamPlayer Desktop (Windows)
## 📝 Description / Descripción
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.
**English:**
A modern Android IPTV streaming application built with Java. Features include live TV channel browsing, event-based content, video-on-demand (VOD), and a robust DNS fallback system for uninterrupted streaming.
### Estructura
**Español:**
Una aplicación moderna de streaming IPTV para Android desarrollada en Java. Incluye navegación de canales de TV en vivo, contenido basado en eventos, video bajo demanda (VOD) y un sistema robusto de respaldo DNS para streaming ininterrumpido.
```
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
```
## 🚀 Features / Características
### Requisitos
**English:**
- Live TV channel browsing with categories
- Event-based content (sports, movies, series)
- Video-on-demand (VOD) support
- Multiple stream URL resolution strategies
- DNS fallback system for reliability
- TV-optimized UI with remote control support
- Real-time updates via Gitea releases
- .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`.
**Español:**
- Navegación de canales de TV en vivo con categorías
- Contenido basado en eventos (deportes, películas, series)
- Soporte para Video bajo demanda (VOD)
- Múltiples estrategias de resolución de URLs de streaming
- Sistema de respaldo DNS para mayor confiabilidad
- Interfaz optimizada para TV con soporte de control remoto
- Actualizaciones en tiempo real mediante releases de Gitea
### Cómo compilar
## 🛠️ Tech Stack / Stack Tecnológico
```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
```
- **Language:** Java
- **Platform:** Android (API 21+)
- **Build System:** Gradle
- **Video Player:** VLC (libVLC)
- **Networking:** OkHttp
- **Architecture:** Repository Pattern
El `.exe` resultante queda en `windows/StreamPlayer.Desktop/bin/Release/net8.0/win-x64/publish/`.
## 📱 Screenshots / Capturas de Pantalla
### Características clave
*Coming soon / Próximamente*
- **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.
## 🔧 Installation / Instalación
### Uso
**English:**
1. Download the latest APK from the releases page
2. Enable "Install from Unknown Sources" in Android settings
3. Install the APK
4. Configure your IPTV provider settings
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.
**Español:**
1. Descarga el último APK de la página de releases
2. Habilita "Instalar desde fuentes desconocidas" en ajustes de Android
3. Instala el APK
4. Configura los ajustes de tu proveedor IPTV
## 🌐 API Integration / Integración API
- Gitea API for release management
- Custom IPTV stream URL resolution
- DNS over HTTPS (DoH) support
## 👨‍💻 Author / Autor
**Renato CBC**
- GitHub: [renato97](https://gitea.cbcren.online/renato97)
## 📄 License / Licencia
Private project / Proyecto privado
---
**Tags / Etiquetas:** `android` `iptv` `streaming` `java` `vlc` `tv-app`
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`.
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,10 +0,0 @@
<?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
View File
@@ -1,62 +0,0 @@
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
View File
@@ -1,31 +0,0 @@
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
View File
@@ -1,24 +0,0 @@
# 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
View File
@@ -1,59 +0,0 @@
<?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>
@@ -1,92 +0,0 @@
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));
}
}
@@ -1,99 +0,0 @@
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;
}
}
@@ -1,121 +0,0 @@
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";
}
}
}
@@ -1,49 +0,0 @@
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;
}
}
@@ -1,218 +0,0 @@
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;
}
}
}
@@ -1,418 +0,0 @@
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);
}
}
}
@@ -1,126 +0,0 @@
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;
}
}
@@ -1,511 +0,0 @@
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();
}
}
}
@@ -1,88 +0,0 @@
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);
}
}
}
@@ -1,19 +0,0 @@
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;
}
}
@@ -1,489 +0,0 @@
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;
}
}
}
@@ -1,14 +0,0 @@
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() {
}
}
@@ -1,6 +0,0 @@
<?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>
@@ -1,18 +0,0 @@
<?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>
@@ -1,39 +0,0 @@
<?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>
@@ -1,6 +0,0 @@
<?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>
@@ -1,45 +0,0 @@
<?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>
@@ -1,13 +0,0 @@
<?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>
@@ -1,7 +0,0 @@
<?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
View File
@@ -1,143 +0,0 @@
<?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>
@@ -1,75 +0,0 @@
<?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
View File
@@ -1,34 +0,0 @@
<?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
View File
@@ -1,57 +0,0 @@
<?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
View File
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:focusable="true"
android:focusableInTouchMode="true"
android:background="@drawable/bg_section_indicator"
android:padding="8dp">
<TextView
android:id="@+id/section_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/section_text_selector"
android:textSize="14sp"
android:textStyle="bold"
tools:text="Canales" />
</LinearLayout>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="channel_grid_span">5</integer>
</resources>
-16
View File
@@ -1,16 +0,0 @@
<?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
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="channel_grid_span">3</integer>
</resources>
-43
View File
@@ -1,43 +0,0 @@
<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
View File
@@ -1,9 +0,0 @@
<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
View File
@@ -1,6 +0,0 @@
<?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
View File
@@ -1,20 +0,0 @@
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
}
-3
View File
@@ -1,3 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=false
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
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
Vendored
-248
View File
@@ -1,248 +0,0 @@
#!/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" "$@"
Vendored
-92
View File
@@ -1,92 +0,0 @@
@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
View File
@@ -1,7 +0,0 @@
# 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
View File
@@ -1,10 +0,0 @@
{
"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
View File
@@ -1,9 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "StreamPlayer"
include ':app'
-10
View File
@@ -1,10 +0,0 @@
{
"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"
}
+23
View File
@@ -0,0 +1,23 @@
<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>
+49
View File
@@ -0,0 +1,49 @@
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);
}
}
}
@@ -0,0 +1,14 @@
namespace StreamPlayer.Desktop;
/// <summary>
/// Centraliza los metadatos de versión y endpoints compartidos con la app Android original.
/// </summary>
public static class AppVersion
{
public const string VersionName = "9.4.6";
public const int VersionCode = 94600;
public const string DeviceRegistryUrl = "http://194.163.191.200:4000";
public const string LatestReleaseApi =
"https://gitea.cbcren.online/api/v1/repos/renato97/app/releases/latest";
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

@@ -0,0 +1,23 @@
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;
}
@@ -0,0 +1,27 @@
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;
}
}
@@ -0,0 +1,63 @@
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;
}
}
@@ -0,0 +1,14 @@
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;
}
@@ -0,0 +1,6 @@
namespace StreamPlayer.Desktop.Models;
public sealed record DeviceStatus(bool IsBlocked, string Reason, string TokenPart)
{
public static DeviceStatus Allowed() => new(false, string.Empty, string.Empty);
}
@@ -0,0 +1,37 @@
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}";
}
}
}
@@ -0,0 +1,3 @@
namespace StreamPlayer.Desktop.Models;
public sealed record StreamChannel(string Name, string PageUrl);
@@ -0,0 +1,52 @@
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]}";
}
}
+21
View File
@@ -0,0 +1,21 @@
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();
}
@@ -0,0 +1,90 @@
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();
}
}
@@ -0,0 +1,91 @@
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();
}
}
@@ -0,0 +1,34 @@
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.
}
}
});
}
}
@@ -0,0 +1,198 @@
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;
}
}
@@ -0,0 +1,83 @@
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;
}
}
@@ -0,0 +1,149 @@
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);
}
@@ -0,0 +1,236 @@
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;
}
}
@@ -0,0 +1,169 @@
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);
}
@@ -0,0 +1,36 @@
<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>
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,37 @@
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;
}
}
@@ -0,0 +1,301 @@
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));
}
}
@@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace StreamPlayer.Desktop.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}
@@ -0,0 +1,35 @@
<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>
@@ -0,0 +1,67 @@
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
namespace StreamPlayer.Desktop.Views;
public partial class BlockedDialog : Window
{
private readonly string _token;
private TextBlock? _reasonText;
private StackPanel? _tokenPanel;
private TextBox? _tokenText;
public BlockedDialog() : this(string.Empty, string.Empty)
{
}
public BlockedDialog(string reason, string token)
{
_token = token ?? string.Empty;
InitializeComponent();
if (_reasonText != null)
{
_reasonText.Text = string.IsNullOrWhiteSpace(reason)
? "Tu dispositivo fue bloqueado por el administrador."
: reason;
}
if (!string.IsNullOrWhiteSpace(_token))
{
if (_tokenPanel != null)
{
_tokenPanel.IsVisible = true;
}
if (_tokenText != null)
{
_tokenText.Text = _token;
}
}
}
private async void OnCopyClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (string.IsNullOrEmpty(_token))
{
return;
}
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard != null)
{
await clipboard.SetTextAsync(_token);
}
}
private void OnCloseClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
Close(true);
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
_reasonText = this.FindControl<TextBlock>("ReasonText");
_tokenPanel = this.FindControl<StackPanel>("TokenPanel");
_tokenText = this.FindControl<TextBox>("TokenText");
}
}
@@ -0,0 +1,192 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:StreamPlayer.Desktop.ViewModels"
xmlns:models="using:StreamPlayer.Desktop.Models"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="StreamPlayer.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/avalonia-logo.ico"
Width="1200"
Height="720"
Title="StreamPlayer Desktop">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<Grid ColumnDefinitions="220,*" RowDefinitions="Auto,Auto,*"
IsEnabled="{Binding IsInteractionLocked, Converter={StaticResource InverseBooleanConverter}}">
<Border Grid.RowSpan="3"
Background="#111217"
Padding="0">
<ListBox ItemsSource="{Binding Sections}"
SelectedItem="{Binding SelectedSection}"
BorderThickness="0"
Background="Transparent"
Foreground="White">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Margin" Value="4"/>
<Setter Property="Padding" Value="14"/>
<Setter Property="CornerRadius" Value="6"/>
</Style>
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#2A2F3A"/>
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="#3A7AFE"/>
<Setter Property="Foreground" Value="White"/>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="models:ChannelSection">
<TextBlock Text="{Binding Title}"
FontSize="15"
TextWrapping="Wrap"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="20,16,20,8"
Spacing="12">
<TextBlock Text="{Binding SelectedSection.Title, FallbackValue=StreamPlayer}"
FontSize="26"
FontWeight="SemiBold"/>
<Button Content="{Binding RefreshButtonLabel}"
Command="{Binding RefreshEventsCommand}"
IsVisible="{Binding IsShowingEvents}"
IsEnabled="{Binding IsRefreshingEvents, Converter={StaticResource InverseBooleanConverter}}"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Padding="12,4"/>
</StackPanel>
<TextBlock Grid.Column="1"
Grid.Row="1"
Text="{Binding StatusMessage}"
Margin="20,0,20,8"
Foreground="#8A8F9C"
FontStyle="Italic"/>
<Grid Grid.Column="1" Grid.Row="2">
<ScrollViewer IsVisible="{Binding IsShowingEvents, Converter={StaticResource InverseBooleanConverter}}"
Margin="10"
VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding VisibleChannels}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel IsItemsHost="True" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:StreamChannel">
<Button Command="{Binding $parent[Window].DataContext.OpenChannelCommand}"
CommandParameter="{Binding .}"
Margin="6"
Padding="16"
Width="220"
HorizontalContentAlignment="Stretch">
<StackPanel>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Text="{Binding PageUrl}" FontSize="11" Foreground="#8A8F9C"
TextWrapping="Wrap"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<ScrollViewer IsVisible="{Binding IsShowingEvents}"
Margin="10"
VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding VisibleEvents}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:LiveEvent">
<Border Margin="0,0,0,8"
Padding="14"
Background="#181C24"
CornerRadius="8">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="{Binding Title}"
FontSize="16"
FontWeight="SemiBold"
TextWrapping="Wrap"/>
<TextBlock Text="{Binding Subtitle}"
Foreground="#8A8F9C"
Margin="0,4,0,0"/>
<TextBlock Text="{Binding ChannelName}"
Foreground="#6FA8FF"
FontSize="13"/>
</StackPanel>
<StackPanel Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Spacing="6">
<Button Content="Ver canal"
Command="{Binding $parent[Window].DataContext.OpenEventCommand}"
CommandParameter="{Binding .}"
Padding="12,4"/>
<Border Background="{Binding IsLive, Converter={StaticResource LiveStatusBrushConverter}}"
CornerRadius="4"
Padding="6"
HorizontalAlignment="Right">
<TextBlock Text="{Binding Status}"
Foreground="White"
FontSize="12"/>
</Border>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Border Background="#CC0E1016"
IsVisible="{Binding IsLoading}">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
<ProgressBar Width="220" IsIndeterminate="True"/>
<TextBlock Text="Cargando contenido..."
Foreground="White"
FontSize="16"/>
</StackPanel>
</Border>
</Grid>
<Border Grid.ColumnSpan="2"
Grid.RowSpan="3"
Background="#CC000000"
IsVisible="{Binding IsInteractionLocked}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<ProgressBar Width="260"
IsIndeterminate="True"
IsVisible="{Binding IsDeviceCheckInProgress}"/>
<TextBlock Text="{Binding DeviceStatusMessage}"
Foreground="White"
FontSize="18"
TextAlignment="Center"
TextWrapping="Wrap"
Margin="0,4"/>
<TextBlock Text="Espera a que verifiquemos tu dispositivo…"
FontSize="14"
Foreground="#DDDDDD"
TextAlignment="Center"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
</Grid>
</Window>

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