feat: Initial IPTV app with Google DNS and in-app updates

This commit is contained in:
Renato
2026-01-28 22:02:28 +00:00
commit 88dfda1482
35 changed files with 7720 additions and 0 deletions

556
README.md Normal file
View File

@@ -0,0 +1,556 @@
# IPTV Player
A modern, feature-rich Android IPTV streaming application built with Jetpack Compose and Media3 ExoPlayer. This app allows users to stream live TV channels from M3U/M3U8 playlists with a beautiful Material Design 3 interface.
## Features
### Core Features
- **IPTV Streaming**: Stream live TV channels from M3U/M3U8 playlists
- **Channel Categories**: Automatic grouping of channels by category (Sports, News, Movies, Kids, etc.)
- **Search Functionality**: Real-time search with debouncing for quick channel discovery
- **Favorites Management**: Mark channels as favorites for quick access
- **Offline Caching**: 24-hour channel cache for offline browsing
- **Pull-to-Refresh**: Swipe down to refresh channel list
### Player Features
- **Media3 ExoPlayer**: Modern, extensible media player with HLS/DASH support
- **Fullscreen Mode**: Immersive landscape playback with gesture controls
- **Audio Focus Management**: Proper audio focus handling with ducking support
- **Multiple Audio Tracks**: Support for multi-language audio streams
- **Video Quality Selection**: Auto and manual quality selection
- **Subtitle Support**: Multi-language subtitle track selection
- **Picture-in-Picture**: Background playback support (Android O+)
### UI/UX Features
- **Material Design 3**: Modern, responsive UI with dynamic theming
- **Jetpack Compose**: Declarative UI with smooth animations
- **Responsive Grid Layout**: Adaptive channel grid (2 columns)
- **Category Filter Chips**: Horizontal scrolling category selection
- **Loading States**: Skeleton screens and progress indicators
- **Error Handling**: User-friendly error messages with retry options
### TV Support
- **Android TV Compatible**: Leanback launcher support
- **D-Pad Navigation**: Full remote control support
- **TV-Optimized UI**: Large, focusable UI elements
## Screenshots
> **Note**: Add your screenshots to the `screenshots/` directory and update the paths below.
| Channels List | Player | Categories |
|--------------|--------|------------|
| ![Channels](screenshots/channels.png) | ![Player](screenshots/player.png) | ![Categories](screenshots/categories.png) |
| Search | Favorites | Settings |
|--------|-----------|----------|
| ![Search](screenshots/search.png) | ![Favorites](screenshots/favorites.png) | ![Settings](screenshots/settings.png) |
## Prerequisites
Before building the project, ensure you have the following installed:
### Required
- **Android Studio Hedgehog (2023.1.1)** or later
- **JDK 17** or later
- **Android SDK** with the following:
- SDK Platform API 34 (Android 14)
- Build-Tools 34.0.0
- Android SDK Command-line Tools
### Optional
- **Git** for version control
- **Android Device** or **Emulator** running Android 7.0 (API 24) or higher
## Installation
### 1. Clone the Repository
```bash
git clone https://github.com/yourusername/iptv-app.git
cd iptv-app
```
### 2. Open in Android Studio
1. Launch Android Studio
2. Select "Open an existing Android Studio project"
3. Navigate to the cloned directory and click "OK"
4. Wait for Gradle sync to complete
### 3. Build the Project
```bash
# Build debug APK
./gradlew assembleDebug
# Build release APK (requires signing configuration)
./gradlew assembleRelease
```
### 4. Run on Device
```bash
# Connect your Android device or start an emulator
# Run the app
./gradlew installDebug
```
## Configuration
### Changing the M3U Playlist URL
The default M3U URL can be configured in the following locations:
#### Option 1: Build Configuration (Recommended)
Edit `app/build.gradle.kts` and add a build config field:
```kotlin
android {
defaultConfig {
buildConfigField("String", "DEFAULT_M3U_URL", "\"https://your-playlist-url.com/playlist.m3u\"")
}
}
```
Then access it in code:
```kotlin
val m3uUrl = BuildConfig.DEFAULT_M3U_URL
```
#### Option 2: Constants File
Create or edit `app/src/main/java/com/iptv/app/Constants.kt`:
```kotlin
package com.iptv.app
object Constants {
const val DEFAULT_M3U_URL = "https://your-playlist-url.com/playlist.m3u"
// Optional: Multiple playlist support
val PLAYLISTS = mapOf(
"Default" to "https://example.com/playlist1.m3u",
"Sports" to "https://example.com/sports.m3u",
"News" to "https://example.com/news.m3u"
)
}
```
#### Option 3: Settings UI (User-Configurable)
The app supports runtime playlist URL changes through the Settings screen. Users can:
1. Open Settings
2. Tap "Playlist URL"
3. Enter a new M3U/M3U8 URL
4. Tap "Save" to apply changes
### Supported Playlist Formats
The app supports standard M3U/M3U8 playlist formats:
```m3u
#EXTM3U
#EXTINF:-1 tvg-id="channel1" tvg-logo="http://logo.png" group-title="News",CNN
http://stream-url.com/cnn.m3u8
#EXTINF:-1 tvg-id="channel2" tvg-logo="http://logo.png" group-title="Sports",ESPN
http://stream-url.com/espn.m3u8
```
**Supported Attributes:**
- `tvg-id`: Channel identifier
- `tvg-logo`: Channel logo URL
- `group-title`: Category/group name
- `tvg-language`: Channel language
- `tvg-country`: Channel country
## Architecture Overview
### MVVM Architecture
The app follows the Model-View-ViewModel (MVVM) architecture pattern:
```
UI Layer (Compose Screens)
|
v
ViewModel Layer (State Management)
|
v
Repository Layer (Data Operations)
|
v
Data Layer (Models, Parsers, Network)
```
### Repository Pattern
The `ChannelRepository` acts as a single source of truth for channel data:
- **Data Fetching**: Fetches from network with Retrofit/OkHttp
- **Caching**: Local cache with 24-hour expiration
- **Favorites**: Persistent storage with SharedPreferences
- **Search**: Debounced search with Flow operators
- **Filtering**: Category-based filtering
### Media3 ExoPlayer Integration
The `PlayerManager` class provides:
- **Player Lifecycle**: Proper creation and disposal
- **Audio Focus**: System audio focus handling
- **Track Selection**: Quality and audio track switching
- **State Management**: Playback state observation
## Project Structure
```
IPTVApp/
├── app/
│ ├── src/main/java/com/iptv/app/
│ │ ├── data/
│ │ │ ├── model/
│ │ │ │ ├── Channel.kt # Channel data class
│ │ │ │ └── Category.kt # Category data class
│ │ │ └── repository/
│ │ │ └── ChannelRepository.kt # Data repository
│ │ ├── ui/
│ │ │ ├── components/ # Reusable UI components
│ │ │ │ ├── ChannelCard.kt
│ │ │ │ ├── CategoryChip.kt
│ │ │ │ ├── VideoPlayer.kt
│ │ │ │ └── PlayerControls.kt
│ │ │ ├── screens/ # Screen composables
│ │ │ │ ├── ChannelsScreen.kt
│ │ │ │ └── PlayerScreen.kt
│ │ │ ├── theme/ # Material 3 theme
│ │ │ │ ├── Color.kt
│ │ │ │ ├── Type.kt
│ │ │ │ └── Theme.kt
│ │ │ └── viewmodel/ # ViewModels
│ │ │ ├── ChannelsViewModel.kt
│ │ │ └── PlayerViewModel.kt
│ │ └── utils/
│ │ ├── M3UParser.kt # M3U playlist parser
│ │ └── PlayerManager.kt # ExoPlayer wrapper
│ ├── src/main/res/
│ │ ├── values/
│ │ │ ├── strings.xml # App strings
│ │ │ ├── colors.xml # Color resources
│ │ │ └── themes.xml # Theme resources
│ │ └── xml/
│ │ └── network_security_config.xml
│ └── build.gradle.kts # App-level build config
├── build.gradle.kts # Project-level build config
├── settings.gradle.kts # Project settings
└── gradle.properties # Gradle properties
```
## Dependencies
### Core Android
| Dependency | Version | Purpose |
|------------|---------|---------|
| `core-ktx` | 1.12.0 | Kotlin extensions |
| `lifecycle-runtime-ktx` | 2.6.2 | Lifecycle awareness |
| `activity-compose` | 1.8.1 | Compose activity |
### Jetpack Compose
| Dependency | Version | Purpose |
|------------|---------|---------|
| `compose-bom` | 2023.10.01 | Bill of Materials |
| `material3` | - | Material Design 3 |
| `material-icons-extended` | - | Extended icons |
| `lifecycle-viewmodel-compose` | 2.6.2 | ViewModel integration |
| `navigation-compose` | 2.7.5 | Navigation |
### Media Playback
| Dependency | Version | Purpose |
|------------|---------|---------|
| `media3-exoplayer` | 1.2.0 | Core player |
| `media3-exoplayer-hls` | 1.2.0 | HLS streaming |
| `media3-exoplayer-dash` | 1.2.0 | DASH streaming |
| `media3-ui` | 1.2.0 | Player UI |
| `media3-session` | 1.2.0 | Media session |
### Networking
| Dependency | Version | Purpose |
|------------|---------|---------|
| `retrofit` | 2.9.0 | HTTP client |
| `okhttp` | 4.12.0 | HTTP engine |
| `logging-interceptor` | 4.12.0 | Request logging |
### Image Loading
| Dependency | Version | Purpose |
|------------|---------|---------|
| `coil-compose` | 2.5.0 | Image loading |
### Coroutines
| Dependency | Version | Purpose |
|------------|---------|---------|
| `kotlinx-coroutines-android` | 1.7.3 | Async operations |
### JSON Parsing
| Dependency | Version | Purpose |
|------------|---------|---------|
| `gson` | 2.10.1 | JSON serialization |
### Testing
| Dependency | Version | Purpose |
|------------|---------|---------|
| `junit` | 4.13.2 | Unit testing |
| `espresso-core` | 3.5.1 | UI testing |
| `compose-ui-test-junit4` | - | Compose testing |
## Building the APK
### Debug APK
```bash
# Build debug APK
./gradlew assembleDebug
# Output location:
# app/build/outputs/apk/debug/app-debug.apk
```
### Release APK
1. **Create a signing keystore** (if not exists):
```bash
keytool -genkey -v -keystore iptv-release.keystore -alias iptv -keyalg RSA -keysize 2048 -validity 10000
```
2. **Configure signing** in `app/build.gradle.kts`:
```kotlin
android {
signingConfigs {
create("release") {
storeFile = file("iptv-release.keystore")
storePassword = System.getenv("STORE_PASSWORD") ?: "your-password"
keyAlias = "iptv"
keyPassword = System.getenv("KEY_PASSWORD") ?: "your-password"
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
}
}
```
3. **Build release APK**:
```bash
# Build release APK
./gradlew assembleRelease
# Output location:
# app/build/outputs/apk/release/app-release.apk
```
### App Bundle (Google Play)
```bash
# Build AAB for Google Play
./gradlew bundleRelease
# Output location:
# app/build/outputs/bundle/release/app-release.aab
```
## Installing on Android Device
### Method 1: Android Studio
1. Connect your Android device via USB
2. Enable USB debugging in Developer Options
3. Click "Run" (green play button) in Android Studio
4. Select your device from the deployment target dialog
### Method 2: ADB (Android Debug Bridge)
```bash
# Install debug APK
adb install app/build/outputs/apk/debug/app-debug.apk
# Install release APK
adb install app/build/outputs/apk/release/app-release.apk
# Install with replacement (if already installed)
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
### Method 3: Direct Download
1. Transfer the APK file to your device
2. Open a file manager on your device
3. Navigate to the APK file location
4. Tap the APK to install
5. Allow installation from unknown sources if prompted
### Method 4: Wireless ADB
```bash
# Connect to device over WiFi
adb tcpip 5555
adb connect <device-ip-address>:5555
# Install APK
adb install app/build/outputs/apk/debug/app-debug.apk
```
## Troubleshooting
### Build Issues
#### Gradle Sync Failed
```
Solution: File -> Invalidate Caches / Restart -> Invalidate and Restart
```
#### Out of Memory Error
Add to `gradle.properties`:
```properties
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m
org.gradle.parallel=true
org.gradle.caching=true
```
#### Kotlin Version Mismatch
Ensure Kotlin version matches in `build.gradle.kts`:
```kotlin
plugins {
id("org.jetbrains.kotlin.android") version "1.9.20"
}
```
### Runtime Issues
#### App Crashes on Launch
- Check that `INTERNET` permission is in `AndroidManifest.xml`
- Verify minimum SDK version (API 24+)
- Check for ProGuard obfuscation issues
#### Channels Not Loading
- Verify the M3U URL is accessible
- Check network connectivity
- Review logcat for parsing errors: `adb logcat | grep M3UParser`
#### Video Not Playing
- Ensure stream URL is valid and accessible
- Check if stream format is supported (HLS, DASH, Progressive)
- Verify codec support on device
- Check logcat for ExoPlayer errors
#### Audio Issues
- Check audio focus is being requested properly
- Verify audio track selection in PlayerManager
- Test with different audio formats
### Streaming Issues
#### Buffering Problems
```kotlin
// Increase buffer size in PlayerManager
val player = ExoPlayer.Builder(context)
.setLoadControl(
DefaultLoadControl.Builder()
.setBufferDurationsMs(
5000, // minBufferMs
50000, // maxBufferMs
1000, // bufferForPlaybackMs
5000 // bufferForPlaybackAfterRebufferMs
)
.build()
)
.build()
```
#### SSL/Certificate Errors
Update `network_security_config.xml`:
```xml
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</base-config>
</network-security-config>
```
### Development Tips
#### Enable Debug Logging
```kotlin
// In PlayerManager
exoPlayer.addAnalyticsListener(EventLogger())
```
#### View Compose Layout Bounds
Enable in Developer Options:
```
Settings -> Developer Options -> Show layout bounds
```
#### Profile Performance
Use Android Studio Profiler:
```
View -> Tool Windows -> Profiler
```
## License
```
Copyright 2024 IPTV Player
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
http://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.
```
## Contributing
Contributions are welcome! Please follow these steps:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## Acknowledgments
- [ExoPlayer](https://github.com/google/ExoPlayer) - Media player library
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - UI toolkit
- [Material Design 3](https://m3.material.io/) - Design system
- [IPTV Org](https://github.com/iptv-org) - Public IPTV playlists
## Support
For issues, questions, or feature requests, please open an issue on GitHub.
---
**Disclaimer**: This application is for educational purposes. Users are responsible for complying with local laws and regulations regarding IPTV streaming. The developers do not provide any IPTV content or playlists.

109
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,109 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-parcelize")
}
android {
namespace = "com.iptv.app"
compileSdk = 34
defaultConfig {
applicationId = "com.iptv.app"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.5"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// AndroidX Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.1")
// Compose BOM
val composeBom = platform("androidx.compose:compose-bom:2023.10.01")
implementation(composeBom)
androidTestImplementation(composeBom)
// Compose UI
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// ViewModel Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
// Navigation Compose
implementation("androidx.navigation:navigation-compose:2.7.5")
// ExoPlayer / Media3
implementation("androidx.media3:media3-exoplayer:1.2.0")
implementation("androidx.media3:media3-exoplayer-hls:1.2.0")
implementation("androidx.media3:media3-exoplayer-dash:1.2.0")
implementation("androidx.media3:media3-datasource-okhttp:1.2.0")
implementation("androidx.media3:media3-ui:1.2.0")
implementation("androidx.media3:media3-session:1.2.0")
// Networking
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-scalars:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// Image loading
implementation("io.coil-kt:coil-compose:2.5.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// JSON parsing (for EPG data if needed)
implementation("com.google.code.gson:gson:2.10.1")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

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

@@ -0,0 +1,2 @@
# ProGuard rules for IPTV App
-keep class com.iptv.app.data.model.** { *; }

View File

@@ -0,0 +1,169 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Internet permission for streaming IPTV content -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Network state permissions for monitoring connectivity -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Wake lock to keep screen on during playback -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Picture-in-Picture mode for Android O and above -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Permissions for downloading and installing APK updates -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application
android:name=".IPTVApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.IPTVApp"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="34">
<!-- Main Activity - Entry point of the application -->
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:launchMode="singleTask"
android:screenOrientation="unspecified"
android:theme="@style/Theme.IPTVApp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Intent filter for opening M3U playlist files -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="file" />
<data android:scheme="content" />
<data android:mimeType="audio/x-mpegurl" />
<data android:mimeType="application/vnd.apple.mpegurl" />
<data android:mimeType="application/x-mpegurl" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Intent filter for opening IPTV URLs -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*" />
<data android:pathPattern=".*\\.m3u" />
<data android:pathPattern=".*\\.m3u8" />
</intent-filter>
</activity>
<!-- Player Activity - Full screen video playback -->
<activity
android:name=".ui.PlayerActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="unspecified"
android:theme="@style/Theme.IPTVApp.Fullscreen"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
android:turnScreenOn="true"
android:keepScreenOn="true" />
<!-- Settings Activity -->
<activity
android:name=".ui.SettingsActivity"
android:exported="false"
android:label="@string/settings"
android:parentActivityName=".ui.MainActivity"
android:theme="@style/Theme.IPTVApp" />
<!-- Channel List Activity -->
<activity
android:name=".ui.ChannelListActivity"
android:exported="false"
android:label="@string/channels"
android:parentActivityName=".ui.MainActivity"
android:theme="@style/Theme.IPTVApp" />
<!-- EPG Activity - Electronic Program Guide -->
<activity
android:name=".ui.EpgActivity"
android:exported="false"
android:label="@string/epg"
android:parentActivityName=".ui.MainActivity"
android:theme="@style/Theme.IPTVApp" />
<!-- ExoPlayer Download Service for offline content -->
<service
android:name=".service.DownloadService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Background playback service -->
<service
android:name=".service.PlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<!-- TV Launcher Intent for Android TV support -->
<activity
android:name=".ui.TvMainActivity"
android:exported="true"
android:theme="@style/Theme.IPTVApp.Leanback"
android:banner="@drawable/banner"
android:icon="@drawable/banner"
android:logo="@drawable/banner"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- FileProvider for sharing APK files -->
<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_provider_paths" />
</provider>
<!-- Intent filter for handling downloaded APKs -->
<activity
android:name=".ui.InstallApkActivity"
android:exported="false"
android:theme="@style/Theme.IPTVApp">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="file" />
<data android:scheme="content" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,147 @@
package com.iptv.app
import android.app.Application
import android.content.Context
import android.os.StrictMode
import android.util.Log
import androidx.media3.common.util.UnstableApi
import com.iptv.app.data.parser.M3UParser
import com.iptv.app.data.repository.ChannelRepository
import com.iptv.app.utils.PlayerManager
/**
* Application class for the IPTV app.
*
* This class initializes app-level components including:
* - Repository initialization
* - Logging configuration
* - StrictMode for development (debug builds only)
* - Media3/ExoPlayer configuration
*/
class IPTVApplication : Application() {
companion object {
private const val TAG = "IPTVApplication"
private const val PREFS_NAME = "iptv_prefs"
/**
* Gets the application instance from a Context.
*/
fun from(context: Context): IPTVApplication {
return context.applicationContext as IPTVApplication
}
}
/**
* Lazy-initialized M3U parser instance.
*/
val m3uParser: M3UParser by lazy {
M3UParser(applicationContext)
}
/**
* Lazy-initialized ChannelRepository instance.
* Used across the app for channel data management.
*/
val channelRepository: ChannelRepository by lazy {
val sharedPreferences = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
ChannelRepository(
context = this,
m3uParser = m3uParser,
sharedPreferences = sharedPreferences
)
}
/**
* Lazy-initialized PlayerManager for audio focus and media session management.
*/
val playerManager: PlayerManager by lazy {
PlayerManager(this)
}
@UnstableApi
override fun onCreate() {
super.onCreate()
// Initialize logging
initializeLogging()
// Setup StrictMode for debug builds
if (BuildConfig.DEBUG) {
setupStrictMode()
}
// Initialize PlayerManager
initializePlayerManager()
Log.d(TAG, "IPTVApplication initialized")
}
/**
* Initializes logging configuration.
* In debug builds, verbose logging is enabled.
* In release builds, only warnings and errors are logged.
*/
private fun initializeLogging() {
if (BuildConfig.DEBUG) {
// Enable verbose logging for debug builds
Log.d(TAG, "Debug logging enabled")
}
}
/**
* Sets up StrictMode to detect potential issues during development.
* Only active in debug builds.
*/
private fun setupStrictMode() {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.penaltyFlashScreen()
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.detectActivityLeaks()
.penaltyLog()
.build()
)
}
/**
* Initializes the PlayerManager for audio focus handling.
*/
@UnstableApi
private fun initializePlayerManager() {
// PlayerManager is initialized lazily, but we can perform any
// one-time setup here if needed
Log.d(TAG, "PlayerManager ready")
}
/**
* Clears the application cache.
* Called when user requests cache clear or on certain error conditions.
*/
suspend fun clearCache() {
try {
channelRepository.clearCache()
Log.d(TAG, "Cache cleared successfully")
} catch (e: Exception) {
Log.e(TAG, "Error clearing cache", e)
}
}
/**
* Gets the default M3U playlist URL.
* This can be overridden via settings in a full implementation.
*/
fun getDefaultM3UUrl(): String {
return "https://iptv-org.github.io/iptv/index.m3u"
}
}

View File

@@ -0,0 +1,184 @@
package com.iptv.app
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.iptv.app.data.model.Channel as DataChannel
import com.iptv.app.data.parser.M3UParser
import com.iptv.app.data.repository.ChannelRepository
import com.iptv.app.ui.components.Channel as UiChannel
import com.iptv.app.ui.screens.ChannelsScreen
import com.iptv.app.ui.screens.ChannelsUiState
import com.iptv.app.ui.theme.IPTVAppTheme
import com.iptv.app.ui.viewmodel.ChannelsViewModel
import com.iptv.app.ui.viewmodel.ChannelsViewModelFactory
import kotlinx.coroutines.launch
/**
* Main entry point for the IPTV application.
*
* This activity sets up the main UI with Jetpack Compose, handles navigation to the player,
* and manages the channel list display with search and filtering capabilities.
*/
class MainActivity : ComponentActivity() {
companion object {
private const val DEFAULT_M3U_URL = "https://iptv-org.github.io/iptv/index.m3u"
}
private lateinit var channelRepository: ChannelRepository
private val viewModel: ChannelsViewModel by viewModels {
ChannelsViewModelFactory(channelRepository, DEFAULT_M3U_URL)
}
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
// Initialize repository
initializeRepository()
// Keep splash screen visible while loading
splashScreen.setKeepOnScreenCondition {
viewModel.uiState.value.isLoading
}
setContent {
IPTVAppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MainScreen(
viewModel = viewModel,
onChannelClick = { channel ->
navigateToPlayer(channel)
}
)
}
}
}
// Collect UI events
collectUiEvents()
}
/**
* Initializes the ChannelRepository with required dependencies.
*/
private fun initializeRepository() {
val sharedPreferences = getSharedPreferences("iptv_prefs", MODE_PRIVATE)
val m3uParser = M3UParser()
channelRepository = ChannelRepository(
context = this,
m3uParser = m3uParser,
sharedPreferences = sharedPreferences
)
}
/**
* Collects UI events from the ViewModel.
*/
private fun collectUiEvents() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
// Handle any one-time events here if needed
state.errorMessage?.let { error ->
// Error is handled in the UI composable
}
}
}
}
}
/**
* Navigates to the PlayerActivity with the selected channel.
*
* @param channel The channel to play
*/
private fun navigateToPlayer(channel: DataChannel) {
val intent = Intent(this, PlayerActivity::class.java).apply {
putExtra(PlayerActivity.EXTRA_CHANNEL_URL, channel.url)
putExtra(PlayerActivity.EXTRA_CHANNEL_NAME, channel.name)
putExtra(PlayerActivity.EXTRA_CHANNEL_ID, channel.id)
putExtra(PlayerActivity.EXTRA_CHANNEL_CATEGORY, channel.category)
putExtra(PlayerActivity.EXTRA_CHANNEL_LOGO, channel.logo)
}
startActivity(intent)
}
}
/**
* Main screen composable that displays the channels list.
*
* @param viewModel The ViewModel managing channel data
* @param onChannelClick Callback when a channel is selected
*/
@Composable
private fun MainScreen(
viewModel: ChannelsViewModel,
onChannelClick: (DataChannel) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
// Map data model channels to UI channels
val uiChannels = uiState.filteredChannels.map { channel ->
UiChannel(
id = channel.id,
name = channel.name,
category = channel.category,
logoUrl = channel.logo,
streamUrl = channel.url,
isFavorite = channel.isFavorite
)
}
// Map ViewModel state to UI state
val channelsUiState = when {
uiState.isLoading && uiState.channels.isEmpty() -> ChannelsUiState.Loading
uiState.errorMessage != null && uiState.channels.isEmpty() ->
ChannelsUiState.Error(uiState.errorMessage ?: "Unknown error")
else -> ChannelsUiState.Success(
channels = uiChannels,
categories = uiState.categories
)
}
ChannelsScreen(
uiState = channelsUiState,
selectedCategory = uiState.selectedCategory,
searchQuery = uiState.searchQuery,
onSearchQueryChange = { query ->
viewModel.setSearchQuery(query)
},
onCategorySelect = { category ->
viewModel.selectCategory(category)
},
onChannelClick = { channelUiModel ->
// Find the full channel from the list
val fullChannel = uiState.channels.find { it.id == channelUiModel.id }
fullChannel?.let { onChannelClick(it) }
},
onFavoriteToggle = { channelUiModel ->
viewModel.toggleFavorite(channelUiModel.id)
},
onRefresh = {
viewModel.refreshChannels()
}
)
}

View File

@@ -0,0 +1,340 @@
package com.iptv.app
import android.app.PictureInPictureParams
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.util.Rational
import android.view.KeyEvent
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.iptv.app.data.parser.M3UParser
import com.iptv.app.data.repository.ChannelRepository
import com.iptv.app.ui.components.PlayerError
import com.iptv.app.ui.components.PlayerState
import com.iptv.app.ui.components.VideoPlayer
import com.iptv.app.ui.theme.IPTVAppTheme
import com.iptv.app.ui.viewmodel.PlaybackState
import com.iptv.app.ui.viewmodel.PlayerEvent
import com.iptv.app.ui.viewmodel.PlayerUiState
import com.iptv.app.ui.viewmodel.PlayerViewModel
import com.iptv.app.ui.viewmodel.PlayerViewModelFactory
import kotlinx.coroutines.launch
/**
* Fullscreen player activity for IPTV channel playback.
*
* This activity receives channel data via Intent extras and displays the video
* using ExoPlayer with custom controls. It supports picture-in-picture mode
* and proper lifecycle management.
*/
class PlayerActivity : ComponentActivity() {
companion object {
const val EXTRA_CHANNEL_URL = "extra_channel_url"
const val EXTRA_CHANNEL_NAME = "extra_channel_name"
const val EXTRA_CHANNEL_ID = "extra_channel_id"
const val EXTRA_CHANNEL_CATEGORY = "extra_channel_category"
const val EXTRA_CHANNEL_LOGO = "extra_channel_logo"
}
private lateinit var channelRepository: ChannelRepository
private val viewModel: PlayerViewModel by viewModels {
PlayerViewModelFactory(channelRepository)
}
private var isInPictureInPictureMode = false
private var currentChannelUrl: String = ""
private var currentChannelName: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Extract channel data from intent
currentChannelUrl = intent.getStringExtra(EXTRA_CHANNEL_URL) ?: ""
currentChannelName = intent.getStringExtra(EXTRA_CHANNEL_NAME) ?: ""
val channelId = intent.getStringExtra(EXTRA_CHANNEL_ID) ?: ""
// Initialize repository
initializeRepository()
// Load channel into ViewModel
if (channelId.isNotBlank()) {
viewModel.onEvent(PlayerEvent.LoadChannel(channelId))
}
// Setup fullscreen immersive mode
setupFullscreen()
setContent {
IPTVAppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
PlayerScreenContent(
viewModel = viewModel,
channelUrl = currentChannelUrl,
channelName = currentChannelName,
onBackPressed = { finish() }
)
}
}
}
// Collect UI events
collectUiEvents()
}
/**
* Initializes the ChannelRepository with required dependencies.
*/
private fun initializeRepository() {
val sharedPreferences = getSharedPreferences("iptv_prefs", MODE_PRIVATE)
val m3uParser = M3UParser()
channelRepository = ChannelRepository(
context = this,
m3uParser = m3uParser,
sharedPreferences = sharedPreferences
)
}
/**
* Sets up fullscreen immersive mode for video playback.
*/
private fun setupFullscreen() {
WindowCompat.setDecorFitsSystemWindows(window, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.hide(WindowInsetsCompat.Type.systemBars())
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
// Keep screen on during playback
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Force landscape for TV/video content
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
/**
* Collects UI events from the ViewModel.
*/
private fun collectUiEvents() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
// Update picture-in-picture params if needed
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
updatePictureInPictureParams(state)
}
}
}
}
}
/**
* Updates picture-in-picture mode parameters based on current state.
*/
@RequiresApi(Build.VERSION_CODES.O)
private fun updatePictureInPictureParams(state: PlayerUiState) {
val aspectRatio = Rational(16, 9)
val params = PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
.build()
setPictureInPictureParams(params)
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Enter picture-in-picture mode when user leaves the app
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
) {
enterPictureInPictureMode(
PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.build()
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: Configuration
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
this.isInPictureInPictureMode = isInPictureInPictureMode
if (isInPictureInPictureMode) {
// Hide controls in PiP mode
viewModel.onEvent(PlayerEvent.ToggleControls)
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return when (keyCode) {
KeyEvent.KEYCODE_BACK -> {
if (isInPictureInPictureMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// Exit PiP mode instead of finishing
moveTaskToBack(false)
true
} else {
finish()
true
}
}
KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_SPACE -> {
viewModel.onEvent(PlayerEvent.ToggleControls)
true
}
KeyEvent.KEYCODE_DPAD_UP -> {
viewModel.onEvent(PlayerEvent.PreviousChannel)
true
}
KeyEvent.KEYCODE_DPAD_DOWN -> {
viewModel.onEvent(PlayerEvent.NextChannel)
true
}
KeyEvent.KEYCODE_VOLUME_UP,
KeyEvent.KEYCODE_VOLUME_DOWN -> {
// Let system handle volume
super.onKeyDown(keyCode, event)
}
else -> super.onKeyDown(keyCode, event)
}
}
override fun onPause() {
super.onPause()
// Pause playback when not in PiP mode
if (!isInPictureInPictureMode) {
viewModel.onEvent(PlayerEvent.Pause)
}
}
override fun onResume() {
super.onResume()
// Resume playback
viewModel.onEvent(PlayerEvent.Play)
}
override fun onStop() {
super.onStop()
if (!isInPictureInPictureMode) {
viewModel.onEvent(PlayerEvent.Stop)
}
}
override fun onDestroy() {
super.onDestroy()
// Clean up window flags
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun finish() {
super.finish()
// Reset orientation when finishing
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
/**
* Player screen content composable.
*
* @param viewModel The ViewModel managing player state
* @param channelUrl The URL of the channel stream
* @param channelName The name of the channel
* @param onBackPressed Callback when back button is pressed
*/
@Composable
private fun PlayerScreenContent(
viewModel: PlayerViewModel,
channelUrl: String,
channelName: String,
onBackPressed: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
// Track player state
var playerState by remember { mutableStateOf<PlayerState>(PlayerState.Idle) }
// Handle errors
DisposableEffect(uiState.errorMessage) {
uiState.errorMessage?.let { error ->
playerState = PlayerState.Error(
PlayerError(
message = error,
recoverable = true
)
)
}
onDispose { }
}
// Video player with ExoPlayer
VideoPlayer(
streamUrl = channelUrl,
channelName = channelName,
modifier = Modifier.fillMaxSize(),
onError = { error ->
playerState = PlayerState.Error(error)
viewModel.reportError(error.message)
},
onPlayerReady = {
playerState = PlayerState.Playing
viewModel.updatePlaybackState(PlaybackState.Playing)
},
showControls = uiState.controlsVisible,
onBackPressed = onBackPressed
)
// Update ViewModel with player state changes
DisposableEffect(playerState) {
when (playerState) {
is PlayerState.Playing -> viewModel.updatePlaybackState(PlaybackState.Playing)
is PlayerState.Paused -> viewModel.updatePlaybackState(PlaybackState.Paused)
is PlayerState.Loading -> viewModel.updatePlaybackState(PlaybackState.Loading)
is PlayerState.Error -> {
val error = (playerState as PlayerState.Error).error
viewModel.reportError(error.message)
}
else -> { /* No action needed */ }
}
onDispose { }
}
}

View File

@@ -0,0 +1,97 @@
package com.iptv.app.data.model
/**
* Data class representing a channel category/group.
* All properties are immutable to ensure thread safety.
*
* @property name The category name (from group-title attribute)
* @property channelCount Number of channels in this category
* @property channels List of channels belonging to this category
*/
data class Category(
val name: String,
val channelCount: Int = 0,
val channels: List<Channel> = emptyList()
) {
/**
* Creates a new category with an additional channel.
* Returns a new instance - maintains immutability.
*/
fun addChannel(channel: Channel): Category {
return this.copy(
channels = this.channels + channel,
channelCount = this.channels.size + 1
)
}
/**
* Creates a new category with multiple channels added.
* Returns a new instance - maintains immutability.
*/
fun addChannels(newChannels: List<Channel>): Category {
return this.copy(
channels = this.channels + newChannels,
channelCount = this.channels.size + newChannels.size
)
}
/**
* Creates a new category with a specific channel removed.
* Returns a new instance - maintains immutability.
*/
fun removeChannel(channelId: String): Category {
val updatedChannels = this.channels.filter { it.id != channelId }
return this.copy(
channels = updatedChannels,
channelCount = updatedChannels.size
)
}
/**
* Creates a new category with channels sorted by name.
* Returns a new instance - maintains immutability.
*/
fun sortedByName(): Category {
return this.copy(channels = this.channels.sortedBy { it.name })
}
/**
* Creates a new category with favorite channels first.
* Returns a new instance - maintains immutability.
*/
fun sortedByFavorite(): Category {
return this.copy(channels = this.channels.sortedByDescending { it.isFavorite })
}
companion object {
/**
* Empty category instance for safe defaults.
*/
val EMPTY = Category(name = "")
/**
* Groups a list of channels by their category.
* Returns an immutable list of Category objects.
*/
fun groupChannelsByCategory(channels: List<Channel>): List<Category> {
return channels
.groupBy { it.category }
.map { (categoryName, categoryChannels) ->
Category(
name = categoryName,
channels = categoryChannels,
channelCount = categoryChannels.size
)
}
}
/**
* Groups channels by category and sorts categories by name.
*/
fun groupAndSortByName(channels: List<Channel>): List<Category> {
return groupChannelsByCategory(channels)
.sortedBy { it.name }
.map { it.sortedByName() }
}
}
}

View File

@@ -0,0 +1,57 @@
package com.iptv.app.data.model
/**
* Data class representing an IPTV channel.
* All properties are immutable to ensure thread safety and prevent accidental mutations.
*
* @property id Unique identifier for the channel (typically from tvg-id)
* @property name Display name of the channel
* @property url Streaming URL for the channel
* @property category Category/group the channel belongs to (from group-title)
* @property logo URL to the channel's logo image
* @property language Primary language of the channel
* @property country Country code or name for the channel
* @property isFavorite Whether the channel is marked as favorite by the user
*/
data class Channel(
val id: String,
val name: String,
val url: String,
val category: String,
val logo: String? = null,
val language: String? = null,
val country: String? = null,
val isFavorite: Boolean = false
) {
/**
* Creates a copy of this channel with favorite status toggled.
* Returns a new instance - maintains immutability.
*/
fun toggleFavorite(): Channel {
return this.copy(isFavorite = !this.isFavorite)
}
/**
* Creates a copy of this channel with updated favorite status.
* Returns a new instance - maintains immutability.
*/
fun setFavorite(favorite: Boolean): Channel {
return if (this.isFavorite == favorite) {
this
} else {
this.copy(isFavorite = favorite)
}
}
companion object {
/**
* Empty channel instance for safe defaults.
*/
val EMPTY = Channel(
id = "",
name = "",
url = "",
category = ""
)
}
}

View File

@@ -0,0 +1,369 @@
package com.iptv.app.data.parser
import android.content.Context
import com.iptv.app.data.model.Category
import com.iptv.app.data.model.Channel
import com.iptv.app.utils.DnsConfigurator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Sealed class representing the result of M3U parsing operations.
* Used for proper error handling without exceptions.
*/
sealed class M3UParseResult {
/**
* Successful parse result containing channels and categories.
*/
data class Success(
val channels: List<Channel>,
val categories: List<Category>
) : M3UParseResult()
/**
* Error result containing error message and optional exception.
*/
data class Error(
val message: String,
val exception: Throwable? = null
) : M3UParseResult()
}
/**
* Parser for M3U playlist files from iptv-org format.
*
* Supports standard M3U format:
* ```
* #EXTM3U
* #EXTINF:-1 tvg-id="Channel1" tvg-logo="http://logo.png" group-title="News",Channel Name
* http://stream-url.com/stream.m3u8
* ```
*/
class M3UParser(private val context: Context? = null) {
companion object {
private const val EXT_M3U = "#EXTM3U"
private const val EXT_INF = "#EXTINF:"
private const val ATTRIBUTE_TVG_ID = "tvg-id"
private const val ATTRIBUTE_TVG_LOGO = "tvg-logo"
private const val ATTRIBUTE_GROUP_TITLE = "group-title"
private const val ATTRIBUTE_TVG_LANGUAGE = "tvg-language"
private const val ATTRIBUTE_TVG_COUNTRY = "tvg-country"
private const val DEFAULT_CATEGORY = "Uncategorized"
}
private val okHttpClient: OkHttpClient by lazy {
context?.let {
DnsConfigurator.createOkHttpClient(it)
} ?: OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
}
/**
* Downloads and parses an M3U playlist from a URL using Google DNS.
*
* @param urlString The URL to download the M3U playlist from
* @return M3UParseResult containing either Success with channels/categories or Error
*/
suspend fun parseFromUrl(urlString: String): M3UParseResult {
return withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url(urlString)
.header("Accept", "application/vnd.apple.mpegurl, audio/mpegurl, text/plain, */*")
.header("User-Agent", "IPTVApp/1.0")
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext M3UParseResult.Error(
message = "HTTP error: ${response.code}"
)
}
val content = response.body?.string()
?: return@withContext M3UParseResult.Error(
message = "Empty response body"
)
parseContent(content)
}
} catch (e: IOException) {
M3UParseResult.Error(
message = "Network error: ${e.message}",
exception = e
)
} catch (e: SecurityException) {
M3UParseResult.Error(
message = "Security error - check internet permission: ${e.message}",
exception = e
)
} catch (e: Exception) {
M3UParseResult.Error(
message = "Unexpected error: ${e.message}",
exception = e
)
}
}
}
/**
* Parses M3U content from a string.
* Alias for parseContent for backward compatibility.
*
* @param content The M3U playlist content as a string
* @return List of Channel objects
*/
fun parseFromString(content: String): List<Channel> {
return when (val result = parseContent(content)) {
is M3UParseResult.Success -> result.channels
is M3UParseResult.Error -> emptyList()
}
}
/**
* Serializes a list of channels to M3U format string.
*
* @param channels List of channels to serialize
* @return M3U format string
*/
fun serializeToString(channels: List<Channel>): String {
val sb = StringBuilder()
sb.appendLine(EXT_M3U)
sb.appendLine()
channels.forEach { channel ->
sb.append(EXT_INF)
sb.append("-1 ")
val attributes = mutableListOf<String>()
if (channel.id.isNotBlank()) {
attributes.add("$ATTRIBUTE_TVG_ID=\"${channel.id}\"")
}
if (!channel.logo.isNullOrBlank()) {
attributes.add("$ATTRIBUTE_TVG_LOGO=\"${channel.logo}\"")
}
if (channel.category.isNotBlank()) {
attributes.add("$ATTRIBUTE_GROUP_TITLE=\"${channel.category}\"")
}
if (!channel.language.isNullOrBlank()) {
attributes.add("$ATTRIBUTE_TVG_LANGUAGE=\"${channel.language}\"")
}
if (!channel.country.isNullOrBlank()) {
attributes.add("$ATTRIBUTE_TVG_COUNTRY=\"${channel.country}\"")
}
if (attributes.isNotEmpty()) {
sb.append(attributes.joinToString(" "))
}
sb.append(",")
sb.appendLine(channel.name)
sb.appendLine(channel.url)
}
return sb.toString()
}
/**
* Parses M3U content from a string.
*
* @param content The M3U playlist content as a string
* @return M3UParseResult containing either Success with channels/categories or Error
*/
fun parseContent(content: String): M3UParseResult {
if (content.isBlank()) {
return M3UParseResult.Error(message = "Empty content")
}
val lines = content.lines()
if (lines.isEmpty()) {
return M3UParseResult.Error(message = "No content to parse")
}
if (!lines[0].trim().startsWith(EXT_M3U)) {
return M3UParseResult.Error(message = "Invalid M3U format - missing #EXTM3U header")
}
val channels = mutableListOf<Channel>()
var currentChannel: ChannelBuilder? = null
for (line in lines) {
val trimmedLine = line.trim()
when {
trimmedLine.isEmpty() -> continue
trimmedLine.startsWith(EXT_M3U) -> continue
trimmedLine.startsWith(EXT_INF) -> {
currentChannel?.let {
channels.add(it.build())
}
currentChannel = parseExtInfLine(trimmedLine)
}
!trimmedLine.startsWith("#") -> {
currentChannel?.let {
it.url = trimmedLine
channels.add(it.build())
currentChannel = null
}
}
}
}
currentChannel?.let {
if (it.url.isNotBlank()) {
channels.add(it.build())
}
}
if (channels.isEmpty()) {
return M3UParseResult.Error(message = "No channels found in playlist")
}
val categories = Category.groupChannelsByCategory(channels)
return M3UParseResult.Success(
channels = channels.toList(),
categories = categories.toList()
)
}
/**
* Parses an #EXTINF line to extract channel metadata.
*
* Format: #EXTINF:duration attributes,name
*/
private fun parseExtInfLine(line: String): ChannelBuilder {
val builder = ChannelBuilder()
val contentStart = line.indexOf(':')
if (contentStart == -1) {
return builder
}
val content = line.substring(contentStart + 1)
val commaIndex = content.lastIndexOf(',')
val attributesPart = if (commaIndex != -1) {
content.substring(0, commaIndex)
} else {
content
}
val namePart = if (commaIndex != -1) {
content.substring(commaIndex + 1)
} else {
""
}
builder.name = namePart.trim()
val attributes = parseAttributes(attributesPart)
builder.id = attributes[ATTRIBUTE_TVG_ID] ?: ""
builder.logo = attributes[ATTRIBUTE_TVG_LOGO]
builder.category = attributes[ATTRIBUTE_GROUP_TITLE] ?: DEFAULT_CATEGORY
builder.language = attributes[ATTRIBUTE_TVG_LANGUAGE]
builder.country = attributes[ATTRIBUTE_TVG_COUNTRY]
return builder
}
/**
* Parses attribute key-value pairs from a string.
*
* Format: attr1="value1" attr2="value2"
*/
private fun parseAttributes(attributesString: String): Map<String, String> {
val attributes = mutableMapOf<String, String>()
val regex = """(\w+)="([^"]*)",""".toRegex()
regex.findAll(attributesString).forEach { matchResult ->
val key = matchResult.groupValues[1]
val value = matchResult.groupValues[2]
attributes[key] = value
}
return attributes.toMap()
}
/**
* Builder class for constructing Channel instances.
* Used internally during parsing.
*/
private class ChannelBuilder {
var id: String = ""
var name: String = ""
var url: String = ""
var category: String = DEFAULT_CATEGORY
var logo: String? = null
var language: String? = null
var country: String? = null
fun build(): Channel {
val finalId = id.ifBlank { generateId() }
return Channel(
id = finalId,
name = name.ifBlank { "Unknown Channel" },
url = url,
category = category,
logo = logo,
language = language,
country = country,
isFavorite = false
)
}
private fun generateId(): String {
return "${name}_${url.hashCode()}".hashCode().toString()
}
}
}
/**
* Extension function to check if parse result is successful.
*/
fun M3UParseResult.isSuccess(): Boolean = this is M3UParseResult.Success
/**
* Extension function to check if parse result is an error.
*/
fun M3UParseResult.isError(): Boolean = this is M3UParseResult.Error
/**
* Extension function to get channels from successful result.
* Returns empty list if result is error.
*/
fun M3UParseResult.getChannelsOrEmpty(): List<Channel> {
return when (this) {
is M3UParseResult.Success -> channels
is M3UParseResult.Error -> emptyList()
}
}
/**
* Extension function to get categories from successful result.
* Returns empty list if result is error.
*/
fun M3UParseResult.getCategoriesOrEmpty(): List<Category> {
return when (this) {
is M3UParseResult.Success -> categories
is M3UParseResult.Error -> emptyList()
}
}
/**
* Extension function to get error message from error result.
* Returns null if result is success.
*/
fun M3UParseResult.getErrorMessage(): String? {
return when (this) {
is M3UParseResult.Success -> null
is M3UParseResult.Error -> message
}
}

View File

@@ -0,0 +1,229 @@
package com.iptv.app.data.remote
import android.content.Context
import android.util.Log
import com.iptv.app.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
/**
* Servicio para verificar y descargar actualizaciones de la app desde Gitea.
*/
class UpdateService(context: Context) {
companion object {
private const val TAG = "UpdateService"
private const val GITEA_API_URL = "https://gitea.cbcren.online/api/v1"
private const val REPO_OWNER = "renato97"
private const val REPO_NAME = "iptv-app"
// Endpoints
private const val LATEST_RELEASE_ENDPOINT = "$GITEA_API_URL/repos/$REPO_OWNER/$REPO_NAME/releases/latest"
// SharedPreferences keys
private const val PREFS_NAME = "update_prefs"
private const val KEY_IGNORED_VERSION = "ignored_version"
private const val KEY_LAST_CHECK = "last_check"
}
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Información de una actualización disponible.
*/
data class UpdateInfo(
val version: String,
val versionCode: Int,
val downloadUrl: String,
val changelog: String,
val fileName: String,
val fileSize: Long,
val isMandatory: Boolean = false
)
/**
* Resultado de la verificación de actualizaciones.
*/
sealed class UpdateResult {
data class UpdateAvailable(val updateInfo: UpdateInfo) : UpdateResult()
data object NoUpdate : UpdateResult()
data class Error(val message: String) : UpdateResult()
}
/**
* Verifica si hay una actualización disponible.
*/
suspend fun checkForUpdate(): UpdateResult {
return try {
val request = Request.Builder()
.url(LATEST_RELEASE_ENDPOINT)
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return UpdateResult.Error("Error del servidor: ${response.code}")
}
val body = response.body?.string()
?: return UpdateResult.Error("Respuesta vacía del servidor")
val json = JSONObject(body)
val tagName = json.getString("tag_name")
val releaseVersionCode = extractVersionCode(tagName)
// Comparar versiones
if (releaseVersionCode > BuildConfig.VERSION_CODE) {
// Verificar si el usuario ignoró esta versión
if (isVersionIgnored(tagName)) {
return UpdateResult.NoUpdate
}
// Extraer información del APK adjunto
val assets = json.getJSONArray("assets")
if (assets.length() > 0) {
val apkAsset = assets.getJSONObject(0)
val updateInfo = UpdateInfo(
version = tagName,
versionCode = releaseVersionCode,
downloadUrl = apkAsset.getString("browser_download_url"),
changelog = json.optString("body", "Sin notas de versión"),
fileName = apkAsset.getString("name"),
fileSize = apkAsset.getLong("size"),
isMandatory = json.optBoolean("prerelease", false).not()
)
UpdateResult.UpdateAvailable(updateInfo)
} else {
UpdateResult.Error("No se encontró APK en el release")
}
} else {
UpdateResult.NoUpdate
}
}
} catch (e: Exception) {
Log.e(TAG, "Error al verificar actualizaciones", e)
UpdateResult.Error("Error de conexión: ${e.message}")
}
}
/**
* Descarga el APK de la actualización.
*/
fun downloadUpdate(updateInfo: UpdateInfo, outputDir: File): Flow<DownloadProgress> = flow {
try {
val request = Request.Builder()
.url(updateInfo.downloadUrl)
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
emit(DownloadProgress.Error("Error de descarga: ${response.code}"))
return@use
}
val body = response.body ?: run {
emit(DownloadProgress.Error("Respuesta vacía"))
return@use
}
val outputFile = File(outputDir, updateInfo.fileName)
val totalBytes = body.contentLength()
body.byteStream().use { input ->
FileOutputStream(outputFile).use { output ->
val buffer = ByteArray(8192)
var downloadedBytes = 0L
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead
val progress = if (totalBytes > 0) {
(downloadedBytes * 100 / totalBytes).toInt()
} else {
-1
}
emit(DownloadProgress.Progress(progress, downloadedBytes, totalBytes))
}
output.flush()
}
}
emit(DownloadProgress.Success(outputFile))
}
} catch (e: Exception) {
Log.e(TAG, "Error al descargar actualización", e)
emit(DownloadProgress.Error("Error: ${e.message}"))
}
}.flowOn(Dispatchers.IO)
/**
* Progreso de descarga.
*/
sealed class DownloadProgress {
data class Progress(val percentage: Int, val downloadedBytes: Long, val totalBytes: Long) : DownloadProgress()
data class Success(val file: File) : DownloadProgress()
data class Error(val message: String) : DownloadProgress()
}
/**
* Ignora una versión específica (no mostrará notificación para esta versión).
*/
fun ignoreVersion(version: String) {
prefs.edit().putString(KEY_IGNORED_VERSION, version).apply()
}
/**
* Verifica si una versión está ignorada.
*/
private fun isVersionIgnored(version: String): Boolean {
return prefs.getString(KEY_IGNORED_VERSION, null) == version
}
/**
* Obtiene la última vez que se verificaron actualizaciones.
*/
fun getLastCheckTime(): Long {
return prefs.getLong(KEY_LAST_CHECK, 0)
}
/**
* Actualiza el tiempo de última verificación.
*/
fun setLastCheckTime(time: Long = System.currentTimeMillis()) {
prefs.edit().putLong(KEY_LAST_CHECK, time).apply()
}
/**
* Extrae el código de versión del tag (ej: "v1.2.3" -> 10203).
*/
private fun extractVersionCode(tagName: String): Int {
val cleaned = tagName.removePrefix("v").removePrefix("V")
val parts = cleaned.split(".")
return if (parts.size >= 3) {
val major = parts[0].toIntOrNull() ?: 0
val minor = parts[1].toIntOrNull() ?: 0
val patch = parts[2].toIntOrNull() ?: 0
major * 10000 + minor * 100 + patch
} else {
cleaned.replace(".", "").toIntOrNull() ?: 0
}
}
}

View File

@@ -0,0 +1,281 @@
package com.iptv.app.data.repository
import android.content.Context
import android.content.SharedPreferences
import com.iptv.app.data.model.Channel
import com.iptv.app.data.parser.M3UParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Repository for managing IPTV channels.
* Handles fetching, caching, filtering, and searching of channels.
*/
class ChannelRepository(
private val context: Context,
private val m3uParser: M3UParser,
private val sharedPreferences: SharedPreferences
) {
companion object {
private const val CACHE_KEY_CHANNELS = "cached_channels"
private const val CACHE_KEY_TIMESTAMP = "cache_timestamp"
private const val CACHE_DURATION_MS = 24 * 60 * 60 * 1000L // 24 hours
private const val FAVORITES_KEY = "favorite_channels"
private const val SEARCH_DEBOUNCE_MS = 300L
}
private val _allChannels = MutableStateFlow<List<Channel>>(emptyList())
private val _isLoading = MutableStateFlow(false)
private val _error = MutableStateFlow<String?>(null)
val allChannels: StateFlow<List<Channel>> = _allChannels.asStateFlow()
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
val error: StateFlow<String?> = _error.asStateFlow()
/**
* Fetches channels from the M3U URL with caching support.
* Returns a Flow that emits the list of channels.
*/
fun fetchChannels(m3uUrl: String, forceRefresh: Boolean = false): Flow<List<Channel>> = flow {
_isLoading.value = true
_error.value = null
try {
val channels = if (!forceRefresh && isCacheValid()) {
// Load from cache
loadChannelsFromCache()
} else {
// Fetch from network
val fetchedChannels = fetchFromNetwork(m3uUrl)
saveChannelsToCache(fetchedChannels)
fetchedChannels
}
_allChannels.value = channels
emit(channels)
} catch (e: IOException) {
_error.value = "Network error: ${e.message}"
// Try to load from cache as fallback
val cachedChannels = loadChannelsFromCache()
if (cachedChannels.isNotEmpty()) {
_allChannels.value = cachedChannels
emit(cachedChannels)
} else {
emit(emptyList())
}
} catch (e: Exception) {
_error.value = "Error loading channels: ${e.message}"
emit(emptyList())
} finally {
_isLoading.value = false
}
}.flowOn(Dispatchers.IO)
/**
* Returns a Flow of all available categories from the channels.
*/
fun getCategories(): Flow<List<String>> = _allChannels
.map { channels ->
channels
.map { it.category }
.distinct()
.filter { it.isNotBlank() }
.sorted()
}
.flowOn(Dispatchers.Default)
/**
* Returns channels filtered by category.
*/
fun getChannelsByCategory(category: String): Flow<List<Channel>> = _allChannels
.map { channels ->
channels.filter { it.category.equals(category, ignoreCase = true) }
}
.flowOn(Dispatchers.Default)
/**
* Searches channels by name with debouncing.
*/
@OptIn(FlowPreview::class)
fun searchChannels(query: String): Flow<List<Channel>> = flow {
emit(query)
}
.debounce(SEARCH_DEBOUNCE_MS)
.combine(_allChannels) { searchQuery, channels ->
if (searchQuery.isBlank()) {
channels
} else {
channels.filter { channel ->
channel.name.contains(searchQuery, ignoreCase = true) ||
channel.category.contains(searchQuery, ignoreCase = true)
}
}
}
.flowOn(Dispatchers.Default)
/**
* Gets a single channel by its ID.
*/
fun getChannelById(channelId: String): Flow<Channel?> = _allChannels
.map { channels ->
channels.find { it.id == channelId }
}
.flowOn(Dispatchers.Default)
/**
* Returns a Flow of favorite channels.
*/
fun getFavoriteChannels(): Flow<List<Channel>> = _allChannels
.map { channels ->
val favoriteIds = getFavoriteIds()
channels.filter { it.id in favoriteIds }
}
.flowOn(Dispatchers.Default)
/**
* Adds a channel to favorites.
*/
suspend fun addToFavorites(channelId: String) {
withContext(Dispatchers.IO) {
val favorites = getFavoriteIds().toMutableSet()
favorites.add(channelId)
saveFavoriteIds(favorites)
}
}
/**
* Removes a channel from favorites.
*/
suspend fun removeFromFavorites(channelId: String) {
withContext(Dispatchers.IO) {
val favorites = getFavoriteIds().toMutableSet()
favorites.remove(channelId)
saveFavoriteIds(favorites)
}
}
/**
* Checks if a channel is in favorites.
*/
fun isFavorite(channelId: String): Flow<Boolean> = flow {
val favorites = getFavoriteIds()
emit(channelId in favorites)
}.flowOn(Dispatchers.IO)
/**
* Toggles favorite status for a channel.
*/
suspend fun toggleFavorite(channelId: String): Boolean {
val favorites = getFavoriteIds().toMutableSet()
val isNowFavorite = if (channelId in favorites) {
favorites.remove(channelId)
false
} else {
favorites.add(channelId)
true
}
saveFavoriteIds(favorites)
return isNowFavorite
}
/**
* Clears the channel cache.
*/
suspend fun clearCache() {
withContext(Dispatchers.IO) {
sharedPreferences.edit()
.remove(CACHE_KEY_CHANNELS)
.remove(CACHE_KEY_TIMESTAMP)
.apply()
_allChannels.value = emptyList()
}
}
/**
* Refreshes channels from network.
*/
suspend fun refreshChannels(m3uUrl: String): Result<List<Channel>> {
return withContext(Dispatchers.IO) {
try {
_isLoading.value = true
_error.value = null
val channels = fetchFromNetwork(m3uUrl)
saveChannelsToCache(channels)
_allChannels.value = channels
Result.success(channels)
} catch (e: Exception) {
_error.value = "Failed to refresh: ${e.message}"
Result.failure(e)
} finally {
_isLoading.value = false
}
}
}
private suspend fun fetchFromNetwork(m3uUrl: String): List<Channel> {
return withContext(Dispatchers.IO) {
m3uParser.parseFromUrl(m3uUrl)
}
}
private fun isCacheValid(): Boolean {
val timestamp = sharedPreferences.getLong(CACHE_KEY_TIMESTAMP, 0)
val currentTime = System.currentTimeMillis()
return currentTime - timestamp < CACHE_DURATION_MS
}
private fun loadChannelsFromCache(): List<Channel> {
val channelsJson = sharedPreferences.getString(CACHE_KEY_CHANNELS, null)
return if (channelsJson != null) {
try {
m3uParser.parseFromString(channelsJson)
} catch (e: Exception) {
emptyList()
}
} else {
emptyList()
}
}
private fun saveChannelsToCache(channels: List<Channel>) {
val channelsJson = m3uParser.serializeToString(channels)
sharedPreferences.edit()
.putString(CACHE_KEY_CHANNELS, channelsJson)
.putLong(CACHE_KEY_TIMESTAMP, System.currentTimeMillis())
.apply()
}
private fun getFavoriteIds(): Set<String> {
return sharedPreferences.getStringSet(FAVORITES_KEY, emptySet()) ?: emptySet()
}
private fun saveFavoriteIds(favorites: Set<String>) {
sharedPreferences.edit()
.putStringSet(FAVORITES_KEY, favorites)
.apply()
}
}
/**
* Sealed class representing the result of a repository operation.
*/
sealed class RepositoryResult<out T> {
data class Success<T>(val data: T) : RepositoryResult<T>()
data class Error(val message: String, val exception: Throwable? = null) : RepositoryResult<Nothing>()
data object Loading : RepositoryResult<Nothing>()
}

View File

@@ -0,0 +1,111 @@
package com.iptv.app.ui.components
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.iptv.app.ui.theme.*
@Composable
fun CategoryChip(
category: String,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val categoryColor = getCategoryColor(category)
FilterChip(
selected = isSelected,
onClick = onClick,
label = {
Text(
text = category,
style = MaterialTheme.typography.labelLarge
)
},
modifier = modifier.padding(horizontal = 4.dp),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = categoryColor.copy(alpha = 0.2f),
selectedLabelColor = categoryColor,
containerColor = MaterialTheme.colorScheme.surfaceVariant,
labelColor = MaterialTheme.colorScheme.onSurfaceVariant
),
border = FilterChipDefaults.filterChipBorder(
enabled = true,
selected = isSelected,
borderColor = if (isSelected) categoryColor else MaterialTheme.colorScheme.outline,
selectedBorderColor = categoryColor,
borderWidth = 1.dp
)
)
}
private fun getCategoryColor(category: String): Color {
return when (category.lowercase()) {
"entertainment" -> CategoryEntertainment
"sports" -> CategorySports
"news" -> CategoryNews
"movies" -> CategoryMovies
"kids" -> CategoryKids
"music" -> CategoryMusic
"documentary" -> CategoryDocumentary
else -> PrimaryLight
}
}
@Preview(showBackground = true)
@Composable
fun CategoryChipPreview() {
IPTVAppTheme {
CategoryChip(
category = "Sports",
isSelected = false,
onClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun CategoryChipSelectedPreview() {
IPTVAppTheme {
CategoryChip(
category = "Sports",
isSelected = true,
onClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun CategoryChipAllCategoriesPreview() {
IPTVAppTheme {
val categories = listOf(
"All",
"Entertainment",
"Sports",
"News",
"Movies",
"Kids",
"Music",
"Documentary"
)
androidx.compose.foundation.layout.Row {
categories.forEachIndexed { index, category ->
CategoryChip(
category = category,
isSelected = index == 1,
onClick = {}
)
}
}
}
}

View File

@@ -0,0 +1,242 @@
package com.iptv.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.iptv.app.ui.theme.*
data class Channel(
val id: String,
val name: String,
val category: String,
val logoUrl: String?,
val streamUrl: String,
val isFavorite: Boolean = false
)
@Composable
fun ChannelCard(
channel: Channel,
onClick: () -> Unit,
onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier
) {
val categoryColor = getCategoryColor(channel.category)
Card(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 2.dp,
pressedElevation = 4.dp
)
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
// Logo container
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
if (channel.logoUrl != null) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(channel.logoUrl)
.crossfade(true)
.build(),
contentDescription = "${channel.name} logo",
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentScale = ContentScale.Fit
)
} else {
// Placeholder with first letter
Box(
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
.background(categoryColor.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Text(
text = channel.name.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.headlineMedium,
color = categoryColor
)
}
}
// Favorite button
IconButton(
onClick = onFavoriteClick,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(32.dp)
) {
Icon(
imageVector = if (channel.isFavorite) {
Icons.Filled.Favorite
} else {
Icons.Outlined.FavoriteBorder
},
contentDescription = if (channel.isFavorite) {
"Remove from favorites"
} else {
"Add to favorites"
},
tint = if (channel.isFavorite) FavoriteColor else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
// Live indicator
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
.clip(RoundedCornerShape(4.dp))
.background(LiveIndicator)
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
text = "LIVE",
style = MaterialTheme.typography.labelSmall,
color = Color.White
)
}
}
// Channel info
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Text(
text = channel.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
// Category chip
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(categoryColor.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = channel.category,
style = MaterialTheme.typography.labelSmall,
color = categoryColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
private fun getCategoryColor(category: String): Color {
return when (category.lowercase()) {
"entertainment" -> CategoryEntertainment
"sports" -> CategorySports
"news" -> CategoryNews
"movies" -> CategoryMovies
"kids" -> CategoryKids
"music" -> CategoryMusic
"documentary" -> CategoryDocumentary
else -> PrimaryLight
}
}
@Preview(showBackground = true)
@Composable
fun ChannelCardPreview() {
IPTVAppTheme {
ChannelCard(
channel = Channel(
id = "1",
name = "ESPN",
category = "Sports",
logoUrl = null,
streamUrl = "",
isFavorite = false
),
onClick = {},
onFavoriteClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun ChannelCardFavoritePreview() {
IPTVAppTheme {
ChannelCard(
channel = Channel(
id = "2",
name = "HBO",
category = "Movies",
logoUrl = null,
streamUrl = "",
isFavorite = true
),
onClick = {},
onFavoriteClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun ChannelCardLongNamePreview() {
IPTVAppTheme {
ChannelCard(
channel = Channel(
id = "3",
name = "National Geographic Channel HD",
category = "Documentary",
logoUrl = null,
streamUrl = "",
isFavorite = false
),
onClick = {},
onFavoriteClick = {}
)
}
}

View File

@@ -0,0 +1,270 @@
package com.iptv.app.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
/**
* Duration in milliseconds before controls auto-hide after inactivity
*/
private const val CONTROLS_AUTO_HIDE_DELAY_MS = 3000L
/**
* PlayerControls composable that displays an overlay with playback controls.
* Controls auto-hide after a period of inactivity.
*
* @param isPlaying Whether the player is currently playing
* @param channelName The name of the channel to display
* @param isVisible Whether the controls are currently visible
* @param onVisibilityChange Callback when visibility changes
* @param onPlayPauseClick Callback when play/pause button is clicked
* @param onBackClick Callback when back button is clicked
* @param modifier Modifier for the composable
*/
@Composable
fun PlayerControls(
isPlaying: Boolean,
channelName: String,
isVisible: Boolean,
onVisibilityChange: (Boolean) -> Unit,
onPlayPauseClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier
) {
var autoHideEnabled by remember { mutableStateOf(true) }
// Auto-hide controls after inactivity
LaunchedEffect(isVisible, autoHideEnabled) {
if (isVisible && autoHideEnabled) {
delay(CONTROLS_AUTO_HIDE_DELAY_MS)
onVisibilityChange(false)
}
}
// Reset auto-hide timer on visibility change
LaunchedEffect(isVisible) {
if (isVisible) {
autoHideEnabled = true
}
}
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
// Toggle controls visibility on tap
onVisibilityChange(!isVisible)
}
) {
// Top gradient bar with back button and channel name
TopControlBar(
channelName = channelName,
onBackClick = {
autoHideEnabled = false
onBackClick()
},
modifier = Modifier.align(Alignment.TopCenter)
)
// Center play/pause button
CenterPlayPauseButton(
isPlaying = isPlaying,
onClick = {
autoHideEnabled = false
onPlayPauseClick()
// Re-enable auto-hide after interaction
autoHideEnabled = true
},
modifier = Modifier.align(Alignment.Center)
)
// Bottom gradient bar (can be extended for additional controls)
BottomControlBar(
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
/**
* Top control bar with back button and channel name
*/
@Composable
private fun TopControlBar(
channelName: String,
onBackClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.7f),
Color.Transparent
)
)
)
.padding(horizontal = 8.dp, vertical = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
IconButton(
onClick = onBackClick,
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = "Now Playing",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.7f)
)
Text(
text = channelName,
style = MaterialTheme.typography.titleMedium,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
/**
* Center play/pause button
*/
@Composable
private fun CenterPlayPauseButton(
isPlaying: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
IconButton(
onClick = onClick,
modifier = modifier
.size(80.dp)
.background(
color = Color.Black.copy(alpha = 0.5f),
shape = androidx.compose.foundation.shape.CircleShape
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
}
/**
* Bottom control bar (placeholder for future controls like volume, settings, etc.)
*/
@Composable
private fun BottomControlBar(
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
.padding(horizontal = 16.dp, vertical = 24.dp)
) {
// Placeholder for future controls like:
// - Volume slider
// - Quality selector
// - Audio track selector
// - Subtitle toggle
}
}
/**
* Preview/PreviewParameter provider for PlayerControls
*/
@Composable
fun PlayerControlsPreview() {
var isVisible by remember { mutableStateOf(true) }
var isPlaying by remember { mutableStateOf(true) }
MaterialTheme {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
PlayerControls(
isPlaying = isPlaying,
channelName = "Test Channel HD",
isVisible = isVisible,
onVisibilityChange = { isVisible = it },
onPlayPauseClick = { isPlaying = !isPlaying },
onBackClick = {}
)
}
}
}

View File

@@ -0,0 +1,237 @@
package com.iptv.app.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.iptv.app.data.remote.UpdateService
import kotlinx.coroutines.launch
/**
* Diálogo de actualización disponible.
*/
@Composable
fun UpdateDialog(
updateInfo: UpdateService.UpdateInfo,
onDismiss: () -> Unit,
onDownload: () -> Unit,
onIgnore: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "Nueva versión disponible",
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
) {
Text(
text = "Versión ${updateInfo.version}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
// Tamaño del archivo
Text(
text = "Tamaño: ${formatFileSize(updateInfo.fileSize)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
// Changelog
Text(
text = "Novedades:",
style = MaterialTheme.typography.labelLarge
)
Spacer(modifier = Modifier.height(4.dp))
Surface(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 200.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
Text(
text = updateInfo.changelog,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.padding(12.dp)
.verticalScroll(rememberScrollState())
)
}
if (updateInfo.isMandatory) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Esta actualización es obligatoria.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
},
confirmButton = {
Button(onClick = onDownload) {
Text("Actualizar")
}
},
dismissButton = {
if (!updateInfo.isMandatory) {
TextButton(onClick = onIgnore) {
Text("Ignorar")
}
}
}
)
}
/**
* Diálogo de progreso de descarga.
*/
@Composable
fun DownloadProgressDialog(
progress: UpdateService.DownloadProgress.Progress,
onCancel: () -> Unit
) {
AlertDialog(
onDismissRequest = { },
title = {
Text("Descargando actualización...")
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
LinearProgressIndicator(
progress = { progress.percentage / 100f },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "${progress.percentage}%",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${formatFileSize(progress.downloadedBytes)} / ${formatFileSize(progress.totalBytes)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = { },
dismissButton = {
TextButton(onClick = onCancel) {
Text("Cancelar")
}
}
)
}
/**
* Diálogo de instalación lista.
*/
@Composable
fun InstallReadyDialog(
fileName: String,
onInstall: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Descarga completada")
},
text = {
Text("La actualización se ha descargado correctamente. ¿Deseas instalarla ahora?")
},
confirmButton = {
Button(onClick = onInstall) {
Text("Instalar")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Después")
}
}
)
}
/**
* Snackbar para mostrar resultado de la actualización.
*/
@Composable
fun UpdateSnackbarHost(
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier
) {
SnackbarHost(
hostState = snackbarHostState,
modifier = modifier
)
}
/**
* Formatea el tamaño de archivo a formato legible.
*/
private fun formatFileSize(bytes: Long): String {
return when {
bytes >= 1024 * 1024 * 1024 -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0))
bytes >= 1024 * 1024 -> "%.2f MB".format(bytes / (1024.0 * 1024.0))
bytes >= 1024 -> "%.2f KB".format(bytes / 1024.0)
else -> "$bytes B"
}
}
/**
* Effect para verificar actualizaciones al iniciar.
*/
@Composable
fun CheckForUpdatesEffect(
updateService: UpdateService,
onUpdateAvailable: (UpdateService.UpdateInfo) -> Unit,
checkInterval: Long = 24 * 60 * 60 * 1000 // 24 horas
) {
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
// Verificar si ya se comprobó recientemente
val lastCheck = updateService.getLastCheckTime()
val now = System.currentTimeMillis()
if (now - lastCheck > checkInterval) {
scope.launch {
val result = updateService.checkForUpdate()
if (result is UpdateService.UpdateResult.UpdateAvailable) {
onUpdateAvailable(result.updateInfo)
}
updateService.setLastCheckTime(now)
}
}
}
}

View File

@@ -0,0 +1,343 @@
package com.iptv.app.ui.components
import android.content.Context
import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
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.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.iptv.app.utils.DnsConfigurator
import com.iptv.app.utils.PlayerManager
/**
* Data class representing player error state
*/
data class PlayerError(
val message: String,
val recoverable: Boolean = false
)
/**
* Sealed class representing the player state
*/
sealed class PlayerState {
data object Idle : PlayerState()
data object Loading : PlayerState()
data object Playing : PlayerState()
data object Paused : PlayerState()
data object Ended : PlayerState()
data class Error(val error: PlayerError) : PlayerState()
}
/**
* VideoPlayer composable that hosts ExoPlayer for HLS stream playback.
*
* @param streamUrl The HLS stream URL to play
* @param channelName The name of the channel (for display purposes)
* @param modifier Modifier for the composable
* @param onError Callback when an error occurs
* @param onPlayerReady Callback when the player is ready
* @param showControls Whether to show the player controls overlay
* @param onBackPressed Callback when back button is pressed
*/
@OptIn(UnstableApi::class)
@Composable
fun VideoPlayer(
streamUrl: String,
channelName: String,
modifier: Modifier = Modifier,
onError: (PlayerError) -> Unit = {},
onPlayerReady: () -> Unit = {},
showControls: Boolean = true,
onBackPressed: () -> Unit = {}
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var playerState by remember { mutableStateOf<PlayerState>(PlayerState.Idle) }
var isControlsVisible by remember { mutableStateOf(true) }
val playerManager = remember { PlayerManager(context) }
val exoPlayer = remember {
createExoPlayer(context, playerManager).apply {
addListener(createPlayerListener(
onStateChange = { state -> playerState = state },
onError = onError,
onReady = onPlayerReady
))
}
}
// Handle stream URL changes
LaunchedEffect(streamUrl) {
if (streamUrl.isNotBlank()) {
playerState = PlayerState.Loading
preparePlayer(context, exoPlayer, streamUrl)
}
}
// Lifecycle management
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
exoPlayer.playWhenReady = true
}
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.playWhenReady = false
}
Lifecycle.Event.ON_STOP -> {
exoPlayer.playWhenReady = false
}
else -> { /* no-op */ }
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
exoPlayer.release()
}
}
Box(modifier = modifier.fillMaxSize()) {
// ExoPlayer View
AndroidView(
factory = { ctx ->
createPlayerView(ctx, exoPlayer)
},
modifier = Modifier.fillMaxSize()
)
// Loading Indicator
if (playerState is PlayerState.Loading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary
)
}
}
// Error Display
if (playerState is PlayerState.Error) {
val error = (playerState as PlayerState.Error).error
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.8f)),
contentAlignment = Alignment.Center
) {
Text(
text = error.message,
color = Color.White,
style = MaterialTheme.typography.bodyLarge
)
}
}
// Custom Controls Overlay
if (showControls && playerState !is PlayerState.Error) {
PlayerControls(
isPlaying = playerState is PlayerState.Playing,
channelName = channelName,
isVisible = isControlsVisible,
onVisibilityChange = { isControlsVisible = it },
onPlayPauseClick = {
exoPlayer.playWhenReady = !exoPlayer.playWhenReady
},
onBackClick = onBackPressed
)
}
}
}
/**
* Creates and configures the ExoPlayer instance
*/
@OptIn(UnstableApi::class)
private fun createExoPlayer(
context: Context,
playerManager: PlayerManager
): ExoPlayer {
val trackSelector = DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters()
.setMaxVideoSizeSd()
.setPreferredAudioLanguage("en")
)
}
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS,
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS,
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
.build()
return ExoPlayer.Builder(context)
.setTrackSelector(trackSelector)
.setLoadControl(loadControl)
.setMediaSourceFactory(DefaultMediaSourceFactory(context))
.setAudioAttributes(playerManager.getAudioAttributes(), true)
.setHandleAudioBecomingNoisy(true)
.build()
.apply {
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
repeatMode = Player.REPEAT_MODE_OFF
}
}
/**
* Creates the PlayerView for displaying video
*/
@OptIn(UnstableApi::class)
private fun createPlayerView(context: Context, player: ExoPlayer): PlayerView {
return PlayerView(context).apply {
this.player = player
useController = false // We use custom controls
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setKeepContentOnPlayerReset(true)
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
}
}
/**
* Creates a Player.Listener to handle player events
*/
private fun createPlayerListener(
onStateChange: (PlayerState) -> Unit,
onError: (PlayerError) -> Unit,
onReady: () -> Unit
): Player.Listener {
return object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_IDLE -> onStateChange(PlayerState.Idle)
Player.STATE_BUFFERING -> onStateChange(PlayerState.Loading)
Player.STATE_READY -> {
onStateChange(PlayerState.Playing)
onReady()
}
Player.STATE_ENDED -> onStateChange(PlayerState.Ended)
}
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
if (playWhenReady && reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) {
onStateChange(PlayerState.Playing)
} else if (!playWhenReady) {
onStateChange(PlayerState.Paused)
}
}
override fun onPlayerError(error: PlaybackException) {
val playerError = when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT ->
PlayerError(
message = "Network connection failed. Please check your internet connection.",
recoverable = true
)
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED ->
PlayerError(
message = "Unable to decode video stream.",
recoverable = false
)
PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW ->
PlayerError(
message = "Stream is behind live window. Reconnecting...",
recoverable = true
)
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS ->
PlayerError(
message = "Stream unavailable. The channel may be offline.",
recoverable = true
)
else ->
PlayerError(
message = "Playback error: ${error.message}",
recoverable = true
)
}
onStateChange(PlayerState.Error(playerError))
onError(playerError)
}
override fun onIsLoadingChanged(isLoading: Boolean) {
if (isLoading) {
onStateChange(PlayerState.Loading)
}
}
}
}
/**
* Prepares the player with the given HLS stream URL
*/
@OptIn(UnstableApi::class)
private fun preparePlayer(context: Context, player: ExoPlayer, streamUrl: String) {
val mediaItem = MediaItem.Builder()
.setUri(streamUrl)
.setMimeType("application/vnd.apple.mpegurl")
.build()
// Create OkHttpClient with Google DNS configuration
val okHttpClient = DnsConfigurator.createOkHttpClient(context)
// Create OkHttpDataSource.Factory with custom DNS client
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
.setConnectTimeoutMs(15000)
.setReadTimeoutMs(15000)
val mediaSource = HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(mediaItem)
player.setMediaSource(mediaSource)
player.prepare()
player.playWhenReady = true
}

View File

@@ -0,0 +1,468 @@
package com.iptv.app.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.iptv.app.ui.components.CategoryChip
import com.iptv.app.ui.components.Channel
import com.iptv.app.ui.components.ChannelCard
import com.iptv.app.ui.theme.IPTVAppTheme
import kotlinx.coroutines.delay
// UI States
sealed class ChannelsUiState {
data object Loading : ChannelsUiState()
data class Success(
val channels: List<Channel>,
val categories: List<String>
) : ChannelsUiState()
data class Error(val message: String) : ChannelsUiState()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChannelsScreen(
uiState: ChannelsUiState,
selectedCategory: String?,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
onCategorySelect: (String?) -> Unit,
onChannelClick: (Channel) -> Unit,
onFavoriteToggle: (Channel) -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier
) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
var isRefreshing by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
Text(
text = "IPTV Channels",
style = MaterialTheme.typography.titleLarge
)
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Search field
SearchField(
query = searchQuery,
onQueryChange = onSearchQueryChange,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
when (uiState) {
is ChannelsUiState.Loading -> {
LoadingContent()
}
is ChannelsUiState.Error -> {
ErrorContent(
message = uiState.message,
onRetry = onRefresh
)
}
is ChannelsUiState.Success -> {
val filteredChannels = remember(
uiState.channels,
selectedCategory,
searchQuery
) {
uiState.channels.filter { channel ->
val matchesCategory = selectedCategory?.let {
channel.category.equals(it, ignoreCase = true)
} ?: true
val matchesSearch = searchQuery.isBlank() ||
channel.name.contains(searchQuery, ignoreCase = true) ||
channel.category.contains(searchQuery, ignoreCase = true)
matchesCategory && matchesSearch
}
}
// Category filter chips
CategoryFilterRow(
categories = uiState.categories,
selectedCategory = selectedCategory,
onCategorySelect = onCategorySelect,
modifier = Modifier.padding(bottom = 8.dp)
)
// Pull to refresh wrapper
PullToRefreshContainer(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
onRefresh()
},
modifier = Modifier.fillMaxSize()
) {
if (filteredChannels.isEmpty()) {
EmptyContent(
message = if (searchQuery.isNotBlank()) {
"No channels found for \"$searchQuery\""
} else {
"No channels available"
}
)
} else {
ChannelsGrid(
channels = filteredChannels,
onChannelClick = onChannelClick,
onFavoriteToggle = onFavoriteToggle,
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
}
// Simulate refresh completion
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
delay(1500)
isRefreshing = false
}
}
}
@Composable
private fun SearchField(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier,
placeholder = { Text("Search channels...") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search"
)
},
trailingIcon = {
if (query.isNotBlank()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Clear search"
)
}
}
},
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = { /* Handle search action */ }
),
shape = MaterialTheme.shapes.extraLarge
)
}
@Composable
private fun CategoryFilterRow(
categories: List<String>,
selectedCategory: String?,
onCategorySelect: (String?) -> Unit,
modifier: Modifier = Modifier
) {
LazyRow(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
item {
CategoryChip(
category = "All",
isSelected = selectedCategory == null,
onClick = { onCategorySelect(null) }
)
}
items(categories) { category ->
CategoryChip(
category = category,
isSelected = selectedCategory == category,
onClick = { onCategorySelect(category) }
)
}
}
}
@Composable
private fun ChannelsGrid(
channels: List<Channel>,
onChannelClick: (Channel) -> Unit,
onFavoriteToggle: (Channel) -> Unit,
modifier: Modifier = Modifier
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = modifier.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 16.dp)
) {
items(
items = channels,
key = { it.id }
) { channel ->
ChannelCard(
channel = channel,
onClick = { onChannelClick(channel) },
onFavoriteClick = { onFavoriteToggle(channel) }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PullToRefreshContainer(
isRefreshing: Boolean,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val pullToRefreshState = rememberPullToRefreshState()
Box(modifier = modifier) {
content()
if (pullToRefreshState.isRefreshing) {
LaunchedEffect(true) {
onRefresh()
}
}
LaunchedEffect(isRefreshing) {
if (isRefreshing) {
pullToRefreshState.startRefresh()
} else {
pullToRefreshState.endRefresh()
}
}
PullToRefreshBox(
state = pullToRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
content = {}
)
}
}
@Composable
private fun LoadingContent() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Loading channels...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ErrorContent(
message: String,
onRetry: () -> Unit
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Oops!",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onRetry) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Try Again")
}
}
}
}
@Composable
private fun EmptyContent(message: String) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
}
}
// Preview functions
@Preview(showBackground = true)
@Composable
fun ChannelsScreenLoadingPreview() {
IPTVAppTheme {
ChannelsScreen(
uiState = ChannelsUiState.Loading,
selectedCategory = null,
searchQuery = "",
onSearchQueryChange = {},
onCategorySelect = {},
onChannelClick = {},
onFavoriteToggle = {},
onRefresh = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun ChannelsScreenErrorPreview() {
IPTVAppTheme {
ChannelsScreen(
uiState = ChannelsUiState.Error("Failed to load channels. Please check your connection."),
selectedCategory = null,
searchQuery = "",
onSearchQueryChange = {},
onCategorySelect = {},
onChannelClick = {},
onFavoriteToggle = {},
onRefresh = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun ChannelsScreenSuccessPreview() {
IPTVAppTheme {
val sampleChannels = listOf(
Channel("1", "ESPN", "Sports", null, ""),
Channel("2", "CNN", "News", null, ""),
Channel("3", "HBO", "Movies", null, "", true),
Channel("4", "Disney Channel", "Kids", null, ""),
Channel("5", "MTV", "Music", null, ""),
Channel("6", "Discovery", "Documentary", null, "")
)
ChannelsScreen(
uiState = ChannelsUiState.Success(
channels = sampleChannels,
categories = listOf("Sports", "News", "Movies", "Kids", "Music", "Documentary")
),
selectedCategory = "Sports",
searchQuery = "",
onSearchQueryChange = {},
onCategorySelect = {},
onChannelClick = {},
onFavoriteToggle = {},
onRefresh = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun ChannelsScreenEmptySearchPreview() {
IPTVAppTheme {
ChannelsScreen(
uiState = ChannelsUiState.Success(
channels = emptyList(),
categories = listOf("Sports", "News")
),
selectedCategory = null,
searchQuery = "xyz",
onSearchQueryChange = {},
onCategorySelect = {},
onChannelClick = {},
onFavoriteToggle = {},
onRefresh = {}
)
}
}

View File

@@ -0,0 +1,420 @@
package com.iptv.app.ui.screens
import android.app.Activity
import android.content.pm.ActivityInfo
import android.view.View
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.iptv.app.ui.components.Channel
import com.iptv.app.ui.theme.IPTVAppTheme
import com.iptv.app.ui.theme.LiveIndicator
import kotlinx.coroutines.delay
@OptIn(UnstableApi::class)
@Composable
fun PlayerScreen(
channel: Channel,
onBackClick: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val activity = context as? Activity
val lifecycleOwner = LocalLifecycleOwner.current
// ExoPlayer setup
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(channel.streamUrl))
prepare()
playWhenReady = true
}
}
// UI state
var isPlaying by remember { mutableStateOf(true) }
var isLoading by remember { mutableStateOf(true) }
var showControls by remember { mutableStateOf(true) }
var isFullscreen by remember { mutableStateOf(true) }
// Auto-hide controls
LaunchedEffect(showControls) {
if (showControls) {
delay(3000)
showControls = false
}
}
// Player listener
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
isLoading = playbackState == Player.STATE_BUFFERING
isPlaying = exoPlayer.isPlaying
}
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
}
}
// Lifecycle handling
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.pause()
}
Lifecycle.Event.ON_RESUME -> {
exoPlayer.play()
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Cleanup
DisposableEffect(Unit) {
onDispose {
exoPlayer.release()
}
}
// Fullscreen handling
DisposableEffect(isFullscreen) {
activity?.let {
if (isFullscreen) {
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
it.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
it.window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
)
} else {
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
it.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
it.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
}
}
onDispose {
activity?.let {
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
it.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
it.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
}
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
showControls = !showControls
}
) {
// Video Player
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
},
modifier = Modifier.fillMaxSize()
)
// Loading indicator
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
color = MaterialTheme.colorScheme.primary,
strokeWidth = 4.dp
)
}
}
// Controls overlay
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut()
) {
PlayerControlsOverlay(
channel = channel,
isPlaying = isPlaying,
isFullscreen = isFullscreen,
onBackClick = onBackClick,
onPlayPauseClick = {
if (exoPlayer.isPlaying) {
exoPlayer.pause()
} else {
exoPlayer.play()
}
},
onFullscreenToggle = {
isFullscreen = !isFullscreen
}
)
}
}
}
@Composable
private fun PlayerControlsOverlay(
channel: Channel,
isPlaying: Boolean,
isFullscreen: Boolean,
onBackClick: () -> Unit,
onPlayPauseClick: () -> Unit,
onFullscreenToggle: () -> Unit
) {
Box(
modifier = Modifier.fillMaxSize()
) {
// Top gradient with back button and channel info
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.8f),
Color.Transparent
)
)
)
.align(Alignment.TopCenter)
.padding(16.dp)
) {
Column {
// Back button row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onBackClick,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Channel info
Row(
verticalAlignment = Alignment.CenterVertically
) {
// Live indicator
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(LiveIndicator)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "LIVE",
style = MaterialTheme.typography.labelSmall,
color = Color.White
)
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = channel.name,
style = MaterialTheme.typography.titleLarge,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = channel.category,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.7f)
)
}
}
}
}
// Center play/pause button
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically(),
modifier = Modifier.align(Alignment.Center)
) {
IconButton(
onClick = onPlayPauseClick,
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.6f))
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(40.dp)
)
}
}
// Bottom controls
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.8f)
)
)
)
.align(Alignment.BottomCenter)
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onFullscreenToggle,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))
) {
Icon(
imageVector = if (isFullscreen) {
Icons.Default.FullscreenExit
} else {
Icons.Default.Fullscreen
},
contentDescription = if (isFullscreen) {
"Exit fullscreen"
} else {
"Enter fullscreen"
},
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
}
}
// Preview
@Preview(showBackground = true, device = "spec:width=1920,height=1080,orientation=landscape")
@Composable
fun PlayerScreenPreview() {
IPTVAppTheme {
PlayerScreen(
channel = Channel(
id = "1",
name = "ESPN HD",
category = "Sports",
logoUrl = null,
streamUrl = "",
isFavorite = false
),
onBackClick = {}
)
}
}
@Preview(showBackground = true, device = "spec:width=1920,height=1080,orientation=landscape")
@Composable
fun PlayerScreenPausedPreview() {
IPTVAppTheme {
Box(
modifier = Modifier.fillMaxSize()
) {
PlayerControlsOverlay(
channel = Channel(
id = "2",
name = "HBO Movies",
category = "Movies",
logoUrl = null,
streamUrl = "",
isFavorite = true
),
isPlaying = false,
isFullscreen = true,
onBackClick = {},
onPlayPauseClick = {},
onFullscreenToggle = {}
)
}
}
}

View File

@@ -0,0 +1,80 @@
package com.iptv.app.ui.theme
import androidx.compose.ui.graphics.Color
// Primary colors
val PrimaryLight = Color(0xFF0066CC)
val OnPrimaryLight = Color(0xFFFFFFFF)
val PrimaryContainerLight = Color(0xFFD1E4FF)
val OnPrimaryContainerLight = Color(0xFF001D36)
val PrimaryDark = Color(0xFF9ECAFF)
val OnPrimaryDark = Color(0xFF003258)
val PrimaryContainerDark = Color(0xFF00497D)
val OnPrimaryContainerDark = Color(0xFFD1E4FF)
// Secondary colors
val SecondaryLight = Color(0xFF535F70)
val OnSecondaryLight = Color(0xFFFFFFFF)
val SecondaryContainerLight = Color(0xFFD7E3F7)
val OnSecondaryContainerLight = Color(0xFF101C2B)
val SecondaryDark = Color(0xFFBBC7DB)
val OnSecondaryDark = Color(0xFF253140)
val SecondaryContainerDark = Color(0xFF3B4858)
val OnSecondaryContainerDark = Color(0xFFD7E3F7)
// Tertiary colors
val TertiaryLight = Color(0xFF6B5778)
val OnTertiaryLight = Color(0xFFFFFFFF)
val TertiaryContainerLight = Color(0xFFF2DAFF)
val OnTertiaryContainerLight = Color(0xFF251431)
val TertiaryDark = Color(0xFFD6BEE4)
val OnTertiaryDark = Color(0xFF3B2948)
val TertiaryContainerDark = Color(0xFF523F5F)
val OnTertiaryContainerDark = Color(0xFFF2DAFF)
// Error colors
val ErrorLight = Color(0xFFBA1A1A)
val OnErrorLight = Color(0xFFFFFFFF)
val ErrorContainerLight = Color(0xFFFFDAD6)
val OnErrorContainerLight = Color(0xFF410002)
val ErrorDark = Color(0xFFFFB4AB)
val OnErrorDark = Color(0xFF690005)
val ErrorContainerDark = Color(0xFF93000A)
val OnErrorContainerDark = Color(0xFFFFDAD6)
// Background colors
val BackgroundLight = Color(0xFFFDFCFF)
val OnBackgroundLight = Color(0xFF1A1C1E)
val SurfaceLight = Color(0xFFFDFCFF)
val OnSurfaceLight = Color(0xFF1A1C1E)
val SurfaceVariantLight = Color(0xFFDFE2EB)
val OnSurfaceVariantLight = Color(0xFF43474E)
val BackgroundDark = Color(0xFF1A1C1E)
val OnBackgroundDark = Color(0xFFE2E2E6)
val SurfaceDark = Color(0xFF1A1C1E)
val OnSurfaceDark = Color(0xFFE2E2E6)
val SurfaceVariantDark = Color(0xFF43474E)
val OnSurfaceVariantDark = Color(0xFFC3C7CF)
// Outline colors
val OutlineLight = Color(0xFF73777F)
val OutlineVariantLight = Color(0xFFC3C7CF)
val OutlineDark = Color(0xFF8D9199)
val OutlineVariantDark = Color(0xFF43474E)
// IPTV specific colors
val LiveIndicator = Color(0xFFE53935)
val FavoriteColor = Color(0xFFFFB300)
val CategoryEntertainment = Color(0xFF7C4DFF)
val CategorySports = Color(0xFF00BFA5)
val CategoryNews = Color(0xFF2962FF)
val CategoryMovies = Color(0xFFFF6D00)
val CategoryKids = Color(0xFFFF4081)
val CategoryMusic = Color(0xFF00E5FF)
val CategoryDocumentary = Color(0xFF76FF03)

View File

@@ -0,0 +1,101 @@
package com.iptv.app.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme(
primary = PrimaryLight,
onPrimary = OnPrimaryLight,
primaryContainer = PrimaryContainerLight,
onPrimaryContainer = OnPrimaryContainerLight,
secondary = SecondaryLight,
onSecondary = OnSecondaryLight,
secondaryContainer = SecondaryContainerLight,
onSecondaryContainer = OnSecondaryContainerLight,
tertiary = TertiaryLight,
onTertiary = OnTertiaryLight,
tertiaryContainer = TertiaryContainerLight,
onTertiaryContainer = OnTertiaryContainerLight,
error = ErrorLight,
onError = OnErrorLight,
errorContainer = ErrorContainerLight,
onErrorContainer = OnErrorContainerLight,
background = BackgroundLight,
onBackground = OnBackgroundLight,
surface = SurfaceLight,
onSurface = OnSurfaceLight,
surfaceVariant = SurfaceVariantLight,
onSurfaceVariant = OnSurfaceVariantLight,
outline = OutlineLight,
outlineVariant = OutlineVariantLight
)
private val DarkColorScheme = darkColorScheme(
primary = PrimaryDark,
onPrimary = OnPrimaryDark,
primaryContainer = PrimaryContainerDark,
onPrimaryContainer = OnPrimaryContainerDark,
secondary = SecondaryDark,
onSecondary = OnSecondaryDark,
secondaryContainer = SecondaryContainerDark,
onSecondaryContainer = OnSecondaryContainerDark,
tertiary = TertiaryDark,
onTertiary = OnTertiaryDark,
tertiaryContainer = TertiaryContainerDark,
onTertiaryContainer = OnTertiaryContainerDark,
error = ErrorDark,
onError = OnErrorDark,
errorContainer = ErrorContainerDark,
onErrorContainer = OnErrorContainerDark,
background = BackgroundDark,
onBackground = OnBackgroundDark,
surface = SurfaceDark,
onSurface = OnSurfaceDark,
surfaceVariant = SurfaceVariantDark,
onSurfaceVariant = OnSurfaceVariantDark,
outline = OutlineDark,
outlineVariant = OutlineVariantDark
)
@Composable
fun IPTVAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,115 @@
package com.iptv.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View File

@@ -0,0 +1,329 @@
package com.iptv.app.ui.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.iptv.app.data.model.Channel
import com.iptv.app.data.repository.ChannelRepository
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* UI State for the Channels screen.
*/
data class ChannelsUiState(
val channels: List<Channel> = emptyList(),
val filteredChannels: List<Channel> = emptyList(),
val categories: List<String> = emptyList(),
val selectedCategory: String? = null,
val searchQuery: String = "",
val favoriteChannelIds: Set<String> = emptySet(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val errorMessage: String? = null,
val hasMoreData: Boolean = true
)
/**
* ViewModel for managing the Channels list screen.
* Handles channel loading, filtering, searching, and favorites management.
*/
@OptIn(FlowPreview::class)
class ChannelsViewModel(
private val channelRepository: ChannelRepository,
private val m3uUrl: String
) : ViewModel() {
companion object {
private const val SEARCH_DEBOUNCE_MS = 300L
}
private val _uiState = MutableStateFlow(ChannelsUiState())
val uiState: StateFlow<ChannelsUiState> = _uiState.asStateFlow()
private val _searchQuery = MutableStateFlow("")
private val _selectedCategory = MutableStateFlow<String?>(null)
init {
observeChannels()
observeCategories()
observeFavorites()
setupSearchAndFilter()
}
/**
* Loads channels from the repository.
*/
fun loadChannels() {
channelRepository.fetchChannels(m3uUrl)
.onStart {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
}
.onEach { channels ->
_uiState.update { state ->
state.copy(
channels = channels,
isLoading = false,
errorMessage = null
)
}
}
.catch { error ->
_uiState.update { state ->
state.copy(
isLoading = false,
errorMessage = error.message ?: "Failed to load channels"
)
}
}
.launchIn(viewModelScope)
}
/**
* Refreshes channels from the network.
*/
fun refreshChannels() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
val result = channelRepository.refreshChannels(m3uUrl)
result
.onSuccess { channels ->
_uiState.update { state ->
state.copy(
channels = channels,
isRefreshing = false,
errorMessage = null
)
}
}
.onFailure { error ->
_uiState.update { state ->
state.copy(
isRefreshing = false,
errorMessage = error.message ?: "Failed to refresh channels"
)
}
}
}
}
/**
* Sets the search query for filtering channels.
*/
fun setSearchQuery(query: String) {
_searchQuery.value = query
_uiState.update { it.copy(searchQuery = query) }
}
/**
* Clears the current search query.
*/
fun clearSearch() {
_searchQuery.value = ""
_uiState.update { it.copy(searchQuery = "") }
}
/**
* Selects a category to filter channels.
*/
fun selectCategory(category: String?) {
_selectedCategory.value = category
_uiState.update { it.copy(selectedCategory = category) }
}
/**
* Clears the category filter.
*/
fun clearCategoryFilter() {
_selectedCategory.value = null
_uiState.update { it.copy(selectedCategory = null) }
}
/**
* Toggles a channel's favorite status.
*/
fun toggleFavorite(channelId: String) {
viewModelScope.launch {
val isNowFavorite = channelRepository.toggleFavorite(channelId)
_uiState.update { state ->
val updatedFavorites = if (isNowFavorite) {
state.favoriteChannelIds + channelId
} else {
state.favoriteChannelIds - channelId
}
state.copy(favoriteChannelIds = updatedFavorites)
}
}
}
/**
* Adds a channel to favorites.
*/
fun addToFavorites(channelId: String) {
viewModelScope.launch {
channelRepository.addToFavorites(channelId)
updateFavoriteIds()
}
}
/**
* Removes a channel from favorites.
*/
fun removeFromFavorites(channelId: String) {
viewModelScope.launch {
channelRepository.removeFromFavorites(channelId)
updateFavoriteIds()
}
}
/**
* Checks if a channel is a favorite.
*/
fun isFavorite(channelId: String): Flow<Boolean> {
return channelRepository.isFavorite(channelId)
}
/**
* Clears any error message.
*/
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
/**
* Clears the channel cache and reloads.
*/
fun clearCacheAndReload() {
viewModelScope.launch {
channelRepository.clearCache()
loadChannels()
}
}
/**
* Gets channels filtered by the current search and category filters.
*/
fun getFilteredChannels(): Flow<List<Channel>> {
return combine(
_uiState.map { it.channels },
_searchQuery,
_selectedCategory
) { channels, query, category ->
channels.filter { channel ->
val matchesSearch = query.isBlank() ||
channel.name.contains(query, ignoreCase = true) ||
channel.category.contains(query, ignoreCase = true)
val matchesCategory = category == null ||
channel.category.equals(category, ignoreCase = true)
matchesSearch && matchesCategory
}
}.distinctUntilChanged()
}
/**
* Gets only favorite channels.
*/
fun getFavoriteChannels(): Flow<List<Channel>> {
return channelRepository.getFavoriteChannels()
}
private fun observeChannels() {
loadChannels()
}
private fun observeCategories() {
channelRepository.getCategories()
.onEach { categories ->
_uiState.update { it.copy(categories = categories) }
}
.catch { error ->
// Log error but don't disrupt UI
}
.launchIn(viewModelScope)
}
private fun observeFavorites() {
channelRepository.getFavoriteChannels()
.map { favorites -> favorites.map { it.id }.toSet() }
.onEach { favoriteIds ->
_uiState.update { it.copy(favoriteChannelIds = favoriteIds) }
}
.catch { error ->
// Log error but don't disrupt UI
}
.launchIn(viewModelScope)
}
private fun setupSearchAndFilter() {
combine(
_uiState.map { it.channels },
_searchQuery
.debounce(SEARCH_DEBOUNCE_MS)
.distinctUntilChanged(),
_selectedCategory.distinctUntilChanged()
) { channels, query, category ->
channels.filter { channel ->
val matchesSearch = query.isBlank() ||
channel.name.contains(query, ignoreCase = true) ||
channel.category.contains(query, ignoreCase = true)
val matchesCategory = category == null ||
channel.category.equals(category, ignoreCase = true)
matchesSearch && matchesCategory
}
}
.onEach { filtered ->
_uiState.update { it.copy(filteredChannels = filtered) }
}
.catch { error ->
_uiState.update { it.copy(errorMessage = "Filter error: ${error.message}") }
}
.launchIn(viewModelScope)
}
private fun updateFavoriteIds() {
channelRepository.getFavoriteChannels()
.map { favorites -> favorites.map { it.id }.toSet() }
.onEach { favoriteIds ->
_uiState.update { it.copy(favoriteChannelIds = favoriteIds) }
}
.launchIn(viewModelScope)
}
}
/**
* Factory for creating ChannelsViewModel with dependencies.
*/
class ChannelsViewModelFactory(
private val channelRepository: ChannelRepository,
private val m3uUrl: String
) : androidx.lifecycle.ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(ChannelsViewModel::class.java)) {
return ChannelsViewModel(channelRepository, m3uUrl) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View File

@@ -0,0 +1,404 @@
package com.iptv.app.ui.viewmodel
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.iptv.app.data.model.Channel
import com.iptv.app.data.repository.ChannelRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Represents the different playback states.
*/
sealed class PlaybackState {
data object Idle : PlaybackState()
data object Loading : PlaybackState()
data object Ready : PlaybackState()
data object Playing : PlaybackState()
data object Paused : PlaybackState()
data object Ended : PlaybackState()
data class Error(val message: String) : PlaybackState()
data class Buffering(val progress: Float) : PlaybackState()
}
/**
* UI State for the Player screen.
*/
data class PlayerUiState(
val currentChannel: Channel? = null,
val playbackState: PlaybackState = PlaybackState.Idle,
val isFullscreen: Boolean = false,
val controlsVisible: Boolean = true,
val currentPosition: Long = 0L,
val duration: Long = 0L,
val volume: Float = 1.0f,
val isMuted: Boolean = false,
val availableQualities: List<String> = emptyList(),
val selectedQuality: String? = null,
val isFavorite: Boolean = false,
val errorMessage: String? = null,
val relatedChannels: List<Channel> = emptyList(),
val showEpg: Boolean = false
)
/**
* Player events that can be triggered from the UI.
*/
sealed class PlayerEvent {
data class LoadChannel(val channelId: String) : PlayerEvent()
data object Play : PlayerEvent()
data object Pause : PlayerEvent()
data object Stop : PlayerEvent()
data class SeekTo(val positionMs: Long) : PlayerEvent()
data class SetVolume(val volume: Float) : PlayerEvent()
data object ToggleMute : PlayerEvent()
data object ToggleFullscreen : PlayerEvent()
data object ToggleControls : PlayerEvent()
data object ToggleFavorite : PlayerEvent()
data class SelectQuality(val quality: String) : PlayerEvent()
data object NextChannel : PlayerEvent()
data object PreviousChannel : PlayerEvent()
data object Retry : PlayerEvent()
data object DismissError : PlayerEvent()
data object ToggleEpg : PlayerEvent()
}
/**
* ViewModel for managing the video player screen.
* Handles playback state, channel navigation, and player controls.
*/
class PlayerViewModel(
private val channelRepository: ChannelRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
companion object {
private const val CONTROLS_HIDE_DELAY_MS = 3000L
private const val POSITION_UPDATE_INTERVAL_MS = 1000L
private const val KEY_LAST_CHANNEL_ID = "last_channel_id"
}
private val _uiState = MutableStateFlow(PlayerUiState())
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
private var controlsHideJob: Job? = null
private var positionUpdateJob: Job? = null
private var currentChannelList: List<Channel> = emptyList()
init {
observeChannels()
}
/**
* Handles player events from the UI.
*/
fun onEvent(event: PlayerEvent) {
when (event) {
is PlayerEvent.LoadChannel -> loadChannel(event.channelId)
is PlayerEvent.Play -> play()
is PlayerEvent.Pause -> pause()
is PlayerEvent.Stop -> stop()
is PlayerEvent.SeekTo -> seekTo(event.positionMs)
is PlayerEvent.SetVolume -> setVolume(event.volume)
is PlayerEvent.ToggleMute -> toggleMute()
is PlayerEvent.ToggleFullscreen -> toggleFullscreen()
is PlayerEvent.ToggleControls -> toggleControls()
is PlayerEvent.ToggleFavorite -> toggleFavorite()
is PlayerEvent.SelectQuality -> selectQuality(event.quality)
is PlayerEvent.NextChannel -> nextChannel()
is PlayerEvent.PreviousChannel -> previousChannel()
is PlayerEvent.Retry -> retry()
is PlayerEvent.DismissError -> dismissError()
is PlayerEvent.ToggleEpg -> toggleEpg()
}
}
/**
* Loads a channel by ID and starts playback.
*/
fun loadChannel(channelId: String) {
viewModelScope.launch {
_uiState.update { it.copy(playbackState = PlaybackState.Loading) }
channelRepository.getChannelById(channelId)
.filterNotNull()
.collect { channel ->
val isFavorite = checkIsFavorite(channelId)
_uiState.update { state ->
state.copy(
currentChannel = channel,
playbackState = PlaybackState.Ready,
isFavorite = isFavorite,
errorMessage = null
)
}
// Save last played channel
savedStateHandle[KEY_LAST_CHANNEL_ID] = channelId
// Load related channels from same category
loadRelatedChannels(channel)
// Start position updates
startPositionUpdates()
}
}
}
/**
* Gets the current channel's stream URL.
*/
fun getCurrentStreamUrl(): Uri? {
return _uiState.value.currentChannel?.let {
Uri.parse(it.streamUrl)
}
}
/**
* Updates the playback state.
*/
fun updatePlaybackState(state: PlaybackState) {
_uiState.update { it.copy(playbackState = state) }
when (state) {
is PlaybackState.Playing -> startPositionUpdates()
is PlaybackState.Paused,
is PlaybackState.Idle,
is PlaybackState.Ended,
is PlaybackState.Error -> stopPositionUpdates()
else -> { /* No action needed */ }
}
}
/**
* Updates the current playback position.
*/
fun updatePosition(positionMs: Long) {
_uiState.update { it.copy(currentPosition = positionMs) }
}
/**
* Updates the media duration.
*/
fun updateDuration(durationMs: Long) {
_uiState.update { it.copy(duration = durationMs) }
}
/**
* Updates the buffering progress.
*/
fun updateBufferingProgress(progress: Float) {
_uiState.update { it.copy(playbackState = PlaybackState.Buffering(progress)) }
}
/**
* Reports a player error.
*/
fun reportError(error: String) {
_uiState.update {
it.copy(
playbackState = PlaybackState.Error(error),
errorMessage = error
)
}
}
override fun onCleared() {
super.onCleared()
stopPositionUpdates()
controlsHideJob?.cancel()
}
private fun play() {
_uiState.update { it.copy(playbackState = PlaybackState.Playing) }
startPositionUpdates()
}
private fun pause() {
_uiState.update { it.copy(playbackState = PlaybackState.Paused) }
stopPositionUpdates()
}
private fun stop() {
_uiState.update {
it.copy(
playbackState = PlaybackState.Idle,
currentPosition = 0L
)
}
stopPositionUpdates()
}
private fun seekTo(positionMs: Long) {
_uiState.update { it.copy(currentPosition = positionMs) }
}
private fun setVolume(volume: Float) {
val clampedVolume = volume.coerceIn(0f, 1f)
_uiState.update {
it.copy(
volume = clampedVolume,
isMuted = clampedVolume == 0f
)
}
}
private fun toggleMute() {
_uiState.update { state ->
state.copy(isMuted = !state.isMuted)
}
}
private fun toggleFullscreen() {
_uiState.update { it.copy(isFullscreen = !it.isFullscreen) }
}
private fun toggleControls() {
val newVisibility = !_uiState.value.controlsVisible
_uiState.update { it.copy(controlsVisible = newVisibility) }
if (newVisibility) {
scheduleControlsHide()
} else {
controlsHideJob?.cancel()
}
}
private fun toggleFavorite() {
val channelId = _uiState.value.currentChannel?.id ?: return
viewModelScope.launch {
val isNowFavorite = channelRepository.toggleFavorite(channelId)
_uiState.update { it.copy(isFavorite = isNowFavorite) }
}
}
private fun selectQuality(quality: String) {
_uiState.update { it.copy(selectedQuality = quality) }
// Quality switching logic would be handled by the player
}
private fun nextChannel() {
val currentChannel = _uiState.value.currentChannel ?: return
val currentIndex = currentChannelList.indexOfFirst { it.id == currentChannel.id }
if (currentIndex != -1 && currentIndex < currentChannelList.size - 1) {
val nextChannel = currentChannelList[currentIndex + 1]
loadChannel(nextChannel.id)
}
}
private fun previousChannel() {
val currentChannel = _uiState.value.currentChannel ?: return
val currentIndex = currentChannelList.indexOfFirst { it.id == currentChannel.id }
if (currentIndex > 0) {
val previousChannel = currentChannelList[currentIndex - 1]
loadChannel(previousChannel.id)
}
}
private fun retry() {
val channelId = _uiState.value.currentChannel?.id
?: savedStateHandle.get<String>(KEY_LAST_CHANNEL_ID)
?: return
loadChannel(channelId)
}
private fun dismissError() {
_uiState.update { it.copy(errorMessage = null) }
}
private fun toggleEpg() {
_uiState.update { it.copy(showEpg = !it.showEpg) }
}
private fun observeChannels() {
channelRepository.allChannels
.onEach { channels ->
currentChannelList = channels
}
.catch { /* Handle error */ }
.launchIn(viewModelScope)
}
private fun loadRelatedChannels(channel: Channel) {
viewModelScope.launch {
channelRepository.getChannelsByCategory(channel.category)
.map { channels ->
channels.filter { it.id != channel.id }.take(10)
}
.collect { related ->
_uiState.update { it.copy(relatedChannels = related) }
}
}
}
private suspend fun checkIsFavorite(channelId: String): Boolean {
return withContext(Dispatchers.IO) {
var isFav = false
channelRepository.isFavorite(channelId)
.collect { isFav = it }
isFav
}
}
private fun scheduleControlsHide() {
controlsHideJob?.cancel()
controlsHideJob = viewModelScope.launch {
delay(CONTROLS_HIDE_DELAY_MS)
_uiState.update { it.copy(controlsVisible = false) }
}
}
private fun startPositionUpdates() {
positionUpdateJob?.cancel()
positionUpdateJob = viewModelScope.launch {
while (true) {
delay(POSITION_UPDATE_INTERVAL_MS)
// Position updates would be triggered from the player
}
}
}
private fun stopPositionUpdates() {
positionUpdateJob?.cancel()
positionUpdateJob = null
}
}
/**
* Factory for creating PlayerViewModel with dependencies.
*/
class PlayerViewModelFactory(
private val channelRepository: ChannelRepository
) : androidx.lifecycle.ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(PlayerViewModel::class.java)) {
return PlayerViewModel(
channelRepository,
SavedStateHandle()
) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}

View File

@@ -0,0 +1,210 @@
package com.iptv.app.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps
import java.net.InetAddress
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
/**
* Configurador de DNS para evitar bloqueos regionales.
* Usa DNS de Google (8.8.8.8, 8.8.4.4) y DoH (DNS over HTTPS).
*/
object DnsConfigurator {
private const val TAG = "DnsConfigurator"
// Google DNS servers
private val GOOGLE_DNS_IPV4 = listOf(
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("8.8.4.4")
)
// Google DNS over HTTPS URL
private const val GOOGLE_DOH_URL = "https://dns.google/dns-query"
// Cloudflare DNS as fallback
private val CLOUDFLARE_DNS_IPV4 = listOf(
InetAddress.getByName("1.1.1.1"),
InetAddress.getByName("1.0.0.1")
)
/**
* Crea un cliente OkHttp con DNS de Google configurado.
*/
fun createOkHttpClient(context: Context): OkHttpClient {
val dns = createGoogleDns(context)
return OkHttpClient.Builder()
.dns(dns)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
/**
* Crea una instancia de DNS usando Google DNS con fallback.
*/
private fun createGoogleDns(context: Context): Dns {
return try {
// Intentar usar DoH primero (más privado y seguro)
createDnsOverHttps()
} catch (e: Exception) {
Log.w(TAG, "DoH no disponible, usando DNS tradicional", e)
// Fallback a DNS tradicional
createTraditionalDns()
}
}
/**
* Crea DNS over HTTPS usando Google DNS.
*/
private fun createDnsOverHttps(): Dns {
val bootstrapClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.build()
return DnsOverHttps.Builder()
.client(bootstrapClient)
.url(java.net.URL(GOOGLE_DOH_URL))
.bootstrapDnsHosts(GOOGLE_DNS_IPV4 + CLOUDFLARE_DNS_IPV4)
.includeIPv6(false)
.build()
}
/**
* Crea DNS tradicional con servidores de Google.
*/
private fun createTraditionalDns(): Dns {
return object : Dns {
@Throws(UnknownHostException::class)
override fun lookup(hostname: String): List<InetAddress> {
// Intentar con Google DNS primero
val googleResult = tryLookup(hostname, GOOGLE_DNS_IPV4)
if (googleResult.isNotEmpty()) {
Log.d(TAG, "DNS resuelto con Google DNS: $hostname")
return googleResult
}
// Fallback a Cloudflare
val cloudflareResult = tryLookup(hostname, CLOUDFLARE_DNS_IPV4)
if (cloudflareResult.isNotEmpty()) {
Log.d(TAG, "DNS resuelto con Cloudflare DNS: $hostname")
return cloudflareResult
}
// Fallback al DNS del sistema
Log.d(TAG, "Usando DNS del sistema para: $hostname")
return Dns.SYSTEM.lookup(hostname)
}
}
}
/**
* Intenta resolver un hostname usando DNS específicos.
*/
private fun tryLookup(hostname: String, dnsServers: List<InetAddress>): List<InetAddress> {
return try {
// Usar el método lookup del sistema pero forzando los DNS
val method = InetAddress::class.java.getDeclaredMethod(
"getAllByName",
String::class.java,
InetAddress::class.java
)
method.isAccessible = true
dnsServers.flatMap { dnsServer ->
try {
@Suppress("UNCHECKED_CAST")
val result = method.invoke(null, hostname, dnsServer) as Array<InetAddress>
result.toList()
} catch (e: Exception) {
emptyList<InetAddress>()
}
}.distinct()
} catch (e: Exception) {
Log.e(TAG, "Error en lookup DNS", e)
emptyList()
}
}
/**
* Fuerza el uso de DNS de Google a nivel de red (requiere API 26+).
*/
fun forceGoogleDns(context: Context) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.requestNetwork(request, object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.d(TAG, "Red disponible: $network")
// La red ya está configurada, el DNS se maneja a nivel de OkHttp
}
})
}
}
}
/**
* DNS personalizado que fuerza el uso de servidores específicos.
*/
class CustomDns(private val dnsServers: List<InetAddress>) : Dns {
@Throws(UnknownHostException::class)
override fun lookup(hostname: String): List<InetAddress> {
// Usar solo los DNS configurados
val exceptions = mutableListOf<Exception>()
for (dnsServer in dnsServers) {
try {
val addresses = lookupWithDns(hostname, dnsServer)
if (addresses.isNotEmpty()) {
return addresses
}
} catch (e: Exception) {
exceptions.add(e)
}
}
// Si todos fallan, usar DNS del sistema como último recurso
return Dns.SYSTEM.lookup(hostname)
}
private fun lookupWithDns(hostname: String, dnsServer: InetAddress): List<InetAddress> {
return try {
val process = Runtime.getRuntime().exec(arrayOf(
"getprop", "net.dns1"
))
process.waitFor()
// Usar reflection para forzar el DNS específico
val method = InetAddress::class.java.getDeclaredMethod(
"getAllByName0",
String::class.java,
Boolean::class.java,
Boolean::class.java
)
method.isAccessible = true
@Suppress("UNCHECKED_CAST")
val result = method.invoke(null, hostname, true, true) as Array<InetAddress>
result.toList()
} catch (e: Exception) {
throw UnknownHostException("No se pudo resolver $hostname con DNS $dnsServer: ${e.message}")
}
}
}

View File

@@ -0,0 +1,409 @@
package com.iptv.app.utils
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.media3.common.AudioAttributes as ExoAudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Data class representing available video quality option
*/
data class VideoQuality(
val name: String,
val width: Int,
val height: Int,
val bitrate: Long
)
/**
* Data class representing available audio track
*/
data class AudioTrack(
val id: String,
val language: String?,
val label: String?,
val isSelected: Boolean = false
)
/**
* Sealed class representing audio focus state
*/
sealed class AudioFocusState {
data object Granted : AudioFocusState()
data object Lost : AudioFocusState()
data object LostTransient : AudioFocusState()
data object LostTransientCanDuck : AudioFocusState()
}
/**
* PlayerManager handles player instance management, track selection, and audio focus.
* This is a singleton-style manager that should be used across the app for consistent
* player behavior.
*/
@OptIn(UnstableApi::class)
class PlayerManager(private val context: Context) {
private var exoPlayer: ExoPlayer? = null
private var audioManager: AudioManager? = null
private var audioFocusRequest: AudioFocusRequest? = null
private var trackSelector: DefaultTrackSelector? = null
private val _audioFocusState = MutableStateFlow<AudioFocusState>(AudioFocusState.Granted)
val audioFocusState: StateFlow<AudioFocusState> = _audioFocusState.asStateFlow()
private val _availableQualities = MutableStateFlow<List<VideoQuality>>(emptyList())
val availableQualities: StateFlow<List<VideoQuality>> = _availableQualities.asStateFlow()
private val _availableAudioTracks = MutableStateFlow<List<AudioTrack>>(emptyList())
val availableAudioTracks: StateFlow<List<AudioTrack>> = _availableAudioTracks.asStateFlow()
private var originalVolume: Int = -1
private var isDucked: Boolean = false
init {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
}
/**
* Creates and configures a new ExoPlayer instance
*/
fun createPlayer(): ExoPlayer {
releasePlayer() // Release any existing player
trackSelector = DefaultTrackSelector(context).apply {
setParameters(
buildUponParameters()
.setPreferredAudioLanguage("en")
.setMaxVideoSizeSd()
)
}
val player = ExoPlayer.Builder(context)
.setTrackSelector(trackSelector!!)
.setAudioAttributes(getAudioAttributes(), true)
.setHandleAudioBecomingNoisy(true)
.build()
.apply {
addListener(createPlayerListener())
}
exoPlayer = player
requestAudioFocus()
return player
}
/**
* Gets the current player instance
*/
fun getPlayer(): ExoPlayer? = exoPlayer
/**
* Releases the current player and audio focus
*/
fun releasePlayer() {
abandonAudioFocus()
exoPlayer?.removeListener(createPlayerListener())
exoPlayer?.release()
exoPlayer = null
trackSelector = null
}
/**
* Gets the audio attributes for ExoPlayer
*/
fun getAudioAttributes(): ExoAudioAttributes {
return ExoAudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
}
/**
* Requests audio focus from the system
*/
fun requestAudioFocus(): Boolean {
val am = audioManager ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
requestAudioFocusApi26(am)
} else {
requestAudioFocusLegacy(am)
}
}
/**
* Abandons audio focus
*/
fun abandonAudioFocus() {
val am = audioManager ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { am.abandonAudioFocusRequest(it) }
} else {
am.abandonAudioFocus(audioFocusChangeListener)
}
audioFocusRequest = null
_audioFocusState.value = AudioFocusState.Lost
}
@RequiresApi(Build.VERSION_CODES.O)
private fun requestAudioFocusApi26(audioManager: AudioManager): Boolean {
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
android.media.AudioAttributes.Builder()
.setUsage(android.media.AudioAttributes.USAGE_MEDIA)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
)
.setOnAudioFocusChangeListener(audioFocusChangeListener)
.build()
audioFocusRequest = focusRequest
val result = audioManager.requestAudioFocus(focusRequest)
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
@Suppress("DEPRECATION")
private fun requestAudioFocusLegacy(audioManager: AudioManager): Boolean {
val result = audioManager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
_audioFocusState.value = AudioFocusState.Granted
exoPlayer?.playWhenReady = true
restoreVolume()
}
AudioManager.AUDIOFOCUS_LOSS -> {
_audioFocusState.value = AudioFocusState.Lost
exoPlayer?.playWhenReady = false
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
_audioFocusState.value = AudioFocusState.LostTransient
exoPlayer?.playWhenReady = false
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
_audioFocusState.value = AudioFocusState.LostTransientCanDuck
duckVolume()
}
}
}
private fun duckVolume() {
if (!isDucked) {
val am = audioManager
originalVolume = am?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: -1
am?.setStreamVolume(
AudioManager.STREAM_MUSIC,
(originalVolume * 0.2f).toInt().coerceAtLeast(0),
0
)
isDucked = true
}
}
private fun restoreVolume() {
if (isDucked && originalVolume >= 0) {
audioManager?.setStreamVolume(AudioManager.STREAM_MUSIC, originalVolume, 0)
isDucked = false
}
}
/**
* Sets the video quality/resolution
*
* @param quality The desired video quality (null for auto)
*/
fun setVideoQuality(quality: VideoQuality?) {
val selector = trackSelector ?: return
val parameters = if (quality == null) {
// Auto quality selection
selector.parameters.buildUponParameters()
.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
} else {
// Manual quality selection
val player = exoPlayer ?: return
val tracks = player.currentTracks
var override: TrackSelectionOverride? = null
for (trackGroup in tracks.groups) {
if (trackGroup.type == C.TRACK_TYPE_VIDEO) {
for (trackIndex in 0 until trackGroup.length) {
val format = trackGroup.getTrackFormat(trackIndex)
if (format.width == quality.width && format.height == quality.height) {
override = TrackSelectionOverride(trackGroup.mediaTrackGroup, trackIndex)
break
}
}
}
}
if (override != null) {
selector.parameters.buildUponParameters()
.setOverrideForType(override)
} else {
selector.parameters.buildUponParameters()
}
}
selector.parameters = parameters.build()
}
/**
* Sets the audio track by language code
*
* @param languageCode The language code (e.g., "en", "es", "fr")
*/
fun setAudioTrack(languageCode: String?) {
val selector = trackSelector ?: return
val parameters = selector.parameters.buildUponParameters()
.setPreferredAudioLanguage(languageCode)
.build()
selector.parameters = parameters
}
/**
* Gets available video qualities from the current stream
*/
fun updateAvailableQualities() {
val player = exoPlayer ?: return
val qualities = mutableListOf<VideoQuality>()
val tracks = player.currentTracks
for (trackGroup in tracks.groups) {
if (trackGroup.type == C.TRACK_TYPE_VIDEO) {
for (trackIndex in 0 until trackGroup.length) {
val format = trackGroup.getTrackFormat(trackIndex)
val quality = VideoQuality(
name = "${format.height}p",
width = format.width,
height = format.height,
bitrate = format.bitrate.toLong()
)
if (!qualities.any { it.height == quality.height }) {
qualities.add(quality)
}
}
}
}
_availableQualities.value = qualities.sortedByDescending { it.height }
}
/**
* Gets available audio tracks from the current stream
*/
fun updateAvailableAudioTracks() {
val player = exoPlayer ?: return
val tracks = mutableListOf<AudioTrack>()
val currentTracks = player.currentTracks
for (trackGroup in currentTracks.groups) {
if (trackGroup.type == C.TRACK_TYPE_AUDIO) {
for (trackIndex in 0 until trackGroup.length) {
val format = trackGroup.getTrackFormat(trackIndex)
val track = AudioTrack(
id = format.id ?: trackIndex.toString(),
language = format.language,
label = format.label,
isSelected = trackGroup.isTrackSelected(trackIndex)
)
tracks.add(track)
}
}
}
_availableAudioTracks.value = tracks
}
private fun createPlayerListener(): androidx.media3.common.Player.Listener {
return object : androidx.media3.common.Player.Listener {
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
updateAvailableQualities()
updateAvailableAudioTracks()
}
}
}
/**
* Enables or disables subtitles/text tracks
*
* @param enabled Whether subtitles should be enabled
* @param language Optional language code for subtitle preference
*/
fun setSubtitlesEnabled(enabled: Boolean, language: String? = null) {
val selector = trackSelector ?: return
val parameters = if (enabled) {
selector.parameters.buildUponParameters()
.setPreferredTextLanguage(language)
.setIgnoredTextSelectionFlags(0)
} else {
selector.parameters.buildUponParameters()
.setIgnoredTextSelectionFlags(C.SELECTION_FLAG_DEFAULT)
}
selector.parameters = parameters.build()
}
/**
* Checks if the player is currently playing
*/
fun isPlaying(): Boolean {
return exoPlayer?.isPlaying == true
}
/**
* Pauses playback
*/
fun pause() {
exoPlayer?.playWhenReady = false
}
/**
* Resumes playback
*/
fun play() {
if (requestAudioFocus()) {
exoPlayer?.playWhenReady = true
}
}
companion object {
/**
* Creates a VideoQuality option for auto selection
*/
fun createAutoQuality(): VideoQuality = VideoQuality(
name = "Auto",
width = 0,
height = 0,
bitrate = 0
)
}
}

View File

@@ -0,0 +1,423 @@
package com.iptv.app.utils
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.core.app.NotificationCompat
import androidx.core.content.FileProvider
import com.iptv.app.R
import java.io.File
import java.io.IOException
/**
* Helper class for handling APK downloads and installations.
* Manages FileProvider URIs, install permissions, notifications, and cleanup.
*/
class UpdateInstallHelper(private val context: Context) {
companion object {
private const val CHANNEL_ID = "update_install_channel"
private const val CHANNEL_NAME = "App Updates"
private const val NOTIFICATION_ID_DOWNLOAD = 1001
private const val NOTIFICATION_ID_INSTALL = 1002
private const val APK_DIRECTORY = "updates"
private const val FILE_PROVIDER_AUTHORITY_SUFFIX = ".fileprovider"
private const val MAX_APK_AGE_DAYS = 7L
private const val MAX_APK_FILES = 3
}
init {
createNotificationChannel()
}
/**
* Installs an APK file using FileProvider for secure file sharing.
* Shows notification for install progress.
*
* @param file The APK file to install
* @throws IOException if the file cannot be accessed
*/
fun installApk(file: File) {
if (!file.exists()) {
throw IOException("APK file does not exist: ${file.absolutePath}")
}
if (!file.canRead()) {
throw IOException("APK file cannot be read: ${file.absolutePath}")
}
showDownloadCompleteNotification(file)
val intent = createInstallIntent(file)
context.startActivity(intent)
}
/**
* Creates an install intent for the given APK file.
*
* @param file The APK file to install
* @return Intent configured for APK installation
*/
private fun createInstallIntent(file: File): Intent {
val uri = getFileProviderUri(file)
return Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
}
}
/**
* Gets a FileProvider URI for the given file.
*
* @param file The file to get URI for
* @return Content URI through FileProvider
*/
private fun getFileProviderUri(file: File): Uri {
val authority = "${context.packageName}$FILE_PROVIDER_AUTHORITY_SUFFIX"
return FileProvider.getUriForFile(context, authority, file)
}
/**
* Checks if the app can request package installs (Android 8+).
* On older Android versions, always returns true.
*
* @return true if install permission is granted or not required
*/
fun canRequestPackageInstalls(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
true
}
}
/**
* Requests install permission from the user (Android 8+).
* On older Android versions, this is a no-op.
*
* @param activity The activity to use for launching the permission request
*/
fun requestInstallPermission(activity: Activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES
).apply {
data = Uri.parse("package:${context.packageName}")
}
activity.startActivityForResult(
intent,
REQUEST_CODE_INSTALL_PERMISSION
)
}
}
/**
* Saves downloaded APK bytes to app-specific external storage.
*
* @param bytes The APK file bytes
* @param versionName The version name for the filename
* @return The saved File
* @throws IOException if the file cannot be saved
*/
@Throws(IOException::class)
fun saveApkFile(bytes: ByteArray, versionName: String): File {
val directory = getApkDirectory()
if (!directory.exists()) {
directory.mkdirs()
}
val timestamp = System.currentTimeMillis()
val fileName = "app-${versionName}-${timestamp}.apk"
val file = File(directory, fileName)
file.writeBytes(bytes)
return file
}
/**
* Gets the directory for storing APK files.
* Uses app-specific external storage for compatibility with scoped storage.
*
* @return The APK storage directory
*/
fun getApkDirectory(): File {
return File(
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
APK_DIRECTORY
)
}
/**
* Cleans up old APK files based on age and count limits.
* Removes files older than MAX_APK_AGE_DAYS and keeps only MAX_APK_FILES most recent.
*/
fun cleanupOldApks() {
val directory = getApkDirectory()
if (!directory.exists() || !directory.isDirectory) {
return
}
val apkFiles = directory.listFiles { file ->
file.isFile && file.extension.equals("apk", ignoreCase = true)
} ?: return
val currentTime = System.currentTimeMillis()
val maxAgeMillis = MAX_APK_AGE_DAYS * 24 * 60 * 60 * 1000
// Delete files older than max age
apkFiles.forEach { file ->
val fileAge = currentTime - file.lastModified()
if (fileAge > maxAgeMillis) {
file.delete()
}
}
// Keep only the most recent MAX_APK_FILES
val remainingFiles = directory.listFiles { file ->
file.isFile && file.extension.equals("apk", ignoreCase = true)
} ?: return
if (remainingFiles.size > MAX_APK_FILES) {
remainingFiles
.sortedByDescending { it.lastModified() }
.drop(MAX_APK_FILES)
.forEach { it.delete() }
}
}
/**
* Shows a notification indicating download is complete and ready to install.
*
* @param file The downloaded APK file
*/
private fun showDownloadCompleteNotification(file: File) {
val intent = createInstallIntent(file)
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or
PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(context.getString(R.string.update_ready_title))
.setContentText(context.getString(R.string.update_ready_message))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
val notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
notificationManager.notify(NOTIFICATION_ID_INSTALL, notification)
}
/**
* Shows a progress notification during APK download.
*
* @param progress Download progress (0-100)
* @param totalBytes Total bytes to download
*/
fun showDownloadProgressNotification(progress: Int, totalBytes: Long) {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_menu_download)
.setContentTitle(context.getString(R.string.update_downloading_title))
.setContentText(formatBytes(totalBytes))
.setProgress(100, progress, false)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
val notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
notificationManager.notify(NOTIFICATION_ID_DOWNLOAD, notification)
}
/**
* Shows a notification for download failure.
*
* @param errorMessage The error message to display
*/
fun showDownloadErrorNotification(errorMessage: String) {
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentTitle(context.getString(R.string.update_error_title))
.setContentText(errorMessage)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.build()
val notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
notificationManager.notify(NOTIFICATION_ID_DOWNLOAD, notification)
}
/**
* Cancels the download progress notification.
*/
fun cancelDownloadNotification() {
val notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
notificationManager.cancel(NOTIFICATION_ID_DOWNLOAD)
}
/**
* Creates the notification channel for Android O+.
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = context.getString(R.string.update_channel_description)
enableLights(true)
enableVibration(true)
}
val notificationManager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
/**
* Formats bytes to human-readable string.
*
* @param bytes Number of bytes
* @return Formatted string (e.g., "2.5 MB")
*/
private fun formatBytes(bytes: Long): String {
val units = arrayOf("B", "KB", "MB", "GB")
var size = bytes.toDouble()
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024
unitIndex++
}
return String.format("%.1f %s", size, units[unitIndex])
}
/**
* Gets the version name of the APK file without installing.
* This is a best-effort attempt and may not work for all APKs.
*
* @param file The APK file
* @return The version name or null if unable to determine
*/
fun getApkVersionName(file: File): String? {
return try {
val packageManager = context.packageManager
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageArchiveInfo(
file.absolutePath,
PackageManager.PackageInfoFlags.of(0L)
)
} else {
@Suppress("DEPRECATION")
packageManager.getPackageArchiveInfo(file.absolutePath, 0)
}
packageInfo?.versionName
} catch (e: Exception) {
null
}
}
/**
* Checks if an APK file is valid and can be installed.
*
* @param file The APK file to validate
* @return true if the file appears to be a valid APK
*/
fun isValidApk(file: File): Boolean {
if (!file.exists() || !file.canRead()) {
return false
}
// Check minimum file size (APK files are typically at least a few KB)
if (file.length() < 1024) {
return false
}
// Try to get package info to validate it's a proper APK
return getApkVersionName(file) != null
}
/**
* Gets the total size of all APK files in the updates directory.
*
* @return Total size in bytes
*/
fun getApkCacheSize(): Long {
val directory = getApkDirectory()
if (!directory.exists() || !directory.isDirectory) {
return 0L
}
return directory.listFiles { file ->
file.isFile && file.extension.equals("apk", ignoreCase = true)
}?.sumOf { it.length() } ?: 0L
}
/**
* Clears all cached APK files.
*
* @return Number of files deleted
*/
fun clearApkCache(): Int {
val directory = getApkDirectory()
if (!directory.exists() || !directory.isDirectory) {
return 0
}
val apkFiles = directory.listFiles { file ->
file.isFile && file.extension.equals("apk", ignoreCase = true)
} ?: return 0
var deletedCount = 0
apkFiles.forEach { file ->
if (file.delete()) {
deletedCount++
}
}
return deletedCount
}
/**
* Request code for install permission callback.
* Use this in onActivityResult to handle the permission result.
*/
val REQUEST_CODE_INSTALL_PERMISSION = 1001
}

View File

@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Primary Brand Colors -->
<color name="primary">#1976D2</color>
<color name="primary_dark">#1565C0</color>
<color name="primary_light">#42A5F5</color>
<color name="primary_variant">#0D47A1</color>
<!-- Secondary/Accent Colors -->
<color name="secondary">#FF5722</color>
<color name="secondary_dark">#E64A19</color>
<color name="secondary_light">#FF8A65</color>
<color name="secondary_variant">#BF360C</color>
<!-- Tertiary Colors -->
<color name="tertiary">#00BCD4</color>
<color name="tertiary_dark">#0097A7</color>
<color name="tertiary_light">#4DD0E1</color>
<!-- Background Colors -->
<color name="background">#FAFAFA</color>
<color name="background_dark">#121212</color>
<color name="surface">#FFFFFF</color>
<color name="surface_dark">#1E1E1E</color>
<color name="surface_variant">#F5F5F5</color>
<color name="surface_variant_dark">#2D2D2D</color>
<!-- Text Colors -->
<color name="text_primary">#212121</color>
<color name="text_primary_dark">#FFFFFF</color>
<color name="text_secondary">#757575</color>
<color name="text_secondary_dark">#B3B3B3</color>
<color name="text_disabled">#9E9E9E</color>
<color name="text_disabled_dark">#666666</color>
<color name="text_on_primary">#FFFFFF</color>
<color name="text_on_secondary">#FFFFFF</color>
<!-- Status Colors -->
<color name="error">#B00020</color>
<color name="error_dark">#CF6679</color>
<color name="success">#4CAF50</color>
<color name="success_dark">#81C784</color>
<color name="warning">#FFC107</color>
<color name="warning_dark">#FFD54F</color>
<color name="info">#2196F3</color>
<color name="info_dark">#64B5F6</color>
<!-- Player Controls -->
<color name="player_controls_background">#CC000000</color>
<color name="player_controls_background_dark">#CC000000</color>
<color name="player_progress_primary">#FF5722</color>
<color name="player_progress_secondary">#757575</color>
<color name="player_progress_buffer">#BDBDBD</color>
<color name="player_text">#FFFFFF</color>
<color name="player_live_indicator">#F44336</color>
<color name="player_live_indicator_dark">#EF5350</color>
<!-- Channel List -->
<color name="channel_number">#757575</color>
<color name="channel_number_dark">#9E9E9E</color>
<color name="channel_name">#212121</color>
<color name="channel_name_dark">#FFFFFF</color>
<color name="channel_epg_current">#757575</color>
<color name="channel_epg_current_dark">#B3B3B3</color>
<color name="channel_epg_next">#9E9E9E</color>
<color name="channel_epg_next_dark">#757575</color>
<color name="channel_favorite">#FFC107</color>
<color name="channel_hd_indicator">#4CAF50</color>
<!-- EPG Colors -->
<color name="epg_time_header">#F5F5F5</color>
<color name="epg_time_header_dark">#2D2D2D</color>
<color name="epg_channel_header">#FFFFFF</color>
<color name="epg_channel_header_dark">#1E1E1E</color>
<color name="epg_program_normal">#FFFFFF</color>
<color name="epg_program_normal_dark">#1E1E1E</color>
<color name="epg_program_selected">#E3F2FD</color>
<color name="epg_program_selected_dark">#0D47A1</color>
<color name="epg_program_live">#FFEBEE</color>
<color name="epg_program_live_dark">#3E2723</color>
<color name="epg_timeline">#1976D2</color>
<color name="epg_timeline_dark">#42A5F5</color>
<!-- Category Colors -->
<color name="category_sports">#4CAF50</color>
<color name="category_news">#F44336</color>
<color name="category_movies">#9C27B0</color>
<color name="category_entertainment">#FF9800</color>
<color name="category_kids">#00BCD4</color>
<color name="category_music">#E91E63</color>
<color name="category_documentary">#795548</color>
<color name="category_education">#3F51B5</color>
<!-- Divider and Border -->
<color name="divider">#E0E0E0</color>
<color name="divider_dark">#424242</color>
<color name="border">#BDBDBD</color>
<color name="border_dark">#616161</color>
<!-- Overlay Colors -->
<color name="overlay_light">#80FFFFFF</color>
<color name="overlay_dark">#80000000</color>
<color name="scrim">#52000000</color>
<color name="scrim_dark">#99000000</color>
<!-- Ripple and Selection -->
<color name="ripple">#1F000000</color>
<color name="ripple_dark">#33FFFFFF</color>
<color name="selection">#E3F2FD</color>
<color name="selection_dark">#0D47A1</color>
<!-- Navigation -->
<color name="navigation_background">#FFFFFF</color>
<color name="navigation_background_dark">#1E1E1E</color>
<color name="navigation_item">#757575</color>
<color name="navigation_item_dark">#B3B3B3</color>
<color name="navigation_item_selected">#1976D2</color>
<color name="navigation_item_selected_dark">#42A5F5</color>
<!-- Search -->
<color name="search_background">#F5F5F5</color>
<color name="search_background_dark">#2D2D2D</color>
<color name="search_hint">#9E9E9E</color>
<color name="search_hint_dark">#757575</color>
<!-- Recording -->
<color name="recording_indicator">#F44336</color>
<color name="recording_background">#FFEBEE</color>
<color name="recording_background_dark">#3E2723</color>
<!-- Catch-up TV -->
<color name="catchup_indicator">#9C27B0</color>
<color name="catchup_background">#F3E5F5</color>
<color name="catchup_background_dark">#311B92</color>
<!-- Transparent Colors -->
<color name="transparent">#00000000</color>
<color name="semi_transparent">#80000000</color>
<color name="highly_transparent">#1A000000</color>
</resources>

View File

@@ -0,0 +1,294 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- App Name -->
<string name="app_name">IPTV Player</string>
<string name="app_name_short">IPTV</string>
<!-- Navigation -->
<string name="nav_home">Home</string>
<string name="nav_channels">Channels</string>
<string name="nav_favorites">Favorites</string>
<string name="nav_epg">TV Guide</string>
<string name="nav_settings">Settings</string>
<string name="nav_search">Search</string>
<string name="nav_recordings">Recordings</string>
<string name="nav_categories">Categories</string>
<!-- Main Screen -->
<string name="welcome_message">Welcome to IPTV Player</string>
<string name="no_playlist">No playlist loaded</string>
<string name="add_playlist_hint">Add a playlist to start watching</string>
<string name="recent_channels">Recent Channels</string>
<string name="featured_channels">Featured</string>
<string name="continue_watching">Continue Watching</string>
<!-- Playlist -->
<string name="playlist_title">Playlist</string>
<string name="playlist_add">Add Playlist</string>
<string name="playlist_edit">Edit Playlist</string>
<string name="playlist_delete">Delete Playlist</string>
<string name="playlist_name">Playlist Name</string>
<string name="playlist_url">Playlist URL</string>
<string name="playlist_file">Select File</string>
<string name="playlist_load">Load Playlist</string>
<string name="playlist_refresh">Refresh Playlist</string>
<string name="playlist_auto_refresh">Auto Refresh</string>
<string name="playlist_last_updated">Last updated: %s</string>
<string name="playlist_empty">Playlist is empty</string>
<string name="playlist_invalid">Invalid playlist format</string>
<string name="playlist_load_error">Failed to load playlist</string>
<string name="playlist_load_success">Playlist loaded successfully</string>
<string name="playlist_m3u_supported">Supported formats: M3U, M3U8</string>
<!-- Channels -->
<string name="channels">Channels</string>
<string name="channel_number">Channel %d</string>
<string name="channel_no_epg">No program information</string>
<string name="channel_current">Now: %s</string>
<string name="channel_next">Next: %s</string>
<string name="channel_live">LIVE</string>
<string name="channel_hd">HD</string>
<string name="channel_fhd">FHD</string>
<string name="channel_4k">4K</string>
<string name="channel_add_favorite">Add to Favorites</string>
<string name="channel_remove_favorite">Remove from Favorites</string>
<string name="channel_no_favorites">No favorite channels</string>
<string name="channel_search_hint">Search channels...</string>
<string name="channel_group_all">All Channels</string>
<string name="channel_group_favorites">Favorites</string>
<string name="channel_group_recent">Recently Watched</string>
<!-- Player -->
<string name="player_play">Play</string>
<string name="player_pause">Pause</string>
<string name="player_stop">Stop</string>
<string name="player_next">Next Channel</string>
<string name="player_previous">Previous Channel</string>
<string name="player_fullscreen">Fullscreen</string>
<string name="player_exit_fullscreen">Exit Fullscreen</string>
<string name="player_aspect_ratio">Aspect Ratio</string>
<string name="player_audio_track">Audio Track</string>
<string name="player_subtitle">Subtitles</string>
<string name="player_subtitle_off">Off</string>
<string name="player_settings">Player Settings</string>
<string name="player_pip">Picture in Picture</string>
<string name="player_cast">Cast</string>
<string name="player_record">Record</string>
<string name="player_stop_record">Stop Recording</string>
<string name="player_buffering">Buffering...</string>
<string name="player_loading">Loading...</string>
<string name="player_error">Playback Error</string>
<string name="player_retry">Retry</string>
<string name="player_live">LIVE</string>
<string name="player_unknown_channel">Unknown Channel</string>
<!-- Aspect Ratios -->
<string name="aspect_auto">Auto</string>
<string name="aspect_fit">Fit Screen</string>
<string name="aspect_fill">Fill Screen</string>
<string name="aspect_zoom">Zoom</string>
<string name="aspect_16_9">16:9</string>
<string name="aspect_4_3">4:3</string>
<!-- EPG / TV Guide -->
<string name="epg">TV Guide</string>
<string name="epg_title">Electronic Program Guide</string>
<string name="epg_no_data">No EPG data available</string>
<string name="epg_loading">Loading EPG...</string>
<string name="epg_load_error">Failed to load EPG</string>
<string name="epg_program_details">Program Details</string>
<string name="epg_program_start">Start: %s</string>
<string name="epg_program_end">End: %s</string>
<string name="epg_program_duration">Duration: %s</string>
<string name="epg_now">Now</string>
<string name="epg_today">Today</string>
<string name="epg_tomorrow">Tomorrow</string>
<string name="epg_yesterday">Yesterday</string>
<string name="epg_catch_up">Catch-up Available</string>
<string name="epg_reminder_set">Reminder Set</string>
<string name="epg_reminder_cancel">Cancel Reminder</string>
<!-- Categories -->
<string name="category_all">All</string>
<string name="category_sports">Sports</string>
<string name="category_news">News</string>
<string name="category_movies">Movies</string>
<string name="category_entertainment">Entertainment</string>
<string name="category_kids">Kids</string>
<string name="category_music">Music</string>
<string name="category_documentary">Documentary</string>
<string name="category_education">Education</string>
<string name="category_lifestyle">Lifestyle</string>
<string name="category_religious">Religious</string>
<string name="category_international">International</string>
<string name="category_local">Local</string>
<string name="category_radio">Radio</string>
<!-- Settings -->
<string name="settings">Settings</string>
<string name="settings_general">General</string>
<string name="settings_player">Player</string>
<string name="settings_playback">Playback</string>
<string name="settings_epg">EPG Settings</string>
<string name="settings_interface">Interface</string>
<string name="settings_network">Network</string>
<string name="settings_advanced">Advanced</string>
<string name="settings_about">About</string>
<!-- General Settings -->
<string name="setting_startup_page">Startup Page</string>
<string name="setting_language">Language</string>
<string name="setting_theme">Theme</string>
<string name="setting_theme_light">Light</string>
<string name="setting_theme_dark">Dark</string>
<string name="setting_theme_system">System Default</string>
<string name="setting_auto_update">Auto Update Playlists</string>
<string name="setting_auto_update_interval">Update Interval</string>
<!-- Player Settings -->
<string name="setting_default_aspect">Default Aspect Ratio</string>
<string name="setting_auto_play">Auto Play Last Channel</string>
<string name="setting_show_channel_info">Show Channel Info</string>
<string name="setting_channel_info_timeout">Channel Info Timeout</string>
<string name="setting_preferred_quality">Preferred Quality</string>
<string name="setting_hardware_decoding">Hardware Decoding</string>
<string name="setting_software_decoding">Software Decoding</string>
<string name="setting_buffer_size">Buffer Size</string>
<string name="setting_audio_language">Preferred Audio Language</string>
<string name="setting_subtitle_language">Preferred Subtitle Language</string>
<!-- EPG Settings -->
<string name="setting_epg_url">EPG URL</string>
<string name="setting_epg_auto_update">Auto Update EPG</string>
<string name="setting_epg_update_interval">EPG Update Interval</string>
<string name="setting_epg_hours_visible">Hours Visible</string>
<string name="setting_show_epg_thumbnails">Show Program Thumbnails</string>
<!-- Network Settings -->
<string name="setting_user_agent">User Agent</string>
<string name="setting_http_timeout">HTTP Timeout</string>
<string name="setting_use_proxy">Use Proxy</string>
<string name="setting_proxy_host">Proxy Host</string>
<string name="setting_proxy_port">Proxy Port</string>
<string name="setting_allow_insecure">Allow Insecure Connections</string>
<!-- Recording -->
<string name="recording">Recording</string>
<string name="recording_start">Start Recording</string>
<string name="recording_stop">Stop Recording</string>
<string name="recording_in_progress">Recording in Progress</string>
<string name="recording_saved">Recording Saved</string>
<string name="recording_failed">Recording Failed</string>
<string name="recording_storage">Storage Location</string>
<string name="recording_no_storage">No storage available</string>
<string name="recording_duration">Recording: %s</string>
<!-- Search -->
<string name="search">Search</string>
<string name="search_hint">Search channels or programs...</string>
<string name="search_no_results">No results found</string>
<string name="search_history">Search History</string>
<string name="search_clear_history">Clear History</string>
<string name="search_filters">Filters</string>
<!-- Errors -->
<string name="error_network">Network error. Please check your connection.</string>
<string name="error_playback">Playback error. Please try again.</string>
<string name="error_unsupported_format">Unsupported media format.</string>
<string name="error_stream_unavailable">Stream unavailable.</string>
<string name="error_timeout">Connection timeout.</string>
<string name="error_no_internet">No internet connection.</string>
<string name="error_invalid_url">Invalid URL.</string>
<string name="error_file_not_found">File not found.</string>
<string name="error_permission_denied">Permission denied.</string>
<string name="error_storage_full">Storage full.</string>
<string name="error_unknown">An unknown error occurred.</string>
<!-- Dialogs -->
<string name="dialog_ok">OK</string>
<string name="dialog_cancel">Cancel</string>
<string name="dialog_yes">Yes</string>
<string name="dialog_no">No</string>
<string name="dialog_save">Save</string>
<string name="dialog_delete">Delete</string>
<string name="dialog_edit">Edit</string>
<string name="dialog_add">Add</string>
<string name="dialog_close">Close</string>
<string name="dialog_back">Back</string>
<string name="dialog_confirm">Confirm</string>
<string name="dialog_loading">Loading...</string>
<string name="dialog_please_wait">Please wait...</string>
<!-- Confirmation Messages -->
<string name="confirm_delete_playlist">Delete this playlist?</string>
<string name="confirm_delete_recording">Delete this recording?</string>
<string name="confirm_clear_history">Clear search history?</string>
<string name="confirm_exit">Exit the app?</string>
<string name="confirm_stop_recording">Stop recording?</string>
<!-- Success Messages -->
<string name="success_saved">Saved successfully</string>
<string name="success_deleted">Deleted successfully</string>
<string name="success_updated">Updated successfully</string>
<string name="success_added">Added successfully</string>
<!-- About -->
<string name="about_version">Version %s</string>
<string name="about_build">Build %d</string>
<string name="about_copyright">Copyright 2024 IPTV Player</string>
<string name="about_license">Licensed under Apache 2.0</string>
<string name="about_privacy_policy">Privacy Policy</string>
<string name="about_terms_of_service">Terms of Service</string>
<string name="about_open_source">Open Source Licenses</string>
<string name="about_rate_app">Rate App</string>
<string name="about_share_app">Share App</string>
<string name="about_feedback">Send Feedback</string>
<!-- Time -->
<string name="time_format_12h">12-hour format</string>
<string name="time_format_24h">24-hour format</string>
<string name="time_just_now">Just now</string>
<string name="time_minutes_ago">%d minutes ago</string>
<string name="time_hours_ago">%d hours ago</string>
<string name="time_yesterday">Yesterday</string>
<!-- Accessibility -->
<string name="accessibility_play">Play button</string>
<string name="accessibility_pause">Pause button</string>
<string name="accessibility_stop">Stop button</string>
<string name="accessibility_fullscreen">Toggle fullscreen</string>
<string name="accessibility_channel_up">Channel up</string>
<string name="accessibility_channel_down">Channel down</string>
<string name="accessibility_volume_up">Volume up</string>
<string name="accessibility_volume_down">Volume down</string>
<string name="accessibility_mute">Mute</string>
<string name="accessibility_settings">Settings</string>
<string name="accessibility_back">Go back</string>
<string name="accessibility_menu">Menu</string>
<string name="accessibility_search">Search</string>
<string name="accessibility_favorite">Toggle favorite</string>
<string name="accessibility_close">Close</string>
<!-- TV/Leanback -->
<string name="tv_browse_title">IPTV Player</string>
<string name="tv_search_title">Search</string>
<string name="tv_settings_title">Settings</string>
<string name="tv_guide_title">TV Guide</string>
<string name="tv_no_channels">No channels available</string>
<string name="tv_press_select">Press SELECT to play</string>
<!-- Content Descriptions -->
<string name="desc_app_logo">App Logo</string>
<string name="desc_channel_logo">Channel Logo</string>
<string name="desc_program_thumbnail">Program Thumbnail</string>
<string name="desc_player_controls">Player Controls</string>
<string name="desc_progress_bar">Progress Bar</string>
<string name="desc_volume_indicator">Volume Indicator</string>
<!-- App Update Notifications -->
<string name="update_channel_description">Notifications for app updates and installations</string>
<string name="update_downloading_title">Downloading Update</string>
<string name="update_ready_title">Update Ready</string>
<string name="update_ready_message">Tap to install the update</string>
<string name="update_error_title">Update Failed</string>
</resources>

View File

@@ -0,0 +1,488 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme - Light -->
<style name="Theme.IPTVApp" parent="Theme.Material3.Light.NoActionBar">
<!-- Primary brand colors -->
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorPrimaryContainer">@color/primary_light</item>
<item name="colorOnPrimary">@color/text_on_primary</item>
<item name="colorOnPrimaryContainer">@color/primary_variant</item>
<!-- Secondary brand colors -->
<item name="colorSecondary">@color/secondary</item>
<item name="colorSecondaryContainer">@color/secondary_light</item>
<item name="colorOnSecondary">@color/text_on_secondary</item>
<item name="colorOnSecondaryContainer">@color/secondary_variant</item>
<!-- Tertiary colors -->
<item name="colorTertiary">@color/tertiary</item>
<item name="colorTertiaryContainer">@color/tertiary_light</item>
<item name="colorOnTertiary">@color/text_on_primary</item>
<item name="colorOnTertiaryContainer">@color/tertiary_dark</item>
<!-- Surface colors -->
<item name="colorSurface">@color/surface</item>
<item name="colorSurfaceVariant">@color/surface_variant</item>
<item name="colorOnSurface">@color/text_primary</item>
<item name="colorOnSurfaceVariant">@color/text_secondary</item>
<!-- Background colors -->
<item name="android:colorBackground">@color/background</item>
<item name="colorOnBackground">@color/text_primary</item>
<!-- Error colors -->
<item name="colorError">@color/error</item>
<item name="colorErrorContainer">@color/error</item>
<item name="colorOnError">@color/text_on_primary</item>
<item name="colorOnErrorContainer">@color/error</item>
<!-- Outline -->
<item name="colorOutline">@color/border</item>
<item name="colorOutlineVariant">@color/divider</item>
<!-- Status bar -->
<item name="android:statusBarColor">@color/primary_dark</item>
<item name="android:windowLightStatusBar">true</item>
<!-- Navigation bar -->
<item name="android:navigationBarColor">@color/background</item>
<item name="android:windowLightNavigationBar">true</item>
<!-- Typography -->
<item name="textAppearanceDisplayLarge">@style/TextAppearance.IPTV.DisplayLarge</item>
<item name="textAppearanceDisplayMedium">@style/TextAppearance.IPTV.DisplayMedium</item>
<item name="textAppearanceDisplaySmall">@style/TextAppearance.IPTV.DisplaySmall</item>
<item name="textAppearanceHeadlineLarge">@style/TextAppearance.IPTV.HeadlineLarge</item>
<item name="textAppearanceHeadlineMedium">@style/TextAppearance.IPTV.HeadlineMedium</item>
<item name="textAppearanceHeadlineSmall">@style/TextAppearance.IPTV.HeadlineSmall</item>
<item name="textAppearanceTitleLarge">@style/TextAppearance.IPTV.TitleLarge</item>
<item name="textAppearanceTitleMedium">@style/TextAppearance.IPTV.TitleMedium</item>
<item name="textAppearanceTitleSmall">@style/TextAppearance.IPTV.TitleSmall</item>
<item name="textAppearanceBodyLarge">@style/TextAppearance.IPTV.BodyLarge</item>
<item name="textAppearanceBodyMedium">@style/TextAppearance.IPTV.BodyMedium</item>
<item name="textAppearanceBodySmall">@style/TextAppearance.IPTV.BodySmall</item>
<item name="textAppearanceLabelLarge">@style/TextAppearance.IPTV.LabelLarge</item>
<item name="textAppearanceLabelMedium">@style/TextAppearance.IPTV.LabelMedium</item>
<item name="textAppearanceLabelSmall">@style/TextAppearance.IPTV.LabelSmall</item>
<!-- Shape appearance -->
<item name="shapeAppearanceSmallComponent">@style/ShapeAppearance.IPTV.SmallComponent</item>
<item name="shapeAppearanceMediumComponent">@style/ShapeAppearance.IPTV.MediumComponent</item>
<item name="shapeAppearanceLargeComponent">@style/ShapeAppearance.IPTV.LargeComponent</item>
<!-- Component styles -->
<item name="materialButtonStyle">@style/Widget.IPTV.Button</item>
<item name="materialCardViewStyle">@style/Widget.IPTV.CardView</item>
<item name="textInputStyle">@style/Widget.IPTV.TextInputLayout</item>
<item name="toolbarStyle">@style/Widget.IPTV.Toolbar</item>
<item name="appBarLayoutStyle">@style/Widget.IPTV.AppBar</item>
<item name="bottomNavigationStyle">@style/Widget.IPTV.BottomNavigation</item>
<item name="tabStyle">@style/Widget.IPTV.TabLayout</item>
<item name="sliderStyle">@style/Widget.IPTV.Slider</item>
<item name="switchStyle">@style/Widget.IPTV.Switch</item>
<item name="checkboxStyle">@style/Widget.IPTV.CheckBox</item>
<item name="radioButtonStyle">@style/Widget.IPTV.RadioButton</item>
<!-- Dialog themes -->
<item name="materialAlertDialogTheme">@style/ThemeOverlay.IPTV.MaterialAlertDialog</item>
<item name="alertDialogTheme">@style/ThemeOverlay.IPTV.AlertDialog</item>
<!-- Popup menu -->
<item name="popupMenuStyle">@style/Widget.IPTV.PopupMenu</item>
</style>
<!-- Dark Theme -->
<style name="Theme.IPTVApp.Dark" parent="Theme.Material3.Dark.NoActionBar">
<!-- Primary brand colors -->
<item name="colorPrimary">@color/primary_light</item>
<item name="colorPrimaryDark">@color/primary</item>
<item name="colorPrimaryContainer">@color/primary_variant</item>
<item name="colorOnPrimary">@color/text_on_primary</item>
<item name="colorOnPrimaryContainer">@color/primary_light</item>
<!-- Secondary brand colors -->
<item name="colorSecondary">@color/secondary_light</item>
<item name="colorSecondaryContainer">@color/secondary_variant</item>
<item name="colorOnSecondary">@color/text_on_secondary</item>
<item name="colorOnSecondaryContainer">@color/secondary_light</item>
<!-- Tertiary colors -->
<item name="colorTertiary">@color/tertiary_light</item>
<item name="colorTertiaryContainer">@color/tertiary_dark</item>
<item name="colorOnTertiary">@color/text_on_primary</item>
<item name="colorOnTertiaryContainer">@color/tertiary_light</item>
<!-- Surface colors -->
<item name="colorSurface">@color/surface_dark</item>
<item name="colorSurfaceVariant">@color/surface_variant_dark</item>
<item name="colorOnSurface">@color/text_primary_dark</item>
<item name="colorOnSurfaceVariant">@color/text_secondary_dark</item>
<!-- Background colors -->
<item name="android:colorBackground">@color/background_dark</item>
<item name="colorOnBackground">@color/text_primary_dark</item>
<!-- Error colors -->
<item name="colorError">@color/error_dark</item>
<item name="colorErrorContainer">@color/error_dark</item>
<item name="colorOnError">@color/text_on_primary</item>
<item name="colorOnErrorContainer">@color/error_dark</item>
<!-- Outline -->
<item name="colorOutline">@color/border_dark</item>
<item name="colorOutlineVariant">@color/divider_dark</item>
<!-- Status bar -->
<item name="android:statusBarColor">@color/background_dark</item>
<item name="android:windowLightStatusBar">false</item>
<!-- Navigation bar -->
<item name="android:navigationBarColor">@color/background_dark</item>
<item name="android:windowLightNavigationBar">false</item>
<!-- Typography -->
<item name="textAppearanceDisplayLarge">@style/TextAppearance.IPTV.DisplayLarge</item>
<item name="textAppearanceDisplayMedium">@style/TextAppearance.IPTV.DisplayMedium</item>
<item name="textAppearanceDisplaySmall">@style/TextAppearance.IPTV.DisplaySmall</item>
<item name="textAppearanceHeadlineLarge">@style/TextAppearance.IPTV.HeadlineLarge</item>
<item name="textAppearanceHeadlineMedium">@style/TextAppearance.IPTV.HeadlineMedium</item>
<item name="textAppearanceHeadlineSmall">@style/TextAppearance.IPTV.HeadlineSmall</item>
<item name="textAppearanceTitleLarge">@style/TextAppearance.IPTV.TitleLarge</item>
<item name="textAppearanceTitleMedium">@style/TextAppearance.IPTV.TitleMedium</item>
<item name="textAppearanceTitleSmall">@style/TextAppearance.IPTV.TitleSmall</item>
<item name="textAppearanceBodyLarge">@style/TextAppearance.IPTV.BodyLarge</item>
<item name="textAppearanceBodyMedium">@style/TextAppearance.IPTV.BodyMedium</item>
<item name="textAppearanceBodySmall">@style/TextAppearance.IPTV.BodySmall</item>
<item name="textAppearanceLabelLarge">@style/TextAppearance.IPTV.LabelLarge</item>
<item name="textAppearanceLabelMedium">@style/TextAppearance.IPTV.LabelMedium</item>
<item name="textAppearanceLabelSmall">@style/TextAppearance.IPTV.LabelSmall</item>
<!-- Shape appearance -->
<item name="shapeAppearanceSmallComponent">@style/ShapeAppearance.IPTV.SmallComponent</item>
<item name="shapeAppearanceMediumComponent">@style/ShapeAppearance.IPTV.MediumComponent</item>
<item name="shapeAppearanceLargeComponent">@style/ShapeAppearance.IPTV.LargeComponent</item>
<!-- Component styles -->
<item name="materialButtonStyle">@style/Widget.IPTV.Button</item>
<item name="materialCardViewStyle">@style/Widget.IPTV.CardView.Dark</item>
<item name="textInputStyle">@style/Widget.IPTV.TextInputLayout.Dark</item>
<item name="toolbarStyle">@style/Widget.IPTV.Toolbar.Dark</item>
<item name="appBarLayoutStyle">@style/Widget.IPTV.AppBar.Dark</item>
<item name="bottomNavigationStyle">@style/Widget.IPTV.BottomNavigation.Dark</item>
<item name="tabStyle">@style/Widget.IPTV.TabLayout.Dark</item>
<item name="sliderStyle">@style/Widget.IPTV.Slider.Dark</item>
<item name="switchStyle">@style/Widget.IPTV.Switch.Dark</item>
<item name="checkboxStyle">@style/Widget.IPTV.CheckBox.Dark</item>
<item name="radioButtonStyle">@style/Widget.IPTV.RadioButton.Dark</item>
<!-- Dialog themes -->
<item name="materialAlertDialogTheme">@style/ThemeOverlay.IPTV.MaterialAlertDialog.Dark</item>
<item name="alertDialogTheme">@style/ThemeOverlay.IPTV.AlertDialog.Dark</item>
<!-- Popup menu -->
<item name="popupMenuStyle">@style/Widget.IPTV.PopupMenu.Dark</item>
</style>
<!-- Fullscreen Player Theme (Immersive) -->
<style name="Theme.IPTVApp.Fullscreen" parent="Theme.IPTVApp">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowTranslucentStatus">true</item>
<item name="android:windowTranslucentNavigation">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:background">@android:color/black</item>
</style>
<!-- Fullscreen Player Theme Dark -->
<style name="Theme.IPTVApp.Fullscreen.Dark" parent="Theme.IPTVApp.Dark">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowTranslucentStatus">true</item>
<item name="android:windowTranslucentNavigation">true</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:background">@android:color/black</item>
</style>
<!-- Android TV / Leanback Theme -->
<style name="Theme.IPTVApp.Leanback" parent="Theme.Leanback">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/secondary</item>
</style>
<!-- ============================================ -->
<!-- Text Appearances -->
<!-- ============================================ -->
<style name="TextAppearance.IPTV.DisplayLarge" parent="TextAppearance.Material3.DisplayLarge">
<item name="fontFamily">@font/roboto_regular</item>
<item name="android:fontFamily">@font/roboto_regular</item>
</style>
<style name="TextAppearance.IPTV.DisplayMedium" parent="TextAppearance.Material3.DisplayMedium">
<item name="fontFamily">@font/roboto_regular</item>
<item name="android:fontFamily">@font/roboto_regular</item>
</style>
<style name="TextAppearance.IPTV.DisplaySmall" parent="TextAppearance.Material3.DisplaySmall">
<item name="fontFamily">@font/roboto_regular</item>
<item name="android:fontFamily">@font/roboto_regular</item>
</style>
<style name="TextAppearance.IPTV.HeadlineLarge" parent="TextAppearance.Material3.HeadlineLarge">
<item name="fontFamily">@font/roboto_medium</item>
<item name="android:fontFamily">@font/roboto_medium</item>
</style>
<style name="TextAppearance.IPTV.HeadlineMedium" parent="TextAppearance.Material3.HeadlineMedium">
<item name="fontFamily">@font/roboto_medium</item>
<item name="android:fontFamily">@font/roboto_medium</item>
</style>
<style name="TextAppearance.IPTV.HeadlineSmall" parent="TextAppearance.Material3.HeadlineSmall">
<item name="fontFamily">@font/roboto_medium</item>
<item name="android:fontFamily">@font/roboto_medium</item>
</style>
<style name="TextAppearance.IPTV.TitleLarge" parent="TextAppearance.Material3.TitleLarge">
<item name="fontFamily">@font/roboto_medium</item>
<item name="android:fontFamily">@font/roboto_medium</item>
</style>
<style name="TextAppearance.IPTV.TitleMedium" parent="TextAppearance.Material3.TitleMedium">
<item name="fontFamily">@font/roboto_medium</item>
<item name="android:fontFamily">@font/roboto_medium</item>
</style>
<style name="TextAppearance.IPTV.TitleSmall" parent="TextAppearance.Material3.TitleSmall">
<item name="fontFamily">@font/roboto_medium</item>
<item name="android:fontFamily">@font/roboto_medium</item>
</style>
<style name="TextAppearance.IPTV.BodyLarge" parent="TextAppearance.Material3.BodyLarge">
<item name="fontFamily">@font/roboto_regular</item>
<item name="android:fontFamily">@font/roboto_regular</item>
</style>
<style name="TextAppearance.IPTV.BodyMedium" parent="TextAppearance.Material3.BodyMedium">
<item name="fontFamily">@font/roboto_regular</item>
<item name="android:fontFamily">@font/roboto_regular</item>
</style>
<style name="TextAppearance.IPTV.BodySmall" parent="TextAppearance.Material3.BodySmall">
<item name="fontFamily">@font/roboto_regular</item>
<item name="android:fontFamily">@font/roboto_regular</item>
</style>
<style name="TextAppearance.IPTV.LabelLarge" parent="TextAppearance.Material3.LabelLarge">
<item name="fontFamily">@font/roboto_medium</item>
<item name="android:fontFamily">@font/roboto_medium</item>
</style>
<style name="TextAppearance.IPTV.LabelMedium" parent="TextAppearance.Material3.LabelMedium">
<item name="fontFamily">@font/roboto_medium</item>
<item name="android:fontFamily">@font/roboto_medium</item>
</style>
<style name="TextAppearance.IPTV.LabelSmall" parent="TextAppearance.Material3.LabelSmall">
<item name="fontFamily">@font/roboto_medium</item>
<item name="android:fontFamily">@font/roboto_medium</item>
</style>
<!-- ============================================ -->
<!-- Shape Appearances -->
<!-- ============================================ -->
<style name="ShapeAppearance.IPTV.SmallComponent" parent="ShapeAppearance.Material3.SmallComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">8dp</item>
</style>
<style name="ShapeAppearance.IPTV.MediumComponent" parent="ShapeAppearance.Material3.MediumComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">12dp</item>
</style>
<style name="ShapeAppearance.IPTV.LargeComponent" parent="ShapeAppearance.Material3.LargeComponent">
<item name="cornerFamily">rounded</item>
<item name="cornerSize">16dp</item>
</style>
<!-- ============================================ -->
<!-- Widget Styles -->
<!-- ============================================ -->
<!-- Button -->
<style name="Widget.IPTV.Button" parent="Widget.Material3.Button">
<item name="android:textAppearance">@style/TextAppearance.IPTV.LabelLarge</item>
<item name="android:minHeight">48dp</item>
<item name="cornerRadius">8dp</item>
</style>
<!-- CardView -->
<style name="Widget.IPTV.CardView" parent="Widget.Material3.CardView.Elevated">
<item name="cardBackgroundColor">@color/surface</item>
<item name="cardElevation">2dp</item>
<item name="cardCornerRadius">12dp</item>
</style>
<style name="Widget.IPTV.CardView.Dark" parent="Widget.Material3.CardView.Elevated">
<item name="cardBackgroundColor">@color/surface_dark</item>
<item name="cardElevation">2dp</item>
<item name="cardCornerRadius">12dp</item>
</style>
<!-- TextInputLayout -->
<style name="Widget.IPTV.TextInputLayout" parent="Widget.Material3.TextInputLayout.OutlinedBox">
<item name="boxStrokeColor">@color/primary</item>
<item name="hintTextColor">@color/primary</item>
</style>
<style name="Widget.IPTV.TextInputLayout.Dark" parent="Widget.Material3.TextInputLayout.OutlinedBox">
<item name="boxStrokeColor">@color/primary_light</item>
<item name="hintTextColor">@color/primary_light</item>
</style>
<!-- Toolbar -->
<style name="Widget.IPTV.Toolbar" parent="Widget.Material3.Toolbar">
<item name="android:background">@color/primary</item>
<item name="titleTextColor">@color/text_on_primary</item>
<item name="subtitleTextColor">@color/text_on_primary</item>
</style>
<style name="Widget.IPTV.Toolbar.Dark" parent="Widget.Material3.Toolbar">
<item name="android:background">@color/surface_dark</item>
<item name="titleTextColor">@color/text_primary_dark</item>
<item name="subtitleTextColor">@color/text_secondary_dark</item>
</style>
<!-- AppBar -->
<style name="Widget.IPTV.AppBar" parent="Widget.Material3.AppBarLayout">
<item name="android:background">@color/primary</item>
<item name="elevation">4dp</item>
</style>
<style name="Widget.IPTV.AppBar.Dark" parent="Widget.Material3.AppBarLayout">
<item name="android:background">@color/surface_dark</item>
<item name="elevation">4dp</item>
</style>
<!-- BottomNavigation -->
<style name="Widget.IPTV.BottomNavigation" parent="Widget.Material3.BottomNavigationView">
<item name="android:background">@color/surface</item>
<item name="itemIconTint">@color/navigation_item</item>
<item name="itemTextColor">@color/navigation_item</item>
<item name="itemRippleColor">@color/ripple</item>
</style>
<style name="Widget.IPTV.BottomNavigation.Dark" parent="Widget.Material3.BottomNavigationView">
<item name="android:background">@color/surface_dark</item>
<item name="itemIconTint">@color/navigation_item_dark</item>
<item name="itemTextColor">@color/navigation_item_dark</item>
<item name="itemRippleColor">@color/ripple_dark</item>
</style>
<!-- TabLayout -->
<style name="Widget.IPTV.TabLayout" parent="Widget.Material3.TabLayout">
<item name="android:background">@color/primary</item>
<item name="tabIndicatorColor">@color/text_on_primary</item>
<item name="tabSelectedTextColor">@color/text_on_primary</item>
<item name="tabTextColor">@color/text_on_primary</item>
</style>
<style name="Widget.IPTV.TabLayout.Dark" parent="Widget.Material3.TabLayout">
<item name="android:background">@color/surface_dark</item>
<item name="tabIndicatorColor">@color/primary_light</item>
<item name="tabSelectedTextColor">@color/primary_light</item>
<item name="tabTextColor">@color/text_secondary_dark</item>
</style>
<!-- Slider -->
<style name="Widget.IPTV.Slider" parent="Widget.Material3.Slider">
<item name="trackColorActive">@color/primary</item>
<item name="trackColorInactive">@color/surface_variant</item>
<item name="thumbColor">@color/primary</item>
</style>
<style name="Widget.IPTV.Slider.Dark" parent="Widget.Material3.Slider">
<item name="trackColorActive">@color/primary_light</item>
<item name="trackColorInactive">@color/surface_variant_dark</item>
<item name="thumbColor">@color/primary_light</item>
</style>
<!-- Switch -->
<style name="Widget.IPTV.Switch" parent="Widget.Material3.CompoundButton.MaterialSwitch">
<item name="thumbTint">@color/primary</item>
<item name="trackTint">@color/primary_light</item>
</style>
<style name="Widget.IPTV.Switch.Dark" parent="Widget.Material3.CompoundButton.MaterialSwitch">
<item name="thumbTint">@color/primary_light</item>
<item name="trackTint">@color/primary</item>
</style>
<!-- CheckBox -->
<style name="Widget.IPTV.CheckBox" parent="Widget.Material3.CompoundButton.CheckBox">
<item name="buttonTint">@color/primary</item>
</style>
<style name="Widget.IPTV.CheckBox.Dark" parent="Widget.Material3.CompoundButton.CheckBox">
<item name="buttonTint">@color/primary_light</item>
</style>
<!-- RadioButton -->
<style name="Widget.IPTV.RadioButton" parent="Widget.Material3.CompoundButton.RadioButton">
<item name="buttonTint">@color/primary</item>
</style>
<style name="Widget.IPTV.RadioButton.Dark" parent="Widget.Material3.CompoundButton.RadioButton">
<item name="buttonTint">@color/primary_light</item>
</style>
<!-- PopupMenu -->
<style name="Widget.IPTV.PopupMenu" parent="Widget.Material3.PopupMenu">
<item name="android:popupBackground">@drawable/popup_background</item>
</style>
<style name="Widget.IPTV.PopupMenu.Dark" parent="Widget.Material3.PopupMenu">
<item name="android:popupBackground">@drawable/popup_background_dark</item>
</style>
<!-- ============================================ -->
<!-- Theme Overlays -->
<!-- ============================================ -->
<style name="ThemeOverlay.IPTV.MaterialAlertDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog">
<item name="colorPrimary">@color/primary</item>
<item name="colorSurface">@color/surface</item>
<item name="colorOnSurface">@color/text_primary</item>
</style>
<style name="ThemeOverlay.IPTV.MaterialAlertDialog.Dark" parent="ThemeOverlay.Material3.MaterialAlertDialog">
<item name="colorPrimary">@color/primary_light</item>
<item name="colorSurface">@color/surface_dark</item>
<item name="colorOnSurface">@color/text_primary_dark</item>
</style>
<style name="ThemeOverlay.IPTV.AlertDialog" parent="ThemeOverlay.Material3.Dialog.Alert">
<item name="colorPrimary">@color/primary</item>
<item name="android:background">@color/surface</item>
</style>
<style name="ThemeOverlay.IPTV.AlertDialog.Dark" parent="ThemeOverlay.Material3.Dialog.Alert">
<item name="colorPrimary">@color/primary_light</item>
<item name="android:background">@color/surface_dark</item>
</style>
</resources>

View File

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

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Network Security Configuration for IPTV App
This configuration allows HTTP traffic for IPTV streams, which is commonly
required since many IPTV providers still use unencrypted HTTP streams.
WARNING: Using cleartext traffic is less secure than HTTPS. Only use this
configuration if your IPTV provider does not support HTTPS.
Security Recommendations:
1. Prefer HTTPS streams whenever possible
2. Use a VPN when streaming over HTTP
3. Validate stream sources before adding them
4. Keep the app updated with security patches
-->
<network-security-config>
<!-- Base configuration - cleartext traffic permitted by default -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<!-- Trust system certificates -->
<certificates src="system" />
<!-- Trust user-added certificates (for debugging with proxies) -->
<certificates src="user" />
</trust-anchors>
</base-config>
<!-- Domain-specific configurations -->
<!-- Add specific domains that require cleartext traffic -->
<domain-config cleartextTrafficPermitted="true">
<!-- Common IPTV stream domain patterns -->
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">192.168.*</domain>
<domain includeSubdomains="true">10.*</domain>
<domain includeSubdomains="true">172.16.*</domain>
<!-- Pinning configuration for known secure domains (example) -->
<!-- Uncomment and configure for production apps with known domains -->
<!--
<pin-set expiration="2025-01-01">
<pin digest="SHA-256">sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<pin digest="SHA-256">sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
-->
</domain-config>
<!-- Debug configuration - only use for development -->
<!-- This allows cleartext traffic to all domains when debugging -->
<debug-overrides>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>

6
build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false
}

5
gradle.properties Normal file
View File

@@ -0,0 +1,5 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

17
settings.gradle.kts Normal file
View File

@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "IPTVApp"
include(":app")