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