feat: Initial IPTV app with Google DNS and in-app updates
This commit is contained in:
556
README.md
Normal file
556
README.md
Normal file
@@ -0,0 +1,556 @@
|
||||
# IPTV Player
|
||||
|
||||
A modern, feature-rich Android IPTV streaming application built with Jetpack Compose and Media3 ExoPlayer. This app allows users to stream live TV channels from M3U/M3U8 playlists with a beautiful Material Design 3 interface.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
- **IPTV Streaming**: Stream live TV channels from M3U/M3U8 playlists
|
||||
- **Channel Categories**: Automatic grouping of channels by category (Sports, News, Movies, Kids, etc.)
|
||||
- **Search Functionality**: Real-time search with debouncing for quick channel discovery
|
||||
- **Favorites Management**: Mark channels as favorites for quick access
|
||||
- **Offline Caching**: 24-hour channel cache for offline browsing
|
||||
- **Pull-to-Refresh**: Swipe down to refresh channel list
|
||||
|
||||
### Player Features
|
||||
- **Media3 ExoPlayer**: Modern, extensible media player with HLS/DASH support
|
||||
- **Fullscreen Mode**: Immersive landscape playback with gesture controls
|
||||
- **Audio Focus Management**: Proper audio focus handling with ducking support
|
||||
- **Multiple Audio Tracks**: Support for multi-language audio streams
|
||||
- **Video Quality Selection**: Auto and manual quality selection
|
||||
- **Subtitle Support**: Multi-language subtitle track selection
|
||||
- **Picture-in-Picture**: Background playback support (Android O+)
|
||||
|
||||
### UI/UX Features
|
||||
- **Material Design 3**: Modern, responsive UI with dynamic theming
|
||||
- **Jetpack Compose**: Declarative UI with smooth animations
|
||||
- **Responsive Grid Layout**: Adaptive channel grid (2 columns)
|
||||
- **Category Filter Chips**: Horizontal scrolling category selection
|
||||
- **Loading States**: Skeleton screens and progress indicators
|
||||
- **Error Handling**: User-friendly error messages with retry options
|
||||
|
||||
### TV Support
|
||||
- **Android TV Compatible**: Leanback launcher support
|
||||
- **D-Pad Navigation**: Full remote control support
|
||||
- **TV-Optimized UI**: Large, focusable UI elements
|
||||
|
||||
## Screenshots
|
||||
|
||||
> **Note**: Add your screenshots to the `screenshots/` directory and update the paths below.
|
||||
|
||||
| Channels List | Player | Categories |
|
||||
|--------------|--------|------------|
|
||||
|  |  |  |
|
||||
|
||||
| Search | Favorites | Settings |
|
||||
|--------|-----------|----------|
|
||||
|  |  |  |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before building the project, ensure you have the following installed:
|
||||
|
||||
### Required
|
||||
- **Android Studio Hedgehog (2023.1.1)** or later
|
||||
- **JDK 17** or later
|
||||
- **Android SDK** with the following:
|
||||
- SDK Platform API 34 (Android 14)
|
||||
- Build-Tools 34.0.0
|
||||
- Android SDK Command-line Tools
|
||||
|
||||
### Optional
|
||||
- **Git** for version control
|
||||
- **Android Device** or **Emulator** running Android 7.0 (API 24) or higher
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/iptv-app.git
|
||||
cd iptv-app
|
||||
```
|
||||
|
||||
### 2. Open in Android Studio
|
||||
|
||||
1. Launch Android Studio
|
||||
2. Select "Open an existing Android Studio project"
|
||||
3. Navigate to the cloned directory and click "OK"
|
||||
4. Wait for Gradle sync to complete
|
||||
|
||||
### 3. Build the Project
|
||||
|
||||
```bash
|
||||
# Build debug APK
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Build release APK (requires signing configuration)
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
### 4. Run on Device
|
||||
|
||||
```bash
|
||||
# Connect your Android device or start an emulator
|
||||
# Run the app
|
||||
./gradlew installDebug
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Changing the M3U Playlist URL
|
||||
|
||||
The default M3U URL can be configured in the following locations:
|
||||
|
||||
#### Option 1: Build Configuration (Recommended)
|
||||
|
||||
Edit `app/build.gradle.kts` and add a build config field:
|
||||
|
||||
```kotlin
|
||||
android {
|
||||
defaultConfig {
|
||||
buildConfigField("String", "DEFAULT_M3U_URL", "\"https://your-playlist-url.com/playlist.m3u\"")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then access it in code:
|
||||
```kotlin
|
||||
val m3uUrl = BuildConfig.DEFAULT_M3U_URL
|
||||
```
|
||||
|
||||
#### Option 2: Constants File
|
||||
|
||||
Create or edit `app/src/main/java/com/iptv/app/Constants.kt`:
|
||||
|
||||
```kotlin
|
||||
package com.iptv.app
|
||||
|
||||
object Constants {
|
||||
const val DEFAULT_M3U_URL = "https://your-playlist-url.com/playlist.m3u"
|
||||
|
||||
// Optional: Multiple playlist support
|
||||
val PLAYLISTS = mapOf(
|
||||
"Default" to "https://example.com/playlist1.m3u",
|
||||
"Sports" to "https://example.com/sports.m3u",
|
||||
"News" to "https://example.com/news.m3u"
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#### Option 3: Settings UI (User-Configurable)
|
||||
|
||||
The app supports runtime playlist URL changes through the Settings screen. Users can:
|
||||
1. Open Settings
|
||||
2. Tap "Playlist URL"
|
||||
3. Enter a new M3U/M3U8 URL
|
||||
4. Tap "Save" to apply changes
|
||||
|
||||
### Supported Playlist Formats
|
||||
|
||||
The app supports standard M3U/M3U8 playlist formats:
|
||||
|
||||
```m3u
|
||||
#EXTM3U
|
||||
|
||||
#EXTINF:-1 tvg-id="channel1" tvg-logo="http://logo.png" group-title="News",CNN
|
||||
http://stream-url.com/cnn.m3u8
|
||||
|
||||
#EXTINF:-1 tvg-id="channel2" tvg-logo="http://logo.png" group-title="Sports",ESPN
|
||||
http://stream-url.com/espn.m3u8
|
||||
```
|
||||
|
||||
**Supported Attributes:**
|
||||
- `tvg-id`: Channel identifier
|
||||
- `tvg-logo`: Channel logo URL
|
||||
- `group-title`: Category/group name
|
||||
- `tvg-language`: Channel language
|
||||
- `tvg-country`: Channel country
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### MVVM Architecture
|
||||
|
||||
The app follows the Model-View-ViewModel (MVVM) architecture pattern:
|
||||
|
||||
```
|
||||
UI Layer (Compose Screens)
|
||||
|
|
||||
v
|
||||
ViewModel Layer (State Management)
|
||||
|
|
||||
v
|
||||
Repository Layer (Data Operations)
|
||||
|
|
||||
v
|
||||
Data Layer (Models, Parsers, Network)
|
||||
```
|
||||
|
||||
### Repository Pattern
|
||||
|
||||
The `ChannelRepository` acts as a single source of truth for channel data:
|
||||
|
||||
- **Data Fetching**: Fetches from network with Retrofit/OkHttp
|
||||
- **Caching**: Local cache with 24-hour expiration
|
||||
- **Favorites**: Persistent storage with SharedPreferences
|
||||
- **Search**: Debounced search with Flow operators
|
||||
- **Filtering**: Category-based filtering
|
||||
|
||||
### Media3 ExoPlayer Integration
|
||||
|
||||
The `PlayerManager` class provides:
|
||||
|
||||
- **Player Lifecycle**: Proper creation and disposal
|
||||
- **Audio Focus**: System audio focus handling
|
||||
- **Track Selection**: Quality and audio track switching
|
||||
- **State Management**: Playback state observation
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
IPTVApp/
|
||||
├── app/
|
||||
│ ├── src/main/java/com/iptv/app/
|
||||
│ │ ├── data/
|
||||
│ │ │ ├── model/
|
||||
│ │ │ │ ├── Channel.kt # Channel data class
|
||||
│ │ │ │ └── Category.kt # Category data class
|
||||
│ │ │ └── repository/
|
||||
│ │ │ └── ChannelRepository.kt # Data repository
|
||||
│ │ ├── ui/
|
||||
│ │ │ ├── components/ # Reusable UI components
|
||||
│ │ │ │ ├── ChannelCard.kt
|
||||
│ │ │ │ ├── CategoryChip.kt
|
||||
│ │ │ │ ├── VideoPlayer.kt
|
||||
│ │ │ │ └── PlayerControls.kt
|
||||
│ │ │ ├── screens/ # Screen composables
|
||||
│ │ │ │ ├── ChannelsScreen.kt
|
||||
│ │ │ │ └── PlayerScreen.kt
|
||||
│ │ │ ├── theme/ # Material 3 theme
|
||||
│ │ │ │ ├── Color.kt
|
||||
│ │ │ │ ├── Type.kt
|
||||
│ │ │ │ └── Theme.kt
|
||||
│ │ │ └── viewmodel/ # ViewModels
|
||||
│ │ │ ├── ChannelsViewModel.kt
|
||||
│ │ │ └── PlayerViewModel.kt
|
||||
│ │ └── utils/
|
||||
│ │ ├── M3UParser.kt # M3U playlist parser
|
||||
│ │ └── PlayerManager.kt # ExoPlayer wrapper
|
||||
│ ├── src/main/res/
|
||||
│ │ ├── values/
|
||||
│ │ │ ├── strings.xml # App strings
|
||||
│ │ │ ├── colors.xml # Color resources
|
||||
│ │ │ └── themes.xml # Theme resources
|
||||
│ │ └── xml/
|
||||
│ │ └── network_security_config.xml
|
||||
│ └── build.gradle.kts # App-level build config
|
||||
├── build.gradle.kts # Project-level build config
|
||||
├── settings.gradle.kts # Project settings
|
||||
└── gradle.properties # Gradle properties
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Android
|
||||
| Dependency | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| `core-ktx` | 1.12.0 | Kotlin extensions |
|
||||
| `lifecycle-runtime-ktx` | 2.6.2 | Lifecycle awareness |
|
||||
| `activity-compose` | 1.8.1 | Compose activity |
|
||||
|
||||
### Jetpack Compose
|
||||
| Dependency | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| `compose-bom` | 2023.10.01 | Bill of Materials |
|
||||
| `material3` | - | Material Design 3 |
|
||||
| `material-icons-extended` | - | Extended icons |
|
||||
| `lifecycle-viewmodel-compose` | 2.6.2 | ViewModel integration |
|
||||
| `navigation-compose` | 2.7.5 | Navigation |
|
||||
|
||||
### Media Playback
|
||||
| Dependency | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| `media3-exoplayer` | 1.2.0 | Core player |
|
||||
| `media3-exoplayer-hls` | 1.2.0 | HLS streaming |
|
||||
| `media3-exoplayer-dash` | 1.2.0 | DASH streaming |
|
||||
| `media3-ui` | 1.2.0 | Player UI |
|
||||
| `media3-session` | 1.2.0 | Media session |
|
||||
|
||||
### Networking
|
||||
| Dependency | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| `retrofit` | 2.9.0 | HTTP client |
|
||||
| `okhttp` | 4.12.0 | HTTP engine |
|
||||
| `logging-interceptor` | 4.12.0 | Request logging |
|
||||
|
||||
### Image Loading
|
||||
| Dependency | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| `coil-compose` | 2.5.0 | Image loading |
|
||||
|
||||
### Coroutines
|
||||
| Dependency | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| `kotlinx-coroutines-android` | 1.7.3 | Async operations |
|
||||
|
||||
### JSON Parsing
|
||||
| Dependency | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| `gson` | 2.10.1 | JSON serialization |
|
||||
|
||||
### Testing
|
||||
| Dependency | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
| `junit` | 4.13.2 | Unit testing |
|
||||
| `espresso-core` | 3.5.1 | UI testing |
|
||||
| `compose-ui-test-junit4` | - | Compose testing |
|
||||
|
||||
## Building the APK
|
||||
|
||||
### Debug APK
|
||||
|
||||
```bash
|
||||
# Build debug APK
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Output location:
|
||||
# app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Release APK
|
||||
|
||||
1. **Create a signing keystore** (if not exists):
|
||||
|
||||
```bash
|
||||
keytool -genkey -v -keystore iptv-release.keystore -alias iptv -keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
|
||||
2. **Configure signing** in `app/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
android {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file("iptv-release.keystore")
|
||||
storePassword = System.getenv("STORE_PASSWORD") ?: "your-password"
|
||||
keyAlias = "iptv"
|
||||
keyPassword = System.getenv("KEY_PASSWORD") ?: "your-password"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Build release APK**:
|
||||
|
||||
```bash
|
||||
# Build release APK
|
||||
./gradlew assembleRelease
|
||||
|
||||
# Output location:
|
||||
# app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
### App Bundle (Google Play)
|
||||
|
||||
```bash
|
||||
# Build AAB for Google Play
|
||||
./gradlew bundleRelease
|
||||
|
||||
# Output location:
|
||||
# app/build/outputs/bundle/release/app-release.aab
|
||||
```
|
||||
|
||||
## Installing on Android Device
|
||||
|
||||
### Method 1: Android Studio
|
||||
|
||||
1. Connect your Android device via USB
|
||||
2. Enable USB debugging in Developer Options
|
||||
3. Click "Run" (green play button) in Android Studio
|
||||
4. Select your device from the deployment target dialog
|
||||
|
||||
### Method 2: ADB (Android Debug Bridge)
|
||||
|
||||
```bash
|
||||
# Install debug APK
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
# Install release APK
|
||||
adb install app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
# Install with replacement (if already installed)
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Method 3: Direct Download
|
||||
|
||||
1. Transfer the APK file to your device
|
||||
2. Open a file manager on your device
|
||||
3. Navigate to the APK file location
|
||||
4. Tap the APK to install
|
||||
5. Allow installation from unknown sources if prompted
|
||||
|
||||
### Method 4: Wireless ADB
|
||||
|
||||
```bash
|
||||
# Connect to device over WiFi
|
||||
adb tcpip 5555
|
||||
adb connect <device-ip-address>:5555
|
||||
|
||||
# Install APK
|
||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Issues
|
||||
|
||||
#### Gradle Sync Failed
|
||||
```
|
||||
Solution: File -> Invalidate Caches / Restart -> Invalidate and Restart
|
||||
```
|
||||
|
||||
#### Out of Memory Error
|
||||
Add to `gradle.properties`:
|
||||
```properties
|
||||
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
```
|
||||
|
||||
#### Kotlin Version Mismatch
|
||||
Ensure Kotlin version matches in `build.gradle.kts`:
|
||||
```kotlin
|
||||
plugins {
|
||||
id("org.jetbrains.kotlin.android") version "1.9.20"
|
||||
}
|
||||
```
|
||||
|
||||
### Runtime Issues
|
||||
|
||||
#### App Crashes on Launch
|
||||
- Check that `INTERNET` permission is in `AndroidManifest.xml`
|
||||
- Verify minimum SDK version (API 24+)
|
||||
- Check for ProGuard obfuscation issues
|
||||
|
||||
#### Channels Not Loading
|
||||
- Verify the M3U URL is accessible
|
||||
- Check network connectivity
|
||||
- Review logcat for parsing errors: `adb logcat | grep M3UParser`
|
||||
|
||||
#### Video Not Playing
|
||||
- Ensure stream URL is valid and accessible
|
||||
- Check if stream format is supported (HLS, DASH, Progressive)
|
||||
- Verify codec support on device
|
||||
- Check logcat for ExoPlayer errors
|
||||
|
||||
#### Audio Issues
|
||||
- Check audio focus is being requested properly
|
||||
- Verify audio track selection in PlayerManager
|
||||
- Test with different audio formats
|
||||
|
||||
### Streaming Issues
|
||||
|
||||
#### Buffering Problems
|
||||
```kotlin
|
||||
// Increase buffer size in PlayerManager
|
||||
val player = ExoPlayer.Builder(context)
|
||||
.setLoadControl(
|
||||
DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
5000, // minBufferMs
|
||||
50000, // maxBufferMs
|
||||
1000, // bufferForPlaybackMs
|
||||
5000 // bufferForPlaybackAfterRebufferMs
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
```
|
||||
|
||||
#### SSL/Certificate Errors
|
||||
Update `network_security_config.xml`:
|
||||
```xml
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system"/>
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
```
|
||||
|
||||
### Development Tips
|
||||
|
||||
#### Enable Debug Logging
|
||||
```kotlin
|
||||
// In PlayerManager
|
||||
exoPlayer.addAnalyticsListener(EventLogger())
|
||||
```
|
||||
|
||||
#### View Compose Layout Bounds
|
||||
Enable in Developer Options:
|
||||
```
|
||||
Settings -> Developer Options -> Show layout bounds
|
||||
```
|
||||
|
||||
#### Profile Performance
|
||||
Use Android Studio Profiler:
|
||||
```
|
||||
View -> Tool Windows -> Profiler
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
```
|
||||
Copyright 2024 IPTV Player
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please follow these steps:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [ExoPlayer](https://github.com/google/ExoPlayer) - Media player library
|
||||
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - UI toolkit
|
||||
- [Material Design 3](https://m3.material.io/) - Design system
|
||||
- [IPTV Org](https://github.com/iptv-org) - Public IPTV playlists
|
||||
|
||||
## Support
|
||||
|
||||
For issues, questions, or feature requests, please open an issue on GitHub.
|
||||
|
||||
---
|
||||
|
||||
**Disclaimer**: This application is for educational purposes. Users are responsible for complying with local laws and regulations regarding IPTV streaming. The developers do not provide any IPTV content or playlists.
|
||||
109
app/build.gradle.kts
Normal file
109
app/build.gradle.kts
Normal file
@@ -0,0 +1,109 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.iptv.app"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.iptv.app"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.5"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AndroidX Core
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||
implementation("androidx.activity:activity-compose:1.8.1")
|
||||
|
||||
// Compose BOM
|
||||
val composeBom = platform("androidx.compose:compose-bom:2023.10.01")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
// Compose UI
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
// ViewModel Compose
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.2")
|
||||
|
||||
// Navigation Compose
|
||||
implementation("androidx.navigation:navigation-compose:2.7.5")
|
||||
|
||||
// ExoPlayer / Media3
|
||||
implementation("androidx.media3:media3-exoplayer:1.2.0")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.2.0")
|
||||
implementation("androidx.media3:media3-exoplayer-dash:1.2.0")
|
||||
implementation("androidx.media3:media3-datasource-okhttp:1.2.0")
|
||||
implementation("androidx.media3:media3-ui:1.2.0")
|
||||
implementation("androidx.media3:media3-session:1.2.0")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-scalars:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Image loading
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||
|
||||
// JSON parsing (for EPG data if needed)
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
2
app/proguard-rules.pro
vendored
Normal file
2
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# ProGuard rules for IPTV App
|
||||
-keep class com.iptv.app.data.model.** { *; }
|
||||
169
app/src/main/AndroidManifest.xml
Normal file
169
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,169 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Internet permission for streaming IPTV content -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- Network state permissions for monitoring connectivity -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- Wake lock to keep screen on during playback -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Picture-in-Picture mode for Android O and above -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
|
||||
<!-- Permissions for downloading and installing APK updates -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
|
||||
<application
|
||||
android:name=".IPTVApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.IPTVApp"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:targetApi="34">
|
||||
|
||||
<!-- Main Activity - Entry point of the application -->
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="unspecified"
|
||||
android:theme="@style/Theme.IPTVApp">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Intent filter for opening M3U playlist files -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="file" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="audio/x-mpegurl" />
|
||||
<data android:mimeType="application/vnd.apple.mpegurl" />
|
||||
<data android:mimeType="application/x-mpegurl" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Intent filter for opening IPTV URLs -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*" />
|
||||
<data android:pathPattern=".*\\.m3u" />
|
||||
<data android:pathPattern=".*\\.m3u8" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Player Activity - Full screen video playback -->
|
||||
<activity
|
||||
android:name=".ui.PlayerActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="unspecified"
|
||||
android:theme="@style/Theme.IPTVApp.Fullscreen"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:resizeableActivity="true"
|
||||
android:turnScreenOn="true"
|
||||
android:keepScreenOn="true" />
|
||||
|
||||
<!-- Settings Activity -->
|
||||
<activity
|
||||
android:name=".ui.SettingsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/settings"
|
||||
android:parentActivityName=".ui.MainActivity"
|
||||
android:theme="@style/Theme.IPTVApp" />
|
||||
|
||||
<!-- Channel List Activity -->
|
||||
<activity
|
||||
android:name=".ui.ChannelListActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/channels"
|
||||
android:parentActivityName=".ui.MainActivity"
|
||||
android:theme="@style/Theme.IPTVApp" />
|
||||
|
||||
<!-- EPG Activity - Electronic Program Guide -->
|
||||
<activity
|
||||
android:name=".ui.EpgActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/epg"
|
||||
android:parentActivityName=".ui.MainActivity"
|
||||
android:theme="@style/Theme.IPTVApp" />
|
||||
|
||||
<!-- ExoPlayer Download Service for offline content -->
|
||||
<service
|
||||
android:name=".service.DownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Background playback service -->
|
||||
<service
|
||||
android:name=".service.PlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<!-- TV Launcher Intent for Android TV support -->
|
||||
<activity
|
||||
android:name=".ui.TvMainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.IPTVApp.Leanback"
|
||||
android:banner="@drawable/banner"
|
||||
android:icon="@drawable/banner"
|
||||
android:logo="@drawable/banner"
|
||||
android:screenOrientation="landscape">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- FileProvider for sharing APK files -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Intent filter for handling downloaded APKs -->
|
||||
<activity
|
||||
android:name=".ui.InstallApkActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.IPTVApp">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="file" />
|
||||
<data android:scheme="content" />
|
||||
<data android:mimeType="application/vnd.android.package-archive" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
147
app/src/main/java/com/iptv/app/IPTVApplication.kt
Normal file
147
app/src/main/java/com/iptv/app/IPTVApplication.kt
Normal file
@@ -0,0 +1,147 @@
|
||||
package com.iptv.app
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.StrictMode
|
||||
import android.util.Log
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.iptv.app.data.parser.M3UParser
|
||||
import com.iptv.app.data.repository.ChannelRepository
|
||||
import com.iptv.app.utils.PlayerManager
|
||||
|
||||
/**
|
||||
* Application class for the IPTV app.
|
||||
*
|
||||
* This class initializes app-level components including:
|
||||
* - Repository initialization
|
||||
* - Logging configuration
|
||||
* - StrictMode for development (debug builds only)
|
||||
* - Media3/ExoPlayer configuration
|
||||
*/
|
||||
class IPTVApplication : Application() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "IPTVApplication"
|
||||
private const val PREFS_NAME = "iptv_prefs"
|
||||
|
||||
/**
|
||||
* Gets the application instance from a Context.
|
||||
*/
|
||||
fun from(context: Context): IPTVApplication {
|
||||
return context.applicationContext as IPTVApplication
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-initialized M3U parser instance.
|
||||
*/
|
||||
val m3uParser: M3UParser by lazy {
|
||||
M3UParser(applicationContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-initialized ChannelRepository instance.
|
||||
* Used across the app for channel data management.
|
||||
*/
|
||||
val channelRepository: ChannelRepository by lazy {
|
||||
val sharedPreferences = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
ChannelRepository(
|
||||
context = this,
|
||||
m3uParser = m3uParser,
|
||||
sharedPreferences = sharedPreferences
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-initialized PlayerManager for audio focus and media session management.
|
||||
*/
|
||||
val playerManager: PlayerManager by lazy {
|
||||
PlayerManager(this)
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Initialize logging
|
||||
initializeLogging()
|
||||
|
||||
// Setup StrictMode for debug builds
|
||||
if (BuildConfig.DEBUG) {
|
||||
setupStrictMode()
|
||||
}
|
||||
|
||||
// Initialize PlayerManager
|
||||
initializePlayerManager()
|
||||
|
||||
Log.d(TAG, "IPTVApplication initialized")
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes logging configuration.
|
||||
* In debug builds, verbose logging is enabled.
|
||||
* In release builds, only warnings and errors are logged.
|
||||
*/
|
||||
private fun initializeLogging() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Enable verbose logging for debug builds
|
||||
Log.d(TAG, "Debug logging enabled")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up StrictMode to detect potential issues during development.
|
||||
* Only active in debug builds.
|
||||
*/
|
||||
private fun setupStrictMode() {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
.detectDiskReads()
|
||||
.detectDiskWrites()
|
||||
.detectNetwork()
|
||||
.penaltyLog()
|
||||
.penaltyFlashScreen()
|
||||
.build()
|
||||
)
|
||||
|
||||
StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectActivityLeaks()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the PlayerManager for audio focus handling.
|
||||
*/
|
||||
@UnstableApi
|
||||
private fun initializePlayerManager() {
|
||||
// PlayerManager is initialized lazily, but we can perform any
|
||||
// one-time setup here if needed
|
||||
Log.d(TAG, "PlayerManager ready")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the application cache.
|
||||
* Called when user requests cache clear or on certain error conditions.
|
||||
*/
|
||||
suspend fun clearCache() {
|
||||
try {
|
||||
channelRepository.clearCache()
|
||||
Log.d(TAG, "Cache cleared successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing cache", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default M3U playlist URL.
|
||||
* This can be overridden via settings in a full implementation.
|
||||
*/
|
||||
fun getDefaultM3UUrl(): String {
|
||||
return "https://iptv-org.github.io/iptv/index.m3u"
|
||||
}
|
||||
}
|
||||
184
app/src/main/java/com/iptv/app/MainActivity.kt
Normal file
184
app/src/main/java/com/iptv/app/MainActivity.kt
Normal file
@@ -0,0 +1,184 @@
|
||||
package com.iptv.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.iptv.app.data.model.Channel as DataChannel
|
||||
import com.iptv.app.data.parser.M3UParser
|
||||
import com.iptv.app.data.repository.ChannelRepository
|
||||
import com.iptv.app.ui.components.Channel as UiChannel
|
||||
import com.iptv.app.ui.screens.ChannelsScreen
|
||||
import com.iptv.app.ui.screens.ChannelsUiState
|
||||
import com.iptv.app.ui.theme.IPTVAppTheme
|
||||
import com.iptv.app.ui.viewmodel.ChannelsViewModel
|
||||
import com.iptv.app.ui.viewmodel.ChannelsViewModelFactory
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Main entry point for the IPTV application.
|
||||
*
|
||||
* This activity sets up the main UI with Jetpack Compose, handles navigation to the player,
|
||||
* and manages the channel list display with search and filtering capabilities.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_M3U_URL = "https://iptv-org.github.io/iptv/index.m3u"
|
||||
}
|
||||
|
||||
private lateinit var channelRepository: ChannelRepository
|
||||
|
||||
private val viewModel: ChannelsViewModel by viewModels {
|
||||
ChannelsViewModelFactory(channelRepository, DEFAULT_M3U_URL)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val splashScreen = installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Initialize repository
|
||||
initializeRepository()
|
||||
|
||||
// Keep splash screen visible while loading
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
viewModel.uiState.value.isLoading
|
||||
}
|
||||
|
||||
setContent {
|
||||
IPTVAppTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
MainScreen(
|
||||
viewModel = viewModel,
|
||||
onChannelClick = { channel ->
|
||||
navigateToPlayer(channel)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect UI events
|
||||
collectUiEvents()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the ChannelRepository with required dependencies.
|
||||
*/
|
||||
private fun initializeRepository() {
|
||||
val sharedPreferences = getSharedPreferences("iptv_prefs", MODE_PRIVATE)
|
||||
val m3uParser = M3UParser()
|
||||
channelRepository = ChannelRepository(
|
||||
context = this,
|
||||
m3uParser = m3uParser,
|
||||
sharedPreferences = sharedPreferences
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects UI events from the ViewModel.
|
||||
*/
|
||||
private fun collectUiEvents() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.uiState.collect { state ->
|
||||
// Handle any one-time events here if needed
|
||||
state.errorMessage?.let { error ->
|
||||
// Error is handled in the UI composable
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the PlayerActivity with the selected channel.
|
||||
*
|
||||
* @param channel The channel to play
|
||||
*/
|
||||
private fun navigateToPlayer(channel: DataChannel) {
|
||||
val intent = Intent(this, PlayerActivity::class.java).apply {
|
||||
putExtra(PlayerActivity.EXTRA_CHANNEL_URL, channel.url)
|
||||
putExtra(PlayerActivity.EXTRA_CHANNEL_NAME, channel.name)
|
||||
putExtra(PlayerActivity.EXTRA_CHANNEL_ID, channel.id)
|
||||
putExtra(PlayerActivity.EXTRA_CHANNEL_CATEGORY, channel.category)
|
||||
putExtra(PlayerActivity.EXTRA_CHANNEL_LOGO, channel.logo)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main screen composable that displays the channels list.
|
||||
*
|
||||
* @param viewModel The ViewModel managing channel data
|
||||
* @param onChannelClick Callback when a channel is selected
|
||||
*/
|
||||
@Composable
|
||||
private fun MainScreen(
|
||||
viewModel: ChannelsViewModel,
|
||||
onChannelClick: (DataChannel) -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
// Map data model channels to UI channels
|
||||
val uiChannels = uiState.filteredChannels.map { channel ->
|
||||
UiChannel(
|
||||
id = channel.id,
|
||||
name = channel.name,
|
||||
category = channel.category,
|
||||
logoUrl = channel.logo,
|
||||
streamUrl = channel.url,
|
||||
isFavorite = channel.isFavorite
|
||||
)
|
||||
}
|
||||
|
||||
// Map ViewModel state to UI state
|
||||
val channelsUiState = when {
|
||||
uiState.isLoading && uiState.channels.isEmpty() -> ChannelsUiState.Loading
|
||||
uiState.errorMessage != null && uiState.channels.isEmpty() ->
|
||||
ChannelsUiState.Error(uiState.errorMessage ?: "Unknown error")
|
||||
else -> ChannelsUiState.Success(
|
||||
channels = uiChannels,
|
||||
categories = uiState.categories
|
||||
)
|
||||
}
|
||||
|
||||
ChannelsScreen(
|
||||
uiState = channelsUiState,
|
||||
selectedCategory = uiState.selectedCategory,
|
||||
searchQuery = uiState.searchQuery,
|
||||
onSearchQueryChange = { query ->
|
||||
viewModel.setSearchQuery(query)
|
||||
},
|
||||
onCategorySelect = { category ->
|
||||
viewModel.selectCategory(category)
|
||||
},
|
||||
onChannelClick = { channelUiModel ->
|
||||
// Find the full channel from the list
|
||||
val fullChannel = uiState.channels.find { it.id == channelUiModel.id }
|
||||
fullChannel?.let { onChannelClick(it) }
|
||||
},
|
||||
onFavoriteToggle = { channelUiModel ->
|
||||
viewModel.toggleFavorite(channelUiModel.id)
|
||||
},
|
||||
onRefresh = {
|
||||
viewModel.refreshChannels()
|
||||
}
|
||||
)
|
||||
}
|
||||
340
app/src/main/java/com/iptv/app/PlayerActivity.kt
Normal file
340
app/src/main/java/com/iptv/app/PlayerActivity.kt
Normal file
@@ -0,0 +1,340 @@
|
||||
package com.iptv.app
|
||||
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Rational
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.iptv.app.data.parser.M3UParser
|
||||
import com.iptv.app.data.repository.ChannelRepository
|
||||
import com.iptv.app.ui.components.PlayerError
|
||||
import com.iptv.app.ui.components.PlayerState
|
||||
import com.iptv.app.ui.components.VideoPlayer
|
||||
import com.iptv.app.ui.theme.IPTVAppTheme
|
||||
import com.iptv.app.ui.viewmodel.PlaybackState
|
||||
import com.iptv.app.ui.viewmodel.PlayerEvent
|
||||
import com.iptv.app.ui.viewmodel.PlayerUiState
|
||||
import com.iptv.app.ui.viewmodel.PlayerViewModel
|
||||
import com.iptv.app.ui.viewmodel.PlayerViewModelFactory
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Fullscreen player activity for IPTV channel playback.
|
||||
*
|
||||
* This activity receives channel data via Intent extras and displays the video
|
||||
* using ExoPlayer with custom controls. It supports picture-in-picture mode
|
||||
* and proper lifecycle management.
|
||||
*/
|
||||
class PlayerActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_CHANNEL_URL = "extra_channel_url"
|
||||
const val EXTRA_CHANNEL_NAME = "extra_channel_name"
|
||||
const val EXTRA_CHANNEL_ID = "extra_channel_id"
|
||||
const val EXTRA_CHANNEL_CATEGORY = "extra_channel_category"
|
||||
const val EXTRA_CHANNEL_LOGO = "extra_channel_logo"
|
||||
}
|
||||
|
||||
private lateinit var channelRepository: ChannelRepository
|
||||
|
||||
private val viewModel: PlayerViewModel by viewModels {
|
||||
PlayerViewModelFactory(channelRepository)
|
||||
}
|
||||
|
||||
private var isInPictureInPictureMode = false
|
||||
private var currentChannelUrl: String = ""
|
||||
private var currentChannelName: String = ""
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Extract channel data from intent
|
||||
currentChannelUrl = intent.getStringExtra(EXTRA_CHANNEL_URL) ?: ""
|
||||
currentChannelName = intent.getStringExtra(EXTRA_CHANNEL_NAME) ?: ""
|
||||
val channelId = intent.getStringExtra(EXTRA_CHANNEL_ID) ?: ""
|
||||
|
||||
// Initialize repository
|
||||
initializeRepository()
|
||||
|
||||
// Load channel into ViewModel
|
||||
if (channelId.isNotBlank()) {
|
||||
viewModel.onEvent(PlayerEvent.LoadChannel(channelId))
|
||||
}
|
||||
|
||||
// Setup fullscreen immersive mode
|
||||
setupFullscreen()
|
||||
|
||||
setContent {
|
||||
IPTVAppTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
PlayerScreenContent(
|
||||
viewModel = viewModel,
|
||||
channelUrl = currentChannelUrl,
|
||||
channelName = currentChannelName,
|
||||
onBackPressed = { finish() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect UI events
|
||||
collectUiEvents()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the ChannelRepository with required dependencies.
|
||||
*/
|
||||
private fun initializeRepository() {
|
||||
val sharedPreferences = getSharedPreferences("iptv_prefs", MODE_PRIVATE)
|
||||
val m3uParser = M3UParser()
|
||||
channelRepository = ChannelRepository(
|
||||
context = this,
|
||||
m3uParser = m3uParser,
|
||||
sharedPreferences = sharedPreferences
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up fullscreen immersive mode for video playback.
|
||||
*/
|
||||
private fun setupFullscreen() {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
window.attributes.layoutInDisplayCutoutMode =
|
||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
|
||||
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||
controller.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
|
||||
// Keep screen on during playback
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
// Force landscape for TV/video content
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects UI events from the ViewModel.
|
||||
*/
|
||||
private fun collectUiEvents() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.uiState.collect { state ->
|
||||
// Update picture-in-picture params if needed
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
updatePictureInPictureParams(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates picture-in-picture mode parameters based on current state.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun updatePictureInPictureParams(state: PlayerUiState) {
|
||||
val aspectRatio = Rational(16, 9)
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(aspectRatio)
|
||||
.build()
|
||||
setPictureInPictureParams(params)
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
// Enter picture-in-picture mode when user leaves the app
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
) {
|
||||
enterPictureInPictureMode(
|
||||
PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(16, 9))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
override fun onPictureInPictureModeChanged(
|
||||
isInPictureInPictureMode: Boolean,
|
||||
newConfig: Configuration
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
this.isInPictureInPictureMode = isInPictureInPictureMode
|
||||
|
||||
if (isInPictureInPictureMode) {
|
||||
// Hide controls in PiP mode
|
||||
viewModel.onEvent(PlayerEvent.ToggleControls)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_BACK -> {
|
||||
if (isInPictureInPictureMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
// Exit PiP mode instead of finishing
|
||||
moveTaskToBack(false)
|
||||
true
|
||||
} else {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
KeyEvent.KEYCODE_ENTER,
|
||||
KeyEvent.KEYCODE_SPACE -> {
|
||||
viewModel.onEvent(PlayerEvent.ToggleControls)
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP -> {
|
||||
viewModel.onEvent(PlayerEvent.PreviousChannel)
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||
viewModel.onEvent(PlayerEvent.NextChannel)
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_VOLUME_UP,
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> {
|
||||
// Let system handle volume
|
||||
super.onKeyDown(keyCode, event)
|
||||
}
|
||||
else -> super.onKeyDown(keyCode, event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
// Pause playback when not in PiP mode
|
||||
if (!isInPictureInPictureMode) {
|
||||
viewModel.onEvent(PlayerEvent.Pause)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Resume playback
|
||||
viewModel.onEvent(PlayerEvent.Play)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (!isInPictureInPictureMode) {
|
||||
viewModel.onEvent(PlayerEvent.Stop)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// Clean up window flags
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
super.finish()
|
||||
// Reset orientation when finishing
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Player screen content composable.
|
||||
*
|
||||
* @param viewModel The ViewModel managing player state
|
||||
* @param channelUrl The URL of the channel stream
|
||||
* @param channelName The name of the channel
|
||||
* @param onBackPressed Callback when back button is pressed
|
||||
*/
|
||||
@Composable
|
||||
private fun PlayerScreenContent(
|
||||
viewModel: PlayerViewModel,
|
||||
channelUrl: String,
|
||||
channelName: String,
|
||||
onBackPressed: () -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Track player state
|
||||
var playerState by remember { mutableStateOf<PlayerState>(PlayerState.Idle) }
|
||||
|
||||
// Handle errors
|
||||
DisposableEffect(uiState.errorMessage) {
|
||||
uiState.errorMessage?.let { error ->
|
||||
playerState = PlayerState.Error(
|
||||
PlayerError(
|
||||
message = error,
|
||||
recoverable = true
|
||||
)
|
||||
)
|
||||
}
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
// Video player with ExoPlayer
|
||||
VideoPlayer(
|
||||
streamUrl = channelUrl,
|
||||
channelName = channelName,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onError = { error ->
|
||||
playerState = PlayerState.Error(error)
|
||||
viewModel.reportError(error.message)
|
||||
},
|
||||
onPlayerReady = {
|
||||
playerState = PlayerState.Playing
|
||||
viewModel.updatePlaybackState(PlaybackState.Playing)
|
||||
},
|
||||
showControls = uiState.controlsVisible,
|
||||
onBackPressed = onBackPressed
|
||||
)
|
||||
|
||||
// Update ViewModel with player state changes
|
||||
DisposableEffect(playerState) {
|
||||
when (playerState) {
|
||||
is PlayerState.Playing -> viewModel.updatePlaybackState(PlaybackState.Playing)
|
||||
is PlayerState.Paused -> viewModel.updatePlaybackState(PlaybackState.Paused)
|
||||
is PlayerState.Loading -> viewModel.updatePlaybackState(PlaybackState.Loading)
|
||||
is PlayerState.Error -> {
|
||||
val error = (playerState as PlayerState.Error).error
|
||||
viewModel.reportError(error.message)
|
||||
}
|
||||
else -> { /* No action needed */ }
|
||||
}
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
97
app/src/main/java/com/iptv/app/data/model/Category.kt
Normal file
97
app/src/main/java/com/iptv/app/data/model/Category.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
package com.iptv.app.data.model
|
||||
|
||||
/**
|
||||
* Data class representing a channel category/group.
|
||||
* All properties are immutable to ensure thread safety.
|
||||
*
|
||||
* @property name The category name (from group-title attribute)
|
||||
* @property channelCount Number of channels in this category
|
||||
* @property channels List of channels belonging to this category
|
||||
*/
|
||||
data class Category(
|
||||
val name: String,
|
||||
val channelCount: Int = 0,
|
||||
val channels: List<Channel> = emptyList()
|
||||
) {
|
||||
/**
|
||||
* Creates a new category with an additional channel.
|
||||
* Returns a new instance - maintains immutability.
|
||||
*/
|
||||
fun addChannel(channel: Channel): Category {
|
||||
return this.copy(
|
||||
channels = this.channels + channel,
|
||||
channelCount = this.channels.size + 1
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new category with multiple channels added.
|
||||
* Returns a new instance - maintains immutability.
|
||||
*/
|
||||
fun addChannels(newChannels: List<Channel>): Category {
|
||||
return this.copy(
|
||||
channels = this.channels + newChannels,
|
||||
channelCount = this.channels.size + newChannels.size
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new category with a specific channel removed.
|
||||
* Returns a new instance - maintains immutability.
|
||||
*/
|
||||
fun removeChannel(channelId: String): Category {
|
||||
val updatedChannels = this.channels.filter { it.id != channelId }
|
||||
return this.copy(
|
||||
channels = updatedChannels,
|
||||
channelCount = updatedChannels.size
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new category with channels sorted by name.
|
||||
* Returns a new instance - maintains immutability.
|
||||
*/
|
||||
fun sortedByName(): Category {
|
||||
return this.copy(channels = this.channels.sortedBy { it.name })
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new category with favorite channels first.
|
||||
* Returns a new instance - maintains immutability.
|
||||
*/
|
||||
fun sortedByFavorite(): Category {
|
||||
return this.copy(channels = this.channels.sortedByDescending { it.isFavorite })
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Empty category instance for safe defaults.
|
||||
*/
|
||||
val EMPTY = Category(name = "")
|
||||
|
||||
/**
|
||||
* Groups a list of channels by their category.
|
||||
* Returns an immutable list of Category objects.
|
||||
*/
|
||||
fun groupChannelsByCategory(channels: List<Channel>): List<Category> {
|
||||
return channels
|
||||
.groupBy { it.category }
|
||||
.map { (categoryName, categoryChannels) ->
|
||||
Category(
|
||||
name = categoryName,
|
||||
channels = categoryChannels,
|
||||
channelCount = categoryChannels.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups channels by category and sorts categories by name.
|
||||
*/
|
||||
fun groupAndSortByName(channels: List<Channel>): List<Category> {
|
||||
return groupChannelsByCategory(channels)
|
||||
.sortedBy { it.name }
|
||||
.map { it.sortedByName() }
|
||||
}
|
||||
}
|
||||
}
|
||||
57
app/src/main/java/com/iptv/app/data/model/Channel.kt
Normal file
57
app/src/main/java/com/iptv/app/data/model/Channel.kt
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.iptv.app.data.model
|
||||
|
||||
/**
|
||||
* Data class representing an IPTV channel.
|
||||
* All properties are immutable to ensure thread safety and prevent accidental mutations.
|
||||
*
|
||||
* @property id Unique identifier for the channel (typically from tvg-id)
|
||||
* @property name Display name of the channel
|
||||
* @property url Streaming URL for the channel
|
||||
* @property category Category/group the channel belongs to (from group-title)
|
||||
* @property logo URL to the channel's logo image
|
||||
* @property language Primary language of the channel
|
||||
* @property country Country code or name for the channel
|
||||
* @property isFavorite Whether the channel is marked as favorite by the user
|
||||
*/
|
||||
data class Channel(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val url: String,
|
||||
val category: String,
|
||||
val logo: String? = null,
|
||||
val language: String? = null,
|
||||
val country: String? = null,
|
||||
val isFavorite: Boolean = false
|
||||
) {
|
||||
/**
|
||||
* Creates a copy of this channel with favorite status toggled.
|
||||
* Returns a new instance - maintains immutability.
|
||||
*/
|
||||
fun toggleFavorite(): Channel {
|
||||
return this.copy(isFavorite = !this.isFavorite)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a copy of this channel with updated favorite status.
|
||||
* Returns a new instance - maintains immutability.
|
||||
*/
|
||||
fun setFavorite(favorite: Boolean): Channel {
|
||||
return if (this.isFavorite == favorite) {
|
||||
this
|
||||
} else {
|
||||
this.copy(isFavorite = favorite)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Empty channel instance for safe defaults.
|
||||
*/
|
||||
val EMPTY = Channel(
|
||||
id = "",
|
||||
name = "",
|
||||
url = "",
|
||||
category = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
369
app/src/main/java/com/iptv/app/data/parser/M3UParser.kt
Normal file
369
app/src/main/java/com/iptv/app/data/parser/M3UParser.kt
Normal file
@@ -0,0 +1,369 @@
|
||||
package com.iptv.app.data.parser
|
||||
|
||||
import android.content.Context
|
||||
import com.iptv.app.data.model.Category
|
||||
import com.iptv.app.data.model.Channel
|
||||
import com.iptv.app.utils.DnsConfigurator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Sealed class representing the result of M3U parsing operations.
|
||||
* Used for proper error handling without exceptions.
|
||||
*/
|
||||
sealed class M3UParseResult {
|
||||
/**
|
||||
* Successful parse result containing channels and categories.
|
||||
*/
|
||||
data class Success(
|
||||
val channels: List<Channel>,
|
||||
val categories: List<Category>
|
||||
) : M3UParseResult()
|
||||
|
||||
/**
|
||||
* Error result containing error message and optional exception.
|
||||
*/
|
||||
data class Error(
|
||||
val message: String,
|
||||
val exception: Throwable? = null
|
||||
) : M3UParseResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parser for M3U playlist files from iptv-org format.
|
||||
*
|
||||
* Supports standard M3U format:
|
||||
* ```
|
||||
* #EXTM3U
|
||||
* #EXTINF:-1 tvg-id="Channel1" tvg-logo="http://logo.png" group-title="News",Channel Name
|
||||
* http://stream-url.com/stream.m3u8
|
||||
* ```
|
||||
*/
|
||||
class M3UParser(private val context: Context? = null) {
|
||||
|
||||
companion object {
|
||||
private const val EXT_M3U = "#EXTM3U"
|
||||
private const val EXT_INF = "#EXTINF:"
|
||||
private const val ATTRIBUTE_TVG_ID = "tvg-id"
|
||||
private const val ATTRIBUTE_TVG_LOGO = "tvg-logo"
|
||||
private const val ATTRIBUTE_GROUP_TITLE = "group-title"
|
||||
private const val ATTRIBUTE_TVG_LANGUAGE = "tvg-language"
|
||||
private const val ATTRIBUTE_TVG_COUNTRY = "tvg-country"
|
||||
private const val DEFAULT_CATEGORY = "Uncategorized"
|
||||
}
|
||||
|
||||
private val okHttpClient: OkHttpClient by lazy {
|
||||
context?.let {
|
||||
DnsConfigurator.createOkHttpClient(it)
|
||||
} ?: OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads and parses an M3U playlist from a URL using Google DNS.
|
||||
*
|
||||
* @param urlString The URL to download the M3U playlist from
|
||||
* @return M3UParseResult containing either Success with channels/categories or Error
|
||||
*/
|
||||
suspend fun parseFromUrl(urlString: String): M3UParseResult {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(urlString)
|
||||
.header("Accept", "application/vnd.apple.mpegurl, audio/mpegurl, text/plain, */*")
|
||||
.header("User-Agent", "IPTVApp/1.0")
|
||||
.build()
|
||||
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext M3UParseResult.Error(
|
||||
message = "HTTP error: ${response.code}"
|
||||
)
|
||||
}
|
||||
|
||||
val content = response.body?.string()
|
||||
?: return@withContext M3UParseResult.Error(
|
||||
message = "Empty response body"
|
||||
)
|
||||
|
||||
parseContent(content)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
M3UParseResult.Error(
|
||||
message = "Network error: ${e.message}",
|
||||
exception = e
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
M3UParseResult.Error(
|
||||
message = "Security error - check internet permission: ${e.message}",
|
||||
exception = e
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
M3UParseResult.Error(
|
||||
message = "Unexpected error: ${e.message}",
|
||||
exception = e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses M3U content from a string.
|
||||
* Alias for parseContent for backward compatibility.
|
||||
*
|
||||
* @param content The M3U playlist content as a string
|
||||
* @return List of Channel objects
|
||||
*/
|
||||
fun parseFromString(content: String): List<Channel> {
|
||||
return when (val result = parseContent(content)) {
|
||||
is M3UParseResult.Success -> result.channels
|
||||
is M3UParseResult.Error -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a list of channels to M3U format string.
|
||||
*
|
||||
* @param channels List of channels to serialize
|
||||
* @return M3U format string
|
||||
*/
|
||||
fun serializeToString(channels: List<Channel>): String {
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine(EXT_M3U)
|
||||
sb.appendLine()
|
||||
|
||||
channels.forEach { channel ->
|
||||
sb.append(EXT_INF)
|
||||
sb.append("-1 ")
|
||||
|
||||
val attributes = mutableListOf<String>()
|
||||
if (channel.id.isNotBlank()) {
|
||||
attributes.add("$ATTRIBUTE_TVG_ID=\"${channel.id}\"")
|
||||
}
|
||||
if (!channel.logo.isNullOrBlank()) {
|
||||
attributes.add("$ATTRIBUTE_TVG_LOGO=\"${channel.logo}\"")
|
||||
}
|
||||
if (channel.category.isNotBlank()) {
|
||||
attributes.add("$ATTRIBUTE_GROUP_TITLE=\"${channel.category}\"")
|
||||
}
|
||||
if (!channel.language.isNullOrBlank()) {
|
||||
attributes.add("$ATTRIBUTE_TVG_LANGUAGE=\"${channel.language}\"")
|
||||
}
|
||||
if (!channel.country.isNullOrBlank()) {
|
||||
attributes.add("$ATTRIBUTE_TVG_COUNTRY=\"${channel.country}\"")
|
||||
}
|
||||
|
||||
if (attributes.isNotEmpty()) {
|
||||
sb.append(attributes.joinToString(" "))
|
||||
}
|
||||
|
||||
sb.append(",")
|
||||
sb.appendLine(channel.name)
|
||||
sb.appendLine(channel.url)
|
||||
}
|
||||
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses M3U content from a string.
|
||||
*
|
||||
* @param content The M3U playlist content as a string
|
||||
* @return M3UParseResult containing either Success with channels/categories or Error
|
||||
*/
|
||||
fun parseContent(content: String): M3UParseResult {
|
||||
if (content.isBlank()) {
|
||||
return M3UParseResult.Error(message = "Empty content")
|
||||
}
|
||||
|
||||
val lines = content.lines()
|
||||
if (lines.isEmpty()) {
|
||||
return M3UParseResult.Error(message = "No content to parse")
|
||||
}
|
||||
|
||||
if (!lines[0].trim().startsWith(EXT_M3U)) {
|
||||
return M3UParseResult.Error(message = "Invalid M3U format - missing #EXTM3U header")
|
||||
}
|
||||
|
||||
val channels = mutableListOf<Channel>()
|
||||
var currentChannel: ChannelBuilder? = null
|
||||
|
||||
for (line in lines) {
|
||||
val trimmedLine = line.trim()
|
||||
|
||||
when {
|
||||
trimmedLine.isEmpty() -> continue
|
||||
trimmedLine.startsWith(EXT_M3U) -> continue
|
||||
trimmedLine.startsWith(EXT_INF) -> {
|
||||
currentChannel?.let {
|
||||
channels.add(it.build())
|
||||
}
|
||||
currentChannel = parseExtInfLine(trimmedLine)
|
||||
}
|
||||
!trimmedLine.startsWith("#") -> {
|
||||
currentChannel?.let {
|
||||
it.url = trimmedLine
|
||||
channels.add(it.build())
|
||||
currentChannel = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentChannel?.let {
|
||||
if (it.url.isNotBlank()) {
|
||||
channels.add(it.build())
|
||||
}
|
||||
}
|
||||
|
||||
if (channels.isEmpty()) {
|
||||
return M3UParseResult.Error(message = "No channels found in playlist")
|
||||
}
|
||||
|
||||
val categories = Category.groupChannelsByCategory(channels)
|
||||
|
||||
return M3UParseResult.Success(
|
||||
channels = channels.toList(),
|
||||
categories = categories.toList()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an #EXTINF line to extract channel metadata.
|
||||
*
|
||||
* Format: #EXTINF:duration attributes,name
|
||||
*/
|
||||
private fun parseExtInfLine(line: String): ChannelBuilder {
|
||||
val builder = ChannelBuilder()
|
||||
|
||||
val contentStart = line.indexOf(':')
|
||||
if (contentStart == -1) {
|
||||
return builder
|
||||
}
|
||||
|
||||
val content = line.substring(contentStart + 1)
|
||||
|
||||
val commaIndex = content.lastIndexOf(',')
|
||||
val attributesPart = if (commaIndex != -1) {
|
||||
content.substring(0, commaIndex)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
val namePart = if (commaIndex != -1) {
|
||||
content.substring(commaIndex + 1)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
builder.name = namePart.trim()
|
||||
|
||||
val attributes = parseAttributes(attributesPart)
|
||||
builder.id = attributes[ATTRIBUTE_TVG_ID] ?: ""
|
||||
builder.logo = attributes[ATTRIBUTE_TVG_LOGO]
|
||||
builder.category = attributes[ATTRIBUTE_GROUP_TITLE] ?: DEFAULT_CATEGORY
|
||||
builder.language = attributes[ATTRIBUTE_TVG_LANGUAGE]
|
||||
builder.country = attributes[ATTRIBUTE_TVG_COUNTRY]
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses attribute key-value pairs from a string.
|
||||
*
|
||||
* Format: attr1="value1" attr2="value2"
|
||||
*/
|
||||
private fun parseAttributes(attributesString: String): Map<String, String> {
|
||||
val attributes = mutableMapOf<String, String>()
|
||||
val regex = """(\w+)="([^"]*)",""".toRegex()
|
||||
|
||||
regex.findAll(attributesString).forEach { matchResult ->
|
||||
val key = matchResult.groupValues[1]
|
||||
val value = matchResult.groupValues[2]
|
||||
attributes[key] = value
|
||||
}
|
||||
|
||||
return attributes.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder class for constructing Channel instances.
|
||||
* Used internally during parsing.
|
||||
*/
|
||||
private class ChannelBuilder {
|
||||
var id: String = ""
|
||||
var name: String = ""
|
||||
var url: String = ""
|
||||
var category: String = DEFAULT_CATEGORY
|
||||
var logo: String? = null
|
||||
var language: String? = null
|
||||
var country: String? = null
|
||||
|
||||
fun build(): Channel {
|
||||
val finalId = id.ifBlank { generateId() }
|
||||
return Channel(
|
||||
id = finalId,
|
||||
name = name.ifBlank { "Unknown Channel" },
|
||||
url = url,
|
||||
category = category,
|
||||
logo = logo,
|
||||
language = language,
|
||||
country = country,
|
||||
isFavorite = false
|
||||
)
|
||||
}
|
||||
|
||||
private fun generateId(): String {
|
||||
return "${name}_${url.hashCode()}".hashCode().toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to check if parse result is successful.
|
||||
*/
|
||||
fun M3UParseResult.isSuccess(): Boolean = this is M3UParseResult.Success
|
||||
|
||||
/**
|
||||
* Extension function to check if parse result is an error.
|
||||
*/
|
||||
fun M3UParseResult.isError(): Boolean = this is M3UParseResult.Error
|
||||
|
||||
/**
|
||||
* Extension function to get channels from successful result.
|
||||
* Returns empty list if result is error.
|
||||
*/
|
||||
fun M3UParseResult.getChannelsOrEmpty(): List<Channel> {
|
||||
return when (this) {
|
||||
is M3UParseResult.Success -> channels
|
||||
is M3UParseResult.Error -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to get categories from successful result.
|
||||
* Returns empty list if result is error.
|
||||
*/
|
||||
fun M3UParseResult.getCategoriesOrEmpty(): List<Category> {
|
||||
return when (this) {
|
||||
is M3UParseResult.Success -> categories
|
||||
is M3UParseResult.Error -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to get error message from error result.
|
||||
* Returns null if result is success.
|
||||
*/
|
||||
fun M3UParseResult.getErrorMessage(): String? {
|
||||
return when (this) {
|
||||
is M3UParseResult.Success -> null
|
||||
is M3UParseResult.Error -> message
|
||||
}
|
||||
}
|
||||
229
app/src/main/java/com/iptv/app/data/remote/UpdateService.kt
Normal file
229
app/src/main/java/com/iptv/app/data/remote/UpdateService.kt
Normal file
@@ -0,0 +1,229 @@
|
||||
package com.iptv.app.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.iptv.app.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Servicio para verificar y descargar actualizaciones de la app desde Gitea.
|
||||
*/
|
||||
class UpdateService(context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "UpdateService"
|
||||
private const val GITEA_API_URL = "https://gitea.cbcren.online/api/v1"
|
||||
private const val REPO_OWNER = "renato97"
|
||||
private const val REPO_NAME = "iptv-app"
|
||||
|
||||
// Endpoints
|
||||
private const val LATEST_RELEASE_ENDPOINT = "$GITEA_API_URL/repos/$REPO_OWNER/$REPO_NAME/releases/latest"
|
||||
|
||||
// SharedPreferences keys
|
||||
private const val PREFS_NAME = "update_prefs"
|
||||
private const val KEY_IGNORED_VERSION = "ignored_version"
|
||||
private const val KEY_LAST_CHECK = "last_check"
|
||||
}
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Información de una actualización disponible.
|
||||
*/
|
||||
data class UpdateInfo(
|
||||
val version: String,
|
||||
val versionCode: Int,
|
||||
val downloadUrl: String,
|
||||
val changelog: String,
|
||||
val fileName: String,
|
||||
val fileSize: Long,
|
||||
val isMandatory: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Resultado de la verificación de actualizaciones.
|
||||
*/
|
||||
sealed class UpdateResult {
|
||||
data class UpdateAvailable(val updateInfo: UpdateInfo) : UpdateResult()
|
||||
data object NoUpdate : UpdateResult()
|
||||
data class Error(val message: String) : UpdateResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si hay una actualización disponible.
|
||||
*/
|
||||
suspend fun checkForUpdate(): UpdateResult {
|
||||
return try {
|
||||
val request = Request.Builder()
|
||||
.url(LATEST_RELEASE_ENDPOINT)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return UpdateResult.Error("Error del servidor: ${response.code}")
|
||||
}
|
||||
|
||||
val body = response.body?.string()
|
||||
?: return UpdateResult.Error("Respuesta vacía del servidor")
|
||||
|
||||
val json = JSONObject(body)
|
||||
val tagName = json.getString("tag_name")
|
||||
val releaseVersionCode = extractVersionCode(tagName)
|
||||
|
||||
// Comparar versiones
|
||||
if (releaseVersionCode > BuildConfig.VERSION_CODE) {
|
||||
// Verificar si el usuario ignoró esta versión
|
||||
if (isVersionIgnored(tagName)) {
|
||||
return UpdateResult.NoUpdate
|
||||
}
|
||||
|
||||
// Extraer información del APK adjunto
|
||||
val assets = json.getJSONArray("assets")
|
||||
if (assets.length() > 0) {
|
||||
val apkAsset = assets.getJSONObject(0)
|
||||
val updateInfo = UpdateInfo(
|
||||
version = tagName,
|
||||
versionCode = releaseVersionCode,
|
||||
downloadUrl = apkAsset.getString("browser_download_url"),
|
||||
changelog = json.optString("body", "Sin notas de versión"),
|
||||
fileName = apkAsset.getString("name"),
|
||||
fileSize = apkAsset.getLong("size"),
|
||||
isMandatory = json.optBoolean("prerelease", false).not()
|
||||
)
|
||||
|
||||
UpdateResult.UpdateAvailable(updateInfo)
|
||||
} else {
|
||||
UpdateResult.Error("No se encontró APK en el release")
|
||||
}
|
||||
} else {
|
||||
UpdateResult.NoUpdate
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error al verificar actualizaciones", e)
|
||||
UpdateResult.Error("Error de conexión: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga el APK de la actualización.
|
||||
*/
|
||||
fun downloadUpdate(updateInfo: UpdateInfo, outputDir: File): Flow<DownloadProgress> = flow {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(updateInfo.downloadUrl)
|
||||
.build()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
emit(DownloadProgress.Error("Error de descarga: ${response.code}"))
|
||||
return@use
|
||||
}
|
||||
|
||||
val body = response.body ?: run {
|
||||
emit(DownloadProgress.Error("Respuesta vacía"))
|
||||
return@use
|
||||
}
|
||||
|
||||
val outputFile = File(outputDir, updateInfo.fileName)
|
||||
val totalBytes = body.contentLength()
|
||||
|
||||
body.byteStream().use { input ->
|
||||
FileOutputStream(outputFile).use { output ->
|
||||
val buffer = ByteArray(8192)
|
||||
var downloadedBytes = 0L
|
||||
var bytesRead: Int
|
||||
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
output.write(buffer, 0, bytesRead)
|
||||
downloadedBytes += bytesRead
|
||||
|
||||
val progress = if (totalBytes > 0) {
|
||||
(downloadedBytes * 100 / totalBytes).toInt()
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
|
||||
emit(DownloadProgress.Progress(progress, downloadedBytes, totalBytes))
|
||||
}
|
||||
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
|
||||
emit(DownloadProgress.Success(outputFile))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error al descargar actualización", e)
|
||||
emit(DownloadProgress.Error("Error: ${e.message}"))
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Progreso de descarga.
|
||||
*/
|
||||
sealed class DownloadProgress {
|
||||
data class Progress(val percentage: Int, val downloadedBytes: Long, val totalBytes: Long) : DownloadProgress()
|
||||
data class Success(val file: File) : DownloadProgress()
|
||||
data class Error(val message: String) : DownloadProgress()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignora una versión específica (no mostrará notificación para esta versión).
|
||||
*/
|
||||
fun ignoreVersion(version: String) {
|
||||
prefs.edit().putString(KEY_IGNORED_VERSION, version).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si una versión está ignorada.
|
||||
*/
|
||||
private fun isVersionIgnored(version: String): Boolean {
|
||||
return prefs.getString(KEY_IGNORED_VERSION, null) == version
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la última vez que se verificaron actualizaciones.
|
||||
*/
|
||||
fun getLastCheckTime(): Long {
|
||||
return prefs.getLong(KEY_LAST_CHECK, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza el tiempo de última verificación.
|
||||
*/
|
||||
fun setLastCheckTime(time: Long = System.currentTimeMillis()) {
|
||||
prefs.edit().putLong(KEY_LAST_CHECK, time).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el código de versión del tag (ej: "v1.2.3" -> 10203).
|
||||
*/
|
||||
private fun extractVersionCode(tagName: String): Int {
|
||||
val cleaned = tagName.removePrefix("v").removePrefix("V")
|
||||
val parts = cleaned.split(".")
|
||||
|
||||
return if (parts.size >= 3) {
|
||||
val major = parts[0].toIntOrNull() ?: 0
|
||||
val minor = parts[1].toIntOrNull() ?: 0
|
||||
val patch = parts[2].toIntOrNull() ?: 0
|
||||
major * 10000 + minor * 100 + patch
|
||||
} else {
|
||||
cleaned.replace(".", "").toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package com.iptv.app.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.iptv.app.data.model.Channel
|
||||
import com.iptv.app.data.parser.M3UParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Repository for managing IPTV channels.
|
||||
* Handles fetching, caching, filtering, and searching of channels.
|
||||
*/
|
||||
class ChannelRepository(
|
||||
private val context: Context,
|
||||
private val m3uParser: M3UParser,
|
||||
private val sharedPreferences: SharedPreferences
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val CACHE_KEY_CHANNELS = "cached_channels"
|
||||
private const val CACHE_KEY_TIMESTAMP = "cache_timestamp"
|
||||
private const val CACHE_DURATION_MS = 24 * 60 * 60 * 1000L // 24 hours
|
||||
private const val FAVORITES_KEY = "favorite_channels"
|
||||
private const val SEARCH_DEBOUNCE_MS = 300L
|
||||
}
|
||||
|
||||
private val _allChannels = MutableStateFlow<List<Channel>>(emptyList())
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
|
||||
val allChannels: StateFlow<List<Channel>> = _allChannels.asStateFlow()
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
/**
|
||||
* Fetches channels from the M3U URL with caching support.
|
||||
* Returns a Flow that emits the list of channels.
|
||||
*/
|
||||
fun fetchChannels(m3uUrl: String, forceRefresh: Boolean = false): Flow<List<Channel>> = flow {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
|
||||
try {
|
||||
val channels = if (!forceRefresh && isCacheValid()) {
|
||||
// Load from cache
|
||||
loadChannelsFromCache()
|
||||
} else {
|
||||
// Fetch from network
|
||||
val fetchedChannels = fetchFromNetwork(m3uUrl)
|
||||
saveChannelsToCache(fetchedChannels)
|
||||
fetchedChannels
|
||||
}
|
||||
|
||||
_allChannels.value = channels
|
||||
emit(channels)
|
||||
} catch (e: IOException) {
|
||||
_error.value = "Network error: ${e.message}"
|
||||
// Try to load from cache as fallback
|
||||
val cachedChannels = loadChannelsFromCache()
|
||||
if (cachedChannels.isNotEmpty()) {
|
||||
_allChannels.value = cachedChannels
|
||||
emit(cachedChannels)
|
||||
} else {
|
||||
emit(emptyList())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Error loading channels: ${e.message}"
|
||||
emit(emptyList())
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Returns a Flow of all available categories from the channels.
|
||||
*/
|
||||
fun getCategories(): Flow<List<String>> = _allChannels
|
||||
.map { channels ->
|
||||
channels
|
||||
.map { it.category }
|
||||
.distinct()
|
||||
.filter { it.isNotBlank() }
|
||||
.sorted()
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* Returns channels filtered by category.
|
||||
*/
|
||||
fun getChannelsByCategory(category: String): Flow<List<Channel>> = _allChannels
|
||||
.map { channels ->
|
||||
channels.filter { it.category.equals(category, ignoreCase = true) }
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* Searches channels by name with debouncing.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
fun searchChannels(query: String): Flow<List<Channel>> = flow {
|
||||
emit(query)
|
||||
}
|
||||
.debounce(SEARCH_DEBOUNCE_MS)
|
||||
.combine(_allChannels) { searchQuery, channels ->
|
||||
if (searchQuery.isBlank()) {
|
||||
channels
|
||||
} else {
|
||||
channels.filter { channel ->
|
||||
channel.name.contains(searchQuery, ignoreCase = true) ||
|
||||
channel.category.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* Gets a single channel by its ID.
|
||||
*/
|
||||
fun getChannelById(channelId: String): Flow<Channel?> = _allChannels
|
||||
.map { channels ->
|
||||
channels.find { it.id == channelId }
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* Returns a Flow of favorite channels.
|
||||
*/
|
||||
fun getFavoriteChannels(): Flow<List<Channel>> = _allChannels
|
||||
.map { channels ->
|
||||
val favoriteIds = getFavoriteIds()
|
||||
channels.filter { it.id in favoriteIds }
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* Adds a channel to favorites.
|
||||
*/
|
||||
suspend fun addToFavorites(channelId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val favorites = getFavoriteIds().toMutableSet()
|
||||
favorites.add(channelId)
|
||||
saveFavoriteIds(favorites)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a channel from favorites.
|
||||
*/
|
||||
suspend fun removeFromFavorites(channelId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val favorites = getFavoriteIds().toMutableSet()
|
||||
favorites.remove(channelId)
|
||||
saveFavoriteIds(favorites)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a channel is in favorites.
|
||||
*/
|
||||
fun isFavorite(channelId: String): Flow<Boolean> = flow {
|
||||
val favorites = getFavoriteIds()
|
||||
emit(channelId in favorites)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
/**
|
||||
* Toggles favorite status for a channel.
|
||||
*/
|
||||
suspend fun toggleFavorite(channelId: String): Boolean {
|
||||
val favorites = getFavoriteIds().toMutableSet()
|
||||
val isNowFavorite = if (channelId in favorites) {
|
||||
favorites.remove(channelId)
|
||||
false
|
||||
} else {
|
||||
favorites.add(channelId)
|
||||
true
|
||||
}
|
||||
saveFavoriteIds(favorites)
|
||||
return isNowFavorite
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the channel cache.
|
||||
*/
|
||||
suspend fun clearCache() {
|
||||
withContext(Dispatchers.IO) {
|
||||
sharedPreferences.edit()
|
||||
.remove(CACHE_KEY_CHANNELS)
|
||||
.remove(CACHE_KEY_TIMESTAMP)
|
||||
.apply()
|
||||
_allChannels.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes channels from network.
|
||||
*/
|
||||
suspend fun refreshChannels(m3uUrl: String): Result<List<Channel>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
|
||||
val channels = fetchFromNetwork(m3uUrl)
|
||||
saveChannelsToCache(channels)
|
||||
_allChannels.value = channels
|
||||
|
||||
Result.success(channels)
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Failed to refresh: ${e.message}"
|
||||
Result.failure(e)
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchFromNetwork(m3uUrl: String): List<Channel> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
m3uParser.parseFromUrl(m3uUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCacheValid(): Boolean {
|
||||
val timestamp = sharedPreferences.getLong(CACHE_KEY_TIMESTAMP, 0)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
return currentTime - timestamp < CACHE_DURATION_MS
|
||||
}
|
||||
|
||||
private fun loadChannelsFromCache(): List<Channel> {
|
||||
val channelsJson = sharedPreferences.getString(CACHE_KEY_CHANNELS, null)
|
||||
return if (channelsJson != null) {
|
||||
try {
|
||||
m3uParser.parseFromString(channelsJson)
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveChannelsToCache(channels: List<Channel>) {
|
||||
val channelsJson = m3uParser.serializeToString(channels)
|
||||
sharedPreferences.edit()
|
||||
.putString(CACHE_KEY_CHANNELS, channelsJson)
|
||||
.putLong(CACHE_KEY_TIMESTAMP, System.currentTimeMillis())
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun getFavoriteIds(): Set<String> {
|
||||
return sharedPreferences.getStringSet(FAVORITES_KEY, emptySet()) ?: emptySet()
|
||||
}
|
||||
|
||||
private fun saveFavoriteIds(favorites: Set<String>) {
|
||||
sharedPreferences.edit()
|
||||
.putStringSet(FAVORITES_KEY, favorites)
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class representing the result of a repository operation.
|
||||
*/
|
||||
sealed class RepositoryResult<out T> {
|
||||
data class Success<T>(val data: T) : RepositoryResult<T>()
|
||||
data class Error(val message: String, val exception: Throwable? = null) : RepositoryResult<Nothing>()
|
||||
data object Loading : RepositoryResult<Nothing>()
|
||||
}
|
||||
111
app/src/main/java/com/iptv/app/ui/components/CategoryChip.kt
Normal file
111
app/src/main/java/com/iptv/app/ui/components/CategoryChip.kt
Normal file
@@ -0,0 +1,111 @@
|
||||
package com.iptv.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.iptv.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun CategoryChip(
|
||||
category: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val categoryColor = getCategoryColor(category)
|
||||
|
||||
FilterChip(
|
||||
selected = isSelected,
|
||||
onClick = onClick,
|
||||
label = {
|
||||
Text(
|
||||
text = category,
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
},
|
||||
modifier = modifier.padding(horizontal = 4.dp),
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = categoryColor.copy(alpha = 0.2f),
|
||||
selectedLabelColor = categoryColor,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
labelColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
border = FilterChipDefaults.filterChipBorder(
|
||||
enabled = true,
|
||||
selected = isSelected,
|
||||
borderColor = if (isSelected) categoryColor else MaterialTheme.colorScheme.outline,
|
||||
selectedBorderColor = categoryColor,
|
||||
borderWidth = 1.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCategoryColor(category: String): Color {
|
||||
return when (category.lowercase()) {
|
||||
"entertainment" -> CategoryEntertainment
|
||||
"sports" -> CategorySports
|
||||
"news" -> CategoryNews
|
||||
"movies" -> CategoryMovies
|
||||
"kids" -> CategoryKids
|
||||
"music" -> CategoryMusic
|
||||
"documentary" -> CategoryDocumentary
|
||||
else -> PrimaryLight
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun CategoryChipPreview() {
|
||||
IPTVAppTheme {
|
||||
CategoryChip(
|
||||
category = "Sports",
|
||||
isSelected = false,
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun CategoryChipSelectedPreview() {
|
||||
IPTVAppTheme {
|
||||
CategoryChip(
|
||||
category = "Sports",
|
||||
isSelected = true,
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun CategoryChipAllCategoriesPreview() {
|
||||
IPTVAppTheme {
|
||||
val categories = listOf(
|
||||
"All",
|
||||
"Entertainment",
|
||||
"Sports",
|
||||
"News",
|
||||
"Movies",
|
||||
"Kids",
|
||||
"Music",
|
||||
"Documentary"
|
||||
)
|
||||
androidx.compose.foundation.layout.Row {
|
||||
categories.forEachIndexed { index, category ->
|
||||
CategoryChip(
|
||||
category = category,
|
||||
isSelected = index == 1,
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
242
app/src/main/java/com/iptv/app/ui/components/ChannelCard.kt
Normal file
242
app/src/main/java/com/iptv/app/ui/components/ChannelCard.kt
Normal file
@@ -0,0 +1,242 @@
|
||||
package com.iptv.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.iptv.app.ui.theme.*
|
||||
|
||||
data class Channel(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val category: String,
|
||||
val logoUrl: String?,
|
||||
val streamUrl: String,
|
||||
val isFavorite: Boolean = false
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ChannelCard(
|
||||
channel: Channel,
|
||||
onClick: () -> Unit,
|
||||
onFavoriteClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val categoryColor = getCategoryColor(channel.category)
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = 2.dp,
|
||||
pressedElevation = 4.dp
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// Logo container
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (channel.logoUrl != null) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(channel.logoUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "${channel.name} logo",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
} else {
|
||||
// Placeholder with first letter
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(categoryColor.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = channel.name.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = categoryColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Favorite button
|
||||
IconButton(
|
||||
onClick = onFavoriteClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (channel.isFavorite) {
|
||||
Icons.Filled.Favorite
|
||||
} else {
|
||||
Icons.Outlined.FavoriteBorder
|
||||
},
|
||||
contentDescription = if (channel.isFavorite) {
|
||||
"Remove from favorites"
|
||||
} else {
|
||||
"Add to favorites"
|
||||
},
|
||||
tint = if (channel.isFavorite) FavoriteColor else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Live indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(LiveIndicator)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "LIVE",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Channel info
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = channel.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Category chip
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(categoryColor.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = channel.category,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = categoryColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCategoryColor(category: String): Color {
|
||||
return when (category.lowercase()) {
|
||||
"entertainment" -> CategoryEntertainment
|
||||
"sports" -> CategorySports
|
||||
"news" -> CategoryNews
|
||||
"movies" -> CategoryMovies
|
||||
"kids" -> CategoryKids
|
||||
"music" -> CategoryMusic
|
||||
"documentary" -> CategoryDocumentary
|
||||
else -> PrimaryLight
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelCardPreview() {
|
||||
IPTVAppTheme {
|
||||
ChannelCard(
|
||||
channel = Channel(
|
||||
id = "1",
|
||||
name = "ESPN",
|
||||
category = "Sports",
|
||||
logoUrl = null,
|
||||
streamUrl = "",
|
||||
isFavorite = false
|
||||
),
|
||||
onClick = {},
|
||||
onFavoriteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelCardFavoritePreview() {
|
||||
IPTVAppTheme {
|
||||
ChannelCard(
|
||||
channel = Channel(
|
||||
id = "2",
|
||||
name = "HBO",
|
||||
category = "Movies",
|
||||
logoUrl = null,
|
||||
streamUrl = "",
|
||||
isFavorite = true
|
||||
),
|
||||
onClick = {},
|
||||
onFavoriteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelCardLongNamePreview() {
|
||||
IPTVAppTheme {
|
||||
ChannelCard(
|
||||
channel = Channel(
|
||||
id = "3",
|
||||
name = "National Geographic Channel HD",
|
||||
category = "Documentary",
|
||||
logoUrl = null,
|
||||
streamUrl = "",
|
||||
isFavorite = false
|
||||
),
|
||||
onClick = {},
|
||||
onFavoriteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
270
app/src/main/java/com/iptv/app/ui/components/PlayerControls.kt
Normal file
270
app/src/main/java/com/iptv/app/ui/components/PlayerControls.kt
Normal file
@@ -0,0 +1,270 @@
|
||||
package com.iptv.app.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Duration in milliseconds before controls auto-hide after inactivity
|
||||
*/
|
||||
private const val CONTROLS_AUTO_HIDE_DELAY_MS = 3000L
|
||||
|
||||
/**
|
||||
* PlayerControls composable that displays an overlay with playback controls.
|
||||
* Controls auto-hide after a period of inactivity.
|
||||
*
|
||||
* @param isPlaying Whether the player is currently playing
|
||||
* @param channelName The name of the channel to display
|
||||
* @param isVisible Whether the controls are currently visible
|
||||
* @param onVisibilityChange Callback when visibility changes
|
||||
* @param onPlayPauseClick Callback when play/pause button is clicked
|
||||
* @param onBackClick Callback when back button is clicked
|
||||
* @param modifier Modifier for the composable
|
||||
*/
|
||||
@Composable
|
||||
fun PlayerControls(
|
||||
isPlaying: Boolean,
|
||||
channelName: String,
|
||||
isVisible: Boolean,
|
||||
onVisibilityChange: (Boolean) -> Unit,
|
||||
onPlayPauseClick: () -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var autoHideEnabled by remember { mutableStateOf(true) }
|
||||
|
||||
// Auto-hide controls after inactivity
|
||||
LaunchedEffect(isVisible, autoHideEnabled) {
|
||||
if (isVisible && autoHideEnabled) {
|
||||
delay(CONTROLS_AUTO_HIDE_DELAY_MS)
|
||||
onVisibilityChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset auto-hide timer on visibility change
|
||||
LaunchedEffect(isVisible) {
|
||||
if (isVisible) {
|
||||
autoHideEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
// Toggle controls visibility on tap
|
||||
onVisibilityChange(!isVisible)
|
||||
}
|
||||
) {
|
||||
// Top gradient bar with back button and channel name
|
||||
TopControlBar(
|
||||
channelName = channelName,
|
||||
onBackClick = {
|
||||
autoHideEnabled = false
|
||||
onBackClick()
|
||||
},
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
|
||||
// Center play/pause button
|
||||
CenterPlayPauseButton(
|
||||
isPlaying = isPlaying,
|
||||
onClick = {
|
||||
autoHideEnabled = false
|
||||
onPlayPauseClick()
|
||||
// Re-enable auto-hide after interaction
|
||||
autoHideEnabled = true
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
|
||||
// Bottom gradient bar (can be extended for additional controls)
|
||||
BottomControlBar(
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Top control bar with back button and channel name
|
||||
*/
|
||||
@Composable
|
||||
private fun TopControlBar(
|
||||
channelName: String,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.7f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onBackClick,
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = "Now Playing",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
Text(
|
||||
text = channelName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Center play/pause button
|
||||
*/
|
||||
@Composable
|
||||
private fun CenterPlayPauseButton(
|
||||
isPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.size(80.dp)
|
||||
.background(
|
||||
color = Color.Black.copy(alpha = 0.5f),
|
||||
shape = androidx.compose.foundation.shape.CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom control bar (placeholder for future controls like volume, settings, etc.)
|
||||
*/
|
||||
@Composable
|
||||
private fun BottomControlBar(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 24.dp)
|
||||
) {
|
||||
// Placeholder for future controls like:
|
||||
// - Volume slider
|
||||
// - Quality selector
|
||||
// - Audio track selector
|
||||
// - Subtitle toggle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview/PreviewParameter provider for PlayerControls
|
||||
*/
|
||||
@Composable
|
||||
fun PlayerControlsPreview() {
|
||||
var isVisible by remember { mutableStateOf(true) }
|
||||
var isPlaying by remember { mutableStateOf(true) }
|
||||
|
||||
MaterialTheme {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
PlayerControls(
|
||||
isPlaying = isPlaying,
|
||||
channelName = "Test Channel HD",
|
||||
isVisible = isVisible,
|
||||
onVisibilityChange = { isVisible = it },
|
||||
onPlayPauseClick = { isPlaying = !isPlaying },
|
||||
onBackClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
237
app/src/main/java/com/iptv/app/ui/components/UpdateDialog.kt
Normal file
237
app/src/main/java/com/iptv/app/ui/components/UpdateDialog.kt
Normal file
@@ -0,0 +1,237 @@
|
||||
package com.iptv.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.iptv.app.data.remote.UpdateService
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Diálogo de actualización disponible.
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateDialog(
|
||||
updateInfo: UpdateService.UpdateInfo,
|
||||
onDismiss: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
onIgnore: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
text = "Nueva versión disponible",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 400.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Versión ${updateInfo.version}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Tamaño del archivo
|
||||
Text(
|
||||
text = "Tamaño: ${formatFileSize(updateInfo.fileSize)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Changelog
|
||||
Text(
|
||||
text = "Novedades:",
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 200.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = updateInfo.changelog,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
)
|
||||
}
|
||||
|
||||
if (updateInfo.isMandatory) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Esta actualización es obligatoria.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onDownload) {
|
||||
Text("Actualizar")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (!updateInfo.isMandatory) {
|
||||
TextButton(onClick = onIgnore) {
|
||||
Text("Ignorar")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Diálogo de progreso de descarga.
|
||||
*/
|
||||
@Composable
|
||||
fun DownloadProgressDialog(
|
||||
progress: UpdateService.DownloadProgress.Progress,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { },
|
||||
title = {
|
||||
Text("Descargando actualización...")
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.percentage / 100f },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "${progress.percentage}%",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "${formatFileSize(progress.downloadedBytes)} / ${formatFileSize(progress.totalBytes)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { },
|
||||
dismissButton = {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text("Cancelar")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Diálogo de instalación lista.
|
||||
*/
|
||||
@Composable
|
||||
fun InstallReadyDialog(
|
||||
fileName: String,
|
||||
onInstall: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text("Descarga completada")
|
||||
},
|
||||
text = {
|
||||
Text("La actualización se ha descargado correctamente. ¿Deseas instalarla ahora?")
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onInstall) {
|
||||
Text("Instalar")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Después")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snackbar para mostrar resultado de la actualización.
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateSnackbarHost(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea el tamaño de archivo a formato legible.
|
||||
*/
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
return when {
|
||||
bytes >= 1024 * 1024 * 1024 -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0))
|
||||
bytes >= 1024 * 1024 -> "%.2f MB".format(bytes / (1024.0 * 1024.0))
|
||||
bytes >= 1024 -> "%.2f KB".format(bytes / 1024.0)
|
||||
else -> "$bytes B"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Effect para verificar actualizaciones al iniciar.
|
||||
*/
|
||||
@Composable
|
||||
fun CheckForUpdatesEffect(
|
||||
updateService: UpdateService,
|
||||
onUpdateAvailable: (UpdateService.UpdateInfo) -> Unit,
|
||||
checkInterval: Long = 24 * 60 * 60 * 1000 // 24 horas
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Verificar si ya se comprobó recientemente
|
||||
val lastCheck = updateService.getLastCheckTime()
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (now - lastCheck > checkInterval) {
|
||||
scope.launch {
|
||||
val result = updateService.checkForUpdate()
|
||||
if (result is UpdateService.UpdateResult.UpdateAvailable) {
|
||||
onUpdateAvailable(result.updateInfo)
|
||||
}
|
||||
updateService.setLastCheckTime(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
343
app/src/main/java/com/iptv/app/ui/components/VideoPlayer.kt
Normal file
343
app/src/main/java/com/iptv/app/ui/components/VideoPlayer.kt
Normal file
@@ -0,0 +1,343 @@
|
||||
package com.iptv.app.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.iptv.app.utils.DnsConfigurator
|
||||
import com.iptv.app.utils.PlayerManager
|
||||
|
||||
/**
|
||||
* Data class representing player error state
|
||||
*/
|
||||
data class PlayerError(
|
||||
val message: String,
|
||||
val recoverable: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Sealed class representing the player state
|
||||
*/
|
||||
sealed class PlayerState {
|
||||
data object Idle : PlayerState()
|
||||
data object Loading : PlayerState()
|
||||
data object Playing : PlayerState()
|
||||
data object Paused : PlayerState()
|
||||
data object Ended : PlayerState()
|
||||
data class Error(val error: PlayerError) : PlayerState()
|
||||
}
|
||||
|
||||
/**
|
||||
* VideoPlayer composable that hosts ExoPlayer for HLS stream playback.
|
||||
*
|
||||
* @param streamUrl The HLS stream URL to play
|
||||
* @param channelName The name of the channel (for display purposes)
|
||||
* @param modifier Modifier for the composable
|
||||
* @param onError Callback when an error occurs
|
||||
* @param onPlayerReady Callback when the player is ready
|
||||
* @param showControls Whether to show the player controls overlay
|
||||
* @param onBackPressed Callback when back button is pressed
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun VideoPlayer(
|
||||
streamUrl: String,
|
||||
channelName: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onError: (PlayerError) -> Unit = {},
|
||||
onPlayerReady: () -> Unit = {},
|
||||
showControls: Boolean = true,
|
||||
onBackPressed: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
var playerState by remember { mutableStateOf<PlayerState>(PlayerState.Idle) }
|
||||
var isControlsVisible by remember { mutableStateOf(true) }
|
||||
|
||||
val playerManager = remember { PlayerManager(context) }
|
||||
|
||||
val exoPlayer = remember {
|
||||
createExoPlayer(context, playerManager).apply {
|
||||
addListener(createPlayerListener(
|
||||
onStateChange = { state -> playerState = state },
|
||||
onError = onError,
|
||||
onReady = onPlayerReady
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stream URL changes
|
||||
LaunchedEffect(streamUrl) {
|
||||
if (streamUrl.isNotBlank()) {
|
||||
playerState = PlayerState.Loading
|
||||
preparePlayer(context, exoPlayer, streamUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle management
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
exoPlayer.playWhenReady = false
|
||||
}
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
exoPlayer.playWhenReady = false
|
||||
}
|
||||
else -> { /* no-op */ }
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
// ExoPlayer View
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
createPlayerView(ctx, exoPlayer)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// Loading Indicator
|
||||
if (playerState is PlayerState.Loading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Error Display
|
||||
if (playerState is PlayerState.Error) {
|
||||
val error = (playerState as PlayerState.Error).error
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.8f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = error.message,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Controls Overlay
|
||||
if (showControls && playerState !is PlayerState.Error) {
|
||||
PlayerControls(
|
||||
isPlaying = playerState is PlayerState.Playing,
|
||||
channelName = channelName,
|
||||
isVisible = isControlsVisible,
|
||||
onVisibilityChange = { isControlsVisible = it },
|
||||
onPlayPauseClick = {
|
||||
exoPlayer.playWhenReady = !exoPlayer.playWhenReady
|
||||
},
|
||||
onBackClick = onBackPressed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and configures the ExoPlayer instance
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createExoPlayer(
|
||||
context: Context,
|
||||
playerManager: PlayerManager
|
||||
): ExoPlayer {
|
||||
val trackSelector = DefaultTrackSelector(context).apply {
|
||||
setParameters(
|
||||
buildUponParameters()
|
||||
.setMaxVideoSizeSd()
|
||||
.setPreferredAudioLanguage("en")
|
||||
)
|
||||
}
|
||||
|
||||
val loadControl = DefaultLoadControl.Builder()
|
||||
.setBufferDurationsMs(
|
||||
DefaultLoadControl.DEFAULT_MIN_BUFFER_MS,
|
||||
DefaultLoadControl.DEFAULT_MAX_BUFFER_MS,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
|
||||
DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
|
||||
)
|
||||
.build()
|
||||
|
||||
return ExoPlayer.Builder(context)
|
||||
.setTrackSelector(trackSelector)
|
||||
.setLoadControl(loadControl)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(context))
|
||||
.setAudioAttributes(playerManager.getAudioAttributes(), true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
.apply {
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
repeatMode = Player.REPEAT_MODE_OFF
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the PlayerView for displaying video
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun createPlayerView(context: Context, player: ExoPlayer): PlayerView {
|
||||
return PlayerView(context).apply {
|
||||
this.player = player
|
||||
useController = false // We use custom controls
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
setKeepContentOnPlayerReset(true)
|
||||
setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Player.Listener to handle player events
|
||||
*/
|
||||
private fun createPlayerListener(
|
||||
onStateChange: (PlayerState) -> Unit,
|
||||
onError: (PlayerError) -> Unit,
|
||||
onReady: () -> Unit
|
||||
): Player.Listener {
|
||||
return object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_IDLE -> onStateChange(PlayerState.Idle)
|
||||
Player.STATE_BUFFERING -> onStateChange(PlayerState.Loading)
|
||||
Player.STATE_READY -> {
|
||||
onStateChange(PlayerState.Playing)
|
||||
onReady()
|
||||
}
|
||||
Player.STATE_ENDED -> onStateChange(PlayerState.Ended)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
|
||||
if (playWhenReady && reason == Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) {
|
||||
onStateChange(PlayerState.Playing)
|
||||
} else if (!playWhenReady) {
|
||||
onStateChange(PlayerState.Paused)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
val playerError = when (error.errorCode) {
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
|
||||
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT ->
|
||||
PlayerError(
|
||||
message = "Network connection failed. Please check your internet connection.",
|
||||
recoverable = true
|
||||
)
|
||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED ->
|
||||
PlayerError(
|
||||
message = "Unable to decode video stream.",
|
||||
recoverable = false
|
||||
)
|
||||
PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW ->
|
||||
PlayerError(
|
||||
message = "Stream is behind live window. Reconnecting...",
|
||||
recoverable = true
|
||||
)
|
||||
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
|
||||
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS ->
|
||||
PlayerError(
|
||||
message = "Stream unavailable. The channel may be offline.",
|
||||
recoverable = true
|
||||
)
|
||||
else ->
|
||||
PlayerError(
|
||||
message = "Playback error: ${error.message}",
|
||||
recoverable = true
|
||||
)
|
||||
}
|
||||
onStateChange(PlayerState.Error(playerError))
|
||||
onError(playerError)
|
||||
}
|
||||
|
||||
override fun onIsLoadingChanged(isLoading: Boolean) {
|
||||
if (isLoading) {
|
||||
onStateChange(PlayerState.Loading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the player with the given HLS stream URL
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun preparePlayer(context: Context, player: ExoPlayer, streamUrl: String) {
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setUri(streamUrl)
|
||||
.setMimeType("application/vnd.apple.mpegurl")
|
||||
.build()
|
||||
|
||||
// Create OkHttpClient with Google DNS configuration
|
||||
val okHttpClient = DnsConfigurator.createOkHttpClient(context)
|
||||
|
||||
// Create OkHttpDataSource.Factory with custom DNS client
|
||||
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
|
||||
.setConnectTimeoutMs(15000)
|
||||
.setReadTimeoutMs(15000)
|
||||
|
||||
val mediaSource = HlsMediaSource.Factory(dataSourceFactory)
|
||||
.createMediaSource(mediaItem)
|
||||
|
||||
player.setMediaSource(mediaSource)
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
}
|
||||
468
app/src/main/java/com/iptv/app/ui/screens/ChannelsScreen.kt
Normal file
468
app/src/main/java/com/iptv/app/ui/screens/ChannelsScreen.kt
Normal file
@@ -0,0 +1,468 @@
|
||||
package com.iptv.app.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.iptv.app.ui.components.CategoryChip
|
||||
import com.iptv.app.ui.components.Channel
|
||||
import com.iptv.app.ui.components.ChannelCard
|
||||
import com.iptv.app.ui.theme.IPTVAppTheme
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// UI States
|
||||
sealed class ChannelsUiState {
|
||||
data object Loading : ChannelsUiState()
|
||||
data class Success(
|
||||
val channels: List<Channel>,
|
||||
val categories: List<String>
|
||||
) : ChannelsUiState()
|
||||
data class Error(val message: String) : ChannelsUiState()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChannelsScreen(
|
||||
uiState: ChannelsUiState,
|
||||
selectedCategory: String?,
|
||||
searchQuery: String,
|
||||
onSearchQueryChange: (String) -> Unit,
|
||||
onCategorySelect: (String?) -> Unit,
|
||||
onChannelClick: (Channel) -> Unit,
|
||||
onFavoriteToggle: (Channel) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "IPTV Channels",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// Search field
|
||||
SearchField(
|
||||
query = searchQuery,
|
||||
onQueryChange = onSearchQueryChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
)
|
||||
|
||||
when (uiState) {
|
||||
is ChannelsUiState.Loading -> {
|
||||
LoadingContent()
|
||||
}
|
||||
is ChannelsUiState.Error -> {
|
||||
ErrorContent(
|
||||
message = uiState.message,
|
||||
onRetry = onRefresh
|
||||
)
|
||||
}
|
||||
is ChannelsUiState.Success -> {
|
||||
val filteredChannels = remember(
|
||||
uiState.channels,
|
||||
selectedCategory,
|
||||
searchQuery
|
||||
) {
|
||||
uiState.channels.filter { channel ->
|
||||
val matchesCategory = selectedCategory?.let {
|
||||
channel.category.equals(it, ignoreCase = true)
|
||||
} ?: true
|
||||
val matchesSearch = searchQuery.isBlank() ||
|
||||
channel.name.contains(searchQuery, ignoreCase = true) ||
|
||||
channel.category.contains(searchQuery, ignoreCase = true)
|
||||
matchesCategory && matchesSearch
|
||||
}
|
||||
}
|
||||
|
||||
// Category filter chips
|
||||
CategoryFilterRow(
|
||||
categories = uiState.categories,
|
||||
selectedCategory = selectedCategory,
|
||||
onCategorySelect = onCategorySelect,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
// Pull to refresh wrapper
|
||||
PullToRefreshContainer(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
onRefresh()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (filteredChannels.isEmpty()) {
|
||||
EmptyContent(
|
||||
message = if (searchQuery.isNotBlank()) {
|
||||
"No channels found for \"$searchQuery\""
|
||||
} else {
|
||||
"No channels available"
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ChannelsGrid(
|
||||
channels = filteredChannels,
|
||||
onChannelClick = onChannelClick,
|
||||
onFavoriteToggle = onFavoriteToggle,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate refresh completion
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
delay(1500)
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
modifier = modifier,
|
||||
placeholder = { Text("Search channels...") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = "Search"
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (query.isNotBlank()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "Clear search"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Search
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSearch = { /* Handle search action */ }
|
||||
),
|
||||
shape = MaterialTheme.shapes.extraLarge
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryFilterRow(
|
||||
categories: List<String>,
|
||||
selectedCategory: String?,
|
||||
onCategorySelect: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyRow(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
item {
|
||||
CategoryChip(
|
||||
category = "All",
|
||||
isSelected = selectedCategory == null,
|
||||
onClick = { onCategorySelect(null) }
|
||||
)
|
||||
}
|
||||
items(categories) { category ->
|
||||
CategoryChip(
|
||||
category = category,
|
||||
isSelected = selectedCategory == category,
|
||||
onClick = { onCategorySelect(category) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelsGrid(
|
||||
channels: List<Channel>,
|
||||
onChannelClick: (Channel) -> Unit,
|
||||
onFavoriteToggle: (Channel) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = modifier.padding(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(vertical = 16.dp)
|
||||
) {
|
||||
items(
|
||||
items = channels,
|
||||
key = { it.id }
|
||||
) { channel ->
|
||||
ChannelCard(
|
||||
channel = channel,
|
||||
onClick = { onChannelClick(channel) },
|
||||
onFavoriteClick = { onFavoriteToggle(channel) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PullToRefreshContainer(
|
||||
isRefreshing: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
Box(modifier = modifier) {
|
||||
content()
|
||||
|
||||
if (pullToRefreshState.isRefreshing) {
|
||||
LaunchedEffect(true) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
pullToRefreshState.startRefresh()
|
||||
} else {
|
||||
pullToRefreshState.endRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
PullToRefreshBox(
|
||||
state = pullToRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
content = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingContent() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Loading channels...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorContent(
|
||||
message: String,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Oops!",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = onRetry) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Try Again")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyContent(message: String) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preview functions
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelsScreenLoadingPreview() {
|
||||
IPTVAppTheme {
|
||||
ChannelsScreen(
|
||||
uiState = ChannelsUiState.Loading,
|
||||
selectedCategory = null,
|
||||
searchQuery = "",
|
||||
onSearchQueryChange = {},
|
||||
onCategorySelect = {},
|
||||
onChannelClick = {},
|
||||
onFavoriteToggle = {},
|
||||
onRefresh = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelsScreenErrorPreview() {
|
||||
IPTVAppTheme {
|
||||
ChannelsScreen(
|
||||
uiState = ChannelsUiState.Error("Failed to load channels. Please check your connection."),
|
||||
selectedCategory = null,
|
||||
searchQuery = "",
|
||||
onSearchQueryChange = {},
|
||||
onCategorySelect = {},
|
||||
onChannelClick = {},
|
||||
onFavoriteToggle = {},
|
||||
onRefresh = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelsScreenSuccessPreview() {
|
||||
IPTVAppTheme {
|
||||
val sampleChannels = listOf(
|
||||
Channel("1", "ESPN", "Sports", null, ""),
|
||||
Channel("2", "CNN", "News", null, ""),
|
||||
Channel("3", "HBO", "Movies", null, "", true),
|
||||
Channel("4", "Disney Channel", "Kids", null, ""),
|
||||
Channel("5", "MTV", "Music", null, ""),
|
||||
Channel("6", "Discovery", "Documentary", null, "")
|
||||
)
|
||||
ChannelsScreen(
|
||||
uiState = ChannelsUiState.Success(
|
||||
channels = sampleChannels,
|
||||
categories = listOf("Sports", "News", "Movies", "Kids", "Music", "Documentary")
|
||||
),
|
||||
selectedCategory = "Sports",
|
||||
searchQuery = "",
|
||||
onSearchQueryChange = {},
|
||||
onCategorySelect = {},
|
||||
onChannelClick = {},
|
||||
onFavoriteToggle = {},
|
||||
onRefresh = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun ChannelsScreenEmptySearchPreview() {
|
||||
IPTVAppTheme {
|
||||
ChannelsScreen(
|
||||
uiState = ChannelsUiState.Success(
|
||||
channels = emptyList(),
|
||||
categories = listOf("Sports", "News")
|
||||
),
|
||||
selectedCategory = null,
|
||||
searchQuery = "xyz",
|
||||
onSearchQueryChange = {},
|
||||
onCategorySelect = {},
|
||||
onChannelClick = {},
|
||||
onFavoriteToggle = {},
|
||||
onRefresh = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
420
app/src/main/java/com/iptv/app/ui/screens/PlayerScreen.kt
Normal file
420
app/src/main/java/com/iptv/app/ui/screens/PlayerScreen.kt
Normal file
@@ -0,0 +1,420 @@
|
||||
package com.iptv.app.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Fullscreen
|
||||
import androidx.compose.material.icons.filled.FullscreenExit
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.iptv.app.ui.components.Channel
|
||||
import com.iptv.app.ui.theme.IPTVAppTheme
|
||||
import com.iptv.app.ui.theme.LiveIndicator
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
channel: Channel,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
// ExoPlayer setup
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(MediaItem.fromUri(channel.streamUrl))
|
||||
prepare()
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// UI state
|
||||
var isPlaying by remember { mutableStateOf(true) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
var isFullscreen by remember { mutableStateOf(true) }
|
||||
|
||||
// Auto-hide controls
|
||||
LaunchedEffect(showControls) {
|
||||
if (showControls) {
|
||||
delay(3000)
|
||||
showControls = false
|
||||
}
|
||||
}
|
||||
|
||||
// Player listener
|
||||
DisposableEffect(exoPlayer) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
isLoading = playbackState == Player.STATE_BUFFERING
|
||||
isPlaying = exoPlayer.isPlaying
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(playing: Boolean) {
|
||||
isPlaying = playing
|
||||
}
|
||||
}
|
||||
exoPlayer.addListener(listener)
|
||||
onDispose {
|
||||
exoPlayer.removeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle handling
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
exoPlayer.play()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
// Fullscreen handling
|
||||
DisposableEffect(isFullscreen) {
|
||||
activity?.let {
|
||||
if (isFullscreen) {
|
||||
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
it.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
it.window.decorView.systemUiVisibility = (
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
or View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
)
|
||||
} else {
|
||||
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
it.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
it.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
activity?.let {
|
||||
it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
it.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
it.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
showControls = !showControls
|
||||
}
|
||||
) {
|
||||
// Video Player
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
player = exoPlayer
|
||||
useController = false
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
// Loading indicator
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(64.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Controls overlay
|
||||
AnimatedVisibility(
|
||||
visible = showControls,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
PlayerControlsOverlay(
|
||||
channel = channel,
|
||||
isPlaying = isPlaying,
|
||||
isFullscreen = isFullscreen,
|
||||
onBackClick = onBackClick,
|
||||
onPlayPauseClick = {
|
||||
if (exoPlayer.isPlaying) {
|
||||
exoPlayer.pause()
|
||||
} else {
|
||||
exoPlayer.play()
|
||||
}
|
||||
},
|
||||
onFullscreenToggle = {
|
||||
isFullscreen = !isFullscreen
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlayerControlsOverlay(
|
||||
channel: Channel,
|
||||
isPlaying: Boolean,
|
||||
isFullscreen: Boolean,
|
||||
onBackClick: () -> Unit,
|
||||
onPlayPauseClick: () -> Unit,
|
||||
onFullscreenToggle: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Top gradient with back button and channel info
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.8f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Column {
|
||||
// Back button row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onBackClick,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Channel info
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Live indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(LiveIndicator)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "LIVE",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = channel.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = channel.category,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center play/pause button
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = fadeIn() + slideInVertically(),
|
||||
exit = fadeOut() + slideOutVertically(),
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onPlayPauseClick,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom controls
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
)
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onFullscreenToggle,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFullscreen) {
|
||||
Icons.Default.FullscreenExit
|
||||
} else {
|
||||
Icons.Default.Fullscreen
|
||||
},
|
||||
contentDescription = if (isFullscreen) {
|
||||
"Exit fullscreen"
|
||||
} else {
|
||||
"Enter fullscreen"
|
||||
},
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preview
|
||||
@Preview(showBackground = true, device = "spec:width=1920,height=1080,orientation=landscape")
|
||||
@Composable
|
||||
fun PlayerScreenPreview() {
|
||||
IPTVAppTheme {
|
||||
PlayerScreen(
|
||||
channel = Channel(
|
||||
id = "1",
|
||||
name = "ESPN HD",
|
||||
category = "Sports",
|
||||
logoUrl = null,
|
||||
streamUrl = "",
|
||||
isFavorite = false
|
||||
),
|
||||
onBackClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true, device = "spec:width=1920,height=1080,orientation=landscape")
|
||||
@Composable
|
||||
fun PlayerScreenPausedPreview() {
|
||||
IPTVAppTheme {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
PlayerControlsOverlay(
|
||||
channel = Channel(
|
||||
id = "2",
|
||||
name = "HBO Movies",
|
||||
category = "Movies",
|
||||
logoUrl = null,
|
||||
streamUrl = "",
|
||||
isFavorite = true
|
||||
),
|
||||
isPlaying = false,
|
||||
isFullscreen = true,
|
||||
onBackClick = {},
|
||||
onPlayPauseClick = {},
|
||||
onFullscreenToggle = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
80
app/src/main/java/com/iptv/app/ui/theme/Color.kt
Normal file
80
app/src/main/java/com/iptv/app/ui/theme/Color.kt
Normal file
@@ -0,0 +1,80 @@
|
||||
package com.iptv.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Primary colors
|
||||
val PrimaryLight = Color(0xFF0066CC)
|
||||
val OnPrimaryLight = Color(0xFFFFFFFF)
|
||||
val PrimaryContainerLight = Color(0xFFD1E4FF)
|
||||
val OnPrimaryContainerLight = Color(0xFF001D36)
|
||||
|
||||
val PrimaryDark = Color(0xFF9ECAFF)
|
||||
val OnPrimaryDark = Color(0xFF003258)
|
||||
val PrimaryContainerDark = Color(0xFF00497D)
|
||||
val OnPrimaryContainerDark = Color(0xFFD1E4FF)
|
||||
|
||||
// Secondary colors
|
||||
val SecondaryLight = Color(0xFF535F70)
|
||||
val OnSecondaryLight = Color(0xFFFFFFFF)
|
||||
val SecondaryContainerLight = Color(0xFFD7E3F7)
|
||||
val OnSecondaryContainerLight = Color(0xFF101C2B)
|
||||
|
||||
val SecondaryDark = Color(0xFFBBC7DB)
|
||||
val OnSecondaryDark = Color(0xFF253140)
|
||||
val SecondaryContainerDark = Color(0xFF3B4858)
|
||||
val OnSecondaryContainerDark = Color(0xFFD7E3F7)
|
||||
|
||||
// Tertiary colors
|
||||
val TertiaryLight = Color(0xFF6B5778)
|
||||
val OnTertiaryLight = Color(0xFFFFFFFF)
|
||||
val TertiaryContainerLight = Color(0xFFF2DAFF)
|
||||
val OnTertiaryContainerLight = Color(0xFF251431)
|
||||
|
||||
val TertiaryDark = Color(0xFFD6BEE4)
|
||||
val OnTertiaryDark = Color(0xFF3B2948)
|
||||
val TertiaryContainerDark = Color(0xFF523F5F)
|
||||
val OnTertiaryContainerDark = Color(0xFFF2DAFF)
|
||||
|
||||
// Error colors
|
||||
val ErrorLight = Color(0xFFBA1A1A)
|
||||
val OnErrorLight = Color(0xFFFFFFFF)
|
||||
val ErrorContainerLight = Color(0xFFFFDAD6)
|
||||
val OnErrorContainerLight = Color(0xFF410002)
|
||||
|
||||
val ErrorDark = Color(0xFFFFB4AB)
|
||||
val OnErrorDark = Color(0xFF690005)
|
||||
val ErrorContainerDark = Color(0xFF93000A)
|
||||
val OnErrorContainerDark = Color(0xFFFFDAD6)
|
||||
|
||||
// Background colors
|
||||
val BackgroundLight = Color(0xFFFDFCFF)
|
||||
val OnBackgroundLight = Color(0xFF1A1C1E)
|
||||
val SurfaceLight = Color(0xFFFDFCFF)
|
||||
val OnSurfaceLight = Color(0xFF1A1C1E)
|
||||
val SurfaceVariantLight = Color(0xFFDFE2EB)
|
||||
val OnSurfaceVariantLight = Color(0xFF43474E)
|
||||
|
||||
val BackgroundDark = Color(0xFF1A1C1E)
|
||||
val OnBackgroundDark = Color(0xFFE2E2E6)
|
||||
val SurfaceDark = Color(0xFF1A1C1E)
|
||||
val OnSurfaceDark = Color(0xFFE2E2E6)
|
||||
val SurfaceVariantDark = Color(0xFF43474E)
|
||||
val OnSurfaceVariantDark = Color(0xFFC3C7CF)
|
||||
|
||||
// Outline colors
|
||||
val OutlineLight = Color(0xFF73777F)
|
||||
val OutlineVariantLight = Color(0xFFC3C7CF)
|
||||
|
||||
val OutlineDark = Color(0xFF8D9199)
|
||||
val OutlineVariantDark = Color(0xFF43474E)
|
||||
|
||||
// IPTV specific colors
|
||||
val LiveIndicator = Color(0xFFE53935)
|
||||
val FavoriteColor = Color(0xFFFFB300)
|
||||
val CategoryEntertainment = Color(0xFF7C4DFF)
|
||||
val CategorySports = Color(0xFF00BFA5)
|
||||
val CategoryNews = Color(0xFF2962FF)
|
||||
val CategoryMovies = Color(0xFFFF6D00)
|
||||
val CategoryKids = Color(0xFFFF4081)
|
||||
val CategoryMusic = Color(0xFF00E5FF)
|
||||
val CategoryDocumentary = Color(0xFF76FF03)
|
||||
101
app/src/main/java/com/iptv/app/ui/theme/Theme.kt
Normal file
101
app/src/main/java/com/iptv/app/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,101 @@
|
||||
package com.iptv.app.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = PrimaryLight,
|
||||
onPrimary = OnPrimaryLight,
|
||||
primaryContainer = PrimaryContainerLight,
|
||||
onPrimaryContainer = OnPrimaryContainerLight,
|
||||
secondary = SecondaryLight,
|
||||
onSecondary = OnSecondaryLight,
|
||||
secondaryContainer = SecondaryContainerLight,
|
||||
onSecondaryContainer = OnSecondaryContainerLight,
|
||||
tertiary = TertiaryLight,
|
||||
onTertiary = OnTertiaryLight,
|
||||
tertiaryContainer = TertiaryContainerLight,
|
||||
onTertiaryContainer = OnTertiaryContainerLight,
|
||||
error = ErrorLight,
|
||||
onError = OnErrorLight,
|
||||
errorContainer = ErrorContainerLight,
|
||||
onErrorContainer = OnErrorContainerLight,
|
||||
background = BackgroundLight,
|
||||
onBackground = OnBackgroundLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = OnSurfaceLight,
|
||||
surfaceVariant = SurfaceVariantLight,
|
||||
onSurfaceVariant = OnSurfaceVariantLight,
|
||||
outline = OutlineLight,
|
||||
outlineVariant = OutlineVariantLight
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = PrimaryDark,
|
||||
onPrimary = OnPrimaryDark,
|
||||
primaryContainer = PrimaryContainerDark,
|
||||
onPrimaryContainer = OnPrimaryContainerDark,
|
||||
secondary = SecondaryDark,
|
||||
onSecondary = OnSecondaryDark,
|
||||
secondaryContainer = SecondaryContainerDark,
|
||||
onSecondaryContainer = OnSecondaryContainerDark,
|
||||
tertiary = TertiaryDark,
|
||||
onTertiary = OnTertiaryDark,
|
||||
tertiaryContainer = TertiaryContainerDark,
|
||||
onTertiaryContainer = OnTertiaryContainerDark,
|
||||
error = ErrorDark,
|
||||
onError = OnErrorDark,
|
||||
errorContainer = ErrorContainerDark,
|
||||
onErrorContainer = OnErrorContainerDark,
|
||||
background = BackgroundDark,
|
||||
onBackground = OnBackgroundDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = OnSurfaceDark,
|
||||
surfaceVariant = SurfaceVariantDark,
|
||||
onSurfaceVariant = OnSurfaceVariantDark,
|
||||
outline = OutlineDark,
|
||||
outlineVariant = OutlineVariantDark
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun IPTVAppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.primary.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
115
app/src/main/java/com/iptv/app/ui/theme/Type.kt
Normal file
115
app/src/main/java/com/iptv/app/ui/theme/Type.kt
Normal file
@@ -0,0 +1,115 @@
|
||||
package com.iptv.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 57.sp,
|
||||
lineHeight = 64.sp,
|
||||
letterSpacing = (-0.25).sp
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 45.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
)
|
||||
329
app/src/main/java/com/iptv/app/ui/viewmodel/ChannelsViewModel.kt
Normal file
329
app/src/main/java/com/iptv/app/ui/viewmodel/ChannelsViewModel.kt
Normal file
@@ -0,0 +1,329 @@
|
||||
package com.iptv.app.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.iptv.app.data.model.Channel
|
||||
import com.iptv.app.data.repository.ChannelRepository
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* UI State for the Channels screen.
|
||||
*/
|
||||
data class ChannelsUiState(
|
||||
val channels: List<Channel> = emptyList(),
|
||||
val filteredChannels: List<Channel> = emptyList(),
|
||||
val categories: List<String> = emptyList(),
|
||||
val selectedCategory: String? = null,
|
||||
val searchQuery: String = "",
|
||||
val favoriteChannelIds: Set<String> = emptySet(),
|
||||
val isLoading: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val hasMoreData: Boolean = true
|
||||
)
|
||||
|
||||
/**
|
||||
* ViewModel for managing the Channels list screen.
|
||||
* Handles channel loading, filtering, searching, and favorites management.
|
||||
*/
|
||||
@OptIn(FlowPreview::class)
|
||||
class ChannelsViewModel(
|
||||
private val channelRepository: ChannelRepository,
|
||||
private val m3uUrl: String
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val SEARCH_DEBOUNCE_MS = 300L
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(ChannelsUiState())
|
||||
val uiState: StateFlow<ChannelsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
private val _selectedCategory = MutableStateFlow<String?>(null)
|
||||
|
||||
init {
|
||||
observeChannels()
|
||||
observeCategories()
|
||||
observeFavorites()
|
||||
setupSearchAndFilter()
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads channels from the repository.
|
||||
*/
|
||||
fun loadChannels() {
|
||||
channelRepository.fetchChannels(m3uUrl)
|
||||
.onStart {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
.onEach { channels ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
channels = channels,
|
||||
isLoading = false,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
}
|
||||
.catch { error ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error.message ?: "Failed to load channels"
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes channels from the network.
|
||||
*/
|
||||
fun refreshChannels() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isRefreshing = true) }
|
||||
|
||||
val result = channelRepository.refreshChannels(m3uUrl)
|
||||
|
||||
result
|
||||
.onSuccess { channels ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
channels = channels,
|
||||
isRefreshing = false,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isRefreshing = false,
|
||||
errorMessage = error.message ?: "Failed to refresh channels"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the search query for filtering channels.
|
||||
*/
|
||||
fun setSearchQuery(query: String) {
|
||||
_searchQuery.value = query
|
||||
_uiState.update { it.copy(searchQuery = query) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current search query.
|
||||
*/
|
||||
fun clearSearch() {
|
||||
_searchQuery.value = ""
|
||||
_uiState.update { it.copy(searchQuery = "") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a category to filter channels.
|
||||
*/
|
||||
fun selectCategory(category: String?) {
|
||||
_selectedCategory.value = category
|
||||
_uiState.update { it.copy(selectedCategory = category) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the category filter.
|
||||
*/
|
||||
fun clearCategoryFilter() {
|
||||
_selectedCategory.value = null
|
||||
_uiState.update { it.copy(selectedCategory = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles a channel's favorite status.
|
||||
*/
|
||||
fun toggleFavorite(channelId: String) {
|
||||
viewModelScope.launch {
|
||||
val isNowFavorite = channelRepository.toggleFavorite(channelId)
|
||||
|
||||
_uiState.update { state ->
|
||||
val updatedFavorites = if (isNowFavorite) {
|
||||
state.favoriteChannelIds + channelId
|
||||
} else {
|
||||
state.favoriteChannelIds - channelId
|
||||
}
|
||||
state.copy(favoriteChannelIds = updatedFavorites)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a channel to favorites.
|
||||
*/
|
||||
fun addToFavorites(channelId: String) {
|
||||
viewModelScope.launch {
|
||||
channelRepository.addToFavorites(channelId)
|
||||
updateFavoriteIds()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a channel from favorites.
|
||||
*/
|
||||
fun removeFromFavorites(channelId: String) {
|
||||
viewModelScope.launch {
|
||||
channelRepository.removeFromFavorites(channelId)
|
||||
updateFavoriteIds()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a channel is a favorite.
|
||||
*/
|
||||
fun isFavorite(channelId: String): Flow<Boolean> {
|
||||
return channelRepository.isFavorite(channelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any error message.
|
||||
*/
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the channel cache and reloads.
|
||||
*/
|
||||
fun clearCacheAndReload() {
|
||||
viewModelScope.launch {
|
||||
channelRepository.clearCache()
|
||||
loadChannels()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets channels filtered by the current search and category filters.
|
||||
*/
|
||||
fun getFilteredChannels(): Flow<List<Channel>> {
|
||||
return combine(
|
||||
_uiState.map { it.channels },
|
||||
_searchQuery,
|
||||
_selectedCategory
|
||||
) { channels, query, category ->
|
||||
channels.filter { channel ->
|
||||
val matchesSearch = query.isBlank() ||
|
||||
channel.name.contains(query, ignoreCase = true) ||
|
||||
channel.category.contains(query, ignoreCase = true)
|
||||
|
||||
val matchesCategory = category == null ||
|
||||
channel.category.equals(category, ignoreCase = true)
|
||||
|
||||
matchesSearch && matchesCategory
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets only favorite channels.
|
||||
*/
|
||||
fun getFavoriteChannels(): Flow<List<Channel>> {
|
||||
return channelRepository.getFavoriteChannels()
|
||||
}
|
||||
|
||||
private fun observeChannels() {
|
||||
loadChannels()
|
||||
}
|
||||
|
||||
private fun observeCategories() {
|
||||
channelRepository.getCategories()
|
||||
.onEach { categories ->
|
||||
_uiState.update { it.copy(categories = categories) }
|
||||
}
|
||||
.catch { error ->
|
||||
// Log error but don't disrupt UI
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun observeFavorites() {
|
||||
channelRepository.getFavoriteChannels()
|
||||
.map { favorites -> favorites.map { it.id }.toSet() }
|
||||
.onEach { favoriteIds ->
|
||||
_uiState.update { it.copy(favoriteChannelIds = favoriteIds) }
|
||||
}
|
||||
.catch { error ->
|
||||
// Log error but don't disrupt UI
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun setupSearchAndFilter() {
|
||||
combine(
|
||||
_uiState.map { it.channels },
|
||||
_searchQuery
|
||||
.debounce(SEARCH_DEBOUNCE_MS)
|
||||
.distinctUntilChanged(),
|
||||
_selectedCategory.distinctUntilChanged()
|
||||
) { channels, query, category ->
|
||||
channels.filter { channel ->
|
||||
val matchesSearch = query.isBlank() ||
|
||||
channel.name.contains(query, ignoreCase = true) ||
|
||||
channel.category.contains(query, ignoreCase = true)
|
||||
|
||||
val matchesCategory = category == null ||
|
||||
channel.category.equals(category, ignoreCase = true)
|
||||
|
||||
matchesSearch && matchesCategory
|
||||
}
|
||||
}
|
||||
.onEach { filtered ->
|
||||
_uiState.update { it.copy(filteredChannels = filtered) }
|
||||
}
|
||||
.catch { error ->
|
||||
_uiState.update { it.copy(errorMessage = "Filter error: ${error.message}") }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun updateFavoriteIds() {
|
||||
channelRepository.getFavoriteChannels()
|
||||
.map { favorites -> favorites.map { it.id }.toSet() }
|
||||
.onEach { favoriteIds ->
|
||||
_uiState.update { it.copy(favoriteChannelIds = favoriteIds) }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating ChannelsViewModel with dependencies.
|
||||
*/
|
||||
class ChannelsViewModelFactory(
|
||||
private val channelRepository: ChannelRepository,
|
||||
private val m3uUrl: String
|
||||
) : androidx.lifecycle.ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(ChannelsViewModel::class.java)) {
|
||||
return ChannelsViewModel(channelRepository, m3uUrl) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
404
app/src/main/java/com/iptv/app/ui/viewmodel/PlayerViewModel.kt
Normal file
404
app/src/main/java/com/iptv/app/ui/viewmodel/PlayerViewModel.kt
Normal file
@@ -0,0 +1,404 @@
|
||||
package com.iptv.app.ui.viewmodel
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.iptv.app.data.model.Channel
|
||||
import com.iptv.app.data.repository.ChannelRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Represents the different playback states.
|
||||
*/
|
||||
sealed class PlaybackState {
|
||||
data object Idle : PlaybackState()
|
||||
data object Loading : PlaybackState()
|
||||
data object Ready : PlaybackState()
|
||||
data object Playing : PlaybackState()
|
||||
data object Paused : PlaybackState()
|
||||
data object Ended : PlaybackState()
|
||||
data class Error(val message: String) : PlaybackState()
|
||||
data class Buffering(val progress: Float) : PlaybackState()
|
||||
}
|
||||
|
||||
/**
|
||||
* UI State for the Player screen.
|
||||
*/
|
||||
data class PlayerUiState(
|
||||
val currentChannel: Channel? = null,
|
||||
val playbackState: PlaybackState = PlaybackState.Idle,
|
||||
val isFullscreen: Boolean = false,
|
||||
val controlsVisible: Boolean = true,
|
||||
val currentPosition: Long = 0L,
|
||||
val duration: Long = 0L,
|
||||
val volume: Float = 1.0f,
|
||||
val isMuted: Boolean = false,
|
||||
val availableQualities: List<String> = emptyList(),
|
||||
val selectedQuality: String? = null,
|
||||
val isFavorite: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val relatedChannels: List<Channel> = emptyList(),
|
||||
val showEpg: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Player events that can be triggered from the UI.
|
||||
*/
|
||||
sealed class PlayerEvent {
|
||||
data class LoadChannel(val channelId: String) : PlayerEvent()
|
||||
data object Play : PlayerEvent()
|
||||
data object Pause : PlayerEvent()
|
||||
data object Stop : PlayerEvent()
|
||||
data class SeekTo(val positionMs: Long) : PlayerEvent()
|
||||
data class SetVolume(val volume: Float) : PlayerEvent()
|
||||
data object ToggleMute : PlayerEvent()
|
||||
data object ToggleFullscreen : PlayerEvent()
|
||||
data object ToggleControls : PlayerEvent()
|
||||
data object ToggleFavorite : PlayerEvent()
|
||||
data class SelectQuality(val quality: String) : PlayerEvent()
|
||||
data object NextChannel : PlayerEvent()
|
||||
data object PreviousChannel : PlayerEvent()
|
||||
data object Retry : PlayerEvent()
|
||||
data object DismissError : PlayerEvent()
|
||||
data object ToggleEpg : PlayerEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel for managing the video player screen.
|
||||
* Handles playback state, channel navigation, and player controls.
|
||||
*/
|
||||
class PlayerViewModel(
|
||||
private val channelRepository: ChannelRepository,
|
||||
private val savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val CONTROLS_HIDE_DELAY_MS = 3000L
|
||||
private const val POSITION_UPDATE_INTERVAL_MS = 1000L
|
||||
private const val KEY_LAST_CHANNEL_ID = "last_channel_id"
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(PlayerUiState())
|
||||
val uiState: StateFlow<PlayerUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var controlsHideJob: Job? = null
|
||||
private var positionUpdateJob: Job? = null
|
||||
private var currentChannelList: List<Channel> = emptyList()
|
||||
|
||||
init {
|
||||
observeChannels()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles player events from the UI.
|
||||
*/
|
||||
fun onEvent(event: PlayerEvent) {
|
||||
when (event) {
|
||||
is PlayerEvent.LoadChannel -> loadChannel(event.channelId)
|
||||
is PlayerEvent.Play -> play()
|
||||
is PlayerEvent.Pause -> pause()
|
||||
is PlayerEvent.Stop -> stop()
|
||||
is PlayerEvent.SeekTo -> seekTo(event.positionMs)
|
||||
is PlayerEvent.SetVolume -> setVolume(event.volume)
|
||||
is PlayerEvent.ToggleMute -> toggleMute()
|
||||
is PlayerEvent.ToggleFullscreen -> toggleFullscreen()
|
||||
is PlayerEvent.ToggleControls -> toggleControls()
|
||||
is PlayerEvent.ToggleFavorite -> toggleFavorite()
|
||||
is PlayerEvent.SelectQuality -> selectQuality(event.quality)
|
||||
is PlayerEvent.NextChannel -> nextChannel()
|
||||
is PlayerEvent.PreviousChannel -> previousChannel()
|
||||
is PlayerEvent.Retry -> retry()
|
||||
is PlayerEvent.DismissError -> dismissError()
|
||||
is PlayerEvent.ToggleEpg -> toggleEpg()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a channel by ID and starts playback.
|
||||
*/
|
||||
fun loadChannel(channelId: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(playbackState = PlaybackState.Loading) }
|
||||
|
||||
channelRepository.getChannelById(channelId)
|
||||
.filterNotNull()
|
||||
.collect { channel ->
|
||||
val isFavorite = checkIsFavorite(channelId)
|
||||
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
currentChannel = channel,
|
||||
playbackState = PlaybackState.Ready,
|
||||
isFavorite = isFavorite,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
// Save last played channel
|
||||
savedStateHandle[KEY_LAST_CHANNEL_ID] = channelId
|
||||
|
||||
// Load related channels from same category
|
||||
loadRelatedChannels(channel)
|
||||
|
||||
// Start position updates
|
||||
startPositionUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current channel's stream URL.
|
||||
*/
|
||||
fun getCurrentStreamUrl(): Uri? {
|
||||
return _uiState.value.currentChannel?.let {
|
||||
Uri.parse(it.streamUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the playback state.
|
||||
*/
|
||||
fun updatePlaybackState(state: PlaybackState) {
|
||||
_uiState.update { it.copy(playbackState = state) }
|
||||
|
||||
when (state) {
|
||||
is PlaybackState.Playing -> startPositionUpdates()
|
||||
is PlaybackState.Paused,
|
||||
is PlaybackState.Idle,
|
||||
is PlaybackState.Ended,
|
||||
is PlaybackState.Error -> stopPositionUpdates()
|
||||
else -> { /* No action needed */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current playback position.
|
||||
*/
|
||||
fun updatePosition(positionMs: Long) {
|
||||
_uiState.update { it.copy(currentPosition = positionMs) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the media duration.
|
||||
*/
|
||||
fun updateDuration(durationMs: Long) {
|
||||
_uiState.update { it.copy(duration = durationMs) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the buffering progress.
|
||||
*/
|
||||
fun updateBufferingProgress(progress: Float) {
|
||||
_uiState.update { it.copy(playbackState = PlaybackState.Buffering(progress)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a player error.
|
||||
*/
|
||||
fun reportError(error: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
playbackState = PlaybackState.Error(error),
|
||||
errorMessage = error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopPositionUpdates()
|
||||
controlsHideJob?.cancel()
|
||||
}
|
||||
|
||||
private fun play() {
|
||||
_uiState.update { it.copy(playbackState = PlaybackState.Playing) }
|
||||
startPositionUpdates()
|
||||
}
|
||||
|
||||
private fun pause() {
|
||||
_uiState.update { it.copy(playbackState = PlaybackState.Paused) }
|
||||
stopPositionUpdates()
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
playbackState = PlaybackState.Idle,
|
||||
currentPosition = 0L
|
||||
)
|
||||
}
|
||||
stopPositionUpdates()
|
||||
}
|
||||
|
||||
private fun seekTo(positionMs: Long) {
|
||||
_uiState.update { it.copy(currentPosition = positionMs) }
|
||||
}
|
||||
|
||||
private fun setVolume(volume: Float) {
|
||||
val clampedVolume = volume.coerceIn(0f, 1f)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
volume = clampedVolume,
|
||||
isMuted = clampedVolume == 0f
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleMute() {
|
||||
_uiState.update { state ->
|
||||
state.copy(isMuted = !state.isMuted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleFullscreen() {
|
||||
_uiState.update { it.copy(isFullscreen = !it.isFullscreen) }
|
||||
}
|
||||
|
||||
private fun toggleControls() {
|
||||
val newVisibility = !_uiState.value.controlsVisible
|
||||
_uiState.update { it.copy(controlsVisible = newVisibility) }
|
||||
|
||||
if (newVisibility) {
|
||||
scheduleControlsHide()
|
||||
} else {
|
||||
controlsHideJob?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggleFavorite() {
|
||||
val channelId = _uiState.value.currentChannel?.id ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
val isNowFavorite = channelRepository.toggleFavorite(channelId)
|
||||
_uiState.update { it.copy(isFavorite = isNowFavorite) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectQuality(quality: String) {
|
||||
_uiState.update { it.copy(selectedQuality = quality) }
|
||||
// Quality switching logic would be handled by the player
|
||||
}
|
||||
|
||||
private fun nextChannel() {
|
||||
val currentChannel = _uiState.value.currentChannel ?: return
|
||||
val currentIndex = currentChannelList.indexOfFirst { it.id == currentChannel.id }
|
||||
|
||||
if (currentIndex != -1 && currentIndex < currentChannelList.size - 1) {
|
||||
val nextChannel = currentChannelList[currentIndex + 1]
|
||||
loadChannel(nextChannel.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun previousChannel() {
|
||||
val currentChannel = _uiState.value.currentChannel ?: return
|
||||
val currentIndex = currentChannelList.indexOfFirst { it.id == currentChannel.id }
|
||||
|
||||
if (currentIndex > 0) {
|
||||
val previousChannel = currentChannelList[currentIndex - 1]
|
||||
loadChannel(previousChannel.id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun retry() {
|
||||
val channelId = _uiState.value.currentChannel?.id
|
||||
?: savedStateHandle.get<String>(KEY_LAST_CHANNEL_ID)
|
||||
?: return
|
||||
|
||||
loadChannel(channelId)
|
||||
}
|
||||
|
||||
private fun dismissError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
private fun toggleEpg() {
|
||||
_uiState.update { it.copy(showEpg = !it.showEpg) }
|
||||
}
|
||||
|
||||
private fun observeChannels() {
|
||||
channelRepository.allChannels
|
||||
.onEach { channels ->
|
||||
currentChannelList = channels
|
||||
}
|
||||
.catch { /* Handle error */ }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun loadRelatedChannels(channel: Channel) {
|
||||
viewModelScope.launch {
|
||||
channelRepository.getChannelsByCategory(channel.category)
|
||||
.map { channels ->
|
||||
channels.filter { it.id != channel.id }.take(10)
|
||||
}
|
||||
.collect { related ->
|
||||
_uiState.update { it.copy(relatedChannels = related) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkIsFavorite(channelId: String): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
var isFav = false
|
||||
channelRepository.isFavorite(channelId)
|
||||
.collect { isFav = it }
|
||||
isFav
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleControlsHide() {
|
||||
controlsHideJob?.cancel()
|
||||
controlsHideJob = viewModelScope.launch {
|
||||
delay(CONTROLS_HIDE_DELAY_MS)
|
||||
_uiState.update { it.copy(controlsVisible = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPositionUpdates() {
|
||||
positionUpdateJob?.cancel()
|
||||
positionUpdateJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(POSITION_UPDATE_INTERVAL_MS)
|
||||
// Position updates would be triggered from the player
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPositionUpdates() {
|
||||
positionUpdateJob?.cancel()
|
||||
positionUpdateJob = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating PlayerViewModel with dependencies.
|
||||
*/
|
||||
class PlayerViewModelFactory(
|
||||
private val channelRepository: ChannelRepository
|
||||
) : androidx.lifecycle.ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(PlayerViewModel::class.java)) {
|
||||
return PlayerViewModel(
|
||||
channelRepository,
|
||||
SavedStateHandle()
|
||||
) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
210
app/src/main/java/com/iptv/app/utils/DnsConfigurator.kt
Normal file
210
app/src/main/java/com/iptv/app/utils/DnsConfigurator.kt
Normal file
@@ -0,0 +1,210 @@
|
||||
package com.iptv.app.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.util.Log
|
||||
import okhttp3.Dns
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.dnsoverhttps.DnsOverHttps
|
||||
import java.net.InetAddress
|
||||
import java.net.UnknownHostException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Configurador de DNS para evitar bloqueos regionales.
|
||||
* Usa DNS de Google (8.8.8.8, 8.8.4.4) y DoH (DNS over HTTPS).
|
||||
*/
|
||||
object DnsConfigurator {
|
||||
|
||||
private const val TAG = "DnsConfigurator"
|
||||
|
||||
// Google DNS servers
|
||||
private val GOOGLE_DNS_IPV4 = listOf(
|
||||
InetAddress.getByName("8.8.8.8"),
|
||||
InetAddress.getByName("8.8.4.4")
|
||||
)
|
||||
|
||||
// Google DNS over HTTPS URL
|
||||
private const val GOOGLE_DOH_URL = "https://dns.google/dns-query"
|
||||
|
||||
// Cloudflare DNS as fallback
|
||||
private val CLOUDFLARE_DNS_IPV4 = listOf(
|
||||
InetAddress.getByName("1.1.1.1"),
|
||||
InetAddress.getByName("1.0.0.1")
|
||||
)
|
||||
|
||||
/**
|
||||
* Crea un cliente OkHttp con DNS de Google configurado.
|
||||
*/
|
||||
fun createOkHttpClient(context: Context): OkHttpClient {
|
||||
val dns = createGoogleDns(context)
|
||||
|
||||
return OkHttpClient.Builder()
|
||||
.dns(dns)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una instancia de DNS usando Google DNS con fallback.
|
||||
*/
|
||||
private fun createGoogleDns(context: Context): Dns {
|
||||
return try {
|
||||
// Intentar usar DoH primero (más privado y seguro)
|
||||
createDnsOverHttps()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "DoH no disponible, usando DNS tradicional", e)
|
||||
// Fallback a DNS tradicional
|
||||
createTraditionalDns()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea DNS over HTTPS usando Google DNS.
|
||||
*/
|
||||
private fun createDnsOverHttps(): Dns {
|
||||
val bootstrapClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
return DnsOverHttps.Builder()
|
||||
.client(bootstrapClient)
|
||||
.url(java.net.URL(GOOGLE_DOH_URL))
|
||||
.bootstrapDnsHosts(GOOGLE_DNS_IPV4 + CLOUDFLARE_DNS_IPV4)
|
||||
.includeIPv6(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea DNS tradicional con servidores de Google.
|
||||
*/
|
||||
private fun createTraditionalDns(): Dns {
|
||||
return object : Dns {
|
||||
@Throws(UnknownHostException::class)
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
// Intentar con Google DNS primero
|
||||
val googleResult = tryLookup(hostname, GOOGLE_DNS_IPV4)
|
||||
if (googleResult.isNotEmpty()) {
|
||||
Log.d(TAG, "DNS resuelto con Google DNS: $hostname")
|
||||
return googleResult
|
||||
}
|
||||
|
||||
// Fallback a Cloudflare
|
||||
val cloudflareResult = tryLookup(hostname, CLOUDFLARE_DNS_IPV4)
|
||||
if (cloudflareResult.isNotEmpty()) {
|
||||
Log.d(TAG, "DNS resuelto con Cloudflare DNS: $hostname")
|
||||
return cloudflareResult
|
||||
}
|
||||
|
||||
// Fallback al DNS del sistema
|
||||
Log.d(TAG, "Usando DNS del sistema para: $hostname")
|
||||
return Dns.SYSTEM.lookup(hostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intenta resolver un hostname usando DNS específicos.
|
||||
*/
|
||||
private fun tryLookup(hostname: String, dnsServers: List<InetAddress>): List<InetAddress> {
|
||||
return try {
|
||||
// Usar el método lookup del sistema pero forzando los DNS
|
||||
val method = InetAddress::class.java.getDeclaredMethod(
|
||||
"getAllByName",
|
||||
String::class.java,
|
||||
InetAddress::class.java
|
||||
)
|
||||
method.isAccessible = true
|
||||
|
||||
dnsServers.flatMap { dnsServer ->
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val result = method.invoke(null, hostname, dnsServer) as Array<InetAddress>
|
||||
result.toList()
|
||||
} catch (e: Exception) {
|
||||
emptyList<InetAddress>()
|
||||
}
|
||||
}.distinct()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error en lookup DNS", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuerza el uso de DNS de Google a nivel de red (requiere API 26+).
|
||||
*/
|
||||
fun forceGoogleDns(context: Context) {
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
.build()
|
||||
|
||||
connectivityManager.requestNetwork(request, object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.d(TAG, "Red disponible: $network")
|
||||
// La red ya está configurada, el DNS se maneja a nivel de OkHttp
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS personalizado que fuerza el uso de servidores específicos.
|
||||
*/
|
||||
class CustomDns(private val dnsServers: List<InetAddress>) : Dns {
|
||||
|
||||
@Throws(UnknownHostException::class)
|
||||
override fun lookup(hostname: String): List<InetAddress> {
|
||||
// Usar solo los DNS configurados
|
||||
val exceptions = mutableListOf<Exception>()
|
||||
|
||||
for (dnsServer in dnsServers) {
|
||||
try {
|
||||
val addresses = lookupWithDns(hostname, dnsServer)
|
||||
if (addresses.isNotEmpty()) {
|
||||
return addresses
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
exceptions.add(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Si todos fallan, usar DNS del sistema como último recurso
|
||||
return Dns.SYSTEM.lookup(hostname)
|
||||
}
|
||||
|
||||
private fun lookupWithDns(hostname: String, dnsServer: InetAddress): List<InetAddress> {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf(
|
||||
"getprop", "net.dns1"
|
||||
))
|
||||
process.waitFor()
|
||||
|
||||
// Usar reflection para forzar el DNS específico
|
||||
val method = InetAddress::class.java.getDeclaredMethod(
|
||||
"getAllByName0",
|
||||
String::class.java,
|
||||
Boolean::class.java,
|
||||
Boolean::class.java
|
||||
)
|
||||
method.isAccessible = true
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val result = method.invoke(null, hostname, true, true) as Array<InetAddress>
|
||||
result.toList()
|
||||
} catch (e: Exception) {
|
||||
throw UnknownHostException("No se pudo resolver $hostname con DNS $dnsServer: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
409
app/src/main/java/com/iptv/app/utils/PlayerManager.kt
Normal file
409
app/src/main/java/com/iptv/app/utils/PlayerManager.kt
Normal file
@@ -0,0 +1,409 @@
|
||||
package com.iptv.app.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.media3.common.AudioAttributes as ExoAudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.TrackSelectionParameters
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Data class representing available video quality option
|
||||
*/
|
||||
data class VideoQuality(
|
||||
val name: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val bitrate: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class representing available audio track
|
||||
*/
|
||||
data class AudioTrack(
|
||||
val id: String,
|
||||
val language: String?,
|
||||
val label: String?,
|
||||
val isSelected: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* Sealed class representing audio focus state
|
||||
*/
|
||||
sealed class AudioFocusState {
|
||||
data object Granted : AudioFocusState()
|
||||
data object Lost : AudioFocusState()
|
||||
data object LostTransient : AudioFocusState()
|
||||
data object LostTransientCanDuck : AudioFocusState()
|
||||
}
|
||||
|
||||
/**
|
||||
* PlayerManager handles player instance management, track selection, and audio focus.
|
||||
* This is a singleton-style manager that should be used across the app for consistent
|
||||
* player behavior.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlayerManager(private val context: Context) {
|
||||
|
||||
private var exoPlayer: ExoPlayer? = null
|
||||
private var audioManager: AudioManager? = null
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var trackSelector: DefaultTrackSelector? = null
|
||||
|
||||
private val _audioFocusState = MutableStateFlow<AudioFocusState>(AudioFocusState.Granted)
|
||||
val audioFocusState: StateFlow<AudioFocusState> = _audioFocusState.asStateFlow()
|
||||
|
||||
private val _availableQualities = MutableStateFlow<List<VideoQuality>>(emptyList())
|
||||
val availableQualities: StateFlow<List<VideoQuality>> = _availableQualities.asStateFlow()
|
||||
|
||||
private val _availableAudioTracks = MutableStateFlow<List<AudioTrack>>(emptyList())
|
||||
val availableAudioTracks: StateFlow<List<AudioTrack>> = _availableAudioTracks.asStateFlow()
|
||||
|
||||
private var originalVolume: Int = -1
|
||||
private var isDucked: Boolean = false
|
||||
|
||||
init {
|
||||
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and configures a new ExoPlayer instance
|
||||
*/
|
||||
fun createPlayer(): ExoPlayer {
|
||||
releasePlayer() // Release any existing player
|
||||
|
||||
trackSelector = DefaultTrackSelector(context).apply {
|
||||
setParameters(
|
||||
buildUponParameters()
|
||||
.setPreferredAudioLanguage("en")
|
||||
.setMaxVideoSizeSd()
|
||||
)
|
||||
}
|
||||
|
||||
val player = ExoPlayer.Builder(context)
|
||||
.setTrackSelector(trackSelector!!)
|
||||
.setAudioAttributes(getAudioAttributes(), true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
.apply {
|
||||
addListener(createPlayerListener())
|
||||
}
|
||||
|
||||
exoPlayer = player
|
||||
requestAudioFocus()
|
||||
|
||||
return player
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current player instance
|
||||
*/
|
||||
fun getPlayer(): ExoPlayer? = exoPlayer
|
||||
|
||||
/**
|
||||
* Releases the current player and audio focus
|
||||
*/
|
||||
fun releasePlayer() {
|
||||
abandonAudioFocus()
|
||||
exoPlayer?.removeListener(createPlayerListener())
|
||||
exoPlayer?.release()
|
||||
exoPlayer = null
|
||||
trackSelector = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the audio attributes for ExoPlayer
|
||||
*/
|
||||
fun getAudioAttributes(): ExoAudioAttributes {
|
||||
return ExoAudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests audio focus from the system
|
||||
*/
|
||||
fun requestAudioFocus(): Boolean {
|
||||
val am = audioManager ?: return false
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
requestAudioFocusApi26(am)
|
||||
} else {
|
||||
requestAudioFocusLegacy(am)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abandons audio focus
|
||||
*/
|
||||
fun abandonAudioFocus() {
|
||||
val am = audioManager ?: return
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { am.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
am.abandonAudioFocus(audioFocusChangeListener)
|
||||
}
|
||||
|
||||
audioFocusRequest = null
|
||||
_audioFocusState.value = AudioFocusState.Lost
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun requestAudioFocusApi26(audioManager: AudioManager): Boolean {
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(
|
||||
android.media.AudioAttributes.Builder()
|
||||
.setUsage(android.media.AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
)
|
||||
.setOnAudioFocusChangeListener(audioFocusChangeListener)
|
||||
.build()
|
||||
|
||||
audioFocusRequest = focusRequest
|
||||
|
||||
val result = audioManager.requestAudioFocus(focusRequest)
|
||||
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun requestAudioFocusLegacy(audioManager: AudioManager): Boolean {
|
||||
val result = audioManager.requestAudioFocus(
|
||||
audioFocusChangeListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
}
|
||||
|
||||
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
_audioFocusState.value = AudioFocusState.Granted
|
||||
exoPlayer?.playWhenReady = true
|
||||
restoreVolume()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
_audioFocusState.value = AudioFocusState.Lost
|
||||
exoPlayer?.playWhenReady = false
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
_audioFocusState.value = AudioFocusState.LostTransient
|
||||
exoPlayer?.playWhenReady = false
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
_audioFocusState.value = AudioFocusState.LostTransientCanDuck
|
||||
duckVolume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun duckVolume() {
|
||||
if (!isDucked) {
|
||||
val am = audioManager
|
||||
originalVolume = am?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: -1
|
||||
am?.setStreamVolume(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
(originalVolume * 0.2f).toInt().coerceAtLeast(0),
|
||||
0
|
||||
)
|
||||
isDucked = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun restoreVolume() {
|
||||
if (isDucked && originalVolume >= 0) {
|
||||
audioManager?.setStreamVolume(AudioManager.STREAM_MUSIC, originalVolume, 0)
|
||||
isDucked = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the video quality/resolution
|
||||
*
|
||||
* @param quality The desired video quality (null for auto)
|
||||
*/
|
||||
fun setVideoQuality(quality: VideoQuality?) {
|
||||
val selector = trackSelector ?: return
|
||||
|
||||
val parameters = if (quality == null) {
|
||||
// Auto quality selection
|
||||
selector.parameters.buildUponParameters()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
|
||||
} else {
|
||||
// Manual quality selection
|
||||
val player = exoPlayer ?: return
|
||||
val tracks = player.currentTracks
|
||||
|
||||
var override: TrackSelectionOverride? = null
|
||||
for (trackGroup in tracks.groups) {
|
||||
if (trackGroup.type == C.TRACK_TYPE_VIDEO) {
|
||||
for (trackIndex in 0 until trackGroup.length) {
|
||||
val format = trackGroup.getTrackFormat(trackIndex)
|
||||
if (format.width == quality.width && format.height == quality.height) {
|
||||
override = TrackSelectionOverride(trackGroup.mediaTrackGroup, trackIndex)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (override != null) {
|
||||
selector.parameters.buildUponParameters()
|
||||
.setOverrideForType(override)
|
||||
} else {
|
||||
selector.parameters.buildUponParameters()
|
||||
}
|
||||
}
|
||||
|
||||
selector.parameters = parameters.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the audio track by language code
|
||||
*
|
||||
* @param languageCode The language code (e.g., "en", "es", "fr")
|
||||
*/
|
||||
fun setAudioTrack(languageCode: String?) {
|
||||
val selector = trackSelector ?: return
|
||||
|
||||
val parameters = selector.parameters.buildUponParameters()
|
||||
.setPreferredAudioLanguage(languageCode)
|
||||
.build()
|
||||
|
||||
selector.parameters = parameters
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets available video qualities from the current stream
|
||||
*/
|
||||
fun updateAvailableQualities() {
|
||||
val player = exoPlayer ?: return
|
||||
val qualities = mutableListOf<VideoQuality>()
|
||||
|
||||
val tracks = player.currentTracks
|
||||
for (trackGroup in tracks.groups) {
|
||||
if (trackGroup.type == C.TRACK_TYPE_VIDEO) {
|
||||
for (trackIndex in 0 until trackGroup.length) {
|
||||
val format = trackGroup.getTrackFormat(trackIndex)
|
||||
val quality = VideoQuality(
|
||||
name = "${format.height}p",
|
||||
width = format.width,
|
||||
height = format.height,
|
||||
bitrate = format.bitrate.toLong()
|
||||
)
|
||||
if (!qualities.any { it.height == quality.height }) {
|
||||
qualities.add(quality)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_availableQualities.value = qualities.sortedByDescending { it.height }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets available audio tracks from the current stream
|
||||
*/
|
||||
fun updateAvailableAudioTracks() {
|
||||
val player = exoPlayer ?: return
|
||||
val tracks = mutableListOf<AudioTrack>()
|
||||
|
||||
val currentTracks = player.currentTracks
|
||||
for (trackGroup in currentTracks.groups) {
|
||||
if (trackGroup.type == C.TRACK_TYPE_AUDIO) {
|
||||
for (trackIndex in 0 until trackGroup.length) {
|
||||
val format = trackGroup.getTrackFormat(trackIndex)
|
||||
val track = AudioTrack(
|
||||
id = format.id ?: trackIndex.toString(),
|
||||
language = format.language,
|
||||
label = format.label,
|
||||
isSelected = trackGroup.isTrackSelected(trackIndex)
|
||||
)
|
||||
tracks.add(track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_availableAudioTracks.value = tracks
|
||||
}
|
||||
|
||||
private fun createPlayerListener(): androidx.media3.common.Player.Listener {
|
||||
return object : androidx.media3.common.Player.Listener {
|
||||
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||
updateAvailableQualities()
|
||||
updateAvailableAudioTracks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables subtitles/text tracks
|
||||
*
|
||||
* @param enabled Whether subtitles should be enabled
|
||||
* @param language Optional language code for subtitle preference
|
||||
*/
|
||||
fun setSubtitlesEnabled(enabled: Boolean, language: String? = null) {
|
||||
val selector = trackSelector ?: return
|
||||
|
||||
val parameters = if (enabled) {
|
||||
selector.parameters.buildUponParameters()
|
||||
.setPreferredTextLanguage(language)
|
||||
.setIgnoredTextSelectionFlags(0)
|
||||
} else {
|
||||
selector.parameters.buildUponParameters()
|
||||
.setIgnoredTextSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||
}
|
||||
|
||||
selector.parameters = parameters.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the player is currently playing
|
||||
*/
|
||||
fun isPlaying(): Boolean {
|
||||
return exoPlayer?.isPlaying == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses playback
|
||||
*/
|
||||
fun pause() {
|
||||
exoPlayer?.playWhenReady = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes playback
|
||||
*/
|
||||
fun play() {
|
||||
if (requestAudioFocus()) {
|
||||
exoPlayer?.playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a VideoQuality option for auto selection
|
||||
*/
|
||||
fun createAutoQuality(): VideoQuality = VideoQuality(
|
||||
name = "Auto",
|
||||
width = 0,
|
||||
height = 0,
|
||||
bitrate = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
423
app/src/main/java/com/iptv/app/utils/UpdateInstallHelper.kt
Normal file
423
app/src/main/java/com/iptv/app/utils/UpdateInstallHelper.kt
Normal file
@@ -0,0 +1,423 @@
|
||||
package com.iptv.app.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import com.iptv.app.R
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Helper class for handling APK downloads and installations.
|
||||
* Manages FileProvider URIs, install permissions, notifications, and cleanup.
|
||||
*/
|
||||
class UpdateInstallHelper(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "update_install_channel"
|
||||
private const val CHANNEL_NAME = "App Updates"
|
||||
private const val NOTIFICATION_ID_DOWNLOAD = 1001
|
||||
private const val NOTIFICATION_ID_INSTALL = 1002
|
||||
private const val APK_DIRECTORY = "updates"
|
||||
private const val FILE_PROVIDER_AUTHORITY_SUFFIX = ".fileprovider"
|
||||
private const val MAX_APK_AGE_DAYS = 7L
|
||||
private const val MAX_APK_FILES = 3
|
||||
}
|
||||
|
||||
init {
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs an APK file using FileProvider for secure file sharing.
|
||||
* Shows notification for install progress.
|
||||
*
|
||||
* @param file The APK file to install
|
||||
* @throws IOException if the file cannot be accessed
|
||||
*/
|
||||
fun installApk(file: File) {
|
||||
if (!file.exists()) {
|
||||
throw IOException("APK file does not exist: ${file.absolutePath}")
|
||||
}
|
||||
|
||||
if (!file.canRead()) {
|
||||
throw IOException("APK file cannot be read: ${file.absolutePath}")
|
||||
}
|
||||
|
||||
showDownloadCompleteNotification(file)
|
||||
|
||||
val intent = createInstallIntent(file)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an install intent for the given APK file.
|
||||
*
|
||||
* @param file The APK file to install
|
||||
* @return Intent configured for APK installation
|
||||
*/
|
||||
private fun createInstallIntent(file: File): Intent {
|
||||
val uri = getFileProviderUri(file)
|
||||
|
||||
return Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a FileProvider URI for the given file.
|
||||
*
|
||||
* @param file The file to get URI for
|
||||
* @return Content URI through FileProvider
|
||||
*/
|
||||
private fun getFileProviderUri(file: File): Uri {
|
||||
val authority = "${context.packageName}$FILE_PROVIDER_AUTHORITY_SUFFIX"
|
||||
return FileProvider.getUriForFile(context, authority, file)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the app can request package installs (Android 8+).
|
||||
* On older Android versions, always returns true.
|
||||
*
|
||||
* @return true if install permission is granted or not required
|
||||
*/
|
||||
fun canRequestPackageInstalls(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.packageManager.canRequestPackageInstalls()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests install permission from the user (Android 8+).
|
||||
* On older Android versions, this is a no-op.
|
||||
*
|
||||
* @param activity The activity to use for launching the permission request
|
||||
*/
|
||||
fun requestInstallPermission(activity: Activity) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val intent = Intent(
|
||||
android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES
|
||||
).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
activity.startActivityForResult(
|
||||
intent,
|
||||
REQUEST_CODE_INSTALL_PERMISSION
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves downloaded APK bytes to app-specific external storage.
|
||||
*
|
||||
* @param bytes The APK file bytes
|
||||
* @param versionName The version name for the filename
|
||||
* @return The saved File
|
||||
* @throws IOException if the file cannot be saved
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun saveApkFile(bytes: ByteArray, versionName: String): File {
|
||||
val directory = getApkDirectory()
|
||||
|
||||
if (!directory.exists()) {
|
||||
directory.mkdirs()
|
||||
}
|
||||
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val fileName = "app-${versionName}-${timestamp}.apk"
|
||||
val file = File(directory, fileName)
|
||||
|
||||
file.writeBytes(bytes)
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the directory for storing APK files.
|
||||
* Uses app-specific external storage for compatibility with scoped storage.
|
||||
*
|
||||
* @return The APK storage directory
|
||||
*/
|
||||
fun getApkDirectory(): File {
|
||||
return File(
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||
APK_DIRECTORY
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up old APK files based on age and count limits.
|
||||
* Removes files older than MAX_APK_AGE_DAYS and keeps only MAX_APK_FILES most recent.
|
||||
*/
|
||||
fun cleanupOldApks() {
|
||||
val directory = getApkDirectory()
|
||||
|
||||
if (!directory.exists() || !directory.isDirectory) {
|
||||
return
|
||||
}
|
||||
|
||||
val apkFiles = directory.listFiles { file ->
|
||||
file.isFile && file.extension.equals("apk", ignoreCase = true)
|
||||
} ?: return
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val maxAgeMillis = MAX_APK_AGE_DAYS * 24 * 60 * 60 * 1000
|
||||
|
||||
// Delete files older than max age
|
||||
apkFiles.forEach { file ->
|
||||
val fileAge = currentTime - file.lastModified()
|
||||
if (fileAge > maxAgeMillis) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only the most recent MAX_APK_FILES
|
||||
val remainingFiles = directory.listFiles { file ->
|
||||
file.isFile && file.extension.equals("apk", ignoreCase = true)
|
||||
} ?: return
|
||||
|
||||
if (remainingFiles.size > MAX_APK_FILES) {
|
||||
remainingFiles
|
||||
.sortedByDescending { it.lastModified() }
|
||||
.drop(MAX_APK_FILES)
|
||||
.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification indicating download is complete and ready to install.
|
||||
*
|
||||
* @param file The downloaded APK file
|
||||
*/
|
||||
private fun showDownloadCompleteNotification(file: File) {
|
||||
val intent = createInstallIntent(file)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(context.getString(R.string.update_ready_title))
|
||||
.setContentText(context.getString(R.string.update_ready_message))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
val notificationManager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID_INSTALL, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a progress notification during APK download.
|
||||
*
|
||||
* @param progress Download progress (0-100)
|
||||
* @param totalBytes Total bytes to download
|
||||
*/
|
||||
fun showDownloadProgressNotification(progress: Int, totalBytes: Long) {
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_download)
|
||||
.setContentTitle(context.getString(R.string.update_downloading_title))
|
||||
.setContentText(formatBytes(totalBytes))
|
||||
.setProgress(100, progress, false)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
|
||||
val notificationManager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID_DOWNLOAD, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification for download failure.
|
||||
*
|
||||
* @param errorMessage The error message to display
|
||||
*/
|
||||
fun showDownloadErrorNotification(errorMessage: String) {
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setContentTitle(context.getString(R.string.update_error_title))
|
||||
.setContentText(errorMessage)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
val notificationManager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
notificationManager.notify(NOTIFICATION_ID_DOWNLOAD, notification)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the download progress notification.
|
||||
*/
|
||||
fun cancelDownloadNotification() {
|
||||
val notificationManager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
notificationManager.cancel(NOTIFICATION_ID_DOWNLOAD)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the notification channel for Android O+.
|
||||
*/
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = context.getString(R.string.update_channel_description)
|
||||
enableLights(true)
|
||||
enableVibration(true)
|
||||
}
|
||||
|
||||
val notificationManager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats bytes to human-readable string.
|
||||
*
|
||||
* @param bytes Number of bytes
|
||||
* @return Formatted string (e.g., "2.5 MB")
|
||||
*/
|
||||
private fun formatBytes(bytes: Long): String {
|
||||
val units = arrayOf("B", "KB", "MB", "GB")
|
||||
var size = bytes.toDouble()
|
||||
var unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.size - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return String.format("%.1f %s", size, units[unitIndex])
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the version name of the APK file without installing.
|
||||
* This is a best-effort attempt and may not work for all APKs.
|
||||
*
|
||||
* @param file The APK file
|
||||
* @return The version name or null if unable to determine
|
||||
*/
|
||||
fun getApkVersionName(file: File): String? {
|
||||
return try {
|
||||
val packageManager = context.packageManager
|
||||
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.getPackageArchiveInfo(
|
||||
file.absolutePath,
|
||||
PackageManager.PackageInfoFlags.of(0L)
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getPackageArchiveInfo(file.absolutePath, 0)
|
||||
}
|
||||
packageInfo?.versionName
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an APK file is valid and can be installed.
|
||||
*
|
||||
* @param file The APK file to validate
|
||||
* @return true if the file appears to be a valid APK
|
||||
*/
|
||||
fun isValidApk(file: File): Boolean {
|
||||
if (!file.exists() || !file.canRead()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check minimum file size (APK files are typically at least a few KB)
|
||||
if (file.length() < 1024) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to get package info to validate it's a proper APK
|
||||
return getApkVersionName(file) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total size of all APK files in the updates directory.
|
||||
*
|
||||
* @return Total size in bytes
|
||||
*/
|
||||
fun getApkCacheSize(): Long {
|
||||
val directory = getApkDirectory()
|
||||
|
||||
if (!directory.exists() || !directory.isDirectory) {
|
||||
return 0L
|
||||
}
|
||||
|
||||
return directory.listFiles { file ->
|
||||
file.isFile && file.extension.equals("apk", ignoreCase = true)
|
||||
}?.sumOf { it.length() } ?: 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all cached APK files.
|
||||
*
|
||||
* @return Number of files deleted
|
||||
*/
|
||||
fun clearApkCache(): Int {
|
||||
val directory = getApkDirectory()
|
||||
|
||||
if (!directory.exists() || !directory.isDirectory) {
|
||||
return 0
|
||||
}
|
||||
|
||||
val apkFiles = directory.listFiles { file ->
|
||||
file.isFile && file.extension.equals("apk", ignoreCase = true)
|
||||
} ?: return 0
|
||||
|
||||
var deletedCount = 0
|
||||
apkFiles.forEach { file ->
|
||||
if (file.delete()) {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Request code for install permission callback.
|
||||
* Use this in onActivityResult to handle the permission result.
|
||||
*/
|
||||
val REQUEST_CODE_INSTALL_PERMISSION = 1001
|
||||
}
|
||||
140
app/src/main/res/values/colors.xml
Normal file
140
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,140 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Primary Brand Colors -->
|
||||
<color name="primary">#1976D2</color>
|
||||
<color name="primary_dark">#1565C0</color>
|
||||
<color name="primary_light">#42A5F5</color>
|
||||
<color name="primary_variant">#0D47A1</color>
|
||||
|
||||
<!-- Secondary/Accent Colors -->
|
||||
<color name="secondary">#FF5722</color>
|
||||
<color name="secondary_dark">#E64A19</color>
|
||||
<color name="secondary_light">#FF8A65</color>
|
||||
<color name="secondary_variant">#BF360C</color>
|
||||
|
||||
<!-- Tertiary Colors -->
|
||||
<color name="tertiary">#00BCD4</color>
|
||||
<color name="tertiary_dark">#0097A7</color>
|
||||
<color name="tertiary_light">#4DD0E1</color>
|
||||
|
||||
<!-- Background Colors -->
|
||||
<color name="background">#FAFAFA</color>
|
||||
<color name="background_dark">#121212</color>
|
||||
<color name="surface">#FFFFFF</color>
|
||||
<color name="surface_dark">#1E1E1E</color>
|
||||
<color name="surface_variant">#F5F5F5</color>
|
||||
<color name="surface_variant_dark">#2D2D2D</color>
|
||||
|
||||
<!-- Text Colors -->
|
||||
<color name="text_primary">#212121</color>
|
||||
<color name="text_primary_dark">#FFFFFF</color>
|
||||
<color name="text_secondary">#757575</color>
|
||||
<color name="text_secondary_dark">#B3B3B3</color>
|
||||
<color name="text_disabled">#9E9E9E</color>
|
||||
<color name="text_disabled_dark">#666666</color>
|
||||
<color name="text_on_primary">#FFFFFF</color>
|
||||
<color name="text_on_secondary">#FFFFFF</color>
|
||||
|
||||
<!-- Status Colors -->
|
||||
<color name="error">#B00020</color>
|
||||
<color name="error_dark">#CF6679</color>
|
||||
<color name="success">#4CAF50</color>
|
||||
<color name="success_dark">#81C784</color>
|
||||
<color name="warning">#FFC107</color>
|
||||
<color name="warning_dark">#FFD54F</color>
|
||||
<color name="info">#2196F3</color>
|
||||
<color name="info_dark">#64B5F6</color>
|
||||
|
||||
<!-- Player Controls -->
|
||||
<color name="player_controls_background">#CC000000</color>
|
||||
<color name="player_controls_background_dark">#CC000000</color>
|
||||
<color name="player_progress_primary">#FF5722</color>
|
||||
<color name="player_progress_secondary">#757575</color>
|
||||
<color name="player_progress_buffer">#BDBDBD</color>
|
||||
<color name="player_text">#FFFFFF</color>
|
||||
<color name="player_live_indicator">#F44336</color>
|
||||
<color name="player_live_indicator_dark">#EF5350</color>
|
||||
|
||||
<!-- Channel List -->
|
||||
<color name="channel_number">#757575</color>
|
||||
<color name="channel_number_dark">#9E9E9E</color>
|
||||
<color name="channel_name">#212121</color>
|
||||
<color name="channel_name_dark">#FFFFFF</color>
|
||||
<color name="channel_epg_current">#757575</color>
|
||||
<color name="channel_epg_current_dark">#B3B3B3</color>
|
||||
<color name="channel_epg_next">#9E9E9E</color>
|
||||
<color name="channel_epg_next_dark">#757575</color>
|
||||
<color name="channel_favorite">#FFC107</color>
|
||||
<color name="channel_hd_indicator">#4CAF50</color>
|
||||
|
||||
<!-- EPG Colors -->
|
||||
<color name="epg_time_header">#F5F5F5</color>
|
||||
<color name="epg_time_header_dark">#2D2D2D</color>
|
||||
<color name="epg_channel_header">#FFFFFF</color>
|
||||
<color name="epg_channel_header_dark">#1E1E1E</color>
|
||||
<color name="epg_program_normal">#FFFFFF</color>
|
||||
<color name="epg_program_normal_dark">#1E1E1E</color>
|
||||
<color name="epg_program_selected">#E3F2FD</color>
|
||||
<color name="epg_program_selected_dark">#0D47A1</color>
|
||||
<color name="epg_program_live">#FFEBEE</color>
|
||||
<color name="epg_program_live_dark">#3E2723</color>
|
||||
<color name="epg_timeline">#1976D2</color>
|
||||
<color name="epg_timeline_dark">#42A5F5</color>
|
||||
|
||||
<!-- Category Colors -->
|
||||
<color name="category_sports">#4CAF50</color>
|
||||
<color name="category_news">#F44336</color>
|
||||
<color name="category_movies">#9C27B0</color>
|
||||
<color name="category_entertainment">#FF9800</color>
|
||||
<color name="category_kids">#00BCD4</color>
|
||||
<color name="category_music">#E91E63</color>
|
||||
<color name="category_documentary">#795548</color>
|
||||
<color name="category_education">#3F51B5</color>
|
||||
|
||||
<!-- Divider and Border -->
|
||||
<color name="divider">#E0E0E0</color>
|
||||
<color name="divider_dark">#424242</color>
|
||||
<color name="border">#BDBDBD</color>
|
||||
<color name="border_dark">#616161</color>
|
||||
|
||||
<!-- Overlay Colors -->
|
||||
<color name="overlay_light">#80FFFFFF</color>
|
||||
<color name="overlay_dark">#80000000</color>
|
||||
<color name="scrim">#52000000</color>
|
||||
<color name="scrim_dark">#99000000</color>
|
||||
|
||||
<!-- Ripple and Selection -->
|
||||
<color name="ripple">#1F000000</color>
|
||||
<color name="ripple_dark">#33FFFFFF</color>
|
||||
<color name="selection">#E3F2FD</color>
|
||||
<color name="selection_dark">#0D47A1</color>
|
||||
|
||||
<!-- Navigation -->
|
||||
<color name="navigation_background">#FFFFFF</color>
|
||||
<color name="navigation_background_dark">#1E1E1E</color>
|
||||
<color name="navigation_item">#757575</color>
|
||||
<color name="navigation_item_dark">#B3B3B3</color>
|
||||
<color name="navigation_item_selected">#1976D2</color>
|
||||
<color name="navigation_item_selected_dark">#42A5F5</color>
|
||||
|
||||
<!-- Search -->
|
||||
<color name="search_background">#F5F5F5</color>
|
||||
<color name="search_background_dark">#2D2D2D</color>
|
||||
<color name="search_hint">#9E9E9E</color>
|
||||
<color name="search_hint_dark">#757575</color>
|
||||
|
||||
<!-- Recording -->
|
||||
<color name="recording_indicator">#F44336</color>
|
||||
<color name="recording_background">#FFEBEE</color>
|
||||
<color name="recording_background_dark">#3E2723</color>
|
||||
|
||||
<!-- Catch-up TV -->
|
||||
<color name="catchup_indicator">#9C27B0</color>
|
||||
<color name="catchup_background">#F3E5F5</color>
|
||||
<color name="catchup_background_dark">#311B92</color>
|
||||
|
||||
<!-- Transparent Colors -->
|
||||
<color name="transparent">#00000000</color>
|
||||
<color name="semi_transparent">#80000000</color>
|
||||
<color name="highly_transparent">#1A000000</color>
|
||||
</resources>
|
||||
294
app/src/main/res/values/strings.xml
Normal file
294
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,294 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- App Name -->
|
||||
<string name="app_name">IPTV Player</string>
|
||||
<string name="app_name_short">IPTV</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="nav_home">Home</string>
|
||||
<string name="nav_channels">Channels</string>
|
||||
<string name="nav_favorites">Favorites</string>
|
||||
<string name="nav_epg">TV Guide</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="nav_search">Search</string>
|
||||
<string name="nav_recordings">Recordings</string>
|
||||
<string name="nav_categories">Categories</string>
|
||||
|
||||
<!-- Main Screen -->
|
||||
<string name="welcome_message">Welcome to IPTV Player</string>
|
||||
<string name="no_playlist">No playlist loaded</string>
|
||||
<string name="add_playlist_hint">Add a playlist to start watching</string>
|
||||
<string name="recent_channels">Recent Channels</string>
|
||||
<string name="featured_channels">Featured</string>
|
||||
<string name="continue_watching">Continue Watching</string>
|
||||
|
||||
<!-- Playlist -->
|
||||
<string name="playlist_title">Playlist</string>
|
||||
<string name="playlist_add">Add Playlist</string>
|
||||
<string name="playlist_edit">Edit Playlist</string>
|
||||
<string name="playlist_delete">Delete Playlist</string>
|
||||
<string name="playlist_name">Playlist Name</string>
|
||||
<string name="playlist_url">Playlist URL</string>
|
||||
<string name="playlist_file">Select File</string>
|
||||
<string name="playlist_load">Load Playlist</string>
|
||||
<string name="playlist_refresh">Refresh Playlist</string>
|
||||
<string name="playlist_auto_refresh">Auto Refresh</string>
|
||||
<string name="playlist_last_updated">Last updated: %s</string>
|
||||
<string name="playlist_empty">Playlist is empty</string>
|
||||
<string name="playlist_invalid">Invalid playlist format</string>
|
||||
<string name="playlist_load_error">Failed to load playlist</string>
|
||||
<string name="playlist_load_success">Playlist loaded successfully</string>
|
||||
<string name="playlist_m3u_supported">Supported formats: M3U, M3U8</string>
|
||||
|
||||
<!-- Channels -->
|
||||
<string name="channels">Channels</string>
|
||||
<string name="channel_number">Channel %d</string>
|
||||
<string name="channel_no_epg">No program information</string>
|
||||
<string name="channel_current">Now: %s</string>
|
||||
<string name="channel_next">Next: %s</string>
|
||||
<string name="channel_live">LIVE</string>
|
||||
<string name="channel_hd">HD</string>
|
||||
<string name="channel_fhd">FHD</string>
|
||||
<string name="channel_4k">4K</string>
|
||||
<string name="channel_add_favorite">Add to Favorites</string>
|
||||
<string name="channel_remove_favorite">Remove from Favorites</string>
|
||||
<string name="channel_no_favorites">No favorite channels</string>
|
||||
<string name="channel_search_hint">Search channels...</string>
|
||||
<string name="channel_group_all">All Channels</string>
|
||||
<string name="channel_group_favorites">Favorites</string>
|
||||
<string name="channel_group_recent">Recently Watched</string>
|
||||
|
||||
<!-- Player -->
|
||||
<string name="player_play">Play</string>
|
||||
<string name="player_pause">Pause</string>
|
||||
<string name="player_stop">Stop</string>
|
||||
<string name="player_next">Next Channel</string>
|
||||
<string name="player_previous">Previous Channel</string>
|
||||
<string name="player_fullscreen">Fullscreen</string>
|
||||
<string name="player_exit_fullscreen">Exit Fullscreen</string>
|
||||
<string name="player_aspect_ratio">Aspect Ratio</string>
|
||||
<string name="player_audio_track">Audio Track</string>
|
||||
<string name="player_subtitle">Subtitles</string>
|
||||
<string name="player_subtitle_off">Off</string>
|
||||
<string name="player_settings">Player Settings</string>
|
||||
<string name="player_pip">Picture in Picture</string>
|
||||
<string name="player_cast">Cast</string>
|
||||
<string name="player_record">Record</string>
|
||||
<string name="player_stop_record">Stop Recording</string>
|
||||
<string name="player_buffering">Buffering...</string>
|
||||
<string name="player_loading">Loading...</string>
|
||||
<string name="player_error">Playback Error</string>
|
||||
<string name="player_retry">Retry</string>
|
||||
<string name="player_live">LIVE</string>
|
||||
<string name="player_unknown_channel">Unknown Channel</string>
|
||||
|
||||
<!-- Aspect Ratios -->
|
||||
<string name="aspect_auto">Auto</string>
|
||||
<string name="aspect_fit">Fit Screen</string>
|
||||
<string name="aspect_fill">Fill Screen</string>
|
||||
<string name="aspect_zoom">Zoom</string>
|
||||
<string name="aspect_16_9">16:9</string>
|
||||
<string name="aspect_4_3">4:3</string>
|
||||
|
||||
<!-- EPG / TV Guide -->
|
||||
<string name="epg">TV Guide</string>
|
||||
<string name="epg_title">Electronic Program Guide</string>
|
||||
<string name="epg_no_data">No EPG data available</string>
|
||||
<string name="epg_loading">Loading EPG...</string>
|
||||
<string name="epg_load_error">Failed to load EPG</string>
|
||||
<string name="epg_program_details">Program Details</string>
|
||||
<string name="epg_program_start">Start: %s</string>
|
||||
<string name="epg_program_end">End: %s</string>
|
||||
<string name="epg_program_duration">Duration: %s</string>
|
||||
<string name="epg_now">Now</string>
|
||||
<string name="epg_today">Today</string>
|
||||
<string name="epg_tomorrow">Tomorrow</string>
|
||||
<string name="epg_yesterday">Yesterday</string>
|
||||
<string name="epg_catch_up">Catch-up Available</string>
|
||||
<string name="epg_reminder_set">Reminder Set</string>
|
||||
<string name="epg_reminder_cancel">Cancel Reminder</string>
|
||||
|
||||
<!-- Categories -->
|
||||
<string name="category_all">All</string>
|
||||
<string name="category_sports">Sports</string>
|
||||
<string name="category_news">News</string>
|
||||
<string name="category_movies">Movies</string>
|
||||
<string name="category_entertainment">Entertainment</string>
|
||||
<string name="category_kids">Kids</string>
|
||||
<string name="category_music">Music</string>
|
||||
<string name="category_documentary">Documentary</string>
|
||||
<string name="category_education">Education</string>
|
||||
<string name="category_lifestyle">Lifestyle</string>
|
||||
<string name="category_religious">Religious</string>
|
||||
<string name="category_international">International</string>
|
||||
<string name="category_local">Local</string>
|
||||
<string name="category_radio">Radio</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings">Settings</string>
|
||||
<string name="settings_general">General</string>
|
||||
<string name="settings_player">Player</string>
|
||||
<string name="settings_playback">Playback</string>
|
||||
<string name="settings_epg">EPG Settings</string>
|
||||
<string name="settings_interface">Interface</string>
|
||||
<string name="settings_network">Network</string>
|
||||
<string name="settings_advanced">Advanced</string>
|
||||
<string name="settings_about">About</string>
|
||||
|
||||
<!-- General Settings -->
|
||||
<string name="setting_startup_page">Startup Page</string>
|
||||
<string name="setting_language">Language</string>
|
||||
<string name="setting_theme">Theme</string>
|
||||
<string name="setting_theme_light">Light</string>
|
||||
<string name="setting_theme_dark">Dark</string>
|
||||
<string name="setting_theme_system">System Default</string>
|
||||
<string name="setting_auto_update">Auto Update Playlists</string>
|
||||
<string name="setting_auto_update_interval">Update Interval</string>
|
||||
|
||||
<!-- Player Settings -->
|
||||
<string name="setting_default_aspect">Default Aspect Ratio</string>
|
||||
<string name="setting_auto_play">Auto Play Last Channel</string>
|
||||
<string name="setting_show_channel_info">Show Channel Info</string>
|
||||
<string name="setting_channel_info_timeout">Channel Info Timeout</string>
|
||||
<string name="setting_preferred_quality">Preferred Quality</string>
|
||||
<string name="setting_hardware_decoding">Hardware Decoding</string>
|
||||
<string name="setting_software_decoding">Software Decoding</string>
|
||||
<string name="setting_buffer_size">Buffer Size</string>
|
||||
<string name="setting_audio_language">Preferred Audio Language</string>
|
||||
<string name="setting_subtitle_language">Preferred Subtitle Language</string>
|
||||
|
||||
<!-- EPG Settings -->
|
||||
<string name="setting_epg_url">EPG URL</string>
|
||||
<string name="setting_epg_auto_update">Auto Update EPG</string>
|
||||
<string name="setting_epg_update_interval">EPG Update Interval</string>
|
||||
<string name="setting_epg_hours_visible">Hours Visible</string>
|
||||
<string name="setting_show_epg_thumbnails">Show Program Thumbnails</string>
|
||||
|
||||
<!-- Network Settings -->
|
||||
<string name="setting_user_agent">User Agent</string>
|
||||
<string name="setting_http_timeout">HTTP Timeout</string>
|
||||
<string name="setting_use_proxy">Use Proxy</string>
|
||||
<string name="setting_proxy_host">Proxy Host</string>
|
||||
<string name="setting_proxy_port">Proxy Port</string>
|
||||
<string name="setting_allow_insecure">Allow Insecure Connections</string>
|
||||
|
||||
<!-- Recording -->
|
||||
<string name="recording">Recording</string>
|
||||
<string name="recording_start">Start Recording</string>
|
||||
<string name="recording_stop">Stop Recording</string>
|
||||
<string name="recording_in_progress">Recording in Progress</string>
|
||||
<string name="recording_saved">Recording Saved</string>
|
||||
<string name="recording_failed">Recording Failed</string>
|
||||
<string name="recording_storage">Storage Location</string>
|
||||
<string name="recording_no_storage">No storage available</string>
|
||||
<string name="recording_duration">Recording: %s</string>
|
||||
|
||||
<!-- Search -->
|
||||
<string name="search">Search</string>
|
||||
<string name="search_hint">Search channels or programs...</string>
|
||||
<string name="search_no_results">No results found</string>
|
||||
<string name="search_history">Search History</string>
|
||||
<string name="search_clear_history">Clear History</string>
|
||||
<string name="search_filters">Filters</string>
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_network">Network error. Please check your connection.</string>
|
||||
<string name="error_playback">Playback error. Please try again.</string>
|
||||
<string name="error_unsupported_format">Unsupported media format.</string>
|
||||
<string name="error_stream_unavailable">Stream unavailable.</string>
|
||||
<string name="error_timeout">Connection timeout.</string>
|
||||
<string name="error_no_internet">No internet connection.</string>
|
||||
<string name="error_invalid_url">Invalid URL.</string>
|
||||
<string name="error_file_not_found">File not found.</string>
|
||||
<string name="error_permission_denied">Permission denied.</string>
|
||||
<string name="error_storage_full">Storage full.</string>
|
||||
<string name="error_unknown">An unknown error occurred.</string>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<string name="dialog_ok">OK</string>
|
||||
<string name="dialog_cancel">Cancel</string>
|
||||
<string name="dialog_yes">Yes</string>
|
||||
<string name="dialog_no">No</string>
|
||||
<string name="dialog_save">Save</string>
|
||||
<string name="dialog_delete">Delete</string>
|
||||
<string name="dialog_edit">Edit</string>
|
||||
<string name="dialog_add">Add</string>
|
||||
<string name="dialog_close">Close</string>
|
||||
<string name="dialog_back">Back</string>
|
||||
<string name="dialog_confirm">Confirm</string>
|
||||
<string name="dialog_loading">Loading...</string>
|
||||
<string name="dialog_please_wait">Please wait...</string>
|
||||
|
||||
<!-- Confirmation Messages -->
|
||||
<string name="confirm_delete_playlist">Delete this playlist?</string>
|
||||
<string name="confirm_delete_recording">Delete this recording?</string>
|
||||
<string name="confirm_clear_history">Clear search history?</string>
|
||||
<string name="confirm_exit">Exit the app?</string>
|
||||
<string name="confirm_stop_recording">Stop recording?</string>
|
||||
|
||||
<!-- Success Messages -->
|
||||
<string name="success_saved">Saved successfully</string>
|
||||
<string name="success_deleted">Deleted successfully</string>
|
||||
<string name="success_updated">Updated successfully</string>
|
||||
<string name="success_added">Added successfully</string>
|
||||
|
||||
<!-- About -->
|
||||
<string name="about_version">Version %s</string>
|
||||
<string name="about_build">Build %d</string>
|
||||
<string name="about_copyright">Copyright 2024 IPTV Player</string>
|
||||
<string name="about_license">Licensed under Apache 2.0</string>
|
||||
<string name="about_privacy_policy">Privacy Policy</string>
|
||||
<string name="about_terms_of_service">Terms of Service</string>
|
||||
<string name="about_open_source">Open Source Licenses</string>
|
||||
<string name="about_rate_app">Rate App</string>
|
||||
<string name="about_share_app">Share App</string>
|
||||
<string name="about_feedback">Send Feedback</string>
|
||||
|
||||
<!-- Time -->
|
||||
<string name="time_format_12h">12-hour format</string>
|
||||
<string name="time_format_24h">24-hour format</string>
|
||||
<string name="time_just_now">Just now</string>
|
||||
<string name="time_minutes_ago">%d minutes ago</string>
|
||||
<string name="time_hours_ago">%d hours ago</string>
|
||||
<string name="time_yesterday">Yesterday</string>
|
||||
|
||||
<!-- Accessibility -->
|
||||
<string name="accessibility_play">Play button</string>
|
||||
<string name="accessibility_pause">Pause button</string>
|
||||
<string name="accessibility_stop">Stop button</string>
|
||||
<string name="accessibility_fullscreen">Toggle fullscreen</string>
|
||||
<string name="accessibility_channel_up">Channel up</string>
|
||||
<string name="accessibility_channel_down">Channel down</string>
|
||||
<string name="accessibility_volume_up">Volume up</string>
|
||||
<string name="accessibility_volume_down">Volume down</string>
|
||||
<string name="accessibility_mute">Mute</string>
|
||||
<string name="accessibility_settings">Settings</string>
|
||||
<string name="accessibility_back">Go back</string>
|
||||
<string name="accessibility_menu">Menu</string>
|
||||
<string name="accessibility_search">Search</string>
|
||||
<string name="accessibility_favorite">Toggle favorite</string>
|
||||
<string name="accessibility_close">Close</string>
|
||||
|
||||
<!-- TV/Leanback -->
|
||||
<string name="tv_browse_title">IPTV Player</string>
|
||||
<string name="tv_search_title">Search</string>
|
||||
<string name="tv_settings_title">Settings</string>
|
||||
<string name="tv_guide_title">TV Guide</string>
|
||||
<string name="tv_no_channels">No channels available</string>
|
||||
<string name="tv_press_select">Press SELECT to play</string>
|
||||
|
||||
<!-- Content Descriptions -->
|
||||
<string name="desc_app_logo">App Logo</string>
|
||||
<string name="desc_channel_logo">Channel Logo</string>
|
||||
<string name="desc_program_thumbnail">Program Thumbnail</string>
|
||||
<string name="desc_player_controls">Player Controls</string>
|
||||
<string name="desc_progress_bar">Progress Bar</string>
|
||||
<string name="desc_volume_indicator">Volume Indicator</string>
|
||||
|
||||
<!-- App Update Notifications -->
|
||||
<string name="update_channel_description">Notifications for app updates and installations</string>
|
||||
<string name="update_downloading_title">Downloading Update</string>
|
||||
<string name="update_ready_title">Update Ready</string>
|
||||
<string name="update_ready_message">Tap to install the update</string>
|
||||
<string name="update_error_title">Update Failed</string>
|
||||
</resources>
|
||||
488
app/src/main/res/values/themes.xml
Normal file
488
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,488 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme - Light -->
|
||||
<style name="Theme.IPTVApp" parent="Theme.Material3.Light.NoActionBar">
|
||||
<!-- Primary brand colors -->
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorPrimaryDark">@color/primary_dark</item>
|
||||
<item name="colorPrimaryContainer">@color/primary_light</item>
|
||||
<item name="colorOnPrimary">@color/text_on_primary</item>
|
||||
<item name="colorOnPrimaryContainer">@color/primary_variant</item>
|
||||
|
||||
<!-- Secondary brand colors -->
|
||||
<item name="colorSecondary">@color/secondary</item>
|
||||
<item name="colorSecondaryContainer">@color/secondary_light</item>
|
||||
<item name="colorOnSecondary">@color/text_on_secondary</item>
|
||||
<item name="colorOnSecondaryContainer">@color/secondary_variant</item>
|
||||
|
||||
<!-- Tertiary colors -->
|
||||
<item name="colorTertiary">@color/tertiary</item>
|
||||
<item name="colorTertiaryContainer">@color/tertiary_light</item>
|
||||
<item name="colorOnTertiary">@color/text_on_primary</item>
|
||||
<item name="colorOnTertiaryContainer">@color/tertiary_dark</item>
|
||||
|
||||
<!-- Surface colors -->
|
||||
<item name="colorSurface">@color/surface</item>
|
||||
<item name="colorSurfaceVariant">@color/surface_variant</item>
|
||||
<item name="colorOnSurface">@color/text_primary</item>
|
||||
<item name="colorOnSurfaceVariant">@color/text_secondary</item>
|
||||
|
||||
<!-- Background colors -->
|
||||
<item name="android:colorBackground">@color/background</item>
|
||||
<item name="colorOnBackground">@color/text_primary</item>
|
||||
|
||||
<!-- Error colors -->
|
||||
<item name="colorError">@color/error</item>
|
||||
<item name="colorErrorContainer">@color/error</item>
|
||||
<item name="colorOnError">@color/text_on_primary</item>
|
||||
<item name="colorOnErrorContainer">@color/error</item>
|
||||
|
||||
<!-- Outline -->
|
||||
<item name="colorOutline">@color/border</item>
|
||||
<item name="colorOutlineVariant">@color/divider</item>
|
||||
|
||||
<!-- Status bar -->
|
||||
<item name="android:statusBarColor">@color/primary_dark</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
|
||||
<!-- Navigation bar -->
|
||||
<item name="android:navigationBarColor">@color/background</item>
|
||||
<item name="android:windowLightNavigationBar">true</item>
|
||||
|
||||
<!-- Typography -->
|
||||
<item name="textAppearanceDisplayLarge">@style/TextAppearance.IPTV.DisplayLarge</item>
|
||||
<item name="textAppearanceDisplayMedium">@style/TextAppearance.IPTV.DisplayMedium</item>
|
||||
<item name="textAppearanceDisplaySmall">@style/TextAppearance.IPTV.DisplaySmall</item>
|
||||
<item name="textAppearanceHeadlineLarge">@style/TextAppearance.IPTV.HeadlineLarge</item>
|
||||
<item name="textAppearanceHeadlineMedium">@style/TextAppearance.IPTV.HeadlineMedium</item>
|
||||
<item name="textAppearanceHeadlineSmall">@style/TextAppearance.IPTV.HeadlineSmall</item>
|
||||
<item name="textAppearanceTitleLarge">@style/TextAppearance.IPTV.TitleLarge</item>
|
||||
<item name="textAppearanceTitleMedium">@style/TextAppearance.IPTV.TitleMedium</item>
|
||||
<item name="textAppearanceTitleSmall">@style/TextAppearance.IPTV.TitleSmall</item>
|
||||
<item name="textAppearanceBodyLarge">@style/TextAppearance.IPTV.BodyLarge</item>
|
||||
<item name="textAppearanceBodyMedium">@style/TextAppearance.IPTV.BodyMedium</item>
|
||||
<item name="textAppearanceBodySmall">@style/TextAppearance.IPTV.BodySmall</item>
|
||||
<item name="textAppearanceLabelLarge">@style/TextAppearance.IPTV.LabelLarge</item>
|
||||
<item name="textAppearanceLabelMedium">@style/TextAppearance.IPTV.LabelMedium</item>
|
||||
<item name="textAppearanceLabelSmall">@style/TextAppearance.IPTV.LabelSmall</item>
|
||||
|
||||
<!-- Shape appearance -->
|
||||
<item name="shapeAppearanceSmallComponent">@style/ShapeAppearance.IPTV.SmallComponent</item>
|
||||
<item name="shapeAppearanceMediumComponent">@style/ShapeAppearance.IPTV.MediumComponent</item>
|
||||
<item name="shapeAppearanceLargeComponent">@style/ShapeAppearance.IPTV.LargeComponent</item>
|
||||
|
||||
<!-- Component styles -->
|
||||
<item name="materialButtonStyle">@style/Widget.IPTV.Button</item>
|
||||
<item name="materialCardViewStyle">@style/Widget.IPTV.CardView</item>
|
||||
<item name="textInputStyle">@style/Widget.IPTV.TextInputLayout</item>
|
||||
<item name="toolbarStyle">@style/Widget.IPTV.Toolbar</item>
|
||||
<item name="appBarLayoutStyle">@style/Widget.IPTV.AppBar</item>
|
||||
<item name="bottomNavigationStyle">@style/Widget.IPTV.BottomNavigation</item>
|
||||
<item name="tabStyle">@style/Widget.IPTV.TabLayout</item>
|
||||
<item name="sliderStyle">@style/Widget.IPTV.Slider</item>
|
||||
<item name="switchStyle">@style/Widget.IPTV.Switch</item>
|
||||
<item name="checkboxStyle">@style/Widget.IPTV.CheckBox</item>
|
||||
<item name="radioButtonStyle">@style/Widget.IPTV.RadioButton</item>
|
||||
|
||||
<!-- Dialog themes -->
|
||||
<item name="materialAlertDialogTheme">@style/ThemeOverlay.IPTV.MaterialAlertDialog</item>
|
||||
<item name="alertDialogTheme">@style/ThemeOverlay.IPTV.AlertDialog</item>
|
||||
|
||||
<!-- Popup menu -->
|
||||
<item name="popupMenuStyle">@style/Widget.IPTV.PopupMenu</item>
|
||||
</style>
|
||||
|
||||
<!-- Dark Theme -->
|
||||
<style name="Theme.IPTVApp.Dark" parent="Theme.Material3.Dark.NoActionBar">
|
||||
<!-- Primary brand colors -->
|
||||
<item name="colorPrimary">@color/primary_light</item>
|
||||
<item name="colorPrimaryDark">@color/primary</item>
|
||||
<item name="colorPrimaryContainer">@color/primary_variant</item>
|
||||
<item name="colorOnPrimary">@color/text_on_primary</item>
|
||||
<item name="colorOnPrimaryContainer">@color/primary_light</item>
|
||||
|
||||
<!-- Secondary brand colors -->
|
||||
<item name="colorSecondary">@color/secondary_light</item>
|
||||
<item name="colorSecondaryContainer">@color/secondary_variant</item>
|
||||
<item name="colorOnSecondary">@color/text_on_secondary</item>
|
||||
<item name="colorOnSecondaryContainer">@color/secondary_light</item>
|
||||
|
||||
<!-- Tertiary colors -->
|
||||
<item name="colorTertiary">@color/tertiary_light</item>
|
||||
<item name="colorTertiaryContainer">@color/tertiary_dark</item>
|
||||
<item name="colorOnTertiary">@color/text_on_primary</item>
|
||||
<item name="colorOnTertiaryContainer">@color/tertiary_light</item>
|
||||
|
||||
<!-- Surface colors -->
|
||||
<item name="colorSurface">@color/surface_dark</item>
|
||||
<item name="colorSurfaceVariant">@color/surface_variant_dark</item>
|
||||
<item name="colorOnSurface">@color/text_primary_dark</item>
|
||||
<item name="colorOnSurfaceVariant">@color/text_secondary_dark</item>
|
||||
|
||||
<!-- Background colors -->
|
||||
<item name="android:colorBackground">@color/background_dark</item>
|
||||
<item name="colorOnBackground">@color/text_primary_dark</item>
|
||||
|
||||
<!-- Error colors -->
|
||||
<item name="colorError">@color/error_dark</item>
|
||||
<item name="colorErrorContainer">@color/error_dark</item>
|
||||
<item name="colorOnError">@color/text_on_primary</item>
|
||||
<item name="colorOnErrorContainer">@color/error_dark</item>
|
||||
|
||||
<!-- Outline -->
|
||||
<item name="colorOutline">@color/border_dark</item>
|
||||
<item name="colorOutlineVariant">@color/divider_dark</item>
|
||||
|
||||
<!-- Status bar -->
|
||||
<item name="android:statusBarColor">@color/background_dark</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
|
||||
<!-- Navigation bar -->
|
||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||
<item name="android:windowLightNavigationBar">false</item>
|
||||
|
||||
<!-- Typography -->
|
||||
<item name="textAppearanceDisplayLarge">@style/TextAppearance.IPTV.DisplayLarge</item>
|
||||
<item name="textAppearanceDisplayMedium">@style/TextAppearance.IPTV.DisplayMedium</item>
|
||||
<item name="textAppearanceDisplaySmall">@style/TextAppearance.IPTV.DisplaySmall</item>
|
||||
<item name="textAppearanceHeadlineLarge">@style/TextAppearance.IPTV.HeadlineLarge</item>
|
||||
<item name="textAppearanceHeadlineMedium">@style/TextAppearance.IPTV.HeadlineMedium</item>
|
||||
<item name="textAppearanceHeadlineSmall">@style/TextAppearance.IPTV.HeadlineSmall</item>
|
||||
<item name="textAppearanceTitleLarge">@style/TextAppearance.IPTV.TitleLarge</item>
|
||||
<item name="textAppearanceTitleMedium">@style/TextAppearance.IPTV.TitleMedium</item>
|
||||
<item name="textAppearanceTitleSmall">@style/TextAppearance.IPTV.TitleSmall</item>
|
||||
<item name="textAppearanceBodyLarge">@style/TextAppearance.IPTV.BodyLarge</item>
|
||||
<item name="textAppearanceBodyMedium">@style/TextAppearance.IPTV.BodyMedium</item>
|
||||
<item name="textAppearanceBodySmall">@style/TextAppearance.IPTV.BodySmall</item>
|
||||
<item name="textAppearanceLabelLarge">@style/TextAppearance.IPTV.LabelLarge</item>
|
||||
<item name="textAppearanceLabelMedium">@style/TextAppearance.IPTV.LabelMedium</item>
|
||||
<item name="textAppearanceLabelSmall">@style/TextAppearance.IPTV.LabelSmall</item>
|
||||
|
||||
<!-- Shape appearance -->
|
||||
<item name="shapeAppearanceSmallComponent">@style/ShapeAppearance.IPTV.SmallComponent</item>
|
||||
<item name="shapeAppearanceMediumComponent">@style/ShapeAppearance.IPTV.MediumComponent</item>
|
||||
<item name="shapeAppearanceLargeComponent">@style/ShapeAppearance.IPTV.LargeComponent</item>
|
||||
|
||||
<!-- Component styles -->
|
||||
<item name="materialButtonStyle">@style/Widget.IPTV.Button</item>
|
||||
<item name="materialCardViewStyle">@style/Widget.IPTV.CardView.Dark</item>
|
||||
<item name="textInputStyle">@style/Widget.IPTV.TextInputLayout.Dark</item>
|
||||
<item name="toolbarStyle">@style/Widget.IPTV.Toolbar.Dark</item>
|
||||
<item name="appBarLayoutStyle">@style/Widget.IPTV.AppBar.Dark</item>
|
||||
<item name="bottomNavigationStyle">@style/Widget.IPTV.BottomNavigation.Dark</item>
|
||||
<item name="tabStyle">@style/Widget.IPTV.TabLayout.Dark</item>
|
||||
<item name="sliderStyle">@style/Widget.IPTV.Slider.Dark</item>
|
||||
<item name="switchStyle">@style/Widget.IPTV.Switch.Dark</item>
|
||||
<item name="checkboxStyle">@style/Widget.IPTV.CheckBox.Dark</item>
|
||||
<item name="radioButtonStyle">@style/Widget.IPTV.RadioButton.Dark</item>
|
||||
|
||||
<!-- Dialog themes -->
|
||||
<item name="materialAlertDialogTheme">@style/ThemeOverlay.IPTV.MaterialAlertDialog.Dark</item>
|
||||
<item name="alertDialogTheme">@style/ThemeOverlay.IPTV.AlertDialog.Dark</item>
|
||||
|
||||
<!-- Popup menu -->
|
||||
<item name="popupMenuStyle">@style/Widget.IPTV.PopupMenu.Dark</item>
|
||||
</style>
|
||||
|
||||
<!-- Fullscreen Player Theme (Immersive) -->
|
||||
<style name="Theme.IPTVApp.Fullscreen" parent="Theme.IPTVApp">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:windowTranslucentStatus">true</item>
|
||||
<item name="android:windowTranslucentNavigation">true</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:background">@android:color/black</item>
|
||||
</style>
|
||||
|
||||
<!-- Fullscreen Player Theme Dark -->
|
||||
<style name="Theme.IPTVApp.Fullscreen.Dark" parent="Theme.IPTVApp.Dark">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:windowTranslucentStatus">true</item>
|
||||
<item name="android:windowTranslucentNavigation">true</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:background">@android:color/black</item>
|
||||
</style>
|
||||
|
||||
<!-- Android TV / Leanback Theme -->
|
||||
<style name="Theme.IPTVApp.Leanback" parent="Theme.Leanback">
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
<item name="android:colorPrimary">@color/primary</item>
|
||||
<item name="android:colorPrimaryDark">@color/primary_dark</item>
|
||||
<item name="android:colorAccent">@color/secondary</item>
|
||||
</style>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- Text Appearances -->
|
||||
<!-- ============================================ -->
|
||||
|
||||
<style name="TextAppearance.IPTV.DisplayLarge" parent="TextAppearance.Material3.DisplayLarge">
|
||||
<item name="fontFamily">@font/roboto_regular</item>
|
||||
<item name="android:fontFamily">@font/roboto_regular</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.DisplayMedium" parent="TextAppearance.Material3.DisplayMedium">
|
||||
<item name="fontFamily">@font/roboto_regular</item>
|
||||
<item name="android:fontFamily">@font/roboto_regular</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.DisplaySmall" parent="TextAppearance.Material3.DisplaySmall">
|
||||
<item name="fontFamily">@font/roboto_regular</item>
|
||||
<item name="android:fontFamily">@font/roboto_regular</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.HeadlineLarge" parent="TextAppearance.Material3.HeadlineLarge">
|
||||
<item name="fontFamily">@font/roboto_medium</item>
|
||||
<item name="android:fontFamily">@font/roboto_medium</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.HeadlineMedium" parent="TextAppearance.Material3.HeadlineMedium">
|
||||
<item name="fontFamily">@font/roboto_medium</item>
|
||||
<item name="android:fontFamily">@font/roboto_medium</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.HeadlineSmall" parent="TextAppearance.Material3.HeadlineSmall">
|
||||
<item name="fontFamily">@font/roboto_medium</item>
|
||||
<item name="android:fontFamily">@font/roboto_medium</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.TitleLarge" parent="TextAppearance.Material3.TitleLarge">
|
||||
<item name="fontFamily">@font/roboto_medium</item>
|
||||
<item name="android:fontFamily">@font/roboto_medium</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.TitleMedium" parent="TextAppearance.Material3.TitleMedium">
|
||||
<item name="fontFamily">@font/roboto_medium</item>
|
||||
<item name="android:fontFamily">@font/roboto_medium</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.TitleSmall" parent="TextAppearance.Material3.TitleSmall">
|
||||
<item name="fontFamily">@font/roboto_medium</item>
|
||||
<item name="android:fontFamily">@font/roboto_medium</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.BodyLarge" parent="TextAppearance.Material3.BodyLarge">
|
||||
<item name="fontFamily">@font/roboto_regular</item>
|
||||
<item name="android:fontFamily">@font/roboto_regular</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.BodyMedium" parent="TextAppearance.Material3.BodyMedium">
|
||||
<item name="fontFamily">@font/roboto_regular</item>
|
||||
<item name="android:fontFamily">@font/roboto_regular</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.BodySmall" parent="TextAppearance.Material3.BodySmall">
|
||||
<item name="fontFamily">@font/roboto_regular</item>
|
||||
<item name="android:fontFamily">@font/roboto_regular</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.LabelLarge" parent="TextAppearance.Material3.LabelLarge">
|
||||
<item name="fontFamily">@font/roboto_medium</item>
|
||||
<item name="android:fontFamily">@font/roboto_medium</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.LabelMedium" parent="TextAppearance.Material3.LabelMedium">
|
||||
<item name="fontFamily">@font/roboto_medium</item>
|
||||
<item name="android:fontFamily">@font/roboto_medium</item>
|
||||
</style>
|
||||
|
||||
<style name="TextAppearance.IPTV.LabelSmall" parent="TextAppearance.Material3.LabelSmall">
|
||||
<item name="fontFamily">@font/roboto_medium</item>
|
||||
<item name="android:fontFamily">@font/roboto_medium</item>
|
||||
</style>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- Shape Appearances -->
|
||||
<!-- ============================================ -->
|
||||
|
||||
<style name="ShapeAppearance.IPTV.SmallComponent" parent="ShapeAppearance.Material3.SmallComponent">
|
||||
<item name="cornerFamily">rounded</item>
|
||||
<item name="cornerSize">8dp</item>
|
||||
</style>
|
||||
|
||||
<style name="ShapeAppearance.IPTV.MediumComponent" parent="ShapeAppearance.Material3.MediumComponent">
|
||||
<item name="cornerFamily">rounded</item>
|
||||
<item name="cornerSize">12dp</item>
|
||||
</style>
|
||||
|
||||
<style name="ShapeAppearance.IPTV.LargeComponent" parent="ShapeAppearance.Material3.LargeComponent">
|
||||
<item name="cornerFamily">rounded</item>
|
||||
<item name="cornerSize">16dp</item>
|
||||
</style>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- Widget Styles -->
|
||||
<!-- ============================================ -->
|
||||
|
||||
<!-- Button -->
|
||||
<style name="Widget.IPTV.Button" parent="Widget.Material3.Button">
|
||||
<item name="android:textAppearance">@style/TextAppearance.IPTV.LabelLarge</item>
|
||||
<item name="android:minHeight">48dp</item>
|
||||
<item name="cornerRadius">8dp</item>
|
||||
</style>
|
||||
|
||||
<!-- CardView -->
|
||||
<style name="Widget.IPTV.CardView" parent="Widget.Material3.CardView.Elevated">
|
||||
<item name="cardBackgroundColor">@color/surface</item>
|
||||
<item name="cardElevation">2dp</item>
|
||||
<item name="cardCornerRadius">12dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.CardView.Dark" parent="Widget.Material3.CardView.Elevated">
|
||||
<item name="cardBackgroundColor">@color/surface_dark</item>
|
||||
<item name="cardElevation">2dp</item>
|
||||
<item name="cardCornerRadius">12dp</item>
|
||||
</style>
|
||||
|
||||
<!-- TextInputLayout -->
|
||||
<style name="Widget.IPTV.TextInputLayout" parent="Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<item name="boxStrokeColor">@color/primary</item>
|
||||
<item name="hintTextColor">@color/primary</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.TextInputLayout.Dark" parent="Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<item name="boxStrokeColor">@color/primary_light</item>
|
||||
<item name="hintTextColor">@color/primary_light</item>
|
||||
</style>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<style name="Widget.IPTV.Toolbar" parent="Widget.Material3.Toolbar">
|
||||
<item name="android:background">@color/primary</item>
|
||||
<item name="titleTextColor">@color/text_on_primary</item>
|
||||
<item name="subtitleTextColor">@color/text_on_primary</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.Toolbar.Dark" parent="Widget.Material3.Toolbar">
|
||||
<item name="android:background">@color/surface_dark</item>
|
||||
<item name="titleTextColor">@color/text_primary_dark</item>
|
||||
<item name="subtitleTextColor">@color/text_secondary_dark</item>
|
||||
</style>
|
||||
|
||||
<!-- AppBar -->
|
||||
<style name="Widget.IPTV.AppBar" parent="Widget.Material3.AppBarLayout">
|
||||
<item name="android:background">@color/primary</item>
|
||||
<item name="elevation">4dp</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.AppBar.Dark" parent="Widget.Material3.AppBarLayout">
|
||||
<item name="android:background">@color/surface_dark</item>
|
||||
<item name="elevation">4dp</item>
|
||||
</style>
|
||||
|
||||
<!-- BottomNavigation -->
|
||||
<style name="Widget.IPTV.BottomNavigation" parent="Widget.Material3.BottomNavigationView">
|
||||
<item name="android:background">@color/surface</item>
|
||||
<item name="itemIconTint">@color/navigation_item</item>
|
||||
<item name="itemTextColor">@color/navigation_item</item>
|
||||
<item name="itemRippleColor">@color/ripple</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.BottomNavigation.Dark" parent="Widget.Material3.BottomNavigationView">
|
||||
<item name="android:background">@color/surface_dark</item>
|
||||
<item name="itemIconTint">@color/navigation_item_dark</item>
|
||||
<item name="itemTextColor">@color/navigation_item_dark</item>
|
||||
<item name="itemRippleColor">@color/ripple_dark</item>
|
||||
</style>
|
||||
|
||||
<!-- TabLayout -->
|
||||
<style name="Widget.IPTV.TabLayout" parent="Widget.Material3.TabLayout">
|
||||
<item name="android:background">@color/primary</item>
|
||||
<item name="tabIndicatorColor">@color/text_on_primary</item>
|
||||
<item name="tabSelectedTextColor">@color/text_on_primary</item>
|
||||
<item name="tabTextColor">@color/text_on_primary</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.TabLayout.Dark" parent="Widget.Material3.TabLayout">
|
||||
<item name="android:background">@color/surface_dark</item>
|
||||
<item name="tabIndicatorColor">@color/primary_light</item>
|
||||
<item name="tabSelectedTextColor">@color/primary_light</item>
|
||||
<item name="tabTextColor">@color/text_secondary_dark</item>
|
||||
</style>
|
||||
|
||||
<!-- Slider -->
|
||||
<style name="Widget.IPTV.Slider" parent="Widget.Material3.Slider">
|
||||
<item name="trackColorActive">@color/primary</item>
|
||||
<item name="trackColorInactive">@color/surface_variant</item>
|
||||
<item name="thumbColor">@color/primary</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.Slider.Dark" parent="Widget.Material3.Slider">
|
||||
<item name="trackColorActive">@color/primary_light</item>
|
||||
<item name="trackColorInactive">@color/surface_variant_dark</item>
|
||||
<item name="thumbColor">@color/primary_light</item>
|
||||
</style>
|
||||
|
||||
<!-- Switch -->
|
||||
<style name="Widget.IPTV.Switch" parent="Widget.Material3.CompoundButton.MaterialSwitch">
|
||||
<item name="thumbTint">@color/primary</item>
|
||||
<item name="trackTint">@color/primary_light</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.Switch.Dark" parent="Widget.Material3.CompoundButton.MaterialSwitch">
|
||||
<item name="thumbTint">@color/primary_light</item>
|
||||
<item name="trackTint">@color/primary</item>
|
||||
</style>
|
||||
|
||||
<!-- CheckBox -->
|
||||
<style name="Widget.IPTV.CheckBox" parent="Widget.Material3.CompoundButton.CheckBox">
|
||||
<item name="buttonTint">@color/primary</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.CheckBox.Dark" parent="Widget.Material3.CompoundButton.CheckBox">
|
||||
<item name="buttonTint">@color/primary_light</item>
|
||||
</style>
|
||||
|
||||
<!-- RadioButton -->
|
||||
<style name="Widget.IPTV.RadioButton" parent="Widget.Material3.CompoundButton.RadioButton">
|
||||
<item name="buttonTint">@color/primary</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.RadioButton.Dark" parent="Widget.Material3.CompoundButton.RadioButton">
|
||||
<item name="buttonTint">@color/primary_light</item>
|
||||
</style>
|
||||
|
||||
<!-- PopupMenu -->
|
||||
<style name="Widget.IPTV.PopupMenu" parent="Widget.Material3.PopupMenu">
|
||||
<item name="android:popupBackground">@drawable/popup_background</item>
|
||||
</style>
|
||||
|
||||
<style name="Widget.IPTV.PopupMenu.Dark" parent="Widget.Material3.PopupMenu">
|
||||
<item name="android:popupBackground">@drawable/popup_background_dark</item>
|
||||
</style>
|
||||
|
||||
<!-- ============================================ -->
|
||||
<!-- Theme Overlays -->
|
||||
<!-- ============================================ -->
|
||||
|
||||
<style name="ThemeOverlay.IPTV.MaterialAlertDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorSurface">@color/surface</item>
|
||||
<item name="colorOnSurface">@color/text_primary</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemeOverlay.IPTV.MaterialAlertDialog.Dark" parent="ThemeOverlay.Material3.MaterialAlertDialog">
|
||||
<item name="colorPrimary">@color/primary_light</item>
|
||||
<item name="colorSurface">@color/surface_dark</item>
|
||||
<item name="colorOnSurface">@color/text_primary_dark</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemeOverlay.IPTV.AlertDialog" parent="ThemeOverlay.Material3.Dialog.Alert">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="android:background">@color/surface</item>
|
||||
</style>
|
||||
|
||||
<style name="ThemeOverlay.IPTV.AlertDialog.Dark" parent="ThemeOverlay.Material3.Dialog.Alert">
|
||||
<item name="colorPrimary">@color/primary_light</item>
|
||||
<item name="android:background">@color/surface_dark</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
7
app/src/main/res/xml/file_provider_paths.xml
Normal file
7
app/src/main/res/xml/file_provider_paths.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-files-path name="downloads" path="Download/updates/" />
|
||||
<external-cache-path name="external_cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
<cache-path name="cache" path="." />
|
||||
</paths>
|
||||
61
app/src/main/res/xml/network_security_config.xml
Normal file
61
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Network Security Configuration for IPTV App
|
||||
|
||||
This configuration allows HTTP traffic for IPTV streams, which is commonly
|
||||
required since many IPTV providers still use unencrypted HTTP streams.
|
||||
|
||||
WARNING: Using cleartext traffic is less secure than HTTPS. Only use this
|
||||
configuration if your IPTV provider does not support HTTPS.
|
||||
|
||||
Security Recommendations:
|
||||
1. Prefer HTTPS streams whenever possible
|
||||
2. Use a VPN when streaming over HTTP
|
||||
3. Validate stream sources before adding them
|
||||
4. Keep the app updated with security patches
|
||||
-->
|
||||
<network-security-config>
|
||||
|
||||
<!-- Base configuration - cleartext traffic permitted by default -->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<!-- Trust system certificates -->
|
||||
<certificates src="system" />
|
||||
<!-- Trust user-added certificates (for debugging with proxies) -->
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<!-- Domain-specific configurations -->
|
||||
<!-- Add specific domains that require cleartext traffic -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<!-- Common IPTV stream domain patterns -->
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<domain includeSubdomains="true">192.168.*</domain>
|
||||
<domain includeSubdomains="true">10.*</domain>
|
||||
<domain includeSubdomains="true">172.16.*</domain>
|
||||
|
||||
<!-- Pinning configuration for known secure domains (example) -->
|
||||
<!-- Uncomment and configure for production apps with known domains -->
|
||||
<!--
|
||||
<pin-set expiration="2025-01-01">
|
||||
<pin digest="SHA-256">sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
|
||||
<pin digest="SHA-256">sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
|
||||
</pin-set>
|
||||
<trust-anchors>
|
||||
<certificates src="system"/>
|
||||
</trust-anchors>
|
||||
-->
|
||||
</domain-config>
|
||||
|
||||
<!-- Debug configuration - only use for development -->
|
||||
<!-- This allows cleartext traffic to all domains when debugging -->
|
||||
<debug-overrides>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</debug-overrides>
|
||||
|
||||
</network-security-config>
|
||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Top-level build file
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
|
||||
id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false
|
||||
}
|
||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
# Project-wide Gradle settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
17
settings.gradle.kts
Normal file
17
settings.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "IPTVApp"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user