Initial upload: iptv-android-tv-hermes

This commit is contained in:
Abderrahim-Mouhcine
2026-08-16 19:57:53 +01:00
commit 534d462553
222 changed files with 22516 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build/
/captures
.externalNativeBuild/
.cxx/
*.apk
*.aab
*.hprof
local.properties
/app/build/
/release/
*.keystore
*.jks
*.json
!app/google-services.json
+136
View File
@@ -0,0 +1,136 @@
# Architecture d'iMedia TV+
## Principes fondateurs
- **Clean Architecture** simplifiée : UI → ViewModel → Repository → Data
- **Unidirectional Data Flow (UDF)** : les events montent, les states descendent
- **Single Activity** : une seule `MainActivity`, navigation par écrans Compose
- **Programmation réactive** : Kotlin Coroutines + Flow pour tout ce qui est async
- **Séparation claire** : chaque couche ignore l'existence des couches au-dessus d'elle
## Flux de données complet
```mermaid
sequenceDiagram
actor User as Utilisateur (Télécommande)
participant UI as Compose Screen
participant VM as ViewModel
participant Repo as Repository
participant API as Retrofit / Xtream
participant DB as Room (Cache)
participant Store as EncryptedSP
User->>UI: Appuie sur OK (connexion)
UI->>VM: Event: Login(server, user, pass)
VM->>Repo: authRepository.login(server, user, pass)
Repo->>API: POST /player_api.php?username=...
API-->>Repo: Response { auth: "1", status: "Active" }
Repo->>Store: saveCredentials(encrypted)
Repo-->>VM: Result.Success(userInfo)
VM->>VM: _uiState = UiState.Success
VM-->>UI: StateFlow: HomeScreen data
UI-->>User: Affiche la liste des chaînes
```
## Diagramme de navigation des écrans
```mermaid
stateDiagram-v2
[*] --> SplashScreen
SplashScreen --> LoginScreen : Pas de session
SplashScreen --> HomeScreen : Session valide
LoginScreen --> HomeScreen : Connexion OK
LoginScreen --> LoginScreen : Erreur → reste
HomeScreen --> PlayerScreen : Sélection chaîne Live
HomeScreen --> ProgramGuideScreen : Appui Guide TV
HomeScreen --> ReplayGuideScreen : Appui Replay
HomeScreen --> VodScreen : Appui VOD
HomeScreen --> SeriesScreen : Appui Séries
ProgramGuideScreen --> PlayerScreen : "Regarder"
ReplayGuideScreen --> PlayerScreen : "Revoir"
VodScreen --> PlayerScreen : Lecture film
SeriesScreen --> PlayerScreen : Lecture épisode
PlayerScreen --> HomeScreen : Retour × 2
PlayerScreen --> ProgramGuideScreen : Retour × 1 (si venu du guide)
PlayerScreen --> ReplayGuideScreen : Retour × 1 (si venu du replay)
HomeScreen --> ConfirmQuit : Retour
ConfirmQuit --> [*] : Confirmer
ConfirmQuit --> HomeScreen : Annuler
```
## Organisation des ViewModels
Chaque ViewModel expose :
- Un `StateFlow<UiState<T>>` pour l'état de l'UI
- Des fonctions `onAction(X)` pour les intents utilisateur
- Une vue unique (`viewModelScope`) pour les coroutines
```kotlin
class HomeViewModel(
private val liveRepo: LiveRepository,
private val epgRepo: EpgRepository,
private val favRepo: FavoritesRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState<HomeData>>(UiState.Loading)
val uiState: StateFlow<UiState<HomeData>> = _uiState.asStateFlow()
sealed interface Action {
data class SelectCategory(val id: Int) : Action
data class ToggleFavorite(val streamId: Int) : Action
data class PinChannel(val streamId: Int) : Action
object Refresh : Action
}
fun onAction(action: Action) {
// ...
}
}
```
## Injection de dépendances
Utilisation d'un **manual DI** simple via `AppContainer` (pas de framework lourd comme Hilt/Dagger pour un projet de cette taille).
```kotlin
// AppContainer.kt
class AppContainer(context: Context) {
private val secureStorage = SecureStorage(context)
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(ErrorInterceptor())
.addInterceptor(AuthInterceptor(secureStorage))
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val retrofit = Retrofit.Builder()
.baseUrl("http://placeholder") // overridden per-call
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val xtreamApi = retrofit.create(XtreamApi::class.java)
// Repositories
val authRepository = AuthRepository(xtreamApi, secureStorage)
val liveRepository = LiveRepository(xtreamApi)
val epgRepository = EpgRepository(xtreamApi, EpgDatabase.getInstance(context))
// ...
}
```
## Décisions d'architecture
| Décision | Alternative écartée | Raison |
|----------|-------------------|--------|
| Single Activity | Multi-Activity | Navigation + état simplifiés |
| Manual DI | Hilt / Koin | Pas de overhead pour projet solo |
| Room pour cache EPG | Simple JSON file | Requêtes typées, ACID, migrations |
| EncryptedSharedPreferences | DataStore | Chiffrement natif, API simple |
| ViewModel + StateFlow | MVVM + LiveData | Flow plus puissant, compose-ready |
| sealed interface pour actions | Callbacks | Testable, traçable, immutable |
+103
View File
@@ -0,0 +1,103 @@
# Commandes PowerShell - build APK et installation Mi Box
## 1. Ouvrir le dossier du projet
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2\12-IPTV Andoid TV HERMES"
```
Adapte le chemin si tu extrais le ZIP ailleurs. Oui, Windows aime transformer les chemins en mini-épreuves administratives.
## 2. Préparer Java et ADB, si nécessaire
```powershell
$env:JAVA_HOME="C:\Program Files\Android\Android Studio\jbr"
$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk"
$env:Path="$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path"
```
Vérification :
```powershell
java -version
adb version
```
## 3. Créer l'APK installable debug
```powershell
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
```
APK généré :
```powershell
.\app\build\outputs\apk\debug\app-debug.apk
```
## 4. Connecter la Mi Box en ADB réseau
Sur la Mi Box, active d'abord :
- Options développeur
- Débogage USB / Débogage réseau
- Autoriser la connexion ADB quand la télévision affiche la demande
Puis sur le PC :
```powershell
adb kill-server
adb start-server
adb connect 192.168.1.156:5555
adb devices
```
Si `adb devices` affiche `unauthorized`, regarde l'écran de la Mi Box et valide l'autorisation RSA. Cette étape existe pour rappeler que même les télévisions veulent signer des contrats.
## 5. Installer l'APK sur la Mi Box
```powershell
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
```
Si Android refuse à cause d'une version déjà installée :
```powershell
adb install -r -d ".\app\build\outputs\apk\debug\app-debug.apk"
```
Si le paquet est corrompu ou bloqué par une ancienne installation :
```powershell
adb uninstall ma.tkgraph.xtreamtv
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
```
## 6. Lancer l'application après installation
```powershell
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
Alternative :
```powershell
adb shell monkey -p ma.tkgraph.xtreamtv 1
```
## 7. Commande complète rapide
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2\12-IPTV Andoid TV HERMES"
$env:JAVA_HOME="C:\Program Files\Android\Android Studio\jbr"
$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk"
$env:Path="$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path"
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb kill-server
adb start-server
adb connect 192.168.1.156:5555
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+28
View File
@@ -0,0 +1,28 @@
# Correctifs focus Android TV - Hermes
## Fichiers modifiés
- `app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FocusHighlight.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt`
## Changements principaux
1. La carte chaîne de l'écran Live est maintenant cliquable sur toute sa largeur.
2. Le `Text` du nom de chaîne n'est plus le seul élément cliquable.
3. Le focus est restauré sur la chaîne après retour depuis le player.
4. Le premier item de la liste reçoit le focus après chargement initial.
5. Le long appui OK ouvre la recherche uniquement sur l'écran Live, plus globalement dans `MainActivity`.
6. Les boutons Replay, Pin, Favori, Back, Guide et Replay ont un contour de focus plus visible.
7. Les champs Login ont une navigation DPAD haut/bas explicite pour éviter le curseur bloqué dans les zones de texte.
8. Dans le Player, OK affiche/masque l'overlay.
## Note de compilation
Le projet n'a pas été compilé dans cet environnement, car le wrapper Gradle doit télécharger `gradle-9.1.0-bin.zip` depuis `services.gradle.org`, et l'environnement d'analyse n'a pas d'accès réseau. Compile le projet sur ta machine Windows avec les commandes de `COMMANDES_BUILD_INSTALL_MIBOX.md`.
+28
View File
@@ -0,0 +1,28 @@
# Correctif focus V2 - appui long OK
## Changement appliqué
Le déclenchement de la recherche par appui long sur OK a été supprimé dans `HomeScreen.kt`.
Avant :
- Appui long OK, première séquence : ouverture de la recherche.
- Appui long OK ensuite : accès aux actions de la chaîne.
Maintenant :
- La recherche reste accessible par le bouton `Rechercher…` dans le header avec les boutons directionnels.
- L'appui long OK n'est plus intercepté globalement par `HomeScreen` pour ouvrir la recherche.
- Le comportement d'accès aux boutons/actions de la chaîne n'est plus précédé par l'ouverture forcée de la recherche.
## Fichier modifié
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt`
## Détail technique
Suppression du bloc :
- `onPreviewKeyEvent` au niveau du `Box` principal.
- variable `okLongPressHandled`.
- import `android.view.KeyEvent as AndroidKeyEvent`.
La recherche est maintenant volontairement limitée au champ/bouton de recherche visible dans le header.
+69
View File
@@ -0,0 +1,69 @@
# Focus Fix V3 - Appui long OK sans déclenchement Replay
## Problème corrigé
Sur Android TV / Mi Box, l'appui long sur OK activait le mode actions, puis le relâchement du même OK pouvait être reçu par le premier bouton d'action, généralement `Replay`.
Résultat : l'utilisateur voulait seulement accéder aux actions de la chaîne, mais l'application ouvrait Replay automatiquement.
## Correction appliquée
Fichier modifié :
```text
app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt
```
Changements principaux :
1. La carte chaîne intercepte elle-même OK :
- OK court = lire la chaîne.
- OK long = ouvrir le mode actions.
- Le relâchement OK après un appui long est consommé, donc il ne déclenche plus `Replay`.
2. Le mode actions n'envoie plus automatiquement le focus sur `Replay`.
- Après appui long OK, la ligne passe en mode actions.
- L'utilisateur appuie ensuite sur droite pour aller vers `Replay`, `Pin`, ou `Favori`.
- Cela évite les faux clics liés à un appui long difficile à doser.
3. Les actions `Replay`, `Pin`, `Favori` ne sont focusables/clickables que lorsque le mode actions est actif.
4. BACK ferme le mode actions et remet le focus sur la carte chaîne.
## Nouvelle logique d'utilisation
- OK court sur une chaîne : lancer la chaîne.
- OK long sur une chaîne : ouvrir les actions.
- Droite : entrer dans les boutons de la chaîne.
- Gauche depuis les boutons : revenir à la carte chaîne.
- BACK : fermer le mode actions.
## Compilation
```powershell
cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES"
$env:JAVA_HOME="D:\Program Files\Android\Android Studio\jbr"
$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk"
$env:Path="$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:Path"
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
```
APK :
```powershell
.\app\build\outputs\apk\debug\app-debug.apk
```
## Installation Mi Box
```powershell
adb kill-server
adb start-server
adb connect 192.168.1.156:5555
adb devices
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+486
View File
@@ -0,0 +1,486 @@
# iMedia TV+ / Xtream Google TV Player (Hermes Edition)
## Spécifications, architecture et guide du projet
**Projet :** Lecteur IPTV Xtream pour Google TV / Android TV
**Package Android :** `ma.tkgraph.xtreamtv`
**Plateforme cible :** Google TV / Android TV
**Langage :** Kotlin
**Interface :** Jetpack Compose / Compose TV
**Lecteur vidéo :** AndroidX Media3 ExoPlayer
**API IPTV :** Xtream Codes compatible
**Date du document :** 11/06/2026
**Version :** 2.0 (Hermes)
---
## 1. Objectif du projet
Développer une application Google TV complète pour lire des contenus IPTV depuis un serveur Xtream, en mettant l'accent sur :
- **Qualité logicielle** : tests, architecture propre, sécurité
- **Expérience utilisateur** : navigation fluide, télécommande, réactivité
- **Résilience** : gestion d'erreur, cache, fallbacks gracieux
- **Maintenabilité** : documentation, historique, conventions
Fonctionnalités couvertes :
- Connexion à un serveur Xtream + mémorisation sécurisée des identifiants
- Lecture Live TV avec EPG court
- Guide TV global + Replay / Catch-up TV
- VOD et Séries avec catalogues complets
- Favoris persistants (Live, VOD, Séries)
- Chaîne épinglée au démarrage
- Navigation complète à la télécommande Google TV
- Support multilingue (français, anglais, arabe)
- Cache local EPG pour performances
---
## 2. Architecture
### 2.1. Diagramme de flux
```mermaid
flowchart TB
subgraph UI ["Couche UI (Compose TV)"]
Login[LoginScreen]
Home[HomeScreen]
Player[PlayerScreen]
Guide[ProgramGuideScreen]
Replay[ReplayGuideScreen]
Vod[VodScreen]
Series[SeriesScreen]
end
subgraph ViewModel ["Couche ViewModel"]
VM_Home[HomeViewModel]
VM_Player[PlayerViewModel]
VM_Vod[VodViewModel]
VM_Series[SeriesViewModel]
end
subgraph Repository ["Couche Repository"]
Repo_Auth[AuthRepository]
Repo_Live[LiveRepository]
Repo_Vod[VodRepository]
Repo_Series[SeriesRepository]
Repo_Epg[EpgRepository]
end
subgraph Data ["Couche Data"]
API[XtreamApi<br/>Retrofit]
Cache[EpgCache<br/>Room / SQLite]
Storage[ConfigStorage<br/>EncryptedSharedPreferences]
end
subgraph External ["Externe"]
Xtream[Xtream Server]
end
Login --> VM_Home
Home --> VM_Home
Player --> VM_Player
Guide --> VM_Player
Replay --> VM_Player
Vod --> VM_Vod
Series --> VM_Series
VM_Home --> Repo_Auth
VM_Home --> Repo_Live
VM_Home --> Repo_Epg
VM_Player --> Repo_Live
VM_Player --> Repo_Epg
VM_Vod --> Repo_Vod
VM_Series --> Repo_Series
Repo_Auth --> Storage
Repo_Live --> API
Repo_Vod --> API
Repo_Series --> API
Repo_Epg --> API
Repo_Epg --> Cache
API --> Xtream
Storage -.->|Fallback| API
```
### 2.2. Flux de navigation
```mermaid
flowchart LR
Splash[Splash<br/>iMedia TV+] --> Login[LoginScreen]
Login --> Home[HomeScreen]
Home -->|Live TV| LiveList[Liste Chaînes]
Home -->|Guide TV| GuideScreen[Guide TV Global]
Home -->|VOD| VodScreen[Catalogue VOD]
Home -->|Séries| SeriesScreen[Catalogue Séries]
LiveList --> Player[PlayerScreen]
GuideScreen --> Player
ReplayGuide --> Player
VodScreen --> Player
SeriesScreen --> Player
Player -->|Retour 1| Overlay
Player -->|Retour 2| PreviousScreen
Home -->|Retour| ConfirmQuit{Confirmer?}
```
### 2.3. Structure des dossiers
```
app/
└── src/
├── main/
│ ├── AndroidManifest.xml
│ ├── java/ma/tkgraph/xtreamtv/
│ │ ├── MainActivity.kt
│ │ ├── data/
│ │ │ ├── model/ ← DTOs Xtream
│ │ │ ├── network/ ← Retrofit API + interceptors
│ │ │ ├── repository/ ← Live, VOD, Series, EPG, Auth
│ │ │ └── storage/ ← EncryptedSharedPreferences
│ │ ├── ui/
│ │ │ ├── components/ ← ChannelLogo, CategoryRow, etc.
│ │ │ ├── screens/ ← Login, Home, Player, Guide, etc.
│ │ │ └── theme/ ← Thème Material 3
│ │ └── util/ ← UrlBuilder, CategorySort, Extensions
│ └── res/
│ ├── drawable/ ← launch_background.xml
│ ├── drawable-nodpi/ ← iMedia TV+ splash PNG
│ ├── mipmap-*/ ← icône app
│ ├── values/ ← strings.xml (default = EN)
│ ├── values-fr/ ← strings.xml (français)
│ └── values-ar/ ← strings.xml (العربية)
├── test/ ← Tests unitaires (JUnit + MockK)
└── androidTest/ ← Tests UI (Compose Test)
```
---
## 3. Stack technique détaillée
| Couche | Technologie | Justification |
|--------|-------------|---------------|
| Langage | Kotlin 2.x | Moderne, sûr, natif Android |
| UI | Jetpack Compose + Compose TV (1.7+) | Déclaratif, natif TV |
| Lecteur | AndroidX Media3 ExoPlayer 1.5+ | Standard Google, HLS/DASH/MP4 |
| Réseau | Retrofit 2.11+ / OkHttp 4.12+ | Robuste, interceptable |
| Images | Coil 3.x | Asynchrone, cache, fallback |
| Cache EPG | Room 2.7+ (SQLite) | Performant, requêtes typées |
| Stockage | EncryptedSharedPreferences + AES-256 | Sécurité des credentials |
| Tests unitaires | JUnit 5 / MockK / Turbine | Kotlin-friendly, Flow test |
| Tests UI | Compose UI Test + Espresso | Automatisation UI TV |
| Logging | Timber | Structuré, tags, crash |
| Crash reporting | Firebase Crashlytics | Monitoring production |
---
## 4. Environnement de développement
| Composant | Valeur |
|-----------|--------|
| OS de développement | Windows 11 |
| IDE | Android Studio (Ladybug+) |
| Terminal | PowerShell |
| Appareil cible | Google TV / Android TV 12+ (API 31+) |
| Connexion | ADB Wi-Fi ou USB |
| JDK | OpenJDK 21 (via Android Studio) |
| SDK Android | API 36, Build-Tools 36.x |
| Gradle | 9.1+ (wrapper) |
### 4.1. Variables d'environnement
```powershell
$env:JAVA_HOME = "D:\Program Files\Android\Android Studio\jbr"
$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
$env:Path = "$env:JAVA_HOME\bin;$env:ANDROID_HOME\platform-tools;$env:ANDROID_HOME\cmdline-tools\latest\bin;D:\Tools\Gradle\gradle-9.1.0\bin;$env:Path"
```
### 4.2. Compilation
```powershell
cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES"
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
```
APK générée : `app\build\outputs\apk\debug\app-debug.apk`
---
## 5. Sécurité
### 5.1. Stockage des identifiants
**Problème de la v1 :** identifiants stockés en clair dans `SharedPreferences`.
**Solution Hermes :** `EncryptedSharedPreferences` avec AES-256 GCM.
```kotlin
// data/storage/SecureStorage.kt
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val prefs = EncryptedSharedPreferences.create(
context,
"imedia_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
```
### 5.2. Règles de sécurité
- Aucun log contenant `username`, `password` ou `server_url`
- Timeout réseau configuré (15s connect, 30s read)
- Validation côté client avant envoi : URL bien formée, champs non vides
- Pas de stockage en cache HTTP des credentials
- OkHttp interceptor pour masquer les auth dans les logs debug
- Préparation au HTTPS (certificats, SSR)
---
## 6. Gestion d'erreurs
### 6.1. Stratégie générale
| Scénario | Comportement | Message utilisateur |
|----------|-------------|---------------------|
| Réseau indisponible | Toast + fallback cache EPG | "Connexion impossible. Vérifiez votre réseau." |
| Timeout API (15s) | Retry 1x, puis message | "Le serveur ne répond pas. Réessayez." |
| Auth échouée | Effacer champs, focus sur login | "Identifiants incorrects ou serveur invalide." |
| Flux vidéo mort | 503 overlay, retour menu | "Flux indisponible pour cette chaîne." |
| EPG vide | Afficher "Aucun programme" | "Aucun programme trouvé pour cette chaîne." |
| Données corrompues | Fallback valeurs par défaut | "Des données sont manquantes — certains affichages peuvent être incomplets." |
### 6.2. État UI avec sealed class
```kotlin
sealed class UiState<out T> {
object Idle : UiState<Nothing>()
object Loading : UiState<Nothing>()
data class Success<T>(val data: T) : UiState<T>()
data class Error(val message: String, val retry: Boolean = true) : UiState<Nothing>()
}
```
### 6.3. Intercepteur réseau
```kotlin
class ErrorInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
return try {
val response = chain.proceed(request)
if (!response.isSuccessful) {
throw XtreamHttpException(response.code, response.message)
}
response
} catch (e: SocketTimeoutException) {
throw XtreamTimeoutException("Le serveur Xtream est injoignable")
} catch (e: UnknownHostException) {
throw XtreamNetworkException("Aucune connexion réseau")
}
}
}
```
---
## 7. Cache EPG
### 7.1. Problème
Le guide TV global peut nécessiter des centaines d'appels API (1 par chaîne). Sans cache, l'écran est inutilisable.
### 7.2. Solution : Room (SQLite)
```kotlin
@Entity(tableName = "epg_cache")
data class EpgCacheEntry(
@PrimaryKey val id: Long,
val streamId: Int,
val channelName: String,
val title: String,
val description: String?,
val startTimestamp: Long,
val endTimestamp: Long,
val cachedAt: Long = System.currentTimeMillis()
)
```
### 7.3. Politique de cache
| Type | Durée | Invalidation |
|------|-------|-------------|
| EPG court (live) | 2 minutes | Rafraîchi au scroll |
| EPG complet (guide) | 15 minutes | Pull-to-refresh manuel |
| Catégories | 1 heure | Au lancement |
| Listes chaînes | 5 minutes | Changement de catégorie |
### 7.4. Flux EPG
```mermaid
flowchart TD
A[Ouvre Guide TV] --> B{Cache valide?}
B -->|Oui < 15min| C[Afficher depuis Room]
B -->|Non| D[Requête API par chaîne]
D --> E[Stocker dans Room]
E --> C
```
---
## 8. Tests
### 8.1. Tests unitaires (JUnit 5 + MockK)
| Fichier | Test | Ce qu'on vérifie |
|---------|------|------------------|
| `AuthRepositoryTest.kt` | Connexion valide/invalide | Repository retourne `Success` ou `Error` |
| `AuthRepositoryTest.kt` | Timeout réseau | Erreur lisible, pas de crash |
| `EpgRepositoryTest.kt` | Cache hit/miss | Appel API si cache expiré, sinon données locales |
| `CategorySortUtilsTest.kt` | Tri FR/AR/autres | Ordre exact des catégories |
| `XtreamUrlBuilderTest.kt` | URLs Live/VOD/Series | Format correct de chaque URL |
| `SecureStorageTest.kt` | Lecture/écriture chiffrée | Données diffférentes en clair vs stocké |
| `PlayerViewModelTest.kt` | Zapping gauche/droite | Chaîne suivante/précédente correcte |
| `FavoritesRepositoryTest.kt` | Ajout/suppression favoris | Persistance après redémarrage |
### 8.2. Tests UI (Compose UI Test)
| Fichier | Test |
|---------|------|
| `LoginScreenTest.kt` | Remplissage formulaire → bouton actif |
| `LoginScreenTest.kt` | Erreur → message d'erreur affiché |
| `HomeScreenTest.kt` | Navigation entre catégories OK |
| `HomeScreenTest.kt` | Favori accessible via télécommande |
| `PlayerScreenTest.kt` | Overlay disparaît après 3s |
### 8.3. Lancer les tests
```powershell
# Tests unitaires uniquement
.\gradlew.bat test --no-daemon
# Tests UI (appareil/émulateur connecté)
.\gradlew.bat connectedAndroidTest --no-daemon
# Les deux
.\gradlew.bat test connectedAndroidTest --no-daemon
```
---
## 9. Internationalisation (i18n)
### 9.1. Langues supportées
| Langue | Dossier | Statut |
|--------|---------|--------|
| Anglais (défaut) | `values/` | ✅ Prévu |
| Français | `values-fr/` | ✅ Prévu |
| Arabe | `values-ar/` | ✅ Prévu (RTL) |
### 9.2. Exemple
```xml
<!-- values/strings.xml -->
<string name="app_name">iMedia TV+</string>
<string name="live_tv">Live TV</string>
<string name="connect">Connect</string>
<string name="network_error">Connection lost. Check your network.</string>
<!-- values-fr/strings.xml -->
<string name="app_name">iMedia TV+</string>
<string name="live_tv">Direct TV</string>
<string name="connect">Connexion</string>
<string name="network_error">Connexion perdue. Vérifiez votre réseau.</string>
<!-- values-ar/strings.xml -->
<string name="app_name">iMedia TV+</string>
<string name="live_tv">البث المباشر</string>
<string name="connect">اتصال</string>
<string name="network_error">انقطع الاتصال. تحقق من شبكتك.</string>
```
---
## 10. Logging et Monitoring
### 10.1. Timber
```kotlin
// Application.kt
class XtreamApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
} else {
Timber.plant(CrashReportingTree())
}
}
}
```
Règles :
- `Timber.d` / `Timber.i` pour debug et infos
- `Timber.w` pour anomalies non bloquantes
- `Timber.e` pour erreurs utilisateur
- **Jamais** de log de credentials, tokens, ou données sensibles
### 10.2. Crash reporting
Firebase Crashlytics est prévu pour la version release :
- Rapports de crash automatiques
- Logs non-sensibles attachés aux rapports
- Clés custom : écran actif, dernier état réseau
---
## 11. Roadmap
### Phase 1 — Fondations (immédiat)
- [x] Structure du projet
- [x] Stack technique définie
- [ ] Connexion Xtream + SecureStorage
- [ ] Live TV avec Media3
### Phase 2 — Fonctionnalités cœur
- [ ] Catégories + Favoris Live
- [ ] EPG court + Replay
- [ ] Guide TV global
- [ ] VOD + Séries
### Phase 3 — Qualité
- [ ] Tests unitaires (couverture > 70%)
- [ ] Cache EPG (Room)
- [ ] Gestion d'erreur complète
- [ ] i18n (FR, EN, AR)
### Phase 4 — Production
- [ ] Signing + ProGuard
- [ ] Firebase Crashlytics
- [ ] Bannière Android TV + icônes finales
- [ ] Politique de confidentialité
- [ ] Publication Play Store
---
## 12. Conclusion
Cette version Hermes reprend l'excellente base de la v1 et y ajoute :
1. **🔐 Sécurité** : chiffrement AES-256 des credentials
2. **📦 Cache EPG** : Room SQLite pour des performances réelles
3. **🧪 Tests** : stratégie complète (unitaire + UI)
4. **🌍 i18n** : support FR/EN/AR
5. **📋 Gestion d'erreur** : sealed class + intercepteurs + messages lisibles
6. **📊 Monitoring** : Timber + Crashlytics
7. **📐 Architecture** : flux documentés en Mermaid
Le logiciel fonctionnait déjà en v1. Cette version le rend fiable, sécurisé et prêt pour la production.
+130
View File
@@ -0,0 +1,130 @@
# Stratégie de test — iMedia TV+
## Principes
- **TDD partout où c'est possible** : écrire le test avant l'implémentation
- **Couverture cible** : > 70% sur les couches `data/` et `util/`
- **Tests UI** : parcours critiques uniquement (login, lecture, favoris)
- **Exécution locale** : tous les tests doivent passer avant chaque commit
## Stack de test
| Technologie | Usage |
|-------------|-------|
| JUnit 5 | Runner principal |
| MockK | Mocking Kotlin (classes, objects, extensions) |
| Turbine | Test des Flow (StateFlow, SharedFlow) |
| Kotlinx Coroutines Test | `runTest`, `TestDispatcher` |
| Compose UI Test | Test des écrans Compose TV |
| Espresso | Complément pour interactions système |
## Tests unitaires
### data/repository/
| Fichier | Scénarios |
|---------|-----------|
| `AuthRepositoryTest.kt` | ✅ Réponse valide → `auth == "1"` → `Success` |
| | ✅ Réponse valide → `auth == "0"` → `Error("Identifiants incorrects")` |
| | ✅ Timeout → `Error("Le serveur ne répond pas")` |
| | ✅ Socket error → `Error("Aucune connexion réseau")` |
| `LiveRepositoryTest.kt` | ✅ Catégories chargées → liste triée FR/AR/autres |
| | ✅ Chaînes filtrées par catégorie |
| | ✅ Flux vide → liste vide (pas de crash) |
| `EpgRepositoryTest.kt` | ✅ Cache valide (< 2min) → pas d'appel API |
| | ✅ Cache expiré → appel API + mise à jour cache |
| | ✅ Pas de cache → appel API + insertion |
| | ✅ EPG vide → `Error("Aucun programme")` |
| `FavoritesRepositoryTest.kt` | ✅ Ajout favori → liste contient l'ID |
| | ✅ Suppression → liste ne contient plus l'ID |
| | ✅ Persistance → données conservées après recreation du repo |
| `SeriesRepositoryTest.kt` | ✅ Détail série retourne saisons + épisodes |
| | ✅ Épisode sans titre → fallback "Épisode N" |
### data/storage/
| Fichier | Scénarios |
|---------|-----------|
| `SecureStorageTest.kt` | ✅ Valeur écrite = valeur lue |
| | ✅ Données différentes après clair vs chiffré |
| | ✅ Clear supprime toutes les données |
| | ✅ Valeur inexistante → null safe |
### util/
| Fichier | Scénarios |
|---------|-----------|
| `CategorySortUtilsTest.kt` | ✅ Catégories FR en premier |
| | ✅ Catégories AR ensuite |
| | ✅ Catégories "Toutes" et "Favoris" toujours en haut |
| | ✅ Autres en dernier |
| | ✅ Ordre alphabétique à l'intérieur des groupes |
| `XtreamUrlBuilderTest.kt` | ✅ URL Live correcte avec m3u8 |
| | ✅ URL VOD avec extension dynamique |
| | ✅ URL série avec épisode_id |
| | ✅ URL Replay avec timestamp formaté |
## Tests d'intégration / UI
| Fichier | Parcours |
|---------|----------|
| `LoginScreenTest.kt` | ✅ Remplit les champs → bouton Connexion activé |
| | ✅ Champs vides → bouton désactivé |
| | ✅ Erreur réseau → message d'erreur visible |
| | ✅ Connexion OK → navigation vers HomeScreen |
| `HomeScreenTest.kt` | ✅ Catégories affichées au chargement |
| | ✅ Changement de catégorie → nouvelles chaînes |
| | ✅ Favori accessible avec télécommande (DPAD) |
| | ✅ Recherche par nom fonctionne |
| `PlayerScreenTest.kt` | ✅ Overlay visible → disparaît après 3s |
| | ✅ Zapping gauche → chaîne précédente |
| | ✅ Premier retour → overlay réapparaît |
| | ✅ Deuxième retour → retour à HomeScreen |
## Exécution
```powershell
# Tout
.\gradlew.bat test connectedAndroidTest --no-daemon
# Tests unitaires uniquement
.\gradlew.bat test --no-daemon
# Tests UI
.\gradlew.bat connectedAndroidTest --no-daemon
# Filtre par classe
.\gradlew.bat test --no-daemon --tests *AuthRepositoryTest*
.\gradlew.bat connectedAndroidTest --no-daemon --tests *LoginScreenTest*
# Rapport de couverture
.\gradlew.bat :app:createDebugCoverageReport --no-daemon
# HTML → app/build/reports/coverage/debug/index.html
```
## Convention d'écriture
```kotlin
class AuthRepositoryTest {
private val api = mockk<XtreamApi>()
private val storage = mockk<SecureStorage>()
private val repo = AuthRepository(api, storage)
@Test
fun `login with valid credentials returns Success`() = runTest {
// Given
coEvery { api.authenticate(any(), any(), any()) } returns AuthResponse(
userInfo = UserInfo(username = "test"),
auth = "1",
status = "Active"
)
// When
val result = repo.login("http://server", "user", "pass")
// Then
assertTrue(result is UiState.Success)
assertEquals("test", (result as UiState.Success).data.username)
}
}
```
+123
View File
@@ -0,0 +1,123 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android") version "2.1.0"
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
}
android {
namespace = "ma.tkgraph.xtreamtv"
compileSdk = 36
defaultConfig {
applicationId = "ma.tkgraph.xtreamtv"
minSdk = 30
targetSdk = 36
versionCode = 2
versionName = "2.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
isMinifyEnabled = false
buildConfigField("boolean", "ENABLE_LOGGING", "true")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("boolean", "ENABLE_LOGGING", "false")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// --- Compose / TV ---
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.tv:tv-foundation:1.0.0")
// --- Activity / Lifecycle ---
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// --- Media3 ExoPlayer ---
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-exoplayer-dash:1.5.1")
implementation("androidx.media3:media3-ui:1.5.1")
implementation("androidx.media3:media3-session:1.5.1")
// --- Network ---
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// --- Images ---
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4")
// --- Security (EncryptedSharedPreferences) ---
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// --- EPG Cache (Room) ---
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
annotationProcessor("androidx.room:room-compiler:2.6.1")
// --- Logging ---
implementation("com.jakewharton.timber:timber:5.0.1")
// --- Core ---
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.leanback:leanback:1.2.0-alpha04")
// ========== TESTS ==========
// Unit tests
testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("io.mockk:mockk:1.13.14")
testImplementation("app.cash.turbine:turbine:1.2.0")
// Android / UI tests
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.test:runner:1.6.2")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
+37
View File
@@ -0,0 +1,37 @@
# --- iMedia TV+ ProGuard Rules ---
# Timber — supprimer les logs debug en release
-assumenosideeffects class timber.log.Timber {
public static *** d(...);
public static *** v(...);
public static *** w(...);
public static *** i(...);
}
# Retrofit
-keepattributes Signature
-keepattributes *Annotation*
-keep class retrofit2.** { *; }
-keepclasseswithmembers class * {
@retrofit2.http.* <methods>;
}
# Gson
-keepattributes Signature
-keep class com.google.gson.** { *; }
-keep class ma.tkgraph.xtreamtv.data.model.** { *; }
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**
# Coil
-keep class coil.** { *; }
# Security Crypto
-keep class androidx.security.crypto.** { *; }
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="true" />
<application
android:name=".XtreamApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:banner="@drawable/ic_banner"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.iMediaTVPlus"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout|uiMode"
android:theme="@style/Theme.iMediaTVPlus.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,53 @@
package ma.tkgraph.xtreamtv
import android.content.Context
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.repository.AuthRepository
import ma.tkgraph.xtreamtv.data.repository.EpgRepository
import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository
import ma.tkgraph.xtreamtv.data.repository.LiveRepository
import ma.tkgraph.xtreamtv.data.repository.M3uRepository
import ma.tkgraph.xtreamtv.data.repository.SeriesRepository
import ma.tkgraph.xtreamtv.data.repository.VodRepository
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import ma.tkgraph.xtreamtv.data.network.XtreamBaseUrlInterceptor
import ma.tkgraph.xtreamtv.data.network.XtreamErrorInterceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Container d'injection de dépendances manuel.
* Pas de Hilt/Dagger pour garder le projet léger.
*/
class AppContainer(context: Context) {
val secureStorage = SecureStorage(context)
val m3uRepository = M3uRepository(secureStorage)
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(XtreamBaseUrlInterceptor())
.addInterceptor(XtreamErrorInterceptor())
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.build()
private val retrofit = Retrofit.Builder()
.baseUrl("http://localhost") // Sera remplacé dynamiquement
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val xtreamApi: XtreamApi = retrofit.create(XtreamApi::class.java)
// ─── Repositories ───
val authRepository = AuthRepository(xtreamApi, secureStorage)
val liveRepository = LiveRepository(xtreamApi, secureStorage, m3uRepository)
val epgRepository = EpgRepository(xtreamApi, secureStorage)
val vodRepository = VodRepository(xtreamApi, secureStorage)
val seriesRepository = SeriesRepository(xtreamApi, secureStorage)
val favoritesRepository = FavoritesRepository(secureStorage)
}
@@ -0,0 +1,112 @@
package ma.tkgraph.xtreamtv
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import ma.tkgraph.xtreamtv.ui.navigation.NavState
import ma.tkgraph.xtreamtv.ui.screens.HomeScreen
import ma.tkgraph.xtreamtv.ui.screens.LoginScreen
import ma.tkgraph.xtreamtv.ui.screens.PlayerScreen
import ma.tkgraph.xtreamtv.ui.screens.ProgramGuideScreen
import ma.tkgraph.xtreamtv.ui.screens.ReplayGuideScreen
import ma.tkgraph.xtreamtv.ui.screens.SeriesScreen
import ma.tkgraph.xtreamtv.ui.screens.VodScreen
import ma.tkgraph.xtreamtv.ui.theme.XtreamTheme
import ma.tkgraph.xtreamtv.util.SessionManager
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntentExtras()
setContent {
XtreamTheme {
Surface(modifier = Modifier.fillMaxSize()) {
AppNavigation()
}
}
}
}
private fun handleIntentExtras() {
val serverUrl = intent.getStringExtra("server_url")
val username = intent.getStringExtra("xtream_user")
val password = intent.getStringExtra("xtream_pass")
if (serverUrl != null && username != null && password != null) {
XtreamApplication.instance.appContainer.secureStorage
.saveConfig(ma.tkgraph.xtreamtv.data.model.XtreamConfig(
serverUrl = serverUrl,
username = username,
password = password
))
}
}
}
/**
* Navigation principale.
* L'auto-démarrage sur la chaîne épinglée est géré par HomeScreen
* qui vérifie le pin au chargement des streams.
*/
@Composable
fun AppNavigation() {
val sessionManager = SessionManager(XtreamApplication.instance.appContainer.secureStorage)
val hasSession = sessionManager.hasValidSession()
var navState by remember { mutableStateOf<NavState>(NavState.Home()) }
var hasAutoPlayedPin by remember { mutableStateOf(false) }
if (!hasSession) {
LoginScreen()
return
}
when (val state = navState) {
is NavState.Home -> HomeScreen(
focusStreamId = state.focusStreamId,
onNavigate = { navState = it },
hasAutoPlayedPin = hasAutoPlayedPin,
onAutoPlayPin = { hasAutoPlayedPin = true }
)
is NavState.Player -> PlayerScreen(
initialStream = state.stream,
allStreams = state.allStreams,
initialIndex = state.currentIndex,
replayUrl = state.replayUrl,
onBack = {
navState = state.returnState ?: NavState.Home()
}
)
is NavState.ProgramGuide -> ProgramGuideScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.Home() }
)
is NavState.ReplayGuide -> ReplayGuideScreen(
stream = state.stream,
onBack = { navState = NavState.Home() },
onPlayReplay = { url ->
navState = NavState.Player(state.stream, listOf(state.stream), 0, replayUrl = url)
}
)
is NavState.Vod -> VodScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.Home() }
)
is NavState.Series -> SeriesScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.Home() }
)
is NavState.SeriesDetail -> SeriesScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.Home() }
)
}
}
@@ -0,0 +1,36 @@
package ma.tkgraph.xtreamtv
import android.app.Application
import timber.log.Timber
/**
* Point d'entrée de l'application iMedia TV+.
* Configure le logging et l'injection de dépendances.
*/
class XtreamApplication : Application() {
/** Container d'injection de dépendances (manual DI). */
lateinit var appContainer: AppContainer
private set
override fun onCreate() {
super.onCreate()
instance = this
appContainer = AppContainer(this)
initLogging()
}
private fun initLogging() {
if (BuildConfig.ENABLE_LOGGING) {
Timber.plant(Timber.DebugTree())
} else {
// En production : Crashlytics tree (à configurer)
// Timber.plant(CrashReportingTree())
}
}
companion object {
lateinit var instance: XtreamApplication
private set
}
}
@@ -0,0 +1,23 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* Réponse de l'API /player_api.php?username=...&password=...
*/
data class AuthResponse(
@SerializedName("user_info") val userInfo: UserInfo? = null,
val auth: String = "0",
val status: String = ""
)
data class UserInfo(
val username: String = "",
@SerializedName("auth") val auth: Int = 0,
val status: String = "",
@SerializedName("created_at") val createdAt: String = "",
@SerializedName("exp_date") val expDate: String = "",
@SerializedName("is_trial") val isTrial: String = "0",
@SerializedName("max_connections") val maxConnections: String = "0",
@SerializedName("allowed_output_formats") val allowedFormats: List<String> = emptyList()
)
@@ -0,0 +1,55 @@
package ma.tkgraph.xtreamtv.data.model
import android.util.Base64
import com.google.gson.annotations.SerializedName
import java.nio.charset.StandardCharsets
data class EpgProgramDto(
@SerializedName("id") val id: String? = null,
@SerializedName("epg_id") val epgId: String? = null,
@SerializedName("title") val rawTitle: String? = null,
@SerializedName("description") val rawDescription: String? = null,
@SerializedName("start") val start: String? = null,
@SerializedName("end") val end: String? = null,
@SerializedName("start_timestamp") val startTimestamp: String? = null,
@SerializedName("stop_timestamp") val stopTimestamp: String? = null,
@SerializedName("has_archive") val hasArchive: Int? = null,
@SerializedName("now_playing") val nowPlaying: Int? = null,
@SerializedName("lang") val lang: String? = null
) {
fun titleText(): String {
return decodeMaybeBase64(rawTitle).ifBlank { "" }
}
fun startMillis(): Long? {
startTimestamp?.toLongOrNull()?.let { return it * 1000L }
return null
}
fun stopMillis(): Long? {
stopTimestamp?.toLongOrNull()?.let { return it * 1000L }
return null
}
/** Durée en minutes, calculée depuis les timestamps ou la plage horaire. */
fun durationMinutes(): Int {
// Depuis les timestamps Unix (en secondes)
val startSec = startTimestamp?.toLongOrNull() ?: return 120
val stopSec = stopTimestamp?.toLongOrNull() ?: return 120
return ((stopSec - startSec) / 60).toInt().coerceIn(1, 720)
}
}
/** Décode si Base64, sinon retourne tel quel. */
private fun decodeMaybeBase64(value: String?): String {
val text = value.orEmpty().trim()
if (text.isBlank()) return ""
return runCatching {
val decoded = Base64.decode(text, Base64.DEFAULT)
String(decoded, StandardCharsets.UTF_8).trim()
}.getOrNull()
?.takeIf { decoded ->
decoded.isNotBlank() && decoded.any { it.isLetterOrDigit() }
}
?: text
}
@@ -0,0 +1,11 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class EpgShortResponse(
@SerializedName("epg_listings") val listings: List<EpgProgramDto>? = emptyList()
)
data class EpgFullResponse(
@SerializedName("epg_listings") val listings: List<EpgProgramDto>? = emptyList()
)
@@ -0,0 +1,12 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour une catégorie Live.
*/
data class LiveCategoryDto(
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("category_name") val categoryName: String = "",
@SerializedName("parent_id") val parentId: Int = 0
)
@@ -0,0 +1,24 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour un flux Live (chaîne TV).
*/
data class LiveStreamDto(
val num: Int = 0,
val name: String = "",
@SerializedName("stream_id") val streamId: Int = 0,
@SerializedName("stream_type") val streamType: String = "",
@SerializedName("stream_icon") val streamIcon: String? = null,
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("epg_channel_id") val epgChannelId: String? = null,
@SerializedName("tv_archive") val tvArchive: Int = 0,
@SerializedName("has_archive") val hasArchive: Boolean = false,
@SerializedName("container_extension") val containerExtension: String = "m3u8",
val added: String = "",
@SerializedName("custom_sid") val customSid: String? = null,
@SerializedName("tv_archive_duration") val archiveDuration: Int = 0,
/** URL directe du flux (utilisé en mode M3U). */
val directUrl: String = ""
)
@@ -0,0 +1,57 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour une série.
*/
data class SeriesDto(
val num: Int = 0,
val name: String = "",
@SerializedName("series_id") val seriesId: Int = 0,
val cover: String? = null,
val plot: String? = null,
val cast: String? = null,
val rating: String? = null,
val genre: String? = null,
val year: String? = null,
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("last_modified") val lastModified: String? = null
)
data class SeriesCategoryDto(
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("category_name") val categoryName: String = "",
@SerializedName("parent_id") val parentId: Int = 0
)
/**
* Détail d'une série (saisons + épisodes).
*/
data class SeriesInfoDto(
val seasons: List<SeasonDto> = emptyList(),
val episodes: Map<String, List<EpisodeDto>> = emptyMap()
)
data class SeasonDto(
val id: Int = 0,
@SerializedName("season_number") val seasonNumber: Int = 0,
val name: String = ""
)
data class EpisodeDto(
val id: Int = 0,
@SerializedName("episode_num") val episodeNum: Int = 0,
val title: String? = null,
val name: String? = null,
@SerializedName("episode_name") val episodeName: String? = null,
@SerializedName("container_extension") val containerExtension: String = "mp4",
val info: EpisodeInfoDto? = null
)
data class EpisodeInfoDto(
@SerializedName("movie_image") val movieImage: String? = null,
val plot: String? = null,
val duration: String? = null,
val rating: String? = null
)
@@ -0,0 +1,16 @@
package ma.tkgraph.xtreamtv.data.model
/**
* État générique pour tous les écrans.
* Usage : ViewModel expose `StateFlow<UiState<T>>`
*/
sealed interface UiState<out T> {
data object Idle : UiState<Nothing>
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(
val message: String,
val retry: Boolean = true,
val exception: XtreamException? = null
) : UiState<Nothing>
}
@@ -0,0 +1,29 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour un film VOD.
*/
data class VodDto(
val num: Int = 0,
val name: String = "",
@SerializedName("stream_id") val streamId: Int = 0,
@SerializedName("stream_type") val streamType: String = "movie",
@SerializedName("stream_icon") val streamIcon: String? = null,
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("container_extension") val containerExtension: String = "mp4",
val rating: String? = null,
val added: String = "",
val description: String? = null,
val year: String? = null,
val genre: String? = null,
val director: String? = null,
val cast: String? = null
)
data class VodCategoryDto(
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("category_name") val categoryName: String = "",
@SerializedName("parent_id") val parentId: Int = 0
)
@@ -0,0 +1,23 @@
package ma.tkgraph.xtreamtv.data.model
/**
* Type de source : serveur Xtream ou fichier M3U.
*/
enum class SourceType { XTREAM, M3U }
/**
* Configuration de connexion (Xtream ou M3U).
*/
data class XtreamConfig(
val serverUrl: String = "",
val username: String = "",
val password: String = "",
val m3uUrl: String = "",
val sourceType: SourceType = SourceType.XTREAM
) {
/** Vrai si une configuration valide est présente (quel que soit le mode). */
val isValid: Boolean get() = when (sourceType) {
SourceType.XTREAM -> serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
SourceType.M3U -> m3uUrl.isNotBlank()
}
}
@@ -0,0 +1,25 @@
package ma.tkgraph.xtreamtv.data.model
import java.io.IOException
/**
* Hiérarchie d'exceptions Xtream.
* Étend IOException pour être compatible avec OkHttp Interceptor.
*/
sealed class XtreamException(
message: String,
cause: Throwable? = null
) : IOException(message, cause) {
class NetworkException(message: String) : XtreamException(message)
class TimeoutException(message: String) : XtreamException(message)
class AuthException(message: String) : XtreamException(message)
class ServerException(val code: Int, message: String) : XtreamException(message)
class ParseException(message: String, cause: Throwable) : XtreamException(message, cause)
class StreamUnavailableException(message: String) : XtreamException(message)
}
@@ -0,0 +1,98 @@
package ma.tkgraph.xtreamtv.data.network
import ma.tkgraph.xtreamtv.data.model.AuthResponse
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.EpgFullResponse
import ma.tkgraph.xtreamtv.data.model.EpgShortResponse
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
import ma.tkgraph.xtreamtv.data.model.VodDto
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Interface Retrofit pour l'API Xtream Codes.
* L'URL de base est définie dynamiquement par le Repository.
*/
interface XtreamApi {
@GET("/player_api.php")
suspend fun authenticate(
@Query("username") username: String,
@Query("password") password: String
): AuthResponse
@GET("/player_api.php")
suspend fun getLiveCategories(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_live_categories"
): List<LiveCategoryDto>
@GET("/player_api.php")
suspend fun getLiveStreams(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_live_streams",
@Query("category_id") categoryId: Int? = null
): List<LiveStreamDto>
@GET("/player_api.php")
suspend fun getShortEpg(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_short_epg",
@Query("stream_id") streamId: String,
@Query("limit") limit: Int = 8
): EpgShortResponse
@GET("/player_api.php")
suspend fun getFullEpg(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_simple_data_table",
@Query("stream_id") streamId: String
): EpgFullResponse
@GET("/player_api.php")
suspend fun getVodCategories(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_vod_categories"
): List<VodCategoryDto>
@GET("/player_api.php")
suspend fun getVodStreams(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_vod_streams",
@Query("category_id") categoryId: Int? = null
): List<VodDto>
@GET("/player_api.php")
suspend fun getSeriesCategories(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_series_categories"
): List<SeriesCategoryDto>
@GET("/player_api.php")
suspend fun getSeries(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_series",
@Query("category_id") categoryId: Int? = null
): List<SeriesDto>
@GET("/player_api.php")
suspend fun getSeriesInfo(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_series_info",
@Query("series_id") seriesId: Int
): SeriesInfoDto
}
@@ -0,0 +1,56 @@
package ma.tkgraph.xtreamtv.data.network
import ma.tkgraph.xtreamtv.XtreamApplication
import okhttp3.Interceptor
import okhttp3.Response
/**
* Intercepteur OkHttp qui réécrit l'URL de base de Retrofit
* avec l'URL du serveur Xtream stockée dans SecureStorage.
*
* Retrofit est initialisé avec "http://localhost" comme placeholder.
* Cet interceptor remplace "localhost" par le vrai serveur Xtream.
*/
class XtreamBaseUrlInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials()
if (config == null) {
// Pas de credentials → laisser l'URL telle quelle (echouera gracieusement)
return chain.proceed(originalRequest)
}
val serverHost = extractHost(config.serverUrl)
val newUrl = originalRequest.url.newBuilder()
.host(serverHost)
.port(extractPort(config.serverUrl))
.build()
val newRequest = originalRequest.newBuilder()
.url(newUrl)
.build()
return chain.proceed(newRequest)
}
private fun extractHost(url: String): String {
return url
.removePrefix("http://")
.removePrefix("https://")
.substringBefore(":")
.substringBefore("/")
}
private fun extractPort(url: String): Int {
val afterProtocol = url
.removePrefix("http://")
.removePrefix("https://")
return if (afterProtocol.contains(":")) {
afterProtocol.substringAfter(":").substringBefore("/").toIntOrNull() ?: 80
} else {
if (url.startsWith("https")) 443 else 80
}
}
}
@@ -0,0 +1,51 @@
package ma.tkgraph.xtreamtv.data.network
import ma.tkgraph.xtreamtv.data.model.XtreamException
import okhttp3.Interceptor
import okhttp3.Response
import timber.log.Timber
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
/**
* Intercepteur OkHttp qui transforme les erreurs réseau
* en exceptions typées pour un traitement uniforme.
*/
class XtreamErrorInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
return try {
val response = chain.proceed(request)
when {
response.code == 401 || response.code == 403 ->
throw XtreamException.AuthException("Authentication failed")
response.code in 400..499 ->
throw XtreamException.ServerException(
response.code, "Client error: ${response.code}"
)
response.code in 500..599 ->
throw XtreamException.ServerException(
response.code, "Server error: ${response.code}"
)
}
response
} catch (e: SocketTimeoutException) {
Timber.w("Network timeout: ${request.url}")
throw XtreamException.TimeoutException("Connection timed out")
} catch (e: ConnectException) {
Timber.w("Connection refused: ${request.url}")
throw XtreamException.NetworkException("Cannot connect to server")
} catch (e: UnknownHostException) {
Timber.w("DNS resolution failed for: ${request.url}")
throw XtreamException.NetworkException("DNS resolution failed")
} catch (e: XtreamException) {
// Déjà typé, on relance tel quel
throw e
}
}
}
@@ -0,0 +1,89 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.AuthResponse
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.model.XtreamException
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import timber.log.Timber
/**
* Gère l'authentification et la session utilisateur.
*/
class AuthRepository(
private val api: XtreamApi,
private val storage: SecureStorage
) {
/**
* Tente de se connecter au serveur Xtream.
*/
suspend fun login(serverUrl: String, username: String, password: String): UiState<UserInfo> {
return try {
Timber.i("Attempting login for user: %s", username)
val response = api.authenticate(username, password)
when {
response.auth != "1" -> {
Timber.w("Login failed: auth field is not 1")
UiState.Error("Invalid credentials or server.", retry = true)
}
response.status != "Active" -> {
Timber.w("Login failed: account status is %s", response.status)
UiState.Error(
"Account is not active. Status: ${response.status}",
retry = false
)
}
else -> {
storage.saveConfig(XtreamConfig(
sourceType = SourceType.XTREAM,
serverUrl = serverUrl,
username = username,
password = password
))
val info = UserInfo(username = username)
Timber.i("Login successful for: %s", username)
UiState.Success(info)
}
}
} catch (e: XtreamException.TimeoutException) {
UiState.Error("Server is not responding. Try again later.", retry = true, exception = e)
} catch (e: XtreamException.NetworkException) {
UiState.Error("Cannot reach server. Check your network.", retry = true, exception = e)
} catch (e: XtreamException.AuthException) {
UiState.Error("Invalid credentials or server.", retry = true, exception = e)
} catch (e: Exception) {
Timber.e(e, "Unexpected error during login")
UiState.Error("Connection failed. Please try again.", retry = true)
}
}
/** Sauvegarde une config M3U. */
fun saveM3uConfig(m3uUrl: String) {
storage.saveConfig(XtreamConfig(
sourceType = SourceType.M3U,
m3uUrl = m3uUrl
))
}
/** Supprime la session et vide le cache M3U. */
fun logout() {
storage.clearAll()
Timber.i("User logged out")
}
fun hasSession(): Boolean = storage.hasConfig()
fun getSavedConfig(): XtreamConfig? = storage.getConfig()
}
/** Informations utilisateur retournées après connexion. */
data class UserInfo(
val username: String,
val status: String = "Active",
val expDate: String? = null,
val maxConnections: String = "1",
val isTrial: Boolean = false
)
@@ -0,0 +1,41 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import timber.log.Timber
class EpgRepository(
private val api: XtreamApi,
private val storage: SecureStorage
) {
private fun config(): XtreamConfig? = storage.getCredentials()
suspend fun getShortEpg(streamId: Int): UiState<List<EpgProgramDto>> {
return try {
val c = config() ?: return UiState.Error("Not logged in")
val response = api.getShortEpg(c.username, c.password, streamId = streamId.toString(), limit = 8)
val programs = response.listings.orEmpty()
.map { it.copy(rawTitle = it.rawTitle) } // titleText() sera appelé côté UI
Timber.i("EPG short %d: %d programs", streamId, programs.size)
UiState.Success(programs)
} catch (e: Exception) {
Timber.w(e, "EPG short failed stream=%d", streamId)
UiState.Success(emptyList())
}
}
suspend fun getFullGuide(streamId: Int): UiState<List<EpgProgramDto>> {
return try {
val c = config() ?: return UiState.Error("Not logged in")
val response = api.getFullEpg(c.username, c.password, streamId = streamId.toString())
val programs = response.listings.orEmpty()
UiState.Success(programs)
} catch (e: Exception) {
Timber.w(e, "EPG full failed stream=%d", streamId)
UiState.Success(emptyList())
}
}
}
@@ -0,0 +1,26 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.XtreamException
import timber.log.Timber
/**
* Helper pour enrober les appels réseau dans un bloc try-catch
* standardisé qui retourne UiState.Error pour toute exception.
*/
suspend fun <T> catchErrors(block: suspend () -> UiState<T>): UiState<T> {
return try {
block()
} catch (e: XtreamException.TimeoutException) {
UiState.Error("Server is not responding. Try again later.", retry = true, exception = e)
} catch (e: XtreamException.NetworkException) {
UiState.Error("Cannot reach server. Check your network.", retry = true, exception = e)
} catch (e: XtreamException.AuthException) {
UiState.Error("Authentication failed.", retry = true, exception = e)
} catch (e: XtreamException.ServerException) {
UiState.Error("Server error (${e.code}). Contact your provider.", retry = true, exception = e)
} catch (e: Exception) {
Timber.e(e, "Unexpected error")
UiState.Error("An unexpected error occurred.", retry = true)
}
}
@@ -0,0 +1,65 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
/**
* Gère les favoris pour Live, VOD et Séries.
* Délègue le stockage à SecureStorage.
*/
class FavoritesRepository(private val storage: SecureStorage) {
// ─── Live Favorites ───
fun getFavoriteLiveIds(): Set<Int> = storage.getFavoriteLiveIds()
fun toggleFavoriteLive(streamId: Int): Boolean {
val current = getFavoriteLiveIds().toMutableSet()
return if (streamId in current) {
current.remove(streamId)
storage.setFavoriteLiveIds(current)
false // removed
} else {
current.add(streamId)
storage.setFavoriteLiveIds(current)
true // added
}
}
fun isFavoriteLive(streamId: Int): Boolean = streamId in getFavoriteLiveIds()
// ─── VOD Favorites ───
fun getFavoriteVodIds(): Set<Int> = storage.getFavoriteVodIds()
fun toggleFavoriteVod(streamId: Int): Boolean {
val current = getFavoriteVodIds().toMutableSet()
return if (streamId in current) {
current.remove(streamId)
storage.setFavoriteVodIds(current)
false
} else {
current.add(streamId)
storage.setFavoriteVodIds(current)
true
}
}
// ─── Series Favorites ───
fun getFavoriteSeriesIds(): Set<Int> = storage.getFavoriteSeriesIds()
fun toggleFavoriteSeries(seriesId: Int): Boolean {
val current = getFavoriteSeriesIds().toMutableSet()
return if (seriesId in current) {
current.remove(seriesId)
storage.setFavoriteSeriesIds(current)
false
} else {
current.add(seriesId)
storage.setFavoriteSeriesIds(current)
true
}
}
// ─── Pinned Channel ───
fun getPinnedChannelId(): Int? = storage.getPinnedChannelId()
fun setPinnedChannelId(streamId: Int?) = storage.setPinnedChannelId(streamId)
}
@@ -0,0 +1,60 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import ma.tkgraph.xtreamtv.util.CategorySortUtils
import timber.log.Timber
/**
* Repository pour les flux Live TV.
* Délègue vers XtreamApi ou M3uParser selon la source configurée.
*/
class LiveRepository(
private val api: XtreamApi,
private val storage: SecureStorage,
private val m3uRepository: M3uRepository
) {
private fun requireConfig(): XtreamConfig? = storage.getConfig()
suspend fun getCategories(): UiState<List<LiveCategoryDto>> {
val config = requireConfig() ?: return UiState.Error("Not logged in")
return when (config.sourceType) {
SourceType.XTREAM -> getXtreamCategories(config)
SourceType.M3U -> m3uRepository.getCategories()
}
}
suspend fun getStreams(categoryId: Int? = null): UiState<List<LiveStreamDto>> {
val config = requireConfig() ?: return UiState.Error("Not logged in")
return when (config.sourceType) {
SourceType.XTREAM -> getXtreamStreams(config, categoryId)
SourceType.M3U -> m3uRepository.getStreams(categoryId)
}
}
private suspend fun getXtreamCategories(config: XtreamConfig): UiState<List<LiveCategoryDto>> {
return try {
val categories = api.getLiveCategories(config.username, config.password)
val sorted = CategorySortUtils.sortLiveCategories(categories)
UiState.Success(sorted)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch Xtream categories")
UiState.Error("Erreur de chargement des catégories")
}
}
private suspend fun getXtreamStreams(config: XtreamConfig, categoryId: Int?): UiState<List<LiveStreamDto>> {
return try {
val streams = api.getLiveStreams(config.username, config.password, categoryId = categoryId)
UiState.Success(streams)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch Xtream streams")
UiState.Error("Erreur de chargement des chaînes")
}
}
}
@@ -0,0 +1,145 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.util.concurrent.TimeUnit
/**
* Parse un fichier M3U (Xtream-compatible) en listes de [LiveStreamDto].
*
* Format supporté :
* #EXTM3U
* #EXTINF:-1 tvg-id="…" tvg-name="…" tvg-logo="…" group-title="Catégorie",Nom de la chaîne
* http://url-du-flux.m3u8
*
* Chaque chaîne reçoit un streamId = hash de son URL (pour les favoris/pin).
*/
object M3uParser {
private const val TIMEOUT_SECONDS = 15L
private val client = OkHttpClient.Builder()
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.followRedirects(true)
.build()
/**
* Télécharge et parse une playlist M3U.
* @param url URL distante ou locale du fichier M3U
* @return Pair (catégories, streams) ou null si échec
*/
suspend fun fetchAndParse(url: String): Pair<List<LiveCategoryDto>, List<LiveStreamDto>>? {
return try {
val request = Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
Timber.w("M3U fetch failed: HTTP ${response.code}")
return null
}
val body = response.body?.string() ?: return null
parse(body)
} catch (e: Exception) {
Timber.e(e, "M3U fetch error")
null
}
}
/**
* Parse le contenu textuel d'un fichier M3U.
*/
fun parse(content: String): Pair<List<LiveCategoryDto>, List<LiveStreamDto>> {
val categoryMap = mutableMapOf<String, Int>() // nom → id (hash)
val categories = mutableListOf<LiveCategoryDto>()
val streams = mutableListOf<LiveStreamDto>()
val lines = content.lines()
var i = 0
var streamIdCounter = 1
while (i < lines.size) {
val line = lines[i].trim()
if (line.startsWith("#EXTINF:")) {
// Extraire les attributs de la ligne EXTINF
val attrs = parseExtInfAttributes(line)
// L'URL est sur la ligne suivante (si elle existe et ne commence pas par #)
val urlLine = if (i + 1 < lines.size) lines[i + 1].trim() else ""
val streamUrl = if (urlLine.isNotBlank() && !urlLine.startsWith("#")) urlLine else ""
if (streamUrl.isNotBlank()) {
val name = attrs["name"] ?: "Chaîne $streamIdCounter"
val logo = attrs["logo"]
val groupTitle = attrs["group-title"]
// Générer un ID stable à partir de l'URL
val stableId = streamUrl.hashCode()
// Fallback si hash trop petit/négatif
val streamId = if (stableId > 0) stableId else streamIdCounter
// Gérer les catégories
if (groupTitle != null && groupTitle !in categoryMap) {
val catId = categoryMap.size + 1
categoryMap[groupTitle] = catId
categories.add(LiveCategoryDto(
categoryId = catId.toString(),
categoryName = groupTitle
))
}
val catIdStr = if (groupTitle != null) categoryMap[groupTitle]?.toString() ?: "" else ""
streams.add(LiveStreamDto(
num = streamIdCounter,
name = name,
streamId = streamId,
streamType = "live",
streamIcon = logo,
categoryId = catIdStr,
containerExtension = streamUrl.substringAfterLast('.', "m3u8"),
directUrl = streamUrl
))
streamIdCounter++
i++ // Sauter la ligne URL
}
}
i++
}
Timber.i("M3U parsed: ${categories.size} categories, ${streams.size} streams")
return Pair(categories, streams)
}
/**
* Extrait les attributs d'une ligne #EXTINF.
* Exemple : #EXTINF:-1 tvg-logo="https://…" group-title="Sports",Nom
*/
private fun parseExtInfAttributes(line: String): Map<String, String> {
val attrs = mutableMapOf<String, String>()
// Extraire les paires clé="valeur"
val regex = Regex("""(\w[\w-]*)\s*=\s*"([^"]*)"""")
for (match in regex.findAll(line)) {
attrs[match.groupValues[1]] = match.groupValues[2]
}
// Extraire le nom de la chaîne (après la dernière virgule)
val commaIdx = line.lastIndexOf(',')
if (commaIdx >= 0 && commaIdx + 1 < line.length) {
val name = line.substring(commaIdx + 1).trim()
if (name.isNotBlank()) {
attrs["name"] = name
}
}
// Fallback sur tvg-name si le nom n'a pas été trouvé après la virgule
if (!attrs.containsKey("name") && attrs.containsKey("tvg-name")) {
attrs["name"] = attrs["tvg-name"]!!
}
return attrs
}
}
@@ -0,0 +1,54 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import timber.log.Timber
/**
* Repository pour les playlists M3U.
*/
class M3uRepository(private val storage: SecureStorage) {
private var cachedCategories: List<LiveCategoryDto> = emptyList()
private var cachedStreams: List<LiveStreamDto> = emptyList()
/** Récupère les catégories depuis le M3U. */
suspend fun getCategories(): UiState<List<LiveCategoryDto>> {
val data = fetchIfNeeded() ?: return UiState.Error("M3U invalide ou non configuré")
return UiState.Success(data.first)
}
/** Récupère les streams depuis le M3U. */
suspend fun getStreams(categoryId: Int? = null): UiState<List<LiveStreamDto>> {
val data = fetchIfNeeded() ?: return UiState.Error("M3U invalide ou non configuré")
val streams = if (categoryId != null) {
data.second.filter { it.categoryId == categoryId.toString() }
} else {
data.second
}
return UiState.Success(streams)
}
/** Invalide le cache M3U. */
fun invalidateCache() {
cachedCategories = emptyList()
cachedStreams = emptyList()
}
private suspend fun fetchIfNeeded(): Pair<List<LiveCategoryDto>, List<LiveStreamDto>>? {
if (cachedStreams.isNotEmpty()) {
return Pair(cachedCategories, cachedStreams)
}
val config = storage.getConfig() ?: return null
if (config.m3uUrl.isBlank()) return null
Timber.i("Fetching M3U from: ${config.m3uUrl}")
val result = M3uParser.fetchAndParse(config.m3uUrl) ?: return null
cachedCategories = result.first
cachedStreams = result.second
return result
}
}
@@ -0,0 +1,49 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import ma.tkgraph.xtreamtv.util.CategorySortUtils
import timber.log.Timber
/**
* Repository pour les séries.
*/
class SeriesRepository(
private val api: XtreamApi,
private val storage: SecureStorage
) {
private fun requireConfig(): XtreamConfig? = storage.getCredentials()
suspend fun getCategories(): UiState<List<SeriesCategoryDto>> = catchErrors {
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
val cats = api.getSeriesCategories(config.username, config.password)
val sorted = CategorySortUtils.sortSeriesCategories(cats)
UiState.Success(sorted)
}
suspend fun getSeriesList(categoryId: Int? = null): UiState<List<SeriesDto>> = catchErrors {
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
val series = api.getSeries(config.username, config.password, categoryId = categoryId)
UiState.Success(series)
}
suspend fun getSeriesInfo(seriesId: Int): UiState<SeriesInfoDto> {
return try {
val config = requireConfig() ?: return UiState.Error("Not logged in")
val info = api.getSeriesInfo(
username = config.username,
password = config.password,
seriesId = seriesId
)
UiState.Success(info)
} catch (e: Exception) {
Timber.e(e, "Failed to load series info for %d", seriesId)
UiState.Error("Failed to load series details.")
}
}
}
@@ -0,0 +1,40 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
/**
* Stocke et lit les compteurs d'utilisation pour trier l'IPTV comme un être humain
* qui ouvre toujours les mêmes chaînes, mais avec une télécommande.
*/
class UsageRepository(private val storage: SecureStorage) {
fun recordLiveCategory(categoryId: Int?) = record(BUCKET_LIVE_CATEGORY, categoryId)
fun recordVodCategory(categoryId: Int?) = record(BUCKET_VOD_CATEGORY, categoryId)
fun recordSeriesCategory(categoryId: Int?) = record(BUCKET_SERIES_CATEGORY, categoryId)
fun recordLiveStream(streamId: Int) = record(BUCKET_LIVE_STREAM, streamId)
fun recordVodItem(streamId: Int) = record(BUCKET_VOD_ITEM, streamId)
fun recordSeriesItem(seriesId: Int) = record(BUCKET_SERIES_ITEM, seriesId)
fun liveCategoryCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_LIVE_CATEGORY)
fun vodCategoryCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_VOD_CATEGORY)
fun seriesCategoryCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_SERIES_CATEGORY)
fun liveStreamCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_LIVE_STREAM)
fun vodItemCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_VOD_ITEM)
fun seriesItemCounts(): Map<String, Int> = storage.getUsageCounters(BUCKET_SERIES_ITEM)
private fun record(bucket: String, id: Int?) {
if (id == null || id < 0) return
storage.incrementUsageCounter(bucket, id.toString())
}
companion object {
private const val BUCKET_LIVE_CATEGORY = "live_category"
private const val BUCKET_VOD_CATEGORY = "vod_category"
private const val BUCKET_SERIES_CATEGORY = "series_category"
private const val BUCKET_LIVE_STREAM = "live_stream"
private const val BUCKET_VOD_ITEM = "vod_item"
private const val BUCKET_SERIES_ITEM = "series_item"
}
}
@@ -0,0 +1,33 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
import ma.tkgraph.xtreamtv.data.model.VodDto
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import ma.tkgraph.xtreamtv.util.CategorySortUtils
import timber.log.Timber
/**
* Repository pour les VOD (films).
*/
class VodRepository(
private val api: XtreamApi,
private val storage: SecureStorage
) {
private fun requireConfig(): XtreamConfig? = storage.getCredentials()
suspend fun getCategories(): UiState<List<VodCategoryDto>> = catchErrors {
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
val cats = api.getVodCategories(config.username, config.password)
val sorted = CategorySortUtils.sortVodCategories(cats)
UiState.Success(sorted)
}
suspend fun getMovies(categoryId: Int? = null): UiState<List<VodDto>> = catchErrors {
val config = requireConfig() ?: return@catchErrors UiState.Error("Not logged in")
val movies = api.getVodStreams(config.username, config.password, categoryId = categoryId)
UiState.Success(movies)
}
}
@@ -0,0 +1,134 @@
package ma.tkgraph.xtreamtv.data.storage
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
/**
* Stockage sécurisé des credentials avec EncryptedSharedPreferences.
*
* - Keys : AES-256 SIV
* - Values : AES-256 GCM
* - MasterKey générée via AndroidKeyStore
*/
class SecureStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// ─── Config complète (Xtream ou M3U) ───
fun saveConfig(config: XtreamConfig) {
prefs.edit()
.putString(KEY_SOURCE_TYPE, config.sourceType.name)
.putString(KEY_SERVER, config.serverUrl)
.putString(KEY_USERNAME, config.username)
.putString(KEY_PASSWORD, config.password)
.putString(KEY_M3U_URL, config.m3uUrl)
.apply()
}
fun getConfig(): XtreamConfig? {
// Essayer le nouveau format (avec sourceType)
val sourceTypeStr = prefs.getString(KEY_SOURCE_TYPE, null)
if (sourceTypeStr != null) {
val sourceType = try { SourceType.valueOf(sourceTypeStr) } catch (_: Exception) { SourceType.XTREAM }
return when (sourceType) {
SourceType.XTREAM -> {
val server = prefs.getString(KEY_SERVER, null) ?: return null
val username = prefs.getString(KEY_USERNAME, null) ?: return null
val password = prefs.getString(KEY_PASSWORD, null) ?: return null
XtreamConfig(sourceType = SourceType.XTREAM, serverUrl = server, username = username, password = password)
}
SourceType.M3U -> {
val m3uUrl = prefs.getString(KEY_M3U_URL, null) ?: return null
XtreamConfig(sourceType = SourceType.M3U, m3uUrl = m3uUrl)
}
}
}
// Fallback : ancien format (sans sourceType, session Xtream directe)
val server = prefs.getString(KEY_SERVER, null) ?: return null
val username = prefs.getString(KEY_USERNAME, null) ?: return null
val password = prefs.getString(KEY_PASSWORD, null) ?: return null
return XtreamConfig(sourceType = SourceType.XTREAM, serverUrl = server, username = username, password = password)
}
/** Retourne la config en ignorant le type (pour les migrations). */
fun getCredentials(): XtreamConfig? = getConfig()
fun hasConfig(): Boolean = prefs.contains(KEY_SOURCE_TYPE)
fun clearAll() {
prefs.edit().clear().apply()
}
// ─── Favorites (Live) ───
fun getFavoriteLiveIds(): Set<Int> =
prefs.getStringSet(KEY_FAV_LIVE, emptySet())
?.mapNotNull { it.toIntOrNull() }
?.toSet() ?: emptySet()
fun setFavoriteLiveIds(ids: Set<Int>) {
prefs.edit().putStringSet(KEY_FAV_LIVE, ids.map { it.toString() }.toSet()).apply()
}
// ─── Favorites (VOD) ───
fun getFavoriteVodIds(): Set<Int> =
prefs.getStringSet(KEY_FAV_VOD, emptySet())
?.mapNotNull { it.toIntOrNull() }
?.toSet() ?: emptySet()
fun setFavoriteVodIds(ids: Set<Int>) {
prefs.edit().putStringSet(KEY_FAV_VOD, ids.map { it.toString() }.toSet()).apply()
}
// ─── Favorites (Series) ───
fun getFavoriteSeriesIds(): Set<Int> =
prefs.getStringSet(KEY_FAV_SERIES, emptySet())
?.mapNotNull { it.toIntOrNull() }
?.toSet() ?: emptySet()
fun setFavoriteSeriesIds(ids: Set<Int>) {
prefs.edit().putStringSet(KEY_FAV_SERIES, ids.map { it.toString() }.toSet()).apply()
}
// ─── Pinned Channel ───
fun getPinnedChannelId(): Int? {
val id = prefs.getString(KEY_PINNED, null) ?: return null
return id.toIntOrNull()
}
fun setPinnedChannelId(streamId: Int?) {
prefs.edit().putString(KEY_PINNED, streamId?.toString()).apply()
}
companion object {
private const val PREFS_NAME = "imedia_secure_prefs"
private const val KEY_SOURCE_TYPE = "source_type"
private const val KEY_SERVER = "server_url"
private const val KEY_USERNAME = "username"
private const val KEY_PASSWORD = "password"
private const val KEY_M3U_URL = "m3u_url"
private const val KEY_FAV_LIVE = "favorite_live"
private const val KEY_FAV_VOD = "favorite_vod"
private const val KEY_FAV_SERIES = "favorite_series"
private const val KEY_PINNED = "pinned_channel"
}
}
@@ -0,0 +1,57 @@
package ma.tkgraph.xtreamtv.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.RoundedCornerShape
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.composed
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Modifier qui ajoute un contour lumineux visible quand l'élément ou un de ses enfants a le focus.
* Idéal pour la navigation télécommande Android TV.
*
* @param focusedBorderColor Couleur du contour quand focusé
* @param unfocusedBorderColor Couleur du contour quand pas focusé (transparent par défaut)
* @param borderWidth Épaisseur du contour
* @param cornerRadius Arrondi des coins
*/
fun Modifier.tvFocusHighlight(
focusedBorderColor: Color = Color(0xFFE50914),
unfocusedBorderColor: Color = Color.Transparent,
borderWidth: Dp = 3.dp,
cornerRadius: Dp = 12.dp
): Modifier = composed {
var isFocused by remember { mutableStateOf(false) }
val animatedAlpha by animateFloatAsState(
targetValue = if (isFocused) 1f else 0f,
animationSpec = tween(durationMillis = 150)
)
this
.onFocusChanged { isFocused = it.isFocused || it.hasFocus }
.then(
if (isFocused) {
Modifier.border(
width = borderWidth,
color = focusedBorderColor.copy(alpha = animatedAlpha),
shape = RoundedCornerShape(cornerRadius)
)
} else {
Modifier.border(
width = borderWidth,
color = unfocusedBorderColor,
shape = RoundedCornerShape(cornerRadius)
)
}
)
}
@@ -0,0 +1,658 @@
package ma.tkgraph.xtreamtv.ui.components
import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup
import android.webkit.ConsoleMessage
import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key as ComposeKey
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
/**
* Commande envoyée depuis PlayerScreen vers le mini lecteur YouTube.
* Le nonce force Compose à rejouer la commande même si le même bouton est pressé plusieurs fois.
*/
data class YoutubeRemoteCommand(
val type: YoutubeCommandType,
val nonce: Long = System.nanoTime()
)
enum class YoutubeCommandType {
TogglePlayPause,
SeekBackward,
SeekForward
}
private enum class YoutubeLoadMode {
Embed,
Site
}
/**
* Mémoire légère de session : garde le dernier lien saisi tant que le processus
* de l'application reste ouvert. Rien n'est écrit sur disque.
*/
private object YoutubeMiniPlayerSessionMemory {
var lastInputText: String = DEFAULT_YOUTUBE_WATCH_PREFIX
}
private data class YoutubeEmbedTarget(
val normalizedUrl: String,
val videoId: String?,
val playlistId: String?,
val channelOrSiteUrl: String?
) {
val canUseEmbed: Boolean = videoId != null || playlistId != null
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun YoutubeMiniPlayer(
modifier: Modifier = Modifier,
remoteCommand: YoutubeRemoteCommand? = null,
onClose: () -> Unit
) {
val inputFocusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val loadButtonFocusRequester = remember { FocusRequester() }
val minimizeButtonFocusRequester = remember { FocusRequester() }
val closeButtonFocusRequester = remember { FocusRequester() }
val minimizedFocusRequester = remember { FocusRequester() }
var isInputFocused by remember { mutableStateOf(false) }
var inputUrl by rememberSaveable(stateSaver = TextFieldValue.Saver) {
val rememberedInput = YoutubeMiniPlayerSessionMemory.lastInputText
.takeIf { it.isNotBlank() }
?: DEFAULT_YOUTUBE_WATCH_PREFIX
mutableStateOf(
TextFieldValue(
text = rememberedInput,
selection = TextRange(rememberedInput.length)
)
)
}
var loadedUrl by rememberSaveable { mutableStateOf("") }
var loadModeName by rememberSaveable { mutableStateOf(YoutubeLoadMode.Embed.name) }
var status by remember { mutableStateOf("V8 focus : ajoutez le code puis Charger") }
var isMinimized by rememberSaveable { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
val loadMode = remember(loadModeName) {
runCatching { YoutubeLoadMode.valueOf(loadModeName) }.getOrDefault(YoutubeLoadMode.Embed)
}
val target = remember(loadedUrl) { parseYoutubeTarget(loadedUrl) }
fun exitInputAndFocusLoadButton() {
focusManager.clearFocus(force = true)
runCatching { loadButtonFocusRequester.requestFocus() }
status = "Champ quitté : OK sur Charger"
}
DisposableEffect(Unit) {
onDispose {
webView?.stopLoading()
webView?.destroy()
webView = null
}
}
BackHandler(enabled = !isMinimized) {
if (isInputFocused) {
exitInputAndFocusLoadButton()
} else {
onClose()
}
}
BackHandler(enabled = isMinimized) {
onClose()
}
fun sendCommand(type: YoutubeCommandType) {
val js = when (type) {
YoutubeCommandType.TogglePlayPause -> "if(window.togglePlay){togglePlay();}"
YoutubeCommandType.SeekBackward -> "if(window.seekBy){seekBy(-10);}"
YoutubeCommandType.SeekForward -> "if(window.seekBy){seekBy(10);}"
}
webView?.evaluateJavascript(js, null)
}
fun loadUrl(mode: YoutubeLoadMode) {
val normalized = normalizeYoutubeUrl(inputUrl.text)
YoutubeMiniPlayerSessionMemory.lastInputText = inputUrl.text
loadedUrl = normalized
loadModeName = mode.name
status = when {
normalized.isBlank() -> "Ajoutez le code vidéo après watch?v="
mode == YoutubeLoadMode.Site -> "Chargement du site YouTube"
else -> "Chargement du player intégré"
}
}
fun restoreFromMinimized() {
isMinimized = false
status = "Mode normal : OK = Play/Pause"
}
LaunchedEffect(remoteCommand) {
remoteCommand?.let { sendCommand(it.type) }
}
LaunchedEffect(Unit) {
runCatching { inputFocusRequester.requestFocus() }
}
LaunchedEffect(isMinimized) {
if (isMinimized) {
status = "Mode minimisé : OK pour agrandir"
runCatching { minimizedFocusRequester.requestFocus() }
} else {
runCatching { inputFocusRequester.requestFocus() }
}
}
val surfaceModifier = modifier.then(
if (isMinimized) {
Modifier
.width(220.dp)
.alpha(0.30f)
.focusRequester(minimizedFocusRequester)
.focusable()
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && (event.key == Key.DirectionCenter || event.key == Key.Enter)) {
restoreFromMinimized()
true
} else if (event.type == KeyEventType.KeyUp && event.key == Key.Back) {
onClose()
true
} else {
false
}
}
} else {
Modifier
.width(520.dp)
.onPreviewKeyEvent { event ->
when {
// Filet de sécurité au niveau du conteneur.
isInputFocused && (event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) &&
(event.key == Key.DirectionRight || event.key == Key.DirectionDown || event.key == Key.Tab || event.key == Key.Back) -> {
if (event.type == KeyEventType.KeyDown) {
exitInputAndFocusLoadButton()
}
true
}
// OK depuis le champ charge directement, utile sur les Mi Box qui gardent le focus texte.
isInputFocused && event.type == KeyEventType.KeyDown &&
(event.key == Key.DirectionCenter || event.key == Key.Enter) -> {
loadUrl(YoutubeLoadMode.Embed)
exitInputAndFocusLoadButton()
true
}
event.type == KeyEventType.KeyUp && event.key == Key.Back -> {
onClose()
true
}
else -> false
}
}
}
)
Surface(
modifier = surfaceModifier,
color = Color.Black.copy(alpha = if (isMinimized) 0.34f else 0.88f),
shape = RoundedCornerShape(if (isMinimized) 14.dp else 18.dp),
shadowElevation = if (isMinimized) 2.dp else 10.dp
) {
Column(
modifier = Modifier
.border(
1.dp,
Color(0xFF00D5FF).copy(alpha = if (isMinimized) 0.16f else 0.45f),
RoundedCornerShape(if (isMinimized) 14.dp else 18.dp)
)
.padding(if (isMinimized) 5.dp else 12.dp),
verticalArrangement = Arrangement.spacedBy(if (isMinimized) 0.dp else 8.dp)
) {
if (!isMinimized) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "YouTube discret · V8",
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "TV muette · OK Play/Pause · Retour fermer",
color = Color(0xFFB8C7D1),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
TextButton(
onClick = onClose,
modifier = Modifier.focusRequester(closeButtonFocusRequester)
) {
Text("Fermer", color = Color.White)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = inputUrl,
onValueChange = {
inputUrl = it
YoutubeMiniPlayerSessionMemory.lastInputText = it.text
},
modifier = Modifier
.weight(1f)
.focusRequester(inputFocusRequester)
.onFocusChanged { isInputFocused = it.isFocused }
.onPreviewKeyEvent { event ->
val exitKeys = event.key == Key.DirectionRight ||
event.key == Key.DirectionDown ||
event.key == Key.Tab ||
event.key == Key.Back
val loadKeys = event.key == Key.DirectionCenter || event.key == Key.Enter
when {
(event.type == KeyEventType.KeyDown || event.type == KeyEventType.KeyUp) && exitKeys -> {
if (event.type == KeyEventType.KeyDown) {
exitInputAndFocusLoadButton()
}
true
}
event.type == KeyEventType.KeyDown && loadKeys -> {
loadUrl(YoutubeLoadMode.Embed)
exitInputAndFocusLoadButton()
true
}
else -> false
}
},
singleLine = true,
label = { Text("Lien ou code YouTube") },
placeholder = { Text("CTGbG3bygLg") }
)
Button(
onClick = { loadUrl(YoutubeLoadMode.Embed) },
modifier = Modifier.focusRequester(loadButtonFocusRequester),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00A9D6))
) {
Text("Charger")
}
Button(
onClick = { isMinimized = true },
modifier = Modifier.focusRequester(minimizeButtonFocusRequester),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF44515A))
) {
Text("Minimiser")
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(if (isMinimized) 112.dp else 240.dp)
.background(Color.Black, RoundedCornerShape(if (isMinimized) 10.dp else 12.dp)),
contentAlignment = Alignment.Center
) {
if (loadedUrl.isBlank()) {
Text("Aucun lien chargé", color = Color(0xFFB8C7D1))
} else {
ComposeKey("hermes-youtube-webview") {
AndroidView(
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.databaseEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(false)
settings.userAgentString = DESKTOP_USER_AGENT
runCatching {
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
}
webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
val message = consoleMessage?.message().orEmpty()
if (message.startsWith("HERMES_YT:")) {
status = message.removePrefix("HERMES_YT:")
return true
}
return super.onConsoleMessage(consoleMessage)
}
}
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
return false
}
override fun onPageFinished(view: WebView?, url: String?) {
if (!status.startsWith("YouTube erreur") && !isMinimized) {
status = if (loadMode == YoutubeLoadMode.Site) {
"Site YouTube chargé : cliquez Play si nécessaire"
} else {
"Player chargé : OK = Play/Pause"
}
}
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
if (request?.isForMainFrame == true) {
status = "Erreur WebView : ${error?.description ?: "chargement impossible"}"
}
}
}
setBackgroundColor(android.graphics.Color.BLACK)
}.also { webView = it }
},
update = { view ->
webView = view
val currentTarget = target
val tagValue = "${loadedUrl}|${loadMode.name}"
if (view.tag != tagValue) {
view.tag = tagValue
when {
currentTarget == null -> Unit
loadMode == YoutubeLoadMode.Site -> {
view.loadUrl(
currentTarget.toWatchOrNormalizedUrl(),
youtubeHeaders()
)
}
currentTarget.canUseEmbed -> {
view.loadDataWithBaseURL(
HERMES_YOUTUBE_ORIGIN,
buildYoutubeStaticIframeHtml(currentTarget),
"text/html",
"utf-8",
currentTarget.toEmbedUrl()
)
}
else -> {
view.loadUrl(
currentTarget.toWatchOrNormalizedUrl(),
youtubeHeaders()
)
}
}
}
},
modifier = Modifier.fillMaxSize()
)
}
}
if (isMinimized) {
Button(
onClick = { restoreFromMinimized() },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(3.dp)
.focusRequester(minimizedFocusRequester),
colors = ButtonDefaults.buttonColors(containerColor = Color.Black.copy(alpha = 0.58f))
) {
Text("OK agrandir", color = Color.White)
}
}
}
if (!isMinimized) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(onClick = { sendCommand(YoutubeCommandType.SeekBackward) }) { Text("-10s") }
Button(onClick = { sendCommand(YoutubeCommandType.TogglePlayPause) }) { Text("Pause / Play") }
Button(onClick = { sendCommand(YoutubeCommandType.SeekForward) }) { Text("+10s") }
Spacer(Modifier.width(8.dp))
Text(
text = status,
color = Color(0xFFB8C7D1),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
}
}
}
}
private const val DEFAULT_YOUTUBE_WATCH_PREFIX = "https://www.youtube.com/watch?v="
private val YOUTUBE_VIDEO_ID_PATTERN = Regex("^[A-Za-z0-9_-]{8,20}$")
private const val HERMES_YOUTUBE_ORIGIN = "https://hermes-youtube.local/"
private const val DESKTOP_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
private fun youtubeHeaders(): Map<String, String> = mapOf(
"Referer" to HERMES_YOUTUBE_ORIGIN,
"Origin" to HERMES_YOUTUBE_ORIGIN.trimEnd('/')
)
private fun normalizeYoutubeUrl(raw: String): String {
val value = raw.trim()
if (value.isBlank() || value == DEFAULT_YOUTUBE_WATCH_PREFIX) return ""
return when {
value.startsWith("http://") || value.startsWith("https://") -> value
value.startsWith("www.") || value.startsWith("youtube.com") || value.startsWith("youtu.be") -> "https://$value"
value.startsWith("watch?v=") -> "https://www.youtube.com/$value"
value.startsWith("v=") -> DEFAULT_YOUTUBE_WATCH_PREFIX + value.removePrefix("v=")
YOUTUBE_VIDEO_ID_PATTERN.matches(value) -> DEFAULT_YOUTUBE_WATCH_PREFIX + value
else -> "https://$value"
}
}
private fun parseYoutubeTarget(rawUrl: String): YoutubeEmbedTarget? {
val normalized = normalizeYoutubeUrl(rawUrl)
if (normalized.isBlank()) return null
return runCatching {
val uri = Uri.parse(normalized)
val host = uri.host.orEmpty().lowercase()
val pathSegments = uri.pathSegments
val videoId = when {
host == "youtu.be" && pathSegments.isNotEmpty() -> pathSegments.firstOrNull()
uri.getQueryParameter("v")?.isNotBlank() == true -> uri.getQueryParameter("v")
pathSegments.size >= 2 && pathSegments[0] in setOf("embed", "shorts", "live") -> pathSegments[1]
else -> null
}?.takeIf { it.length in 8..20 }
val playlistId = uri.getQueryParameter("list")?.takeIf { it.isNotBlank() }
YoutubeEmbedTarget(
normalizedUrl = normalized,
videoId = videoId,
playlistId = playlistId,
channelOrSiteUrl = normalized
)
}.getOrNull()
}
private fun YoutubeEmbedTarget.toEmbedUrl(): String {
val origin = HERMES_YOUTUBE_ORIGIN.trimEnd('/').urlEncode()
val common = "autoplay=1&controls=1&enablejsapi=1&playsinline=1&rel=0&modestbranding=1&origin=$origin"
return when {
videoId != null -> "https://www.youtube.com/embed/$videoId?$common"
playlistId != null -> "https://www.youtube.com/embed/videoseries?list=${playlistId.urlEncode()}&$common"
else -> normalizedUrl
}
}
private fun YoutubeEmbedTarget.toWatchOrNormalizedUrl(): String {
return when {
videoId != null -> "https://www.youtube.com/watch?v=$videoId"
else -> channelOrSiteUrl ?: normalizedUrl
}
}
private fun buildYoutubeStaticIframeHtml(target: YoutubeEmbedTarget): String {
val embedUrl = target.toEmbedUrl().escapeHtml()
return """
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<meta name="referrer" content="strict-origin-when-cross-origin">
<style>
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#ytplayer { width:100%; height:100%; background:#000; border:0; display:block; }
</style>
</head>
<body>
<iframe
id="ytplayer"
src="$embedUrl"
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin">
</iframe>
<script src="https://www.youtube.com/iframe_api"></script>
<script>
var player = null;
var lastState = -1;
function logHermes(message) {
try { console.log('HERMES_YT:' + message); } catch(e) {}
}
function onYouTubeIframeAPIReady() {
player = new YT.Player('ytplayer', {
events: {
'onReady': function(event) {
logHermes('Player prêt');
try { event.target.playVideo(); } catch(e) { logHermes('Cliquez Play si nécessaire'); }
},
'onStateChange': function(event) {
lastState = event.data;
if (event.data === YT.PlayerState.PLAYING) logHermes('Lecture YouTube');
if (event.data === YT.PlayerState.PAUSED) logHermes('Pause YouTube');
if (event.data === YT.PlayerState.BUFFERING) logHermes('Chargement YouTube');
},
'onError': function(event) {
logHermes('YouTube erreur ' + event.data + ' : embed refusé');
}
}
});
}
function togglePlay() {
if (!player || !player.getPlayerState) return;
var state = player.getPlayerState();
if (state === YT.PlayerState.PLAYING) {
player.pauseVideo();
} else {
player.playVideo();
}
}
function seekBy(seconds) {
if (!player || !player.getCurrentTime || !player.seekTo) return;
var current = 0;
try { current = player.getCurrentTime(); } catch(e) { current = 0; }
var next = Math.max(0, current + seconds);
player.seekTo(next, true);
}
</script>
</body>
</html>
""".trimIndent()
}
private fun String.escapeHtml(): String =
replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
private fun String.urlEncode(): String = Uri.encode(this)
@@ -0,0 +1,40 @@
package ma.tkgraph.xtreamtv.ui.navigation
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.VodDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
/**
* État de navigation de l'application.
* Chaque écran est un état avec ses propres données contextuelles.
*/
sealed class NavState {
/** Écran d'accueil = Live TV avec catégories et chaînes. */
data class Home(val focusStreamId: Int? = null) : NavState()
/** Lecteur vidéo. */
data class Player(
val stream: LiveStreamDto,
val allStreams: List<LiveStreamDto> = emptyList(),
val currentIndex: Int = 0,
val replayUrl: String? = null,
val returnState: NavState? = null
) : NavState()
/** Guide TV global. */
data object ProgramGuide : NavState()
/** Guide Replay pour une chaîne spécifique. */
data class ReplayGuide(val stream: LiveStreamDto) : NavState()
/** Catalogue VOD (films). */
data class Vod(val initialCategoryId: Int? = null) : NavState()
/** Catalogue séries. */
data class Series(val initialCategoryId: Int? = null) : NavState()
/** Détail d'une série (saisons + épisodes). */
data class SeriesDetail(val series: SeriesDto, val info: SeriesInfoDto) : NavState()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,397 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextInputAction
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.ui.theme.DesignColors
import ma.tkgraph.xtreamtv.ui.viewmodels.LoginViewModel
/**
* Écran de connexion / configuration.
* Permet de choisir entre serveur Xtream et fichier M3U.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(viewModel: LoginViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val serverUrl = viewModel.serverUrl
val username = viewModel.username
val password = viewModel.password
val m3uUrl = viewModel.m3uUrl
val sourceType = viewModel.sourceType
var showModeMenu by remember { mutableStateOf(false) }
val serverFocusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
val passwordFocusRequester = remember { FocusRequester() }
val m3uFocusRequester = remember { FocusRequester() }
val submitFocusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(sourceType) {
runCatching {
if (sourceType == SourceType.XTREAM) serverFocusRequester.requestFocus()
else m3uFocusRequester.requestFocus()
}
}
// Vérifier si des credentials sont déjà stockés
val savedConfig = remember {
XtreamApplication.instance.appContainer.secureStorage.getConfig()
}
Surface(
modifier = Modifier.fillMaxSize(),
color = DesignColors.bg1
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3)
)
)
.verticalScroll(rememberScrollState())
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Spacer(Modifier.height(32.dp))
// Titre avec gradient orange
Text(
text = "iMedia TV+",
style = MaterialTheme.typography.headlineLarge.copy(
brush = Brush.linearGradient(
colors = listOf(DesignColors.orange, DesignColors.orange2)
)
),
fontWeight = FontWeight.Black,
letterSpacing = (-0.055).sp
)
Spacer(Modifier.height(4.dp))
Text(
text = "Configuration",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF888888)
)
Spacer(Modifier.height(24.dp))
// ─── Sélecteur de mode ───
Surface(
shape = RoundedCornerShape(12.dp),
color = Color(0xFF1A1A1A),
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Source", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ModeButton(
label = "Xtream",
isSelected = sourceType == SourceType.XTREAM,
onClick = { viewModel.switchSourceType(SourceType.XTREAM) }
)
ModeButton(
label = "M3U",
isSelected = sourceType == SourceType.M3U,
onClick = { viewModel.switchSourceType(SourceType.M3U) }
)
}
}
}
Spacer(Modifier.height(16.dp))
// ─── Champs selon le mode ───
Surface(
shape = RoundedCornerShape(12.dp),
color = Color(0xFF1A1A1A),
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
if (sourceType == SourceType.XTREAM) {
// ── Mode Xtream ──
Text("Serveur Xtream", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = serverUrl,
onValueChange = { viewModel.updateServerUrl(it) },
label = { Text("URL du serveur") },
placeholder = { Text("http://serveur:8080") },
singleLine = true,
enabled = uiState !is UiState.Loading,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { usernameFocusRequester.requestFocus() }
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(serverFocusRequester)
.onFocusChanged { if (it.isFocused) keyboardController?.show() }
.moveFocusOnDpad(
down = { usernameFocusRequester.requestFocus() }
),
colors = fieldColors()
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { viewModel.updateUsername(it) },
label = { Text("Identifiant") },
singleLine = true,
enabled = uiState !is UiState.Loading,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(usernameFocusRequester)
.onFocusChanged { if (it.isFocused) keyboardController?.show() }
.moveFocusOnDpad(
up = { serverFocusRequester.requestFocus() },
down = { passwordFocusRequester.requestFocus() }
),
colors = fieldColors()
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { viewModel.updatePassword(it) },
label = { Text("Mot de passe") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
enabled = uiState !is UiState.Loading,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { keyboardController?.hide(); viewModel.login() }
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocusRequester)
.onFocusChanged { if (it.isFocused) keyboardController?.show() }
.moveFocusOnDpad(
up = { usernameFocusRequester.requestFocus() },
down = { submitFocusRequester.requestFocus() }
),
colors = fieldColors()
)
Spacer(Modifier.height(16.dp))
Button(
onClick = { viewModel.login() },
enabled = uiState !is UiState.Loading
&& serverUrl.isNotBlank()
&& username.isNotBlank()
&& password.isNotBlank(),
modifier = Modifier
.width(300.dp)
.height(48.dp)
.align(Alignment.CenterHorizontally)
.focusRequester(submitFocusRequester)
.moveFocusOnDpad(
up = { passwordFocusRequester.requestFocus() }
),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE50914))
) {
if (uiState is UiState.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text("Connexion", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
} else {
// ── Mode M3U ──
Text("Playlist M3U", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = m3uUrl,
onValueChange = { viewModel.updateM3uUrl(it) },
label = { Text("URL du fichier M3U") },
placeholder = { Text("https://exemple.com/playlist.m3u") },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { keyboardController?.hide(); viewModel.saveM3uConfig() }
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(m3uFocusRequester)
.onFocusChanged { if (it.isFocused) keyboardController?.show() }
.moveFocusOnDpad(
down = { submitFocusRequester.requestFocus() }
),
colors = fieldColors()
)
Spacer(Modifier.height(4.dp))
Text(
"Entrez l'URL d'un fichier M3U distant",
color = Color(0xFF666666),
fontSize = 11.sp
)
Spacer(Modifier.height(16.dp))
Button(
onClick = { viewModel.saveM3uConfig() },
enabled = m3uUrl.isNotBlank(),
modifier = Modifier
.width(300.dp)
.height(48.dp)
.align(Alignment.CenterHorizontally)
.focusRequester(submitFocusRequester)
.moveFocusOnDpad(
up = { m3uFocusRequester.requestFocus() }
),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))
) {
Text("Appliquer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
}
}
// Message d'erreur
if (uiState is UiState.Error) {
Spacer(Modifier.height(16.dp))
Surface(shape = RoundedCornerShape(10.dp), color = Color(0xFF4A1C1C)) {
Text(
text = (uiState as UiState.Error).message,
modifier = Modifier.padding(16.dp),
color = Color(0xFFFF5252)
)
}
}
// Si déjà connecté, afficher les infos
if (savedConfig != null) {
Spacer(Modifier.height(16.dp))
Surface(
shape = RoundedCornerShape(10.dp),
color = Color(0xFF1A3A1A),
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
) {
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text("Session active", color = Color(0xFF4CAF50), fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.weight(1f))
TextButton(onClick = { viewModel.logout() }) {
Text("Déconnecter", color = Color(0xFFFF5252), fontSize = 12.sp)
}
}
}
}
Spacer(Modifier.height(16.dp))
}
}
}
@Composable
private fun RowScope.ModeButton(label: String, isSelected: Boolean, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(8.dp),
color = if (isSelected) Color(0xFFE50914) else Color(0xFF333333),
modifier = Modifier.weight(1f)
) {
Text(
label,
modifier = Modifier.padding(vertical = 10.dp),
color = if (isSelected) Color.White else Color(0xFFAAAAAA),
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
}
}
private fun Modifier.moveFocusOnDpad(
up: (() -> Unit)? = null,
down: (() -> Unit)? = null
): Modifier = onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionUp -> {
up?.invoke()
up != null
}
Key.DirectionDown -> {
down?.invoke()
down != null
}
else -> false
}
}
@Composable
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFFE50914),
unfocusedBorderColor = Color(0xFF333333),
focusedLabelColor = Color(0xFFE50914),
unfocusedLabelColor = Color(0xFF888888),
cursorColor = Color.White,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White
)
@@ -0,0 +1,285 @@
package ma.tkgraph.xtreamtv.ui.screens
import android.view.ViewGroup
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import ma.tkgraph.xtreamtv.ui.viewmodels.PlayerViewModel
import ma.tkgraph.xtreamtv.ui.components.YoutubeMiniPlayer
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
@Composable
fun PlayerScreen(
initialStream: LiveStreamDto,
allStreams: List<LiveStreamDto>,
initialIndex: Int,
replayUrl: String? = null,
viewModel: PlayerViewModel = viewModel(),
onBack: () -> Unit = {}
) {
val context = LocalContext.current
val state by viewModel.state.collectAsStateWithLifecycle()
var player by remember { mutableStateOf<ExoPlayer?>(null) }
val focusRequester = remember { FocusRequester() }
var youtubeVisible by remember { mutableStateOf(false) }
LaunchedEffect(initialStream) {
viewModel.initialize(initialStream, allStreams, initialIndex)
}
// Use replay URL if provided, otherwise build live URL
val streamUrl = remember(state.stream, replayUrl, state.restartTrigger) {
replayUrl ?: viewModel.buildUrl(state.stream)
}
DisposableEffect(Unit) {
onDispose { player?.release() }
}
// Auto-hide overlay after 3 seconds
LaunchedEffect(state.showOverlay) {
if (state.showOverlay) {
delay(3000)
viewModel.hideOverlay()
}
}
// Update player when channel changes, with resolution listener
LaunchedEffect(streamUrl) {
if (streamUrl.isNotEmpty()) {
val oldPlayer = player
if (oldPlayer != null) {
oldPlayer.stop()
oldPlayer.release()
}
val newPlayer = ExoPlayer.Builder(context).build().apply {
volume = if (youtubeVisible) 0f else 1f
setMediaItem(MediaItem.fromUri(streamUrl))
addListener(object : Player.Listener {
override fun onVideoSizeChanged(videoSize: VideoSize) {
if (videoSize.width > 0 && videoSize.height > 0) {
val res = "${videoSize.width}x${videoSize.height}"
viewModel.updateResolution(res)
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_READY) {
viewModel.resetFreezeCounter()
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
viewModel.resetFreezeCounter()
}
}
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
// Si le replay échoue, essayer le live en fallback
if (replayUrl != null) {
player?.stop()
player?.setMediaItem(MediaItem.fromUri(viewModel.buildUrl(state.stream)))
player?.prepare()
player?.play()
}
}
})
prepare()
play()
}
player = newPlayer
// Freeze detection : attend 15s de grâce au début,
// puis ne redémarre qu'après 10s de buffering continu
val startTime = System.currentTimeMillis()
while (isActive && viewModel.canRestart()) {
delay(5000)
val elapsed = System.currentTimeMillis() - startTime
if (elapsed < 15_000) continue // grâce de 15s au démarrage
val p = player
if (p != null && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) {
delay(5000)
if (isActive && player == p && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) {
viewModel.requestRestart()
}
}
}
}
}
LaunchedEffect(player, youtubeVisible) {
player?.volume = if (youtubeVisible) 0f else 1f
}
// Request focus for remote-control key events
LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } }
val isLoadingStream by remember { derivedStateOf { streamUrl.isEmpty() } }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(focusRequester)
.focusable()
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyUp) {
if (youtubeVisible) {
// IMPORTANT : quand le mini lecteur YouTube est ouvert,
// le parent ne consomme plus aucune touche.
// Le mini lecteur gère lui-même OK, Retour, Droite/Bas depuis le champ texte.
// Cela évite les différences de focus entre modèles de Mi Box.
false
} else {
when (event.key) {
Key.DirectionUp -> { youtubeVisible = true; viewModel.hideOverlay(); true }
Key.DirectionLeft -> { viewModel.zappingLeft(); true }
Key.DirectionRight -> { viewModel.zappingRight(); true }
Key.DirectionCenter, Key.Enter -> { viewModel.toggleOverlay(); true }
Key.Back -> {
if (state.showOverlay) {
player?.stop()
player?.release()
player = null
onBack()
} else {
viewModel.toggleOverlay()
}
true
}
else -> false
}
}
} else false
}
) {
// Video player area
if (!isLoadingStream) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
useController = false
keepScreenOn = true
}
},
update = { view -> view.player = player },
modifier = Modifier.fillMaxSize()
)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(state.stream.name, color = Color.White, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp))
Text("Chargement du flux…", color = Color.Gray)
}
}
}
// Overlay : logo + nom + résolution + programme en cours/suivant
AnimatedVisibility(
visible = state.showOverlay,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.TopStart).fillMaxWidth()
) {
Surface(
color = Color.Black.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth().padding(16.dp)
) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.Top) {
// Logo
if (state.stream.streamIcon != null) {
coil3.compose.AsyncImage(
model = state.stream.streamIcon,
contentDescription = state.stream.name,
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.width(12.dp))
}
Column(modifier = Modifier.weight(1f)) {
// Nom de la chaîne
Text(
state.stream.name,
style = MaterialTheme.typography.headlineSmall,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Résolution
if (state.resolution.isNotEmpty()) {
Spacer(Modifier.height(2.dp))
Text(state.resolution, color = Color(0xFFBBBBBB), style = MaterialTheme.typography.bodyMedium)
}
// Programme en cours
if (state.currentProgram.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("En cours", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall)
Text(state.currentProgram, color = Color.White, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
// Programme suivant
if (state.nextProgram.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text("Suivant", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall)
Text(state.nextProgram, color = Color(0xFFCCCCCC), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
if (youtubeVisible) {
YoutubeMiniPlayer(
onClose = { youtubeVisible = false },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(20.dp)
)
}
// Bottom controls hint
if (state.showOverlay && allStreams.size > 1) {
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
Text("▲ YouTube discret", color = Color.LightGray)
Text("◀ Chaîne précédente", color = Color.LightGray)
Text("Retour : menu", color = Color.LightGray)
Text("Chaîne suivante ▶", color = Color.LightGray)
}
}
}
}
@@ -0,0 +1,112 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
import ma.tkgraph.xtreamtv.ui.viewmodels.GuideViewModel
import ma.tkgraph.xtreamtv.ui.navigation.NavState
@Composable
fun ProgramGuideScreen(
viewModel: GuideViewModel = viewModel(),
onNavigate: (NavState) -> Unit = {},
onBack: () -> Unit = {}
) {
val streamsState by viewModel.streams.collectAsStateWithLifecycle()
val epgState by viewModel.epg.collectAsStateWithLifecycle()
val selectedStreamId by viewModel.selectedStreamId.collectAsStateWithLifecycle()
BackHandler { onBack() }
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = onBack,
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
borderWidth = 2.dp,
cornerRadius = 8.dp
)
) { Text("\u25C0 Back") }
Spacer(Modifier.width(16.dp))
Text("TV Guide", style = MaterialTheme.typography.headlineMedium)
}
when (val strs = streamsState) {
is UiState.Success -> {
val streamList = strs.data
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
items(streamList) { stream ->
Surface(
onClick = { viewModel.selectStream(stream.streamId) },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cornerRadius = 10.dp
),
shape = RoundedCornerShape(10.dp),
color = if (stream.streamId == selectedStreamId) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(stream.name, style = MaterialTheme.typography.bodyLarge)
if (stream.streamId == selectedStreamId) {
when (val epg = epgState) {
is UiState.Success -> {
epg.data.take(5).forEach { program ->
Text(" ${program.start?.take(5) ?: ""} - ${program.titleText()}", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
is UiState.Loading -> Text("Loading EPG...", style = MaterialTheme.typography.bodySmall)
else -> {}
}
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SuggestionChip(
onClick = { onNavigate(NavState.Player(stream, listOf(stream), 0)) },
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
borderWidth = 2.dp,
cornerRadius = 16.dp
),
label = { Text("Live") }
)
SuggestionChip(
onClick = { onNavigate(NavState.ReplayGuide(stream)) },
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
borderWidth = 2.dp,
cornerRadius = 16.dp
),
label = { Text("Replay") }
)
}
}
}
}
}
}
}
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
else -> {}
}
}
}
}
@@ -0,0 +1,96 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
import ma.tkgraph.xtreamtv.ui.viewmodels.ReplayViewModel
@Composable
fun ReplayGuideScreen(
stream: LiveStreamDto,
viewModel: ReplayViewModel = viewModel(),
onBack: () -> Unit = {},
onPlayReplay: (String) -> Unit = {}
) {
val epgState by viewModel.epg.collectAsStateWithLifecycle()
LaunchedEffect(stream) { viewModel.loadReplay(stream) }
BackHandler { onBack() }
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = onBack,
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
borderWidth = 2.dp,
cornerRadius = 8.dp
)
) { Text("\u25C0 Back") }
Spacer(Modifier.width(16.dp))
Text("Replay - ${stream.name}", style = MaterialTheme.typography.headlineMedium)
}
when (val epg = epgState) {
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(epg.message) }
is UiState.Success -> {
val grouped = epg.data.groupBy { it.start?.take(10) ?: "" }
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
grouped.forEach { (day, programs) ->
item {
Text(day, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = 8.dp))
}
items(programs) { program ->
Surface(
onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } },
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
cornerRadius = 10.dp
),
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) {
Text(program.titleText(), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${program.start?.take(16) ?: ""} - ${program.end?.take(5) ?: ""}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
SuggestionChip(
onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } },
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = MaterialTheme.colorScheme.primary,
borderWidth = 2.dp,
cornerRadius = 16.dp
),
label = { Text("Revoir") }
)
}
}
}
}
}
}
else -> {}
}
}
}
}
@@ -0,0 +1,210 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
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.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.EpisodeDto
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
import ma.tkgraph.xtreamtv.ui.viewmodels.SeriesViewModel
import ma.tkgraph.xtreamtv.ui.navigation.NavState
@Composable
fun SeriesScreen(
viewModel: SeriesViewModel = viewModel(),
onNavigate: (NavState) -> Unit = {},
onBack: () -> Unit = {}
) {
val categoriesState by viewModel.categories.collectAsStateWithLifecycle()
val seriesState by viewModel.series.collectAsStateWithLifecycle()
val seriesInfoState by viewModel.seriesInfo.collectAsStateWithLifecycle()
val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle()
val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle()
var selectedSeries by remember { mutableStateOf<SeriesDto?>(null) }
BackHandler { if (selectedSeries != null) selectedSeries = null else onBack() }
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) {
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = {
if (selectedSeries != null) selectedSeries = null
else onBack()
},
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = Color(0xFF4CAF50),
borderWidth = 2.dp,
cornerRadius = 8.dp
)
) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) }
Spacer(Modifier.width(8.dp))
Text(if (selectedSeries != null) selectedSeries!!.name else "Series",
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
color = if (selectedSeries != null) Color.White else Color(0xFF4CAF50))
}
// Categories (hidden in detail view)
if (selectedSeries == null) {
val catData = categoriesState
if (catData is UiState.Success) {
val catList = catData.data
LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item { Chip("All", selectedCatId == null, Color(0xFF4CAF50)) { viewModel.selectCategory(null) } }
item { Chip("\u2605 Favorites", selectedCatId == -1, Color(0xFF4CAF50)) { viewModel.selectCategory(-1) } }
catList.forEach { cat ->
item(key = cat.categoryId) {
Chip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1, Color(0xFF4CAF50)) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) }
}
}
}
}
}
// Detail view
val detailSeries = selectedSeries
if (detailSeries != null) {
val info = seriesInfoState
when {
info is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
info is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(info.message, color = MaterialTheme.colorScheme.error) }
info is UiState.Success -> {
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
info.data.seasons.forEach { season ->
item { Text("Season ${season.seasonNumber}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 8.dp), color = Color(0xFF4CAF50)) }
val episodes = info.data.episodes[season.seasonNumber.toString()] ?: emptyList()
items(episodes, key = { it.id }) { ep ->
Surface(onClick = {
val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials()
if (config != null) {
val url = ma.tkgraph.xtreamtv.util.UrlBuilder(config).seriesUrl(ep.id, ep.containerExtension)
onNavigate(NavState.Player(
stream = ma.tkgraph.xtreamtv.data.model.LiveStreamDto(
name = "${detailSeries.name} - Ep ${ep.episodeNum}",
streamId = ep.id,
containerExtension = ep.containerExtension
),
allStreams = emptyList(),
currentIndex = 0,
replayUrl = url,
returnState = NavState.Series()
))
}
}, modifier = Modifier
.fillMaxWidth()
.padding(vertical = 3.dp)
.tvFocusHighlight(
focusedBorderColor = Color(0xFF4CAF50),
cornerRadius = 10.dp
), shape = RoundedCornerShape(10.dp), color = Color(0xFF1A1A1A)) {
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Text("Ep ${ep.episodeNum}: ${ep.title ?: ep.name ?: ep.episodeName ?: ""}", style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White, modifier = Modifier.weight(1f))
Text("\u25B6", color = Color(0xFF4CAF50))
}
}
}
}
}
}
else -> { Box(Modifier.weight(1f)) } // Idle state
}
return@Surface
}
// Grid
val s = seriesState
when {
s is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
s is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(s.message, color = MaterialTheme.colorScheme.error) }
s is UiState.Success -> {
val seriesList = if (selectedCatId == -1) s.data.filter { it.seriesId in favoriteIds } else s.data
LazyVerticalGrid(columns = GridCells.Adaptive(160.dp), modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), contentPadding = PaddingValues(bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
items(seriesList, key = { it.seriesId }) { series ->
Surface(onClick = { selectedSeries = series; viewModel.loadSeriesInfo(series.seriesId) }, modifier = Modifier.fillMaxWidth().tvFocusHighlight(focusedBorderColor = Color(0xFF4CAF50), cornerRadius = 12.dp), shape = RoundedCornerShape(12.dp), color = Color(0xFF1A1A1A), tonalElevation = 2.dp) {
var favFocused by remember { mutableStateOf(false) }
Box {
Column {
Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) {
if (series.cover != null) coil3.compose.AsyncImage(model = series.cover, contentDescription = series.name, modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)))
else Text(series.name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFF4CAF50), fontWeight = FontWeight.Bold)
}
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(series.name, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
}
}
IconButton(
onClick = { viewModel.toggleFavorite(series.seriesId) },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(32.dp)
.onFocusChanged { favFocused = it.isFocused }
.border(
width = if (favFocused) 2.dp else 0.dp,
color = if (favFocused) Color(0xFFFFD700) else Color.Transparent,
shape = RoundedCornerShape(50)
)
) {
Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (series.seriesId in favoriteIds) Color(0xFFFFD700) else Color(0xFF555555))
}
}
}
}
}
}
else -> { Box(Modifier.weight(1f)) }
}
}
}
}
@Composable
private fun Chip(label: String, isSelected: Boolean, accentColor: Color, onClick: () -> Unit) {
var isFocused by remember { mutableStateOf(false) }
val bgColor = when {
isSelected -> accentColor
isFocused -> Color(0xFF1A3A1A)
else -> Color(0xFF252525)
}
Surface(
onClick = onClick,
modifier = Modifier
.onFocusChanged { isFocused = it.isFocused }
.border(
width = 3.dp,
color = when {
isFocused -> accentColor
else -> Color.Transparent
},
shape = RoundedCornerShape(20.dp)
),
shape = RoundedCornerShape(20.dp),
color = bgColor
) {
Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp)
}
}
@@ -0,0 +1,205 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.compose.foundation.background
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.foundation.border
import androidx.activity.compose.BackHandler
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
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.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
import ma.tkgraph.xtreamtv.ui.viewmodels.VodViewModel
import ma.tkgraph.xtreamtv.ui.navigation.NavState
@Composable
fun VodScreen(
viewModel: VodViewModel = viewModel(),
onNavigate: (NavState) -> Unit = {},
onBack: () -> Unit = {}
) {
val categoriesState by viewModel.categories.collectAsStateWithLifecycle()
val moviesState by viewModel.movies.collectAsStateWithLifecycle()
val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle()
val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle()
BackHandler { onBack() }
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) {
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(
onClick = onBack,
modifier = Modifier.tvFocusHighlight(
focusedBorderColor = Color(0xFFE50914),
borderWidth = 2.dp,
cornerRadius = 8.dp
)
) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) }
Spacer(Modifier.width(8.dp))
Text("Movies", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color(0xFFE50914))
}
// Categories with ★ Favorites
val catsState = categoriesState
when (catsState) {
is UiState.Success -> {
val catList = catsState.data
LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
item { CatChip("All", selectedCatId == null) { viewModel.selectCategory(null) } }
item { CatChip("\u2605 Favorites", selectedCatId == -1) { viewModel.selectCategory(-1) } }
catList.forEach { cat ->
item(key = cat.categoryId) {
CatChip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) }
}
}
}
}
else -> {}
}
// Movies grid with posters
when (val movies = moviesState) {
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(movies.message, color = MaterialTheme.colorScheme.error) }
is UiState.Success -> {
val movieList = if (selectedCatId == -1) movies.data.filter { it.streamId in favoriteIds } else movies.data
LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp),
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(movieList, key = { it.streamId }) { movie ->
MovieCard(
name = movie.name,
posterUrl = movie.streamIcon,
rating = movie.rating,
isFavorite = movie.streamId in favoriteIds,
onToggleFav = { viewModel.toggleFavorite(movie.streamId) },
onPlay = {
val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials()
if (config != null) {
val url = ma.tkgraph.xtreamtv.util.UrlBuilder(config).vodUrl(movie.streamId, movie.containerExtension)
onNavigate(NavState.Player(
stream = ma.tkgraph.xtreamtv.data.model.LiveStreamDto(
name = movie.name,
streamId = movie.streamId,
streamIcon = movie.streamIcon,
containerExtension = movie.containerExtension
),
allStreams = emptyList(),
currentIndex = 0,
replayUrl = url,
returnState = NavState.Vod()
))
}
}
)
}
}
}
else -> {}
}
}
}
}
@Composable
private fun CatChip(label: String, isSelected: Boolean, onClick: () -> Unit) {
var isFocused by remember { mutableStateOf(false) }
val bgColor = when {
isSelected -> Color(0xFFE50914)
isFocused -> Color(0xFF3A1A1A)
else -> Color(0xFF252525)
}
Surface(
onClick = onClick,
modifier = Modifier
.onFocusChanged { isFocused = it.isFocused }
.border(
width = 3.dp,
color = when {
isFocused -> Color(0xFFE50914)
else -> Color.Transparent
},
shape = RoundedCornerShape(20.dp)
),
shape = RoundedCornerShape(20.dp),
color = bgColor
) {
Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp)
}
}
@Composable
private fun MovieCard(name: String, posterUrl: String?, rating: String?, isFavorite: Boolean, onToggleFav: () -> Unit, onPlay: () -> Unit) {
var favFocused by remember { mutableStateOf(false) }
Surface(
onClick = onPlay,
modifier = Modifier.fillMaxWidth().tvFocusHighlight(focusedBorderColor = Color(0xFFE50914), cornerRadius = 12.dp),
shape = RoundedCornerShape(12.dp),
color = Color(0xFF1A1A1A),
tonalElevation = 2.dp
) {
Box {
Column {
// Poster
Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) {
if (posterUrl != null) {
coil3.compose.AsyncImage(
model = posterUrl,
contentDescription = name,
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
)
} else {
Text(name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFFE50914), fontWeight = FontWeight.Bold)
}
}
// Info bar
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
if (rating != null) Text(rating, style = MaterialTheme.typography.labelSmall, color = Color(0xFFAAAAAA))
}
}
}
// Favorite icon in top-right corner (doesn't steal card focus)
IconButton(
onClick = onToggleFav,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp)
.size(32.dp)
.onFocusChanged { favFocused = it.isFocused }
.border(
width = if (favFocused) 2.dp else 0.dp,
color = if (favFocused) Color(0xFFFFD700) else Color.Transparent,
shape = RoundedCornerShape(50)
)
) {
Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF555555))
}
}
}
}
@@ -0,0 +1,54 @@
package ma.tkgraph.xtreamtv.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// ─── Couleurs du design iMedia TV+ ───
object DesignColors {
val bg1 = Color(0xFF020817)
val bg2 = Color(0xFF071a3d)
val bg3 = Color(0xFF2b0f53)
val panel = Color(0xBD1A1C49) // rgba(26,28,73,0.74)
val panel2 = Color(0xB3121D49) // rgba(18,29,73,0.70)
val stroke = Color(0x1FFFFFFF) // rgba(255,255,255,0.12)
val strokeStrong = Color(0x6B5EA1FF) // rgba(94,161,255,0.42)
val text = Color(0xFFF7F8FF)
val muted = Color(0xFFA9B2D8)
val muted2 = Color(0xFF7F8AB6)
val orange = Color(0xFFFF512F)
val orange2 = Color(0xFFFFB000)
val blue = Color(0xFF1469FF)
val blue2 = Color(0xFF09B7FF)
val red = Color(0xFFFF3D64)
val green = Color(0xFF00D9B2)
val purple = Color(0xFF875CFF)
val liveRed = Color(0xFFFE3152)
val liveRedDark = Color(0xFFBA1733)
}
private val DesignColorScheme = darkColorScheme(
primary = DesignColors.orange,
onPrimary = Color.White,
primaryContainer = DesignColors.orange.copy(alpha = 0.2f),
secondary = DesignColors.blue,
onSecondary = Color.White,
surface = Color(0xBD1A1C49),
surfaceVariant = Color(0x70121D49),
onSurface = DesignColors.text,
onSurfaceVariant = DesignColors.muted,
background = DesignColors.bg1,
onBackground = DesignColors.text,
error = DesignColors.red,
onError = Color.White,
outline = DesignColors.stroke
)
@Composable
fun XtreamTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DesignColorScheme,
content = content
)
}
@@ -0,0 +1,44 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.UiState
class GuideViewModel : ViewModel() {
private val liveRepo = XtreamApplication.instance.appContainer.liveRepository
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
private val _streams = MutableStateFlow<UiState<List<LiveStreamDto>>>(UiState.Success(emptyList()))
val streams: StateFlow<UiState<List<LiveStreamDto>>> = _streams.asStateFlow()
private val _selectedStreamId = MutableStateFlow<Int?>(null)
val selectedStreamId: StateFlow<Int?> = _selectedStreamId.asStateFlow()
private val _epg = MutableStateFlow<UiState<List<EpgProgramDto>>>(UiState.Idle)
val epg: StateFlow<UiState<List<EpgProgramDto>>> = _epg.asStateFlow()
init { loadStreams() }
fun loadStreams() {
viewModelScope.launch {
val r = liveRepo.getStreams()
if (r is UiState.Success) _streams.value = r
}
}
fun selectStream(streamId: Int) {
_selectedStreamId.value = streamId
viewModelScope.launch {
val r = epgRepo.getFullGuide(streamId)
if (r is UiState.Success) _epg.value = r
}
}
}
@@ -0,0 +1,97 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.UiState
class HomeViewModel : ViewModel() {
private val liveRepo = XtreamApplication.instance.appContainer.liveRepository
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
private val _categories = MutableStateFlow<UiState<List<LiveCategoryDto>>>(
UiState.Success(emptyList())
)
val categories: StateFlow<UiState<List<LiveCategoryDto>>> = _categories.asStateFlow()
private val _streams = MutableStateFlow<UiState<List<LiveStreamDto>>>(
UiState.Success(emptyList())
)
val streams: StateFlow<UiState<List<LiveStreamDto>>> = _streams.asStateFlow()
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
// État réactif pour les favoris et l'épinglage
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteLiveIds())
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
private val _pinnedChannelId = MutableStateFlow(favRepo.getPinnedChannelId())
val pinnedChannelId: StateFlow<Int?> = _pinnedChannelId.asStateFlow()
init {
loadCategories()
loadStreams()
}
fun loadCategories() {
viewModelScope.launch {
val result = liveRepo.getCategories()
if (result is UiState.Success) {
_categories.value = result
} else if (result is UiState.Error) {
_categories.value = UiState.Success(emptyList())
}
}
}
fun selectCategory(categoryId: Int?) {
_selectedCategoryId.value = categoryId
if (categoryId == -1) {
// ★ Favorites : charger toutes les chaînes, filtrer dans l'UI
loadStreams(null)
} else {
loadStreams(categoryId)
}
}
fun loadStreams(categoryId: Int? = null) {
viewModelScope.launch {
val result = liveRepo.getStreams(categoryId)
if (result is UiState.Success) {
_streams.value = result
} else if (result is UiState.Error) {
_streams.value = UiState.Success(emptyList())
}
}
}
fun toggleFavorite(streamId: Int) {
favRepo.toggleFavoriteLive(streamId)
_favoriteIds.value = favRepo.getFavoriteLiveIds()
}
fun togglePin(streamId: Int) {
val current = _pinnedChannelId.value
if (current == streamId) {
favRepo.setPinnedChannelId(null)
_pinnedChannelId.value = null
} else {
favRepo.setPinnedChannelId(streamId)
_pinnedChannelId.value = streamId
}
}
/** Déconnexion : efface config + cache M3U. */
fun logout() {
XtreamApplication.instance.appContainer.m3uRepository.invalidateCache()
XtreamApplication.instance.appContainer.authRepository.logout()
}
}
@@ -0,0 +1,107 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.repository.AuthRepository
import ma.tkgraph.xtreamtv.data.repository.UserInfo
/**
* ViewModel pour l'écran de connexion / configuration.
* Gère la connexion Xtream et la configuration M3U.
*/
class LoginViewModel : ViewModel() {
private val authRepository: AuthRepository =
XtreamApplication.instance.appContainer.authRepository
private val m3uRepository = XtreamApplication.instance.appContainer.m3uRepository
private val _uiState = MutableStateFlow<UiState<UserInfo>>(UiState.Idle)
val uiState: StateFlow<UiState<UserInfo>> = _uiState.asStateFlow()
/** Type de source (Compose-reactive). */
var sourceType by mutableStateOf(SourceType.XTREAM)
var serverUrl: String = ""
private set
var username: String = ""
private set
var password: String = ""
private set
var m3uUrl: String = ""
private set
init {
// Charger la config existante
val config = XtreamApplication.instance.appContainer.secureStorage.getConfig()
if (config != null) {
sourceType = config.sourceType
serverUrl = config.serverUrl
username = config.username
password = config.password
m3uUrl = config.m3uUrl
}
}
fun switchSourceType(type: SourceType) {
sourceType = type
}
fun updateServerUrl(value: String) {
serverUrl = value
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
}
fun updateUsername(value: String) {
username = value
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
}
fun updatePassword(value: String) {
password = value
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
}
fun updateM3uUrl(value: String) {
m3uUrl = value
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
}
/** Connexion au serveur Xtream. */
fun login() {
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) return
viewModelScope.launch {
_uiState.value = UiState.Loading
val result = authRepository.login(serverUrl, username, password)
_uiState.value = result
}
}
/** Sauvegarde la config M3U et invalide le cache. */
fun saveM3uConfig() {
if (m3uUrl.isBlank()) return
m3uRepository.invalidateCache()
authRepository.saveM3uConfig(m3uUrl)
_uiState.value = UiState.Success(UserInfo(username = "M3U"))
}
/** Déconnexion : efface la config et le cache M3U. */
fun logout() {
m3uRepository.invalidateCache()
authRepository.logout()
serverUrl = ""
username = ""
password = ""
m3uUrl = ""
sourceType = SourceType.XTREAM
_uiState.value = UiState.Idle
}
}
@@ -0,0 +1,153 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.SourceType
import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository
import ma.tkgraph.xtreamtv.util.UrlBuilder
import timber.log.Timber
data class PlayerState(
val stream: LiveStreamDto = LiveStreamDto(),
val allStreams: List<LiveStreamDto> = emptyList(),
val currentIndex: Int = 0,
val showOverlay: Boolean = true,
val resolution: String = "",
val currentProgram: String = "",
val nextProgram: String = "",
val restartTrigger: Int = 0,
val freezeRetries: Int = 0
)
class PlayerViewModel : ViewModel() {
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
private val _state = MutableStateFlow(PlayerState(showOverlay = true))
val state: StateFlow<PlayerState> = _state.asStateFlow()
fun initialize(stream: LiveStreamDto, allStreams: List<LiveStreamDto>, index: Int) {
_state.value = PlayerState(
stream = stream,
allStreams = allStreams,
currentIndex = index,
showOverlay = true
)
loadEpg(stream.streamId)
}
/**
* Construit l'URL de lecture selon la source configurée.
* - Mode Xtream : construit l'URL via [UrlBuilder]
* - Mode M3U : utilise [LiveStreamDto.directUrl] directement
*/
fun buildUrl(stream: LiveStreamDto): String {
// En mode M3U, utiliser l'URL directe du stream
if (stream.directUrl.isNotBlank()) {
return stream.directUrl
}
// Mode Xtream : construction via UrlBuilder
val config = XtreamApplication.instance.appContainer.secureStorage.getConfig()
?: return ""
if (config.sourceType == SourceType.M3U && stream.directUrl.isNotBlank()) {
return stream.directUrl
}
return UrlBuilder(config).liveUrl(stream.streamId, stream.containerExtension)
}
fun updateResolution(resolution: String) {
_state.value = _state.value.copy(resolution = resolution)
}
private fun loadEpg(streamId: Int) {
viewModelScope.launch {
val result = epgRepo.getShortEpg(streamId)
if (result is ma.tkgraph.xtreamtv.data.model.UiState.Success) {
val programs = result.data
if (programs.isNotEmpty()) {
val now = System.currentTimeMillis() / 1000
val current = programs.firstOrNull {
val start = it.startTimestamp?.toLongOrNull()
val stop = it.stopTimestamp?.toLongOrNull()
start != null && start <= now && (stop == null || stop >= now)
} ?: programs.firstOrNull()
val currentIdx = programs.indexOfFirst { it.titleText() == current?.titleText() }
val next = if (currentIdx >= 0 && currentIdx + 1 < programs.size) programs[currentIdx + 1]
else null
_state.value = _state.value.copy(
currentProgram = current?.titleText() ?: "",
nextProgram = next?.titleText() ?: ""
)
}
}
}
}
fun zappingLeft() {
val current = _state.value
if (current.allStreams.isEmpty()) return
val newIndex = if (current.currentIndex > 0) current.currentIndex - 1
else current.allStreams.lastIndex
val newStream = current.allStreams[newIndex]
_state.value = PlayerState(
stream = newStream,
allStreams = current.allStreams,
currentIndex = newIndex,
showOverlay = true
)
loadEpg(newStream.streamId)
}
fun zappingRight() {
val current = _state.value
if (current.allStreams.isEmpty()) return
val newIndex = if (current.currentIndex < current.allStreams.lastIndex) current.currentIndex + 1
else 0
val newStream = current.allStreams[newIndex]
_state.value = PlayerState(
stream = newStream,
allStreams = current.allStreams,
currentIndex = newIndex,
showOverlay = true
)
loadEpg(newStream.streamId)
}
fun toggleOverlay() {
_state.value = _state.value.copy(showOverlay = !_state.value.showOverlay)
}
fun hideOverlay() {
_state.value = _state.value.copy(showOverlay = false)
}
fun requestRestart() {
val s = _state.value
if (s.freezeRetries >= 3) return
val retries = s.freezeRetries + 1
Timber.i("Freeze détecté sur ${s.stream.name} — tentative $retries/3")
_state.value = s.copy(
restartTrigger = s.restartTrigger + 1,
freezeRetries = retries
)
}
fun resetFreezeCounter() {
val s = _state.value
if (s.freezeRetries > 0) {
_state.value = s.copy(freezeRetries = 0)
}
}
fun canRestart(): Boolean = _state.value.freezeRetries < 3
fun isFavorite(streamId: Int): Boolean = favRepo.isFavoriteLive(streamId)
fun toggleFavorite(streamId: Int) { favRepo.toggleFavoriteLive(streamId) }
}
@@ -0,0 +1,57 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.UiState
class ReplayViewModel : ViewModel() {
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
private val _epg = MutableStateFlow<UiState<List<EpgProgramDto>>>(UiState.Idle)
val epg: StateFlow<UiState<List<EpgProgramDto>>> = _epg.asStateFlow()
private var currentStream: LiveStreamDto? = null
fun loadReplay(stream: LiveStreamDto) {
currentStream = stream
viewModelScope.launch {
_epg.value = UiState.Loading
val result = epgRepo.getFullGuide(stream.streamId)
if (result is UiState.Success) {
// Filtrer : garder uniquement les programmes passés (déjà diffusés)
val nowSeconds = System.currentTimeMillis() / 1000
val pastPrograms = result.data.filter { prog ->
val start = prog.startTimestamp?.toLongOrNull() ?: 0
start > 0 && start <= nowSeconds
}.sortedByDescending { it.startTimestamp?.toLongOrNull() ?: 0 }
_epg.value = UiState.Success(pastPrograms)
} else {
_epg.value = result
}
}
}
fun buildReplayUrl(program: EpgProgramDto): String? {
val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials()
?: return null
val stream = currentStream ?: return null
val durationMinutes = program.durationMinutes()
return ma.tkgraph.xtreamtv.util.UrlBuilder(config)
.replayUrl(
streamId = stream.streamId,
start = program.start.orEmpty(),
durationMinutes = durationMinutes,
extension = "ts"
)
}
}
@@ -0,0 +1,74 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
import ma.tkgraph.xtreamtv.data.model.UiState
class SeriesViewModel : ViewModel() {
private val seriesRepo = XtreamApplication.instance.appContainer.seriesRepository
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
private val _categories = MutableStateFlow<UiState<List<SeriesCategoryDto>>>(UiState.Success(emptyList()))
val categories: StateFlow<UiState<List<SeriesCategoryDto>>> = _categories.asStateFlow()
private val _series = MutableStateFlow<UiState<List<SeriesDto>>>(UiState.Success(emptyList()))
val series: StateFlow<UiState<List<SeriesDto>>> = _series.asStateFlow()
private val _seriesInfo = MutableStateFlow<UiState<SeriesInfoDto>>(UiState.Idle)
val seriesInfo: StateFlow<UiState<SeriesInfoDto>> = _seriesInfo.asStateFlow()
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteSeriesIds())
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
init {
loadCategories()
loadSeries()
}
fun loadCategories() {
viewModelScope.launch {
val r = seriesRepo.getCategories()
if (r is UiState.Success) _categories.value = r
}
}
fun loadSeries(categoryId: Int? = null) {
viewModelScope.launch {
val r = seriesRepo.getSeriesList(categoryId)
if (r is UiState.Success) _series.value = r
}
}
fun selectCategory(categoryId: Int?) {
_selectedCategoryId.value = categoryId
if (categoryId == -1) {
loadSeries(null) // Load all, filter in UI
} else {
loadSeries(categoryId)
}
}
fun loadSeriesInfo(seriesId: Int) {
viewModelScope.launch {
val r = seriesRepo.getSeriesInfo(seriesId)
if (r is UiState.Success) _seriesInfo.value = r
}
}
fun toggleFavorite(seriesId: Int) {
favRepo.toggleFavoriteSeries(seriesId)
_favoriteIds.value = favRepo.getFavoriteSeriesIds()
}
}
@@ -0,0 +1,63 @@
package ma.tkgraph.xtreamtv.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.XtreamApplication
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
import ma.tkgraph.xtreamtv.data.model.VodDto
class VodViewModel : ViewModel() {
private val vodRepo = XtreamApplication.instance.appContainer.vodRepository
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
private val _categories = MutableStateFlow<UiState<List<VodCategoryDto>>>(UiState.Success(emptyList()))
val categories: StateFlow<UiState<List<VodCategoryDto>>> = _categories.asStateFlow()
private val _movies = MutableStateFlow<UiState<List<VodDto>>>(UiState.Success(emptyList()))
val movies: StateFlow<UiState<List<VodDto>>> = _movies.asStateFlow()
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteVodIds())
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
init {
loadCategories()
loadMovies()
}
fun loadCategories() {
viewModelScope.launch {
val r = vodRepo.getCategories()
if (r is UiState.Success) _categories.value = r
}
}
fun loadMovies(categoryId: Int? = null) {
viewModelScope.launch {
val r = vodRepo.getMovies(categoryId)
if (r is UiState.Success) _movies.value = r
}
}
fun selectCategory(categoryId: Int?) {
_selectedCategoryId.value = categoryId
if (categoryId == -1) {
loadMovies(null) // Load all, filter in UI
} else {
loadMovies(categoryId)
}
}
fun toggleFavorite(streamId: Int) {
favRepo.toggleFavoriteVod(streamId)
_favoriteIds.value = favRepo.getFavoriteVodIds()
}
}
@@ -0,0 +1,88 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
/**
* Trie les catégories dans l'ordre préféré :
* 1. Catégories spéciales (Toutes, Favoris)
* 2. Catégories prioritaires (Bein Sports, etc.)
* 3. Catégories françaises
* 4. Catégories arabes
* 5. Autres catégories (ordre alphabétique)
*/
object CategorySortUtils {
private val PRIORITY_KEYWORDS = listOf(
"bein", "beIN"
)
private val FRENCH_KEYWORDS = listOf(
"france", "français", "francaise", "française",
"fr", "fra", "francais", "info", "infos"
)
private val ARABIC_KEYWORDS = listOf(
"arab", "arabic", "arabe", "arabia",
"arabique", "maghreb", "moyen-orient",
"عربي", "عربية", "العربية"
)
private val SPECIAL_NAMES = listOf(
"all channels", "toutes les chaînes",
"all movies", "tous les films",
"all series", "toutes les séries",
"favorites", "favoris", "★"
)
fun sortLiveCategories(categories: List<LiveCategoryDto>): List<LiveCategoryDto> {
return categories.sortedWith(categoryComparator(
nameSelector = { it.categoryName },
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
))
}
fun sortVodCategories(categories: List<VodCategoryDto>): List<VodCategoryDto> {
return categories.sortedWith(categoryComparator(
nameSelector = { it.categoryName },
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
))
}
fun sortSeriesCategories(categories: List<SeriesCategoryDto>): List<SeriesCategoryDto> {
return categories.sortedWith(categoryComparator(
nameSelector = { it.categoryName },
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
))
}
private fun <T> categoryComparator(
nameSelector: (T) -> String,
isSpecial: (String) -> Boolean
): Comparator<T> = Comparator { a, b ->
val nameA = nameSelector(a).lowercase().trim()
val nameB = nameSelector(b).lowercase().trim()
when {
isSpecial(nameA) && !isSpecial(nameB) -> -1
!isSpecial(nameA) && isSpecial(nameB) -> 1
isPriority(nameA) && !isPriority(nameB) -> -1
!isPriority(nameA) && isPriority(nameB) -> 1
isFrench(nameA) && !isFrench(nameB) -> -1
!isFrench(nameA) && isFrench(nameB) -> 1
isArabic(nameA) && !isArabic(nameB) -> -1
!isArabic(nameA) && isArabic(nameB) -> 1
else -> nameA.compareTo(nameB)
}
}
private fun isPriority(name: String): Boolean =
PRIORITY_KEYWORDS.any { name.contains(it) }
private fun isFrench(name: String): Boolean =
FRENCH_KEYWORDS.any { name.contains(it) }
private fun isArabic(name: String): Boolean =
ARABIC_KEYWORDS.any { name.contains(it) } || name.any { it in '\u0600'..'\u06FF' }
}
@@ -0,0 +1,14 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
/**
* Vérifie l'état de la session au démarrage.
*/
class SessionManager(private val storage: SecureStorage) {
fun hasValidSession(): Boolean {
val config = storage.getConfig() ?: return false
return config.isValid
}
}
@@ -0,0 +1,53 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
class UrlBuilder(private val config: XtreamConfig) {
fun liveUrl(streamId: Int, extension: String = "m3u8"): String =
"${baseUrl}/live/${config.username}/${config.password}/$streamId.$extension"
fun vodUrl(streamId: Int, extension: String = "mp4"): String =
"${baseUrl}/movie/${config.username}/${config.password}/$streamId.$extension"
fun seriesUrl(episodeId: Int, extension: String = "mp4"): String =
"${baseUrl}/series/${config.username}/${config.password}/$episodeId.$extension"
/**
* Construit l'URL timeshift pour le replay.
* Format : /timeshift/USER/PASS/DURATION_MINUTES/DATE:HH-MM/STREAM_ID.ts
*
* @param streamId ID de la chaîne
* @param start Date de début au format "2026-06-11 20:00"
* @param durationMinutes Durée en minutes (entre 1 et 720)
* @param extension Extension du fichier (ts par défaut)
*/
fun replayUrl(
streamId: Int,
start: String,
durationMinutes: Int = 120,
extension: String = "ts"
): String {
val safeDuration = durationMinutes.coerceIn(1, 720)
val formattedStart = formatTimeshiftStart(start)
return "${baseUrl}/timeshift/${config.username}/${config.password}/$safeDuration/$formattedStart/$streamId.$extension"
}
/**
* Formate la date pour le timeshift.
* "2026-06-11 20:00" → "2026-06-11:20-00"
*/
private fun formatTimeshiftStart(start: String): String {
val match = Regex("""(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})""").find(start.trim())
if (match != null) {
val date = match.groupValues[1]
val hour = match.groupValues[2]
val minute = match.groupValues[3]
return "$date:$hour-$minute"
}
return start.trim().take(16).replace(" ", ":").replace(Regex(""":(\d{2})$"""), "-$1")
}
private val baseUrl: String
get() = config.serverUrl.trimEnd('/')
}
@@ -0,0 +1,21 @@
package ma.tkgraph.xtreamtv.util
/**
* Trie les éléments les plus utilisés en premier, puis conserve l'ordre serveur.
* L'ordre serveur reste important : certains fournisseurs IPTV rangent déjà leurs listes, parfois volontairement.
*/
object UsageSortUtils {
fun <T> sortByUsage(
items: List<T>,
usageCounts: Map<String, Int>,
idSelector: (T) -> String
): List<T> {
if (items.size < 2 || usageCounts.isEmpty()) return items
return items.withIndex()
.sortedWith(
compareByDescending<IndexedValue<T>> { indexed -> usageCounts[idSelector(indexed.value)] ?: 0 }
.thenBy { indexed -> indexed.index }
)
.map { it.value }
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Splash background for iMedia TV+
Fond sombre avec le nom de l'app en logo textuel.
Remplacez par l'image imedia_tv_background.png quand elle sera disponible.
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Fond sombre -->
<item android:drawable="@android:color/black" />
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,78 @@
<resources>
<string name="app_name">iMedia TV+</string>
<!-- Navigation -->
<string name="live_tv">البث المباشر</string>
<string name="vod">الأفلام</string>
<string name="series">المسلسلات</string>
<string name="guide">دليل التلفاز</string>
<string name="settings">الإعدادات</string>
<!-- Login -->
<string name="server_url">رابط الخادم</string>
<string name="username">اسم المستخدم</string>
<string name="password">كلمة المرور</string>
<string name="connect">اتصال</string>
<string name="connecting">جارٍ الاتصال…</string>
<string name="login_hint">مثال: http://server:8080</string>
<!-- Errors -->
<string name="error_network">تعذر الاتصال بالخادم. تحقق من شبكتك.</string>
<string name="error_timeout">الخادم لا يستجيب. حاول مرة أخرى لاحقاً.</string>
<string name="error_auth">بيانات الدخول غير صحيحة أو الخادم غير صالح.</string>
<string name="error_stream">البث غير متاح لهذه القناة.</string>
<string name="error_server">خطأ في الخادم (%d). اتصل بمزود الخدمة.</string>
<string name="error_epg_empty">لم يتم العثور على برنامج لهذه القناة.</string>
<string name="error_loading">فشل التحميل.</string>
<string name="retry">إعادة المحاولة</string>
<!-- Live TV -->
<string name="all_channels">جميع القنوات</string>
<string name="favorites">★ المفضلة</string>
<string name="search_channels">البحث عن قنوات…</string>
<string name="no_channels">لم يتم العثور على قنوات</string>
<string name="pinned_channel">مثبتة</string>
<string name="pin_channel">تثبيت هذه القناة</string>
<string name="unpin_channel">إلغاء التثبيت</string>
<string name="add_favorite">إضافة إلى المفضلة</string>
<string name="remove_favorite">إزالة من المفضلة</string>
<!-- Player -->
<string name="now_playing">قيد التشغيل</string>
<string name="next_channel">القناة التالية</string>
<string name="previous_channel">القناة السابقة</string>
<string name="current_program">البرنامج الحالي</string>
<string name="next_program">التالي</string>
<string name="replay">إعادة</string>
<string name="live">مباشر</string>
<!-- Replay -->
<string name="replay_guide">الإعادة — %s</string>
<string name="watch_replay">مشاهدة الإعادة</string>
<string name="no_replay">الإعادة غير متاحة</string>
<string name="days_available">الأيام المتاحة</string>
<!-- VOD -->
<string name="all_movies">جميع الأفلام</string>
<string name="search_movies">البحث عن أفلام…</string>
<string name="no_movies">لم يتم العثور على أفلام</string>
<string name="watch_movie">مشاهدة الفيلم</string>
<!-- Series -->
<string name="all_series">جميع المسلسلات</string>
<string name="search_series">البحث عن مسلسلات…</string>
<string name="no_series">لم يتم العثور على مسلسلات</string>
<string name="season">الموسم %d</string>
<string name="episode">الحلقة %d</string>
<string name="episode_format">الحلقة %1$d • %2$s</string>
<!-- General -->
<string name="exit_confirm">هل تريد الخروج من iMedia TV+؟</string>
<string name="exit_confirm_desc">هل أنت متأكد من رغبتك في الخروج؟</string>
<string name="yes">نعم</string>
<string name="no">لا</string>
<string name="loading">جارٍ التحميل…</string>
<string name="no_data">لا توجد بيانات متاحة</string>
<string name="disconnect">قطع الاتصال</string>
<string name="version">الإصدار %s</string>
</resources>
@@ -0,0 +1,78 @@
<resources>
<string name="app_name">iMedia TV+</string>
<!-- Navigation -->
<string name="live_tv">Direct TV</string>
<string name="vod">Films</string>
<string name="series">Séries</string>
<string name="guide">Guide TV</string>
<string name="settings">Paramètres</string>
<!-- Login -->
<string name="server_url">URL du serveur</string>
<string name="username">Identifiant</string>
<string name="password">Mot de passe</string>
<string name="connect">Connexion</string>
<string name="connecting">Connexion…</string>
<string name="login_hint">ex: http://serveur:8080</string>
<!-- Errors -->
<string name="error_network">Impossible de se connecter au serveur. Vérifiez votre réseau.</string>
<string name="error_timeout">Le serveur ne répond pas. Réessayez plus tard.</string>
<string name="error_auth">Identifiants incorrects ou serveur invalide.</string>
<string name="error_stream">Flux indisponible pour cette chaîne.</string>
<string name="error_server">Erreur serveur (%d). Contactez votre fournisseur.</string>
<string name="error_epg_empty">Aucun programme trouvé pour cette chaîne.</string>
<string name="error_loading">Échec du chargement.</string>
<string name="retry">Réessayer</string>
<!-- Live TV -->
<string name="all_channels">Toutes les chaînes</string>
<string name="favorites">★ Favoris</string>
<string name="search_channels">Rechercher des chaînes…</string>
<string name="no_channels">Aucune chaîne trouvée</string>
<string name="pinned_channel">Épinglée</string>
<string name="pin_channel">Épingler cette chaîne</string>
<string name="unpin_channel">Désépingler</string>
<string name="add_favorite">Ajouter aux favoris</string>
<string name="remove_favorite">Retirer des favoris</string>
<!-- Player -->
<string name="now_playing">En cours</string>
<string name="next_channel">Chaîne suivante</string>
<string name="previous_channel">Chaîne précédente</string>
<string name="current_program">Programme en cours</string>
<string name="next_program">Suivant</string>
<string name="replay">Replay</string>
<string name="live">Direct</string>
<!-- Replay -->
<string name="replay_guide">Replay — %s</string>
<string name="watch_replay">Revoir</string>
<string name="no_replay">Replay non disponible</string>
<string name="days_available">Jours disponibles</string>
<!-- VOD -->
<string name="all_movies">Tous les films</string>
<string name="search_movies">Rechercher des films…</string>
<string name="no_movies">Aucun film trouvé</string>
<string name="watch_movie">Regarder le film</string>
<!-- Series -->
<string name="all_series">Toutes les séries</string>
<string name="search_series">Rechercher des séries…</string>
<string name="no_series">Aucune série trouvée</string>
<string name="season">Saison %d</string>
<string name="episode">Épisode %d</string>
<string name="episode_format">Épisode %1$d • %2$s</string>
<!-- General -->
<string name="exit_confirm">Quitter iMedia TV+ ?</string>
<string name="exit_confirm_desc">Êtes-vous sûr de vouloir quitter ?</string>
<string name="yes">Oui</string>
<string name="no">Non</string>
<string name="loading">Chargement…</string>
<string name="no_data">Aucune donnée disponible</string>
<string name="disconnect">Déconnexion</string>
<string name="version">Version %s</string>
</resources>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#FFE50914</color>
<color name="primary_dark">#FFB30000</color>
<color name="on_primary">#FFFFFFFF</color>
<color name="primary_container">#FF4A1C1C</color>
<color name="secondary">#FF1A73E8</color>
<color name="on_secondary">#FFFFFFFF</color>
<color name="surface">#FF1A1A1A</color>
<color name="surface_variant">#FF252525</color>
<color name="on_surface">#FFFFFFFF</color>
<color name="background">#FF0A0A0A</color>
<color name="on_background">#FFEEEEEE</color>
<color name="favorite">#FFFFD700</color>
<color name="error">#FFFF5252</color>
<color name="success">#FF4CAF50</color>
<color name="replay_badge">#FF00BCD4</color>
<color name="card_border">#FF333333</color>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="config_splash_timeout">3000</integer>
<integer name="config_overlay_timeout">3000</integer>
<integer name="config_connect_timeout">15000</integer>
<integer name="config_read_timeout">30000</integer>
<integer name="config_epg_cache_minutes">2</integer>
<integer name="config_guide_cache_minutes">15</integer>
</resources>
+78
View File
@@ -0,0 +1,78 @@
<resources>
<string name="app_name">iMedia TV+</string>
<!-- Navigation -->
<string name="live_tv">Live TV</string>
<string name="vod">Movies</string>
<string name="series">Series</string>
<string name="guide">TV Guide</string>
<string name="settings">Settings</string>
<!-- Login -->
<string name="server_url">Server URL</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="connect">Connect</string>
<string name="connecting">Connecting…</string>
<string name="login_hint">e.g. http://server:8080</string>
<!-- Errors -->
<string name="error_network">Cannot reach server. Check your network.</string>
<string name="error_timeout">Server is not responding. Try again later.</string>
<string name="error_auth">Invalid credentials or server.</string>
<string name="error_stream">Stream unavailable for this channel.</string>
<string name="error_server">Server error (%d). Contact your provider.</string>
<string name="error_epg_empty">No program found for this channel.</string>
<string name="error_loading">Failed to load content.</string>
<string name="retry">Retry</string>
<!-- Live TV -->
<string name="all_channels">All Channels</string>
<string name="favorites">★ Favorites</string>
<string name="search_channels">Search channels…</string>
<string name="no_channels">No channels found</string>
<string name="pinned_channel">Pinned</string>
<string name="pin_channel">Pin this channel</string>
<string name="unpin_channel">Unpin</string>
<string name="add_favorite">Add to favorites</string>
<string name="remove_favorite">Remove from favorites</string>
<!-- Player -->
<string name="now_playing">Now playing</string>
<string name="next_channel">Next channel</string>
<string name="previous_channel">Previous channel</string>
<string name="current_program">Current program</string>
<string name="next_program">Next</string>
<string name="replay">Replay</string>
<string name="live">Live</string>
<!-- Replay -->
<string name="replay_guide">Replay — %s</string>
<string name="watch_replay">Watch replay</string>
<string name="no_replay">No replay available</string>
<string name="days_available">Available days</string>
<!-- VOD -->
<string name="all_movies">All Movies</string>
<string name="search_movies">Search movies…</string>
<string name="no_movies">No movies found</string>
<string name="watch_movie">Watch movie</string>
<!-- Series -->
<string name="all_series">All Series</string>
<string name="search_series">Search series…</string>
<string name="no_series">No series found</string>
<string name="season">Season %d</string>
<string name="episode">Episode %d</string>
<string name="episode_format">Episode %1$d • %2$s</string>
<!-- General -->
<string name="exit_confirm">Exit iMedia TV+?</string>
<string name="exit_confirm_desc">Are you sure you want to exit?</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="loading">Loading…</string>
<string name="no_data">No data available</string>
<string name="disconnect">Disconnect</string>
<string name="version">Version %s</string>
</resources>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.iMediaTVPlus" parent="android:Theme.Material.NoActionBar">
<item name="android:colorBackground">#FF0A0A0A</item>
<item name="android:statusBarColor">#FF0A0A0A</item>
<item name="android:navigationBarColor">#FF0A0A0A</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="Theme.iMediaTVPlus.Splash" parent="Theme.iMediaTVPlus">
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Xtream servers often use HTTP; we accept it explicitly -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,74 @@
package ma.tkgraph.xtreamtv.data.repository
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import ma.tkgraph.xtreamtv.data.model.AuthResponse
import ma.tkgraph.xtreamtv.data.model.UiState
import ma.tkgraph.xtreamtv.data.model.UserInfo
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class AuthRepositoryTest {
private val api: XtreamApi = mockk()
private val storage: SecureStorage = mockk()
private lateinit var repo: AuthRepository
@BeforeEach
fun setup() {
repo = AuthRepository(api, storage)
}
@Test
fun `login with valid credentials returns Success`() = runTest {
// Given
coEvery { api.authenticate(any(), any()) } returns AuthResponse(
userInfo = UserInfo(username = "testuser"),
auth = "1",
status = "Active"
)
// When
val result = repo.login("http://server:8080", "testuser", "pass123")
// Then
assertTrue(result is UiState.Success)
assertEquals("testuser", (result as UiState.Success).data.username)
}
@Test
fun `login with invalid auth returns Error`() = runTest {
// Given
coEvery { api.authenticate(any(), any()) } returns AuthResponse(
auth = "0",
status = "Active"
)
// When
val result = repo.login("http://server:8080", "baduser", "badpass")
// Then
assertTrue(result is UiState.Error)
}
@Test
fun `login with expired account returns Error`() = runTest {
// Given
coEvery { api.authenticate(any(), any()) } returns AuthResponse(
auth = "1",
status = "Expired"
)
// When
val result = repo.login("http://server:8080", "user", "pass")
// Then
assertTrue(result is UiState.Error)
assertTrue((result as UiState.Error).message.contains("expired", ignoreCase = true))
}
}
@@ -0,0 +1,50 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class CategorySortUtilsTest {
@Test
fun `sortLiveCategories puts FR first, AR second, others last`() {
// Given
val categories = listOf(
LiveCategoryDto(categoryId = "1", categoryName = "Sports"),
LiveCategoryDto(categoryId = "2", categoryName = "France Info"),
LiveCategoryDto(categoryId = "3", categoryName = "العربية"),
LiveCategoryDto(categoryId = "4", categoryName = "French Cinema"),
LiveCategoryDto(categoryId = "5", categoryName = "News")
)
// When
val sorted = CategorySortUtils.sortLiveCategories(categories)
// Then
assertEquals("France Info", sorted[0].categoryName)
assertEquals("French Cinema", sorted[1].categoryName)
assertEquals("العربية", sorted[2].categoryName)
assertEquals("News", sorted[3].categoryName)
assertEquals("Sports", sorted[4].categoryName)
}
@Test
fun `special categories always come first`() {
// Given
val categories = listOf(
LiveCategoryDto(categoryId = "1", categoryName = "News"),
LiveCategoryDto(categoryId = "2", categoryName = "★ Favorites"),
LiveCategoryDto(categoryId = "3", categoryName = "All Channels"),
LiveCategoryDto(categoryId = "4", categoryName = "France Info")
)
// When
val sorted = CategorySortUtils.sortLiveCategories(categories)
// Then
assertEquals("All Channels", sorted[0].categoryName)
assertEquals("★ Favorites", sorted[1].categoryName)
assertEquals("France Info", sorted[2].categoryName)
assertEquals("News", sorted[3].categoryName)
}
}
@@ -0,0 +1,50 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class UrlBuilderTest {
private lateinit var builder: UrlBuilder
private val config = XtreamConfig("http://server:8080", "user", "pass")
@BeforeEach
fun setup() {
builder = UrlBuilder(config)
}
@Test
fun `liveUrl builds correct URL`() {
val url = builder.liveUrl(42)
assertEquals("http://server:8080/live/user/pass/42.m3u8", url)
}
@Test
fun `liveUrl with custom extension`() {
val url = builder.liveUrl(42, "ts")
assertEquals("http://server:8080/live/user/pass/42.ts", url)
}
@Test
fun `vodUrl builds correct URL`() {
val url = builder.vodUrl(123, "mp4")
assertEquals("http://server:8080/movie/user/pass/123.mp4", url)
}
@Test
fun `seriesUrl builds correct URL`() {
val url = builder.seriesUrl(456, "mkv")
assertEquals("http://server:8080/series/user/pass/456.mkv", url)
}
@Test
fun `replayUrl builds correct URL`() {
val url = builder.replayUrl(3600, "2026-06-10:12-00", 42)
assertEquals(
"http://server:8080/timeshift/user/pass/3600/2026-06-10:12-00/42.ts",
url
)
}
}
+123
View File
@@ -0,0 +1,123 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android") version "2.1.0"
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
}
android {
namespace = "ma.tkgraph.xtreamtv"
compileSdk = 36
defaultConfig {
applicationId = "ma.tkgraph.xtreamtv"
minSdk = 30
targetSdk = 36
versionCode = 23
versionName = "2.0.23-login-success-nav"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
debug {
isMinifyEnabled = false
buildConfigField("boolean", "ENABLE_LOGGING", "true")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("boolean", "ENABLE_LOGGING", "false")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// --- Compose / TV ---
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.tv:tv-material:1.0.0")
implementation("androidx.tv:tv-foundation:1.0.0")
// --- Activity / Lifecycle ---
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
// --- Media3 ExoPlayer ---
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-exoplayer-dash:1.5.1")
implementation("androidx.media3:media3-ui:1.5.1")
implementation("androidx.media3:media3-session:1.5.1")
// --- Network ---
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
// --- Images ---
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4")
// --- Security (EncryptedSharedPreferences) ---
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// --- EPG Cache (Room) ---
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
annotationProcessor("androidx.room:room-compiler:2.6.1")
// --- Logging ---
implementation("com.jakewharton.timber:timber:5.0.1")
// --- Core ---
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.leanback:leanback:1.2.0-alpha04")
// ========== TESTS ==========
// Unit tests
testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("io.mockk:mockk:1.13.14")
testImplementation("app.cash.turbine:turbine:1.2.0")
// Android / UI tests
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.test:runner:1.6.2")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
+153
View File
@@ -0,0 +1,153 @@
# Gestion d'erreurs — iMedia TV+
## Architecture
```mermaid
flowchart TB
API[Serveur Xtream] -->|Timeout / 4xx / 5xx| Interceptor[OkHttp ErrorInterceptor]
Interceptor -->|Exception typée| Repository
Repository -->|Result / UiState.Error| ViewModel
ViewModel -->|Message localisé| UI[Écran Compose]
UI -->|Snackbar / Toast / Overlay| User[Utilisateur]
```
## Hiérarchie d'erreurs
```kotlin
/**
* Toutes les erreurs Xtream sont des sealed classes
* pour un `when` exhaustif dans l'UI.
*/
sealed class XtreamException(message: String, cause: Throwable? = null) : Exception(message, cause) {
class NetworkException(message: String) : XtreamException(message)
class TimeoutException(message: String) : XtreamException(message)
class AuthException(message: String) : XtreamException(message)
class ServerException(val code: Int, message: String) : XtreamException(message)
class ParseException(message: String, cause: Throwable) : XtreamException(message, cause)
class StreamUnavailableException(message: String) : XtreamException(message)
}
```
## Table des erreurs
| N° | Condition | Exception | Code UI | Message FR | Message EN |
|----|-----------|-----------|---------|------------|------------|
| 1 | Serveur injoignable | `NetworkException` | Erreur réseau | "Impossible de se connecter au serveur. Vérifiez votre réseau." | "Cannot reach server. Check your network." |
| 2 | Timeout dépassé (15s) | `TimeoutException` | Erreur réseau | "Le serveur ne répond pas. Réessayez plus tard." | "Server is not responding. Try again later." |
| 3 | Auth invalide | `AuthException` | Login bloqué | "Identifiants incorrects ou serveur invalide." | "Invalid credentials or server." |
| 4 | Flux vidéo cassé | `StreamUnavailableException` | Overlay erreur | "Flux indisponible pour cette chaîne." | "Stream unavailable for this channel." |
| 5 | HTTP 4xx | `ServerException` | Toast | "Erreur serveur (code X). Contactez votre fournisseur." | "Server error (code X). Contact your provider." |
| 6 | HTTP 5xx | `ServerException` | Toast | "Le serveur Xtream rencontre un problème." | "Xtream server is having issues." |
| 7 | JSON mal formé | `ParseException` | Fallback silencieux | "Données reçues incorrectes." | "Received data is malformed." |
| 8 | EPG vide | N/A (pas une erreur) | Texte vide | "Aucun programme trouvé pour cette chaîne." | "No program found for this channel." |
| 9 | Cache corrompu | N/A | Suppression + reload | "Mise à jour des données..." | "Updating data..." |
## État loading global
```kotlin
/**
* État unique pour TOUS les écrans.
* Permet un traitement uniforme dans les composants UI.
*/
sealed interface UiState<out T> {
data object Idle : UiState<Nothing>
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(
val message: String, // Message affiché à l'utilisateur
val retry: Boolean = true, // Afficher un bouton "Réessayer" ?
val exception: XtreamException? = null // Pour logging uniquement
) : UiState<Nothing>
}
```
## Intercepteur OkHttp
```kotlin
class XtreamErrorInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
return try {
val response = chain.proceed(request)
if (!response.isSuccessful) {
when (response.code) {
401, 403 -> throw XtreamException.AuthException(
"Authentication failed"
)
in 400..499 -> throw XtreamException.ServerException(
response.code, "Client error"
)
in 500..599 -> throw XtreamException.ServerException(
response.code, "Server error"
)
}
}
response
} catch (e: SocketTimeoutException) {
throw XtreamException.TimeoutException("Connection timed out")
} catch (e: ConnectException) {
throw XtreamException.NetworkException("Cannot connect")
} catch (e: UnknownHostException) {
throw XtreamException.NetworkException("DNS resolution failed")
}
}
}
```
## Comportement UI par erreur
### Écran de connexion
```
[Champs vides]
❌ Bouton Connexion désactivé
[Erreur réseau]
✅ Bouton actif, message rouge sous le formulaire
✅ Champs conservés (pas de reset)
[Auth refusée]
✅ Message : "Identifiants incorrects"
✅ Focus revient sur le champ mot de passe
```
### Écran Live TV
```
[Erreur chargement chaînes]
✅ Toast 3s : "Impossible de charger les chaînes"
✅ Cache affiché si disponible
[Chaîne sans flux]
✅ Overlay : "Flux indisponible" + bouton "Chaîne suivante"
```
### Lecteur vidéo
```
[Flux mort pendant lecture]
✅ Remplacer par écran noir + message
✅ Bouton "Chaîne suivante" visible
✅ Retour possible vers le menu
```
## Règles de logging
| Niveau | Usage | Exemple |
|--------|-------|---------|
| `Timber.d` | Debug uniquement (pas en prod) | "Loading 42 live channels" |
| `Timber.i` | Événements importants | "User logged in: user123" |
| `Timber.w` | Anomalies non bloquantes | "EPG cache expired for stream 99" |
| `Timber.e` | Erreurs utilisateur | "Stream unavailable for stream 42" |
**❌ Interdit** : `Log.d("PASSWORD", password)` ou tout log contenant des credentials.
## Annexes
### Codes Xtream que l'on peut rencontrer
| Code | Signification | Action |
|------|--------------|--------|
| `auth = "0"` | Compte invalide | Message login |
| `status = "Expired"` | Abonnement expiré | Message distinct |
| `status = "Disabled"` | Compte désactivé | Message distinct |
| `tv_archive = 0` | Pas d'archive | Cacher bouton Replay |
| `has_archive = false` | Pas d'archive | Cacher bouton Replay |
| Réponse vide | Serveur HS ou mauvaise URL | Erreur réseau générique |
+159
View File
@@ -0,0 +1,159 @@
# Sécurité — iMedia TV+
## Principes
1. **Aucun secret en clair sur le disque ou dans les logs**
2. **Chiffrement de bout en bout** du stockage local des credentials
3. **Isolation** : l'application ne partage pas les credentials avec d'autres apps
4. **Principle of least privilege** : permissions Android minimales
## Stockage des credentials
### Implémentation
```kotlin
// data/storage/SecureStorage.kt
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class SecureStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"imedia_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveCredentials(server: String, username: String, password: String) {
prefs.edit()
.putString("server_url", server)
.putString("username", username)
.putString("password", password)
.apply()
}
fun getCredentials(): Credentials? {
val server = prefs.getString("server_url", null)
val username = prefs.getString("username", null)
val password = prefs.getString("password", null)
return if (server != null && username != null && password != null) {
Credentials(server, username, password)
} else null
}
fun clearCredentials() {
prefs.edit().clear().apply()
}
}
```
Pour les favoris et la chaîne épinglée (données non sensibles), la version 1 utilisait des `SharedPreferences` classiques — acceptable. Si on veut tout unifier, on utilise `EncryptedSharedPreferences` partout.
### Ce qui est chiffré
| Donnée | Niveau | Sensibilité |
|--------|--------|-------------|
| Server URL | AES-256 GCM | Haute |
| Username | AES-256 GCM | Haute |
| Password | AES-256 GCM | Critique |
| Favoris | AES-256 SIV keys | Moyenne |
| Chaîne épinglée | AES-256 SIV keys | Basse |
## Fichier de mapping des endpoints
**Important :** les URLs Xtream construites avec le builder ne doivent pas non plus être logguées.
```kotlin
// util/XtreamUrlBuilder.kt
class XtreamUrlBuilder(private val credentials: Credentials) {
fun liveUrl(streamId: Int): String =
"${credentials.server}/live/${credentials.username}/${credentials.password}/$streamId.m3u8"
// ⚠️ Attention : cette méthode est appelée par PlayerScreen
// — NE PAS logger son résultat
}
```
## Règles strictes
### ✅ À faire
- Utiliser `Timber.d` pour les logs de debug (supprimés en release par ProGuard)
- Vérifier les inputs utilisateur avant de les envoyer (URL bien formée)
- Timeout réseau configuré (15s connect, 30s read)
- Nettoyer les credentials à la déconnexion
- Minify + ProGuard en release (obfuscation)
### ❌ Interdit
```kotlin
// JAMAIS :
Log.d("Auth", "Login with $username / $password")
Timber.d("Server URL: $serverUrl")
println("Password is: $password")
Log.e("API", "Stream URL: ${urlBuilder.liveUrl(42)}")
// En release, ces logs ne devraient pas survivre,
// mais ne prenez pas le risque en debug non plus.
```
## ProGuard
```pro
# app/proguard-rules.pro
# Sécurité : ne pas exposer les données chiffrées
-keepclassmembers class ma.tkgraph.xtreamtv.data.storage.** { *; }
# OkHttp / Retrofit déjà couverts par leurs propres rules
# Ne pas garder de logs en release
-assumenosideeffects class timber.log.Timber {
public static *** d(...);
public static *** v(...);
}
```
## Permissions Android
```xml
<!-- AndroidManifest.xml — le strict minimum -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- ⚠️ PAS de READ_EXTERNAL_STORAGE ni WRITE_EXTERNAL_STORAGE -->
```
L'application ne lit pas de fichiers locaux et ne sauvegarde que des données dans son propre espace de stockage privé — pas besoin de permissions de stockage.
## HTTPS
Bien que le protocole Xtream utilise souvent HTTP (non chiffré), l'application doit :
1. **Accepter HTTP** pour la compatibilité
2. **Préférer HTTPS** si le serveur le supporte
3. **Ne pas désactiver la validation** SSL/TLS (pas de `TrustAllCertificates`)
Si le fournisseur Xtream le permet, les credentials transitent en HTTPS, ce qui protège le mot de passe en transit.
## Audit
Une amélioration future possible : `Sentry` ou `Firebase Crashlytics` pour monitorer les crashs. Attention cependant à ne pas envoyer de données sensibles dans les rapports :
```kotlin
// Dans Application.onCreate()
FirebaseCrashlytics.getInstance().setCustomKey("screen", "login")
// ❌ PAS FirebaseCrashlytics.getInstance().setCustomKey("username", username)
```
## Checklist de sécurité avant release
- [ ] Aucun log de credentials dans le code (grep "password" dans les fichiers source)
- [ ] EncryptedSharedPreferences en place, pas de fallback SharedPreferences
- [ ] Timeouts réseau configurés
- [ ] Permissions minimales (seulement INTERNET + ACCESS_NETWORK_STATE)
- [ ] ProGuard activé en release
- [ ] Pas de validation SSL désactivée
- [ ] Gestion de la déconnexion efface TOUTES les données
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 -XX:+UseG1GC
org.gradle.parallel=false
org.gradle.configuration-cache=false
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+37
View File
@@ -0,0 +1,37 @@
# --- iMedia TV+ ProGuard Rules ---
# Timber — supprimer les logs debug en release
-assumenosideeffects class timber.log.Timber {
public static *** d(...);
public static *** v(...);
public static *** w(...);
public static *** i(...);
}
# Retrofit
-keepattributes Signature
-keepattributes *Annotation*
-keep class retrofit2.** { *; }
-keepclasseswithmembers class * {
@retrofit2.http.* <methods>;
}
# Gson
-keepattributes Signature
-keep class com.google.gson.** { *; }
-keep class ma.tkgraph.xtreamtv.data.model.** { *; }
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**
# Coil
-keep class coil.** { *; }
# Security Crypto
-keep class androidx.security.crypto.** { *; }
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "iMediaTVPlus"
include(":app")
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="true" />
<application
android:name=".XtreamApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:banner="@drawable/ic_banner"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.iMediaTVPlus"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout|uiMode"
android:theme="@style/Theme.iMediaTVPlus.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,57 @@
package ma.tkgraph.xtreamtv
import android.content.Context
import ma.tkgraph.xtreamtv.data.network.XtreamApi
import ma.tkgraph.xtreamtv.data.repository.AuthRepository
import ma.tkgraph.xtreamtv.data.repository.EpgRepository
import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository
import ma.tkgraph.xtreamtv.data.repository.LiveRepository
import ma.tkgraph.xtreamtv.data.repository.M3uRepository
import ma.tkgraph.xtreamtv.data.repository.RecentLiveRepository
import ma.tkgraph.xtreamtv.data.repository.SeriesRepository
import ma.tkgraph.xtreamtv.data.repository.UsageRepository
import ma.tkgraph.xtreamtv.data.repository.VodRepository
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
import ma.tkgraph.xtreamtv.data.network.XtreamBaseUrlInterceptor
import ma.tkgraph.xtreamtv.data.network.XtreamErrorInterceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
/**
* Container d'injection de dépendances manuel.
* Pas de Hilt/Dagger pour garder le projet léger.
*/
class AppContainer(context: Context) {
val secureStorage = SecureStorage(context)
val m3uRepository = M3uRepository(secureStorage)
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(XtreamBaseUrlInterceptor())
.addInterceptor(XtreamErrorInterceptor())
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.build()
private val retrofit = Retrofit.Builder()
.baseUrl("http://localhost") // Sera remplacé dynamiquement
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val xtreamApi: XtreamApi = retrofit.create(XtreamApi::class.java)
// ─── Repositories ───
val authRepository = AuthRepository(xtreamApi, secureStorage)
val usageRepository = UsageRepository(secureStorage)
val recentLiveRepository = RecentLiveRepository(context)
val liveRepository = LiveRepository(xtreamApi, secureStorage, m3uRepository, usageRepository)
val epgRepository = EpgRepository(xtreamApi, secureStorage)
val vodRepository = VodRepository(xtreamApi, secureStorage, usageRepository)
val seriesRepository = SeriesRepository(xtreamApi, secureStorage, usageRepository)
val favoritesRepository = FavoritesRepository(secureStorage)
}
@@ -0,0 +1,145 @@
package ma.tkgraph.xtreamtv
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import ma.tkgraph.xtreamtv.ui.navigation.NavState
import ma.tkgraph.xtreamtv.ui.screens.GlobalSearchScreen
import ma.tkgraph.xtreamtv.ui.screens.HomeScreen
import ma.tkgraph.xtreamtv.ui.screens.LoginScreen
import ma.tkgraph.xtreamtv.ui.screens.MainMenuScreen
import ma.tkgraph.xtreamtv.ui.screens.PlayerScreen
import ma.tkgraph.xtreamtv.ui.screens.ProgramGuideScreen
import ma.tkgraph.xtreamtv.ui.screens.ReplayGuideScreen
import ma.tkgraph.xtreamtv.ui.screens.SeriesScreen
import ma.tkgraph.xtreamtv.ui.screens.VodScreen
import ma.tkgraph.xtreamtv.ui.screens.YoutubeScreen
import ma.tkgraph.xtreamtv.ui.theme.XtreamTheme
import ma.tkgraph.xtreamtv.util.SessionManager
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntentExtras()
setContent {
XtreamTheme {
Surface(modifier = Modifier.fillMaxSize()) {
AppNavigation()
}
}
}
}
private fun handleIntentExtras() {
val serverUrl = intent.getStringExtra("server_url")
val username = intent.getStringExtra("xtream_user")
val password = intent.getStringExtra("xtream_pass")
if (serverUrl != null && username != null && password != null) {
XtreamApplication.instance.appContainer.secureStorage
.saveConfig(ma.tkgraph.xtreamtv.data.model.XtreamConfig(
serverUrl = serverUrl,
username = username,
password = password
))
}
}
}
/**
* Navigation principale.
* L'auto-démarrage sur la chaîne épinglée est géré par HomeScreen
* qui vérifie le pin au chargement des streams.
*/
@Composable
fun AppNavigation() {
val sessionManager = remember {
SessionManager(XtreamApplication.instance.appContainer.secureStorage)
}
var hasSession by remember {
mutableStateOf(sessionManager.hasValidSession())
}
var navState by remember { mutableStateOf<NavState>(NavState.MainMenu) }
var hasAutoPlayedPin by remember { mutableStateOf(false) }
if (!hasSession) {
LoginScreen(
onLoginSuccess = {
hasSession = true
navState = NavState.MainMenu
}
)
return
}
when (val state = navState) {
is NavState.MainMenu -> MainMenuScreen(
onOpenTv = { navState = NavState.Home() },
onOpenVod = { navState = NavState.Vod() },
onOpenSeries = { navState = NavState.Series() },
onOpenSearch = { navState = NavState.GlobalSearch },
onOpenYoutube = { navState = NavState.Youtube },
onNavigate = { navState = it }
)
is NavState.GlobalSearch -> GlobalSearchScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu }
)
is NavState.Youtube -> YoutubeScreen(
onBack = { navState = NavState.MainMenu }
)
is NavState.Home -> HomeScreen(
focusStreamId = state.focusStreamId,
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu },
hasAutoPlayedPin = hasAutoPlayedPin,
onAutoPlayPin = { hasAutoPlayedPin = true }
)
is NavState.Player -> PlayerScreen(
initialStream = state.stream,
allStreams = state.allStreams,
initialIndex = state.currentIndex,
replayUrl = state.replayUrl,
progressKey = state.progressKey,
progressKeys = state.progressKeys,
onBack = {
navState = state.returnState ?: NavState.Home()
}
)
is NavState.ProgramGuide -> ProgramGuideScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu }
)
is NavState.ReplayGuide -> ReplayGuideScreen(
stream = state.stream,
onBack = { navState = NavState.Home(state.stream.streamId) },
onPlayReplay = { url ->
navState = NavState.Player(state.stream, listOf(state.stream), 0, replayUrl = url)
}
)
is NavState.Vod -> VodScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu }
)
is NavState.Series -> SeriesScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu }
)
is NavState.SeriesDetail -> SeriesScreen(
onNavigate = { navState = it },
onBack = { navState = NavState.MainMenu }
)
}
}
@@ -0,0 +1,36 @@
package ma.tkgraph.xtreamtv
import android.app.Application
import timber.log.Timber
/**
* Point d'entrée de l'application iMedia TV+.
* Configure le logging et l'injection de dépendances.
*/
class XtreamApplication : Application() {
/** Container d'injection de dépendances (manual DI). */
lateinit var appContainer: AppContainer
private set
override fun onCreate() {
super.onCreate()
instance = this
appContainer = AppContainer(this)
initLogging()
}
private fun initLogging() {
if (BuildConfig.ENABLE_LOGGING) {
Timber.plant(Timber.DebugTree())
} else {
// En production : Crashlytics tree (à configurer)
// Timber.plant(CrashReportingTree())
}
}
companion object {
lateinit var instance: XtreamApplication
private set
}
}
@@ -0,0 +1,23 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* Réponse de l'API /player_api.php?username=...&password=...
*/
data class AuthResponse(
@SerializedName("user_info") val userInfo: UserInfo? = null,
val auth: String = "0",
val status: String = ""
)
data class UserInfo(
val username: String = "",
@SerializedName("auth") val auth: Int = 0,
val status: String = "",
@SerializedName("created_at") val createdAt: String = "",
@SerializedName("exp_date") val expDate: String = "",
@SerializedName("is_trial") val isTrial: String = "0",
@SerializedName("max_connections") val maxConnections: String = "0",
@SerializedName("allowed_output_formats") val allowedFormats: List<String> = emptyList()
)
@@ -0,0 +1,55 @@
package ma.tkgraph.xtreamtv.data.model
import android.util.Base64
import com.google.gson.annotations.SerializedName
import java.nio.charset.StandardCharsets
data class EpgProgramDto(
@SerializedName("id") val id: String? = null,
@SerializedName("epg_id") val epgId: String? = null,
@SerializedName("title") val rawTitle: String? = null,
@SerializedName("description") val rawDescription: String? = null,
@SerializedName("start") val start: String? = null,
@SerializedName("end") val end: String? = null,
@SerializedName("start_timestamp") val startTimestamp: String? = null,
@SerializedName("stop_timestamp") val stopTimestamp: String? = null,
@SerializedName("has_archive") val hasArchive: Int? = null,
@SerializedName("now_playing") val nowPlaying: Int? = null,
@SerializedName("lang") val lang: String? = null
) {
fun titleText(): String {
return decodeMaybeBase64(rawTitle).ifBlank { "" }
}
fun startMillis(): Long? {
startTimestamp?.toLongOrNull()?.let { return it * 1000L }
return null
}
fun stopMillis(): Long? {
stopTimestamp?.toLongOrNull()?.let { return it * 1000L }
return null
}
/** Durée en minutes, calculée depuis les timestamps ou la plage horaire. */
fun durationMinutes(): Int {
// Depuis les timestamps Unix (en secondes)
val startSec = startTimestamp?.toLongOrNull() ?: return 120
val stopSec = stopTimestamp?.toLongOrNull() ?: return 120
return ((stopSec - startSec) / 60).toInt().coerceIn(1, 720)
}
}
/** Décode si Base64, sinon retourne tel quel. */
private fun decodeMaybeBase64(value: String?): String {
val text = value.orEmpty().trim()
if (text.isBlank()) return ""
return runCatching {
val decoded = Base64.decode(text, Base64.DEFAULT)
String(decoded, StandardCharsets.UTF_8).trim()
}.getOrNull()
?.takeIf { decoded ->
decoded.isNotBlank() && decoded.any { it.isLetterOrDigit() }
}
?: text
}
@@ -0,0 +1,11 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class EpgShortResponse(
@SerializedName("epg_listings") val listings: List<EpgProgramDto>? = emptyList()
)
data class EpgFullResponse(
@SerializedName("epg_listings") val listings: List<EpgProgramDto>? = emptyList()
)
@@ -0,0 +1,12 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour une catégorie Live.
*/
data class LiveCategoryDto(
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("category_name") val categoryName: String = "",
@SerializedName("parent_id") val parentId: Int = 0
)
@@ -0,0 +1,24 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour un flux Live (chaîne TV).
*/
data class LiveStreamDto(
val num: Int = 0,
val name: String = "",
@SerializedName("stream_id") val streamId: Int = 0,
@SerializedName("stream_type") val streamType: String = "",
@SerializedName("stream_icon") val streamIcon: String? = null,
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("epg_channel_id") val epgChannelId: String? = null,
@SerializedName("tv_archive") val tvArchive: Int = 0,
@SerializedName("has_archive") val hasArchive: Boolean = false,
@SerializedName("container_extension") val containerExtension: String = "m3u8",
val added: String = "",
@SerializedName("custom_sid") val customSid: String? = null,
@SerializedName("tv_archive_duration") val archiveDuration: Int = 0,
/** URL directe du flux (utilisé en mode M3U). */
val directUrl: String = ""
)
@@ -0,0 +1,57 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour une série.
*/
data class SeriesDto(
val num: Int = 0,
val name: String = "",
@SerializedName("series_id") val seriesId: Int = 0,
val cover: String? = null,
val plot: String? = null,
val cast: String? = null,
val rating: String? = null,
val genre: String? = null,
val year: String? = null,
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("last_modified") val lastModified: String? = null
)
data class SeriesCategoryDto(
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("category_name") val categoryName: String = "",
@SerializedName("parent_id") val parentId: Int = 0
)
/**
* Détail d'une série (saisons + épisodes).
*/
data class SeriesInfoDto(
val seasons: List<SeasonDto> = emptyList(),
val episodes: Map<String, List<EpisodeDto>> = emptyMap()
)
data class SeasonDto(
val id: Int = 0,
@SerializedName("season_number") val seasonNumber: Int = 0,
val name: String = ""
)
data class EpisodeDto(
val id: Int = 0,
@SerializedName("episode_num") val episodeNum: Int = 0,
val title: String? = null,
val name: String? = null,
@SerializedName("episode_name") val episodeName: String? = null,
@SerializedName("container_extension") val containerExtension: String = "mp4",
val info: EpisodeInfoDto? = null
)
data class EpisodeInfoDto(
@SerializedName("movie_image") val movieImage: String? = null,
val plot: String? = null,
val duration: String? = null,
val rating: String? = null
)
@@ -0,0 +1,16 @@
package ma.tkgraph.xtreamtv.data.model
/**
* État générique pour tous les écrans.
* Usage : ViewModel expose `StateFlow<UiState<T>>`
*/
sealed interface UiState<out T> {
data object Idle : UiState<Nothing>
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(
val message: String,
val retry: Boolean = true,
val exception: XtreamException? = null
) : UiState<Nothing>
}
@@ -0,0 +1,29 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
/**
* DTO pour un film VOD.
*/
data class VodDto(
val num: Int = 0,
val name: String = "",
@SerializedName("stream_id") val streamId: Int = 0,
@SerializedName("stream_type") val streamType: String = "movie",
@SerializedName("stream_icon") val streamIcon: String? = null,
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("container_extension") val containerExtension: String = "mp4",
val rating: String? = null,
val added: String = "",
val description: String? = null,
val year: String? = null,
val genre: String? = null,
val director: String? = null,
val cast: String? = null
)
data class VodCategoryDto(
@SerializedName("category_id") val categoryId: String = "",
@SerializedName("category_name") val categoryName: String = "",
@SerializedName("parent_id") val parentId: Int = 0
)

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