commit 88dfda14829d592cbc21b73eed87c9fb217c6cd6 Author: Renato Date: Wed Jan 28 22:02:28 2026 +0000 feat: Initial IPTV app with Google DNS and in-app updates diff --git a/README.md b/README.md new file mode 100644 index 0000000..04e9b9a --- /dev/null +++ b/README.md @@ -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 :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 + + + + + + + +``` + +### 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..91d54cb --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..30ebd79 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# ProGuard rules for IPTV App +-keep class com.iptv.app.data.model.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9c63623 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/iptv/app/IPTVApplication.kt b/app/src/main/java/com/iptv/app/IPTVApplication.kt new file mode 100644 index 0000000..315771b --- /dev/null +++ b/app/src/main/java/com/iptv/app/IPTVApplication.kt @@ -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" + } +} diff --git a/app/src/main/java/com/iptv/app/MainActivity.kt b/app/src/main/java/com/iptv/app/MainActivity.kt new file mode 100644 index 0000000..80d779d --- /dev/null +++ b/app/src/main/java/com/iptv/app/MainActivity.kt @@ -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() + } + ) +} diff --git a/app/src/main/java/com/iptv/app/PlayerActivity.kt b/app/src/main/java/com/iptv/app/PlayerActivity.kt new file mode 100644 index 0000000..6d67869 --- /dev/null +++ b/app/src/main/java/com/iptv/app/PlayerActivity.kt @@ -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.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 { } + } +} diff --git a/app/src/main/java/com/iptv/app/data/model/Category.kt b/app/src/main/java/com/iptv/app/data/model/Category.kt new file mode 100644 index 0000000..56d5a6a --- /dev/null +++ b/app/src/main/java/com/iptv/app/data/model/Category.kt @@ -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 = 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): 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): List { + 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): List { + return groupChannelsByCategory(channels) + .sortedBy { it.name } + .map { it.sortedByName() } + } + } +} diff --git a/app/src/main/java/com/iptv/app/data/model/Channel.kt b/app/src/main/java/com/iptv/app/data/model/Channel.kt new file mode 100644 index 0000000..588cf6e --- /dev/null +++ b/app/src/main/java/com/iptv/app/data/model/Channel.kt @@ -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 = "" + ) + } +} diff --git a/app/src/main/java/com/iptv/app/data/parser/M3UParser.kt b/app/src/main/java/com/iptv/app/data/parser/M3UParser.kt new file mode 100644 index 0000000..e6a57f1 --- /dev/null +++ b/app/src/main/java/com/iptv/app/data/parser/M3UParser.kt @@ -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, + val categories: List + ) : 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 { + 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): String { + val sb = StringBuilder() + sb.appendLine(EXT_M3U) + sb.appendLine() + + channels.forEach { channel -> + sb.append(EXT_INF) + sb.append("-1 ") + + val attributes = mutableListOf() + 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() + 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 { + val attributes = mutableMapOf() + 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 { + 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 { + 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 + } +} diff --git a/app/src/main/java/com/iptv/app/data/remote/UpdateService.kt b/app/src/main/java/com/iptv/app/data/remote/UpdateService.kt new file mode 100644 index 0000000..861d010 --- /dev/null +++ b/app/src/main/java/com/iptv/app/data/remote/UpdateService.kt @@ -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 = 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 + } + } +} diff --git a/app/src/main/java/com/iptv/app/data/repository/ChannelRepository.kt b/app/src/main/java/com/iptv/app/data/repository/ChannelRepository.kt new file mode 100644 index 0000000..09f632c --- /dev/null +++ b/app/src/main/java/com/iptv/app/data/repository/ChannelRepository.kt @@ -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>(emptyList()) + private val _isLoading = MutableStateFlow(false) + private val _error = MutableStateFlow(null) + + val allChannels: StateFlow> = _allChannels.asStateFlow() + val isLoading: StateFlow = _isLoading.asStateFlow() + val error: StateFlow = _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> = 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> = _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> = _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> = 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 = _allChannels + .map { channels -> + channels.find { it.id == channelId } + } + .flowOn(Dispatchers.Default) + + /** + * Returns a Flow of favorite channels. + */ + fun getFavoriteChannels(): Flow> = _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 = 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> { + 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 { + 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 { + 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) { + val channelsJson = m3uParser.serializeToString(channels) + sharedPreferences.edit() + .putString(CACHE_KEY_CHANNELS, channelsJson) + .putLong(CACHE_KEY_TIMESTAMP, System.currentTimeMillis()) + .apply() + } + + private fun getFavoriteIds(): Set { + return sharedPreferences.getStringSet(FAVORITES_KEY, emptySet()) ?: emptySet() + } + + private fun saveFavoriteIds(favorites: Set) { + sharedPreferences.edit() + .putStringSet(FAVORITES_KEY, favorites) + .apply() + } +} + +/** + * Sealed class representing the result of a repository operation. + */ +sealed class RepositoryResult { + data class Success(val data: T) : RepositoryResult() + data class Error(val message: String, val exception: Throwable? = null) : RepositoryResult() + data object Loading : RepositoryResult() +} diff --git a/app/src/main/java/com/iptv/app/ui/components/CategoryChip.kt b/app/src/main/java/com/iptv/app/ui/components/CategoryChip.kt new file mode 100644 index 0000000..1458a8c --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/components/CategoryChip.kt @@ -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 = {} + ) + } + } + } +} diff --git a/app/src/main/java/com/iptv/app/ui/components/ChannelCard.kt b/app/src/main/java/com/iptv/app/ui/components/ChannelCard.kt new file mode 100644 index 0000000..f1f398c --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/components/ChannelCard.kt @@ -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 = {} + ) + } +} diff --git a/app/src/main/java/com/iptv/app/ui/components/PlayerControls.kt b/app/src/main/java/com/iptv/app/ui/components/PlayerControls.kt new file mode 100644 index 0000000..f046fa0 --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/components/PlayerControls.kt @@ -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 = {} + ) + } + } +} diff --git a/app/src/main/java/com/iptv/app/ui/components/UpdateDialog.kt b/app/src/main/java/com/iptv/app/ui/components/UpdateDialog.kt new file mode 100644 index 0000000..98241ac --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/components/UpdateDialog.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/iptv/app/ui/components/VideoPlayer.kt b/app/src/main/java/com/iptv/app/ui/components/VideoPlayer.kt new file mode 100644 index 0000000..632a49a --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/components/VideoPlayer.kt @@ -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.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 +} diff --git a/app/src/main/java/com/iptv/app/ui/screens/ChannelsScreen.kt b/app/src/main/java/com/iptv/app/ui/screens/ChannelsScreen.kt new file mode 100644 index 0000000..54326d8 --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/screens/ChannelsScreen.kt @@ -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, + val categories: List + ) : 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, + 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, + 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 = {} + ) + } +} diff --git a/app/src/main/java/com/iptv/app/ui/screens/PlayerScreen.kt b/app/src/main/java/com/iptv/app/ui/screens/PlayerScreen.kt new file mode 100644 index 0000000..cf3994e --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/screens/PlayerScreen.kt @@ -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 = {} + ) + } + } +} diff --git a/app/src/main/java/com/iptv/app/ui/theme/Color.kt b/app/src/main/java/com/iptv/app/ui/theme/Color.kt new file mode 100644 index 0000000..ec62fd9 --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/iptv/app/ui/theme/Theme.kt b/app/src/main/java/com/iptv/app/ui/theme/Theme.kt new file mode 100644 index 0000000..6d24ca9 --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/iptv/app/ui/theme/Type.kt b/app/src/main/java/com/iptv/app/ui/theme/Type.kt new file mode 100644 index 0000000..774d9d9 --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/theme/Type.kt @@ -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 + ) +) diff --git a/app/src/main/java/com/iptv/app/ui/viewmodel/ChannelsViewModel.kt b/app/src/main/java/com/iptv/app/ui/viewmodel/ChannelsViewModel.kt new file mode 100644 index 0000000..5818d21 --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/viewmodel/ChannelsViewModel.kt @@ -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 = emptyList(), + val filteredChannels: List = emptyList(), + val categories: List = emptyList(), + val selectedCategory: String? = null, + val searchQuery: String = "", + val favoriteChannelIds: Set = 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 = _uiState.asStateFlow() + + private val _searchQuery = MutableStateFlow("") + private val _selectedCategory = MutableStateFlow(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 { + 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> { + 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> { + 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 create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ChannelsViewModel::class.java)) { + return ChannelsViewModel(channelRepository, m3uUrl) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/java/com/iptv/app/ui/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/iptv/app/ui/viewmodel/PlayerViewModel.kt new file mode 100644 index 0000000..49ce731 --- /dev/null +++ b/app/src/main/java/com/iptv/app/ui/viewmodel/PlayerViewModel.kt @@ -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 = emptyList(), + val selectedQuality: String? = null, + val isFavorite: Boolean = false, + val errorMessage: String? = null, + val relatedChannels: List = 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 = _uiState.asStateFlow() + + private var controlsHideJob: Job? = null + private var positionUpdateJob: Job? = null + private var currentChannelList: List = 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(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 create(modelClass: Class): T { + if (modelClass.isAssignableFrom(PlayerViewModel::class.java)) { + return PlayerViewModel( + channelRepository, + SavedStateHandle() + ) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/java/com/iptv/app/utils/DnsConfigurator.kt b/app/src/main/java/com/iptv/app/utils/DnsConfigurator.kt new file mode 100644 index 0000000..b9fda53 --- /dev/null +++ b/app/src/main/java/com/iptv/app/utils/DnsConfigurator.kt @@ -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 { + // 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): List { + 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 + result.toList() + } catch (e: Exception) { + emptyList() + } + }.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) : Dns { + + @Throws(UnknownHostException::class) + override fun lookup(hostname: String): List { + // Usar solo los DNS configurados + val exceptions = mutableListOf() + + 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 { + 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 + result.toList() + } catch (e: Exception) { + throw UnknownHostException("No se pudo resolver $hostname con DNS $dnsServer: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/iptv/app/utils/PlayerManager.kt b/app/src/main/java/com/iptv/app/utils/PlayerManager.kt new file mode 100644 index 0000000..92aa4ab --- /dev/null +++ b/app/src/main/java/com/iptv/app/utils/PlayerManager.kt @@ -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.Granted) + val audioFocusState: StateFlow = _audioFocusState.asStateFlow() + + private val _availableQualities = MutableStateFlow>(emptyList()) + val availableQualities: StateFlow> = _availableQualities.asStateFlow() + + private val _availableAudioTracks = MutableStateFlow>(emptyList()) + val availableAudioTracks: StateFlow> = _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() + + 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() + + 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 + ) + } +} diff --git a/app/src/main/java/com/iptv/app/utils/UpdateInstallHelper.kt b/app/src/main/java/com/iptv/app/utils/UpdateInstallHelper.kt new file mode 100644 index 0000000..d66334f --- /dev/null +++ b/app/src/main/java/com/iptv/app/utils/UpdateInstallHelper.kt @@ -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 +} diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..1cb91da --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,140 @@ + + + + #1976D2 + #1565C0 + #42A5F5 + #0D47A1 + + + #FF5722 + #E64A19 + #FF8A65 + #BF360C + + + #00BCD4 + #0097A7 + #4DD0E1 + + + #FAFAFA + #121212 + #FFFFFF + #1E1E1E + #F5F5F5 + #2D2D2D + + + #212121 + #FFFFFF + #757575 + #B3B3B3 + #9E9E9E + #666666 + #FFFFFF + #FFFFFF + + + #B00020 + #CF6679 + #4CAF50 + #81C784 + #FFC107 + #FFD54F + #2196F3 + #64B5F6 + + + #CC000000 + #CC000000 + #FF5722 + #757575 + #BDBDBD + #FFFFFF + #F44336 + #EF5350 + + + #757575 + #9E9E9E + #212121 + #FFFFFF + #757575 + #B3B3B3 + #9E9E9E + #757575 + #FFC107 + #4CAF50 + + + #F5F5F5 + #2D2D2D + #FFFFFF + #1E1E1E + #FFFFFF + #1E1E1E + #E3F2FD + #0D47A1 + #FFEBEE + #3E2723 + #1976D2 + #42A5F5 + + + #4CAF50 + #F44336 + #9C27B0 + #FF9800 + #00BCD4 + #E91E63 + #795548 + #3F51B5 + + + #E0E0E0 + #424242 + #BDBDBD + #616161 + + + #80FFFFFF + #80000000 + #52000000 + #99000000 + + + #1F000000 + #33FFFFFF + #E3F2FD + #0D47A1 + + + #FFFFFF + #1E1E1E + #757575 + #B3B3B3 + #1976D2 + #42A5F5 + + + #F5F5F5 + #2D2D2D + #9E9E9E + #757575 + + + #F44336 + #FFEBEE + #3E2723 + + + #9C27B0 + #F3E5F5 + #311B92 + + + #00000000 + #80000000 + #1A000000 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..9ff03bc --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,294 @@ + + + + IPTV Player + IPTV + + + Home + Channels + Favorites + TV Guide + Settings + Search + Recordings + Categories + + + Welcome to IPTV Player + No playlist loaded + Add a playlist to start watching + Recent Channels + Featured + Continue Watching + + + Playlist + Add Playlist + Edit Playlist + Delete Playlist + Playlist Name + Playlist URL + Select File + Load Playlist + Refresh Playlist + Auto Refresh + Last updated: %s + Playlist is empty + Invalid playlist format + Failed to load playlist + Playlist loaded successfully + Supported formats: M3U, M3U8 + + + Channels + Channel %d + No program information + Now: %s + Next: %s + LIVE + HD + FHD + 4K + Add to Favorites + Remove from Favorites + No favorite channels + Search channels... + All Channels + Favorites + Recently Watched + + + Play + Pause + Stop + Next Channel + Previous Channel + Fullscreen + Exit Fullscreen + Aspect Ratio + Audio Track + Subtitles + Off + Player Settings + Picture in Picture + Cast + Record + Stop Recording + Buffering... + Loading... + Playback Error + Retry + LIVE + Unknown Channel + + + Auto + Fit Screen + Fill Screen + Zoom + 16:9 + 4:3 + + + TV Guide + Electronic Program Guide + No EPG data available + Loading EPG... + Failed to load EPG + Program Details + Start: %s + End: %s + Duration: %s + Now + Today + Tomorrow + Yesterday + Catch-up Available + Reminder Set + Cancel Reminder + + + All + Sports + News + Movies + Entertainment + Kids + Music + Documentary + Education + Lifestyle + Religious + International + Local + Radio + + + Settings + General + Player + Playback + EPG Settings + Interface + Network + Advanced + About + + + Startup Page + Language + Theme + Light + Dark + System Default + Auto Update Playlists + Update Interval + + + Default Aspect Ratio + Auto Play Last Channel + Show Channel Info + Channel Info Timeout + Preferred Quality + Hardware Decoding + Software Decoding + Buffer Size + Preferred Audio Language + Preferred Subtitle Language + + + EPG URL + Auto Update EPG + EPG Update Interval + Hours Visible + Show Program Thumbnails + + + User Agent + HTTP Timeout + Use Proxy + Proxy Host + Proxy Port + Allow Insecure Connections + + + Recording + Start Recording + Stop Recording + Recording in Progress + Recording Saved + Recording Failed + Storage Location + No storage available + Recording: %s + + + Search + Search channels or programs... + No results found + Search History + Clear History + Filters + + + Network error. Please check your connection. + Playback error. Please try again. + Unsupported media format. + Stream unavailable. + Connection timeout. + No internet connection. + Invalid URL. + File not found. + Permission denied. + Storage full. + An unknown error occurred. + + + OK + Cancel + Yes + No + Save + Delete + Edit + Add + Close + Back + Confirm + Loading... + Please wait... + + + Delete this playlist? + Delete this recording? + Clear search history? + Exit the app? + Stop recording? + + + Saved successfully + Deleted successfully + Updated successfully + Added successfully + + + Version %s + Build %d + Copyright 2024 IPTV Player + Licensed under Apache 2.0 + Privacy Policy + Terms of Service + Open Source Licenses + Rate App + Share App + Send Feedback + + + 12-hour format + 24-hour format + Just now + %d minutes ago + %d hours ago + Yesterday + + + Play button + Pause button + Stop button + Toggle fullscreen + Channel up + Channel down + Volume up + Volume down + Mute + Settings + Go back + Menu + Search + Toggle favorite + Close + + + IPTV Player + Search + Settings + TV Guide + No channels available + Press SELECT to play + + + App Logo + Channel Logo + Program Thumbnail + Player Controls + Progress Bar + Volume Indicator + + + Notifications for app updates and installations + Downloading Update + Update Ready + Tap to install the update + Update Failed + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..db1cf89 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,488 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml new file mode 100644 index 0000000..5179bf8 --- /dev/null +++ b/app/src/main/res/xml/file_provider_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..b049778 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + localhost + 127.0.0.1 + 192.168.* + 10.* + 172.16.* + + + + + + + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4f078c2 --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2cf094b --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f2db29d --- /dev/null +++ b/settings.gradle.kts @@ -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")