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 |
+30
View File
@@ -0,0 +1,30 @@
# Build + installation Mi Box - tri par utilisation
Fonction ajoutée :
- les catégories LIVE / VOD / Séries sont triées avec les plus utilisées en premier ;
- les chaînes LIVE sont triées avec les plus utilisées en premier ;
- les films VOD et séries sont aussi triés par utilisation, parce qu'autant ne pas s'arrêter au milieu du gué comme un cahier des charges fatigué ;
- les compteurs sont stockés localement dans `EncryptedSharedPreferences`.
## Commandes PowerShell depuis le dossier du projet
```powershell
cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES"
$env:JAVA_HOME = "D:\Program Files\Android\Android Studio\jbr"
$env:Path = "$env:JAVA_HOME\bin;$env:Path"
$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
$env:ANDROID_SDK_ROOT = "$env:LOCALAPPDATA\Android\Sdk"
$env:Path = "$env:ANDROID_HOME\platform-tools;$env:ANDROID_HOME\cmdline-tools\latest\bin;$env:ANDROID_HOME\emulator;$env:Path"
Set-Content -Path ".\local.properties" -Value "sdk.dir=$($env:LOCALAPPDATA.Replace('\','/'))/Android/Sdk"
.\gradlew.bat :app:assembleDebug
adb connect 192.168.1.8:5555
adb devices
adb -s 192.168.1.8:5555 install -r .\app\build\outputs\apk\debug\app-debug.apk
```
Si `adb connect` refuse : active le débogage réseau sur la Mi Box, vérifie que PC et Mi Box sont sur le même Wi-Fi, puis accepte l'autorisation RSA affichée sur la TV.
+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
```
+39
View File
@@ -0,0 +1,39 @@
# V10.1 - Correction compilation FastScrollKeys
Correction du fichier :
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FastScrollKeys.kt`
## Problème corrigé
La V10 utilisait l'import :
```kotlin
import androidx.compose.ui.input.key.nativeKeyEvent
```
Sur la version Compose utilisée par le projet, cet import n'est pas disponible, ce qui provoquait :
```text
Unresolved reference 'nativeKeyEvent'
```
## Nouvelle approche
La V10.1 ne dépend plus de `nativeKeyEvent` dans `FastScrollKeys.kt`.
Elle utilise :
- `Key.DirectionUp`
- `Key.DirectionDown`
- des touches Android TV converties en `Key(...)` pour `PAGE_UP`, `PAGE_DOWN`, `CHANNEL_UP`, `CHANNEL_DOWN`
- un compteur de répétition pour détecter les appuis longs Haut/Bas
## Comportement attendu
- Appui long Haut : remonter au début de la liste
- Appui long Bas : descendre à la fin de la liste
- Channel+ / PageUp : remonter par page
- Channel- / PageDown : descendre par page
Base : V10, elle-même basée sur V9 + focus V8 stable.
+37
View File
@@ -0,0 +1,37 @@
# V10 - Navigation rapide dans les longues listes
Base utilisée : `12-IPTV_Android_TV_HERMES_focus_fix_v9_menu_home.zip`.
## Objectif
Éviter de devoir défiler manuellement des centaines de lignes avec la télécommande.
## Raccourcis ajoutés
| Touche télécommande | Résultat |
|---|---|
| Appui long Haut | Aller directement au début de la liste |
| Appui long Bas | Aller directement à la fin de la liste |
| Channel+ / PageUp | Remonter d'environ 15 éléments |
| Channel- / PageDown | Descendre d'environ 15 éléments |
## Écrans concernés
- TV : liste des chaînes
- VOD : grille des films
- Séries : grille des séries
- Détail série : liste des épisodes
## Fichiers modifiés
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt`
## Nouveau fichier
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/components/FastScrollKeys.kt`
## Remarque importante
Sur l'écran TV, la navigation rapide est désactivée pendant le mode actions Replay / Épingler / Favori, afin de ne pas casser la logique focus validée en V8/V9.
+30
View File
@@ -0,0 +1,30 @@
# Fix V22 - Auth response parsing
Base: V21 login-server-fix.
Problem:
- The app reached the Xtream server, but showed "Identifiants invalides ou serveur incorrect".
- Many Xtream servers return auth/status inside `user_info`, for example:
`{"user_info":{"auth":1,"status":"Active"}, ...}`
- The previous login logic checked `auth` at the root level, which is often absent.
- Result: false invalid-login message even when credentials are correct.
Fix:
- AuthRepository now reads:
- `response.userInfo.auth` first
- fallback to root `response.auth`
- `response.userInfo.status` first
- fallback to root `response.status`
- It accepts `auth = 1` and `auth = true`.
- It keeps the V21 temporary server save fix.
- It restores previous config on failed login.
Scope:
- Only AuthRepository.kt and version number are changed.
- Login input dialog/focus from V19/V20 stays.
- Server URL normalization from V21 stays.
- No changes to LIVE, replay, player, search, YouTube, recent LIVE, continue watching, or home scroll.
Version:
- versionCode = 22
- versionName = 2.0.22-auth-response-fix
+43
View File
@@ -0,0 +1,43 @@
# Version 2.0.4-continue-search
Base utilisée : 2.0.3-usage-sort-v3 validée.
## Modifications
- Nom affiché de l'application remis à `iMedia TV+` dans les ressources Android.
- Ajout d'une entrée `RECHERCHE` dans le menu principal.
- Ajout d'un écran `Recherche globale` qui cherche dans :
- LIVE ;
- VOD ;
- Séries.
- Ajout d'une section `Continuer à regarder` sur le menu principal.
- La reprise utilise les progressions locales existantes :
- `vod:<streamId>` ;
- `series:<seriesId>:<episodeId>`.
- Ajout d'un horodatage local `:lastWatched` pour trier les reprises par dernier visionnage.
## Notes
- Pour les anciennes progressions sans horodatage, les contenus peuvent apparaître sans ordre précis au début.
- Après une nouvelle lecture, l'ordre devient fiable grâce à `lastWatched`.
- La recherche séries lance le premier épisode de la série trouvée.
- La recherche VOD lance le film directement.
- La recherche LIVE lance la chaîne directement.
## Commandes
```powershell
cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES"
.\gradlew.bat clean :app:assembleDebug
adb connect 192.168.1.8:5555
adb -s 192.168.1.8:5555 install -r .\app\build\outputs\apk\debug\app-debug.apk
adb -s 192.168.1.8:5555 shell monkey -p ma.tkgraph.xtreamtv -c android.intent.category.LAUNCHER 1
```
Mi Box 2 :
```powershell
adb connect 192.168.1.56:5555
adb -s 192.168.1.56:5555 install -r .\app\build\outputs\apk\debug\app-debug.apk
adb -s 192.168.1.56:5555 shell monkey -p ma.tkgraph.xtreamtv -c android.intent.category.LAUNCHER 1
```
+26
View File
@@ -0,0 +1,26 @@
# Correctif V12 - Continuer le visionnage
Base : `2.0.11-fast-continue-search`.
Version : `2.0.12-continue-fix`.
## Problème constaté
Après la V11, la recherche globale rend bien la main instantanément, mais le volet `Continuer à regarder` peut ne plus s'afficher.
Cause : la V11 utilisait uniquement les nouvelles métadonnées locales ajoutées au moment de la lecture. Les progressions créées avant cette version, ou certains lancements qui ne sauvegardaient pas encore toutes les informations de reprise, n'étaient donc plus visibles dans le volet.
## Correction
- Le volet `Continuer à regarder` lit d'abord les métadonnées locales rapides.
- Si d'anciennes progressions existent sans métadonnées, l'app reconstruit ces métadonnées une fois depuis le catalogue VOD/Séries.
- Les métadonnées reconstruites sont sauvegardées pour que les prochains affichages soient rapides.
- L'URL de lecture réelle est maintenant sauvegardée dans les métadonnées, notamment pour les films VOD.
- Le volet affiche un état `Chargement des reprises…` au lieu de disparaître silencieusement.
## Non modifié
- Chargement LIVE non modifié.
- Lecture vidéo non modifiée côté buffering.
- Recherche globale V11 conservée.
- Catégorie YouTube conservée.
+49
View File
@@ -0,0 +1,49 @@
# FIX_FAST_CONTINUE_SEARCH_V11
Version: `2.0.11-fast-continue-search`
Base: rollback stable V10 basé sur V5.
## Objectif
Corriger les deux lenteurs mesurées :
- `Continuer à regarder` ~24 s
- `Recherche globale ALL` ~30 s
sans toucher au chargement LIVE, au replay, ni au lecteur.
## Changements
### Continuer à regarder
Avant, le menu principal rechargeait le catalogue VOD complet, le catalogue Séries complet, puis les détails de séries pour reconstruire les éléments en cours.
Maintenant :
- le lecteur sauvegarde des métadonnées minimales au moment de la lecture ;
- le menu principal lit ces métadonnées locales ;
- aucun catalogue VOD/Séries n'est chargé pour afficher `Continuer à regarder`.
Note : les anciennes progressions créées avant cette version n'ont pas encore ces métadonnées. Elles réapparaissent après avoir relancé le film/épisode au moins une fois.
### Recherche globale
Avant, l'écran chargeait LIVE + VOD + Séries dès l'ouverture.
Maintenant :
- l'écran ne charge rien au démarrage ;
- l'utilisateur choisit `LIVE`, `VOD`, `SÉRIES` ou `ALL` ;
- il tape la recherche puis clique sur `Lancer` ;
- les résultats sont affichés progressivement ;
- LIVE devient le filtre par défaut pour éviter le chargement massif inutile.
## Fichiers modifiés
- `app/build.gradle.kts`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/PlaybackProgressRepository.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/MainMenuViewModel.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GlobalSearchViewModel.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/GlobalSearchScreen.kt`
+14
View File
@@ -0,0 +1,14 @@
# Fix V16 - Home scroll compile fix
Base: V14 recent-live-visible.
Changes:
- Adds vertical scrolling on the home screen.
- Keeps recent LIVE row above Continue Watching.
- Scrolls the home screen when focus enters recent LIVE / continue watching rows.
- Removes unsupported `rememberBringIntoViewRequester`, replaced by `rememberScrollState` + `animateScrollTo`.
- Does not change LIVE loading, replay, player, YouTube or global search logic.
Version:
- versionCode = 16
- versionName = 2.0.16-home-scroll-compile-fix
+12
View File
@@ -0,0 +1,12 @@
# Fix V17 - Home vertical scroll simple
Base: V16.
Fix:
- Removes `rememberCoroutineScope` dependency that failed to compile on the user's project.
- Keeps `verticalScroll(rememberScrollState())` so the home screen can scroll vertically with TV remote navigation.
- Does not change LIVE, replay, player, global search, YouTube, recent LIVE data, or continue watching data.
Version:
- versionCode = 17
- versionName = 2.0.17-home-scroll-simple
+45
View File
@@ -0,0 +1,45 @@
# Correctif V8 - LIVE plus rapide avec cache
Version : `2.0.8-live-fast-cache`
## Problème constaté
Après le correctif V7, l'écran LIVE chargeait correctement, mais plus lentement.
La V7 avait rendu le chargement plus sûr en lançant les opérations dans l'ordre :
1. catégories LIVE ;
2. chaînes LIVE.
Sur de gros catalogues, surtout avec `All`, cela ajoutait une attente visible.
## Correctif V8
### 1. Chargement LIVE en parallèle sécurisé
L'écran LIVE relance maintenant :
- catégories LIVE ;
- chaînes LIVE ;
en parallèle, mais avec des verrous/cache dans le repository pour éviter le bug d'écran vide.
### 2. Cache mémoire Xtream
`LiveRepository` garde en mémoire :
- les catégories LIVE Xtream ;
- les chaînes LIVE par catégorie ;
- les chaînes LIVE `All`.
Résultat : revenir sur LIVE ne recharge plus inutilement le catalogue.
### 3. Cache M3U disque court
`M3uRepository` garde maintenant une copie locale du fichier M3U pendant 6 heures.
Résultat : après un premier chargement, l'ouverture LIVE suivante peut parser le fichier local au lieu de retélécharger toute la playlist.
### 4. Invalidation propre
À la déconnexion, les caches LIVE/M3U sont vidés.
## Fichiers modifiés
- `app/build.gradle.kts`
- `app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/LiveRepository.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uParser.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt`
+26
View File
@@ -0,0 +1,26 @@
# Correctif V7 - LIVE ne charge pas directement
Version: `2.0.7-live-load-fix`
## Problème constaté
Après la version `2.0.6-search-on-demand`, l'écran LIVE pouvait rester vide alors que la recherche globale retrouvait bien les chaînes.
## Cause probable
L'écran LIVE lançait le chargement des catégories et des chaînes en parallèle dès l'initialisation du ViewModel. Sur certaines configurations, surtout avec M3U/cache sur Mi Box, ce démarrage parallèle peut laisser l'écran avec un état vide ou non relancé.
## Correctif
- Initialisation LIVE passée en `UiState.Loading` au lieu de `Success(emptyList())`.
- Chargement initial LIVE rendu séquentiel : catégories puis chaînes.
- Ajout de `ensureLoaded()` appelé à l'ouverture de l'écran LIVE pour relancer si l'écran est vide.
- Les erreurs ne sont plus masquées en liste vide : elles sont affichées.
- Sécurisation du cache M3U avec `Mutex` pour éviter deux fetchs parallèles du même catalogue.
## Fichiers modifiés
- `app/build.gradle.kts`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/M3uRepository.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt`
+23
View File
@@ -0,0 +1,23 @@
# Fix V19 - Login dialog input
Base: V17 home-scroll-simple.
Problem:
- Direct text input on Android TV / Mi Box login fields is unreliable.
- V18 native inline EditText still did not solve text entry on the target device.
Fix:
- Login fields are no longer typed directly.
- Each field is a TV-focusable card.
- Press OK on a field to open a native Android input dialog with an EditText.
- The dialog requests focus and explicitly opens the soft keyboard.
- Confirm with OK in the dialog to copy the entered text into the login screen.
- DPAD navigation between fields remains.
Scope:
- Only LoginScreen.kt and version number are changed.
- LIVE, replay, player, global search, YouTube, recent LIVE, continue watching and home scroll are untouched.
Version:
- versionCode = 19
- versionName = 2.0.19-login-dialog-input
+20
View File
@@ -0,0 +1,20 @@
# Fix V20 - Login focus fix
Base: V19 login-dialog-input.
Problem:
- Text input works in the native dialog, but DPAD navigation skips the second field.
- The cause is DPAD handling on KeyUp: the system can already move focus on KeyDown, then our custom handler moves again on KeyUp. One press can jump two fields.
Fix:
- DPAD focus handling now consumes KeyDown instead of KeyUp.
- This prevents double focus movement.
- Help text was updated to explain: arrows change fields, OK opens input.
Scope:
- Only LoginScreen.kt and version number are changed.
- LIVE, replay, player, global search, YouTube, recent LIVE, continue watching and home scroll are untouched.
Version:
- versionCode = 20
- versionName = 2.0.20-login-focus-fix
+17
View File
@@ -0,0 +1,17 @@
# Fix V18 - Login input fix
Base: V17 home-scroll-simple.
Problem:
- On some Android TV / Mi Box devices, Compose `OutlinedTextField` receives focus but does not reliably open text entry.
Fix:
- Replaces login text fields with native Android `EditText` through `AndroidView`.
- Explicitly opens the soft keyboard on focus/click.
- Keeps DPAD navigation between fields.
- Only changes LoginScreen and version number.
- Does not touch LIVE, replay, player, global search, YouTube, recent LIVE, or continue watching.
Version:
- versionCode = 18
- versionName = 2.0.18-login-input-fix
+22
View File
@@ -0,0 +1,22 @@
# Fix V21 - Login server connection
Base: V20 login-focus-fix.
Problem:
- During first Xtream login, the Retrofit base URL interceptor reads the server URL from SecureStorage.
- But the server URL was only saved after successful login.
- Result: first login could call the placeholder localhost and show "Cannot reach server", even when the server works.
Fix:
- AuthRepository now normalizes and temporarily saves the Xtream config before calling player_api.php.
- If login fails, previous config is restored, or only connection config is cleared while preserving local data.
- XtreamBaseUrlInterceptor now also respects http/https scheme and normalizes host/port.
- Server URL, username, password and M3U URL are trimmed before use.
Scope:
- Network login path only.
- No changes to LIVE, replay, player, global search, YouTube, recent LIVE, continue watching, or home scroll.
Version:
- versionCode = 21
- versionName = 2.0.21-login-server-fix
+24
View File
@@ -0,0 +1,24 @@
# Fix V23 - Login success navigation
Base: V22 auth-response-fix.
Problem:
- Login could succeed and save the configuration, but the app stayed on LoginScreen.
- In MainActivity, `hasSession` was computed once as a normal local value, not a Compose state.
- After a successful login, the parent navigation was not notified and did not recompose to MainMenu.
Fix:
- `AppNavigation` now keeps `hasSession` as mutable Compose state.
- `LoginScreen` now accepts `onLoginSuccess`.
- `LoginScreen` triggers `onLoginSuccess()` when `uiState` becomes `UiState.Success`.
- On success, navigation goes to `NavState.MainMenu`.
Scope:
- Only MainActivity.kt, LoginScreen.kt and version number changed.
- All V22 login server/auth parsing fixes remain.
- Login input dialog/focus fixes remain.
- No changes to LIVE, replay, player, global search, YouTube, recent LIVE, continue watching, or home scroll.
Version:
- versionCode = 23
- versionName = 2.0.23-login-success-nav
+30
View File
@@ -0,0 +1,30 @@
# Correctif V9 - rollback performance
Version: `2.0.9-performance-rollback`
## Objectif
Annuler le correctif V8 `2.0.8-live-fast-cache`, car il ralentissait toute l’application : LIVE, replay et démarrage des flux.
## Ce qui est retiré
- Cache disque M3U 6 heures.
- Cache mémoire Xtream ajouté en V8.
- Chargement parallèle catégories + chaînes LIVE ajouté en V8.
- Invalidation globale des caches LIVE/M3U ajoutée en V8.
## Ce qui est conservé
Cette V9 repart de la V7, donc elle garde :
- Tri par utilisation V3.
- Continuer à regarder V4.
- Recherche globale à la demande V6.
- Catégorie YouTube V5.
- Correctif écran LIVE vide V7.
## Pourquoi
La V8 a ajouté trop de logique de cache/chargement parallèle dans les repositories LIVE/M3U. Sur Mi Box, cela peut créer de la contention réseau/CPU/disque et ralentir le lancement des chaînes et du replay.
La V9 revient à un comportement plus simple et stable.
+26
View File
@@ -0,0 +1,26 @@
# V13 - Dernières chaînes LIVE
Base: `2.0.12-continue-fix`
Version: `2.0.13-recent-live`
## Ajout
- Nouvelle ligne `Dernières chaînes LIVE` dans le menu principal, affichée au-dessus de `Continuer à regarder`.
- Les chaînes LIVE lancées depuis l'écran TV sont sauvegardées localement.
- Les chaînes LIVE lancées depuis la recherche globale sont sauvegardées localement.
- Le zapping gauche/droite dans le lecteur met aussi à jour les dernières chaînes si le flux est bien LIVE.
- Lecture directe depuis cette ligne sans appel serveur ni chargement catalogue.
## Fichiers modifiés
- `app/build.gradle.kts`
- `app/src/main/java/ma/tkgraph/xtreamtv/AppContainer.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/RecentLiveRepository.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/GlobalSearchScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/MainMenuScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/HomeViewModel.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/GlobalSearchViewModel.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/MainMenuViewModel.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/PlayerViewModel.kt`
+22
View File
@@ -0,0 +1,22 @@
# FIX_ROLLBACK_V5_STABLE_V10
Version: 2.0.10-v5-stable-rollback
Base code: 2.0.5-youtube-category, dernière version signalée comme fluide avant les régressions introduites par la recherche à la demande V6 puis les correctifs V7/V8/V9.
Objectif:
- Revenir au comportement stable V5.
- Garder un versionCode supérieur (10) pour permettre l’installation par-dessus V6/V7/V8/V9 sans erreur de downgrade.
Contenu conservé depuis V5:
- Tri par utilisation V3.
- Continuer à regarder.
- Recherche globale classique.
- Catégorie YouTube.
- Nom iMedia TV+.
Non inclus volontairement:
- Recherche globale à la demande V6.
- Correctifs LIVE V7.
- Cache/optimisation LIVE V8.
- Rollback V9 basé sur V7.
+44
View File
@@ -0,0 +1,44 @@
# Correctif tri par utilisation + nom de l'application
## Pourquoi l'app apparaît comme `ma.tkgraph.xtreamtv`
`ma.tkgraph.xtreamtv` est l'`applicationId` Android, c'est-à-dire l'identifiant technique du package installé.
Ce n'est pas censé être le nom public de l'application. Le nom affiché vient de `@string/app_name`.
Dans ce correctif, le nom affiché reste défini dans :
```text
app/src/main/res/values/strings.xml
app/src/main/res/values-fr/strings.xml
app/src/main/res/values-ar/strings.xml
```
Le nom affiché est maintenant `HERMES IPTV`.
Le package technique reste `ma.tkgraph.xtreamtv` pour ne pas perdre les données locales de l'application.
## Problème corrigé
Le premier ZIP avait placé les fichiers modifiés dans un mauvais sous-dossier `app/app/...`.
Le module réellement compilé est `app/...`, donc Gradle recompilait l'ancien code. Résultat : build OK, mais le tri ne pouvait pas marcher.
Ce ZIP remet les modifications dans le bon module :
```text
app/src/main/java/ma/tkgraph/xtreamtv/...
```
## Commandes
```powershell
cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES"
.\gradlew.bat :app:assembleDebug
adb connect 192.168.1.8:5555
adb -s 192.168.1.8:5555 install -r .\app\build\outputs\apk\debug\app-debug.apk
adb -s 192.168.1.8:5555 shell monkey -p ma.tkgraph.xtreamtv -c android.intent.category.LAUNCHER 1
```
## Important
Le tri commence à devenir visible après utilisation : ouvre des catégories/chaînes plusieurs fois, reviens à l'écran, puis recharge l'écran si nécessaire.
+22
View File
@@ -0,0 +1,22 @@
# Correctif V2 - tri par utilisation sans casser le lecteur
Cette version corrige l'erreur précédente : les fichiers du lecteur `PlayerScreen.kt` et `PlayerViewModel.kt` ne sont plus remplacés par une ancienne version.
Fonctions ajoutées :
- tri des catégories Live/VOD/Séries par utilisation ;
- tri des chaînes Live par utilisation ;
- tri des films VOD par utilisation ;
- tri des séries par utilisation ;
- nom affiché de l'application : `HERMES IPTV` ;
- package technique conservé : `ma.tkgraph.xtreamtv`.
Commandes :
```powershell
cd "D:\07-Logiciels\12-IPTV Andoid TV HERMES"
.\gradlew.bat :app:assembleDebug
adb connect 192.168.1.8:5555
adb -s 192.168.1.8:5555 install -r .\app\build\outputs\apk\debug\app-debug.apk
adb -s 192.168.1.8:5555 shell monkey -p ma.tkgraph.xtreamtv -c android.intent.category.LAUNCHER 1
```
+32
View File
@@ -0,0 +1,32 @@
# FIX_USAGE_SORT_V3
Correctif V3 du tri par utilisation.
## Problème constaté
La V2 enregistrait les compteurs dans le stockage, mais l'interface gardait souvent les listes déjà chargées.
Résultat : après un clic ou une lecture, le compteur pouvait être incrémenté sans que l'ordre visible change tout de suite.
## Correctif appliqué
- `SecureStorage.incrementUsageCounter(...)` utilise maintenant `commit()` au lieu de `apply()` pour rendre l'incrément disponible immédiatement.
- `HomeViewModel` retrie immédiatement :
- les catégories LIVE après sélection ;
- les chaînes LIVE après lecture.
- `VodViewModel` retrie immédiatement :
- les catégories VOD après sélection ;
- les films après lecture.
- `SeriesViewModel` retrie immédiatement :
- les catégories Séries après sélection ;
- les séries après ouverture.
- Les barres horizontales de catégories LIVE / VOD / Séries reviennent au début après changement d'ordre, pour que le résultat soit visible de gauche à droite.
## Test recommandé
1. Installer l'APK V3.
2. Ouvrir une catégorie plusieurs fois.
3. Revenir à la ligne des catégories : elle doit remonter vers le début.
4. Lancer une chaîne ou un film plusieurs fois.
5. Revenir à la liste : l'élément utilisé doit remonter dans la liste.
Note : `All` et `Favorites` restent volontairement devant car ce sont des boutons spéciaux.
+35
View File
@@ -0,0 +1,35 @@
# iMedia TV+ - V5 YouTube category
Version: `2.0.5-youtube-category`
## Objectif
Ajouter une catégorie `YOUTUBE` dans le menu principal pour gérer des vidéos YouTube indépendamment de LIVE/VOD/Séries.
## Fonctionnalités ajoutées
- Nouvelle tuile `YOUTUBE` dans le menu principal.
- Nouvel écran `YoutubeScreen`.
- Ajout de vidéos par lien complet ou code vidéo YouTube.
- Titre optionnel lors de l'ajout.
- Sauvegarde locale des vidéos dans `SharedPreferences`.
- Liste des vidéos enregistrées avec miniature YouTube quand un `videoId` est détecté.
- Suppression d'une vidéo enregistrée.
- Lecteur YouTube plein écran via `WebView`.
- Commandes dans le lecteur:
- OK / bouton Play/Pause : pause ou reprise.
- Gauche : -10 secondes.
- Droite : +10 secondes.
- Retour : fermer le lecteur.
- Fallback vers le site YouTube si l'embed n'est pas possible.
## Fichiers principaux
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/YoutubeScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/viewmodels/YoutubeViewModel.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/YoutubeRepository.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/util/YoutubeUrlUtils.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/MainMenuScreen.kt`
- `app/build.gradle.kts`
## Notes
Certaines vidéos YouTube peuvent refuser l'intégration `embed`. Dans ce cas l'écran tente d'ouvrir l'URL YouTube normale dans la WebView.
+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
```
+27
View File
@@ -0,0 +1,27 @@
# Focus Fix V4
## Objectif
Restaurer un focus visuel clair sur les cartes chaînes après la correction V3.
## Problème constaté
La V3 empêchait bien le lancement automatique de Replay après un appui long OK, mais le contour de focus sur les chaînes pouvait disparaître.
Cause probable : l'ordre des modifiers Compose. Sur Android TV, `onFocusChanged` doit être placé avant `focusable()` pour capter correctement l'état de focus de la carte.
## Correction
Dans `HomeScreen.kt`, la carte chaîne utilise maintenant un état de focus explicite :
- `onFocusChanged { isFocused = it.isFocused || it.hasFocus }` avant `focusable()` ;
- bordure orange visible quand la carte a le focus ;
- bordure blanche quand le mode actions est ouvert ;
- conservation de la correction V3 : l'appui long OK ouvre les actions sans cliquer automatiquement sur Replay.
## Résultat attendu
- OK court : lance la chaîne.
- OK long : ouvre le mode actions.
- La carte chaîne sélectionnée affiche à nouveau un contour visible.
- Replay ne se déclenche plus automatiquement au relâchement du bouton OK.
+24
View File
@@ -0,0 +1,24 @@
# Focus Fix V5 - Navigation actions chaîne
## Correction principale
La navigation dans le mode actions d'une chaîne est maintenant contrôlée explicitement.
Avant :
- Depuis `Replay`, la touche gauche pouvait envoyer le focus vers `Paramètres`.
- La touche droite depuis `Replay` ne permettait pas d'aller vers `Épingler` et `Favori`.
- La touche bas pouvait sortir brutalement de la zone actions.
Maintenant :
- `OK long` sur une chaîne ouvre le mode actions.
- `Droite` depuis la carte chaîne place le focus sur `Replay`.
- `Droite` depuis `Replay` va vers `Épingler`.
- `Droite` depuis `Épingler` va vers `Favori`.
- `Gauche` revient dans l'ordre inverse.
- `Gauche` depuis `Replay` ferme les actions et remet le focus sur la chaîne.
- `Haut` / `Bas` depuis une action ferment les actions et remettent le focus sur la chaîne.
- Le relâchement du premier OK long reste consommé, pour éviter le clic Replay automatique.
## Fichier modifié
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt`
+28
View File
@@ -0,0 +1,28 @@
# Focus Fix V6 — Navigation actions chaîne
## Objectif
Corriger l'accès aux actions de chaîne depuis la télécommande Android TV.
## Problème V5
Les actions `Replay`, `Épingler` et `Favori` étaient trois éléments focusables séparés. Sur certaines box Android TV, le focus Compose quittait la rangée d'actions au lieu de passer au bouton suivant.
Symptômes constatés :
- depuis `Replay`, flèche droite ne menait pas à `Épingler` ;
- flèche gauche pouvait partir vers `Paramètres` ;
- impossible d'atteindre `Favori`.
## Correction V6
La rangée d'actions est maintenant un seul bloc focusable avec une sélection interne :
- `OK long` sur une chaîne : ouvre le mode actions ;
- `Droite` depuis la carte : entre dans les actions, sur `Replay` ;
- `Droite` dans les actions : Replay → Épingler → Favori ;
- `Gauche` dans les actions : Favori → Épingler → Replay ;
- `Gauche` depuis Replay : ferme les actions et revient à la chaîne ;
- `Haut` / `Bas` : ferme les actions et revient à la chaîne ;
- `OK` : exécute l'action sélectionnée.
Cette méthode évite de laisser Compose choisir le prochain focus entre trois petits boutons. Le focus reste dans le bloc d'actions jusqu'à fermeture volontaire.
## Fichier modifié
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt`
+20
View File
@@ -0,0 +1,20 @@
# Focus Fix V7
Correction ciblée dans `HomeScreen.kt`.
## Problème corrigé
Dans la V6, le parent de la carte chaîne interceptait toujours la touche `DirectionRight` quand le mode actions était ouvert.
Conséquence : même quand le focus était visuellement sur `Replay`, l’événement droite était consommé par la carte parent avant d’arriver au bloc actions. La sélection restait donc bloquée sur Replay.
## Correction
Le parent ne consomme maintenant `DirectionRight` que si le bloc actions n’a pas encore le focus.
Une fois dans les actions :
- Droite : Replay → Épingler → Favori
- Gauche : Favori → Épingler → Replay
- Gauche depuis Replay : retour à la chaîne
- OK : exécute l’action sélectionnée
+16
View File
@@ -0,0 +1,16 @@
# Correctif V8 - Replay qui lançait le Live
## Problème
Quand le focus était sur le bloc d'actions et que l'action sélectionnée était `Replay`, un appui sur OK lançait la chaîne Live au lieu d'ouvrir le guide Replay.
## Cause
Le parent de la carte chaîne interceptait encore les événements OK / Enter en `onPreviewKeyEvent` avant le bloc d'actions. Comme le parent associe OK court à `onPlay()`, le clic sur Replay était transformé en lecture Live.
## Correction
Quand le bloc d'actions a le focus, le parent ne traite plus OK / Enter et laisse le bloc d'actions exécuter l'action sélectionnée :
- Replay => `onReplay()`
- Épingler => `onTogglePin()`
- Favori => `onToggleFav()`
La navigation gauche / droite du bloc d'actions reste inchangée.
+81
View File
@@ -0,0 +1,81 @@
# HERMES focus_fix_v9_menu_home
Base utilisée : `12-IPTV_Android_TV_HERMES_focus_fix_v8.zip`.
## Objectif
Ajouter un écran d'accueil principal avec trois entrées :
- TV
- VOD
- SÉRIES
Le bouton Paramètres est placé dans le coin supérieur droit du nouvel accueil.
## Changements principaux
### Navigation
- Ajout de `NavState.MainMenu`.
- L'application démarre maintenant sur le menu principal après connexion.
- Depuis TV, VOD et SÉRIES, le bouton BACK revient au menu principal.
- Depuis le menu principal, BACK affiche une confirmation de sortie.
### Écran TV
- Les boutons `Movies` et `Series` ont été retirés du header TV.
- La recherche reste accessible depuis l'écran TV.
- Le bouton Paramètres reste disponible aussi dans TV par sécurité, mais le point d'accès principal est maintenant sur le menu d'accueil.
- La logique de focus/actions V8 a été conservée.
### Nouvel écran
Nouveau fichier :
```text
app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/MainMenuScreen.kt
```
Fichiers modifiés :
```text
app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt
app/src/main/java/ma/tkgraph/xtreamtv/ui/navigation/NavState.kt
app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt
```
## Commandes de build
```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
```
## 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
```
## Test conseillé
1. Lancer l'app : vérifier que le menu principal apparaît.
2. OK sur TV : ouvre la liste des chaînes.
3. BACK depuis TV : revient au menu.
4. OK sur VOD : ouvre les films.
5. BACK depuis VOD : revient au menu.
6. OK sur SÉRIES : ouvre les séries.
7. BACK depuis SÉRIES : revient au menu.
8. Dans TV, vérifier que les boutons `Movies` et `Series` ne sont plus présents.
9. Vérifier que la navigation focus V8 sur les chaînes fonctionne toujours : OK long, Droite, Replay, Pin, Favori.
+66
View File
@@ -0,0 +1,66 @@
# Patch HERMES Android TV — Mini lecteur YouTube discret
Ce ZIP contient uniquement les fichiers ajoutés/modifiés pour ajouter un mini lecteur YouTube visible au-dessus du lecteur TV existant.
## Fichiers ajoutés/modifiés
### Version principale du projet
- `12-IPTV Andoid TV HERMES/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt`
- `12-IPTV Andoid TV HERMES/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt`
### Copie miroir incluse dans ton ZIP d'origine
Le ZIP d'origine contenait aussi une copie interne du projet dans `app/app/`. Pour éviter le grand cirque Android Studio, les mêmes fichiers sont aussi fournis ici :
- `12-IPTV Andoid TV HERMES/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/components/YoutubeMiniPlayer.kt`
- `12-IPTV Andoid TV HERMES/app/app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt`
Si ton projet est ouvert depuis le dossier racine `12-IPTV Andoid TV HERMES`, la version importante est `app/src/...`.
Si ton projet est ouvert depuis `12-IPTV Andoid TV HERMES/app`, la version importante peut être `app/app/src/...`.
## Fonctionnement
Dans l'écran de lecture TV :
- `▲` ouvre le mini lecteur YouTube.
- La TV reste affichée mais son volume passe à `0`.
- Colle un lien YouTube dans le champ puis clique sur `Charger`.
- `OK` met YouTube en pause/lecture.
- `◀` recule YouTube de 10 secondes.
- `▶` avance YouTube de 10 secondes.
- `Retour` ferme le mini lecteur YouTube et remet le son TV.
## Types de liens supportés
Support propre avec commandes JavaScript :
- `https://www.youtube.com/watch?v=VIDEO_ID`
- `https://youtu.be/VIDEO_ID`
- `https://www.youtube.com/live/VIDEO_ID`
- `https://www.youtube.com/shorts/VIDEO_ID`
- liens avec playlist `list=...`
Les liens de chaîne type `@chaine/live` ou certaines pages YouTube peuvent s'ouvrir dans le WebView, mais les commandes avance/recul peuvent être limitées. YouTube adore compliquer ce qui devrait être simple, visiblement.
## Notes techniques
- Aucun changement Gradle ajouté.
- Aucun changement Manifest ajouté : la permission `INTERNET` existe déjà.
- Le mini lecteur utilise `WebView` + YouTube IFrame API.
- Le lecteur TV existant reste basé sur ExoPlayer / Media3.
- Le mini lecteur reste visible pour éviter le mode audio YouTube caché, qui peut poser problème avec les règles YouTube.
## Vérification
J'ai tenté une compilation Gradle dans l'environnement disponible, mais le wrapper Gradle voulait télécharger `gradle-9.1.0-bin.zip` et l'environnement n'avait pas accès à `services.gradle.org`.
Commande tentée :
```bash
./gradlew :app:compileDebugKotlin --no-daemon --stacktrace
```
Erreur rencontrée :
```text
java.net.UnknownHostException: services.gradle.org
```
Le patch a donc été préparé avec vérification statique des chemins et du code, mais il doit être compilé/testé dans ton environnement Android Studio ou sur ta machine Windows où Gradle fonctionne déjà. Comme toujours, la compilation Android choisit le moment parfait pour réclamer Internet, parce que la civilisation était trop calme.
+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.
+39
View File
@@ -0,0 +1,39 @@
# Site web iMedia TV+
Site vitrine statique pour vendre l'application iMedia TV+.
## Fichiers
- `index.html`
- `styles.css`
- `script.js`
- `assets/logo.svg`
## À modifier avant publication
1. Remplacer `contact@votre-domaine.com` dans `index.html`.
2. Remplacer les textes `Sur devis` par tes prix.
3. Ajouter ton WhatsApp, ton nom commercial et tes mentions légales.
## Publication
Envoie le dossier sur Netlify, Vercel, GitHub Pages ou un hébergement classique.
## Payhip - Starter
Le bouton Starter pointe vers :
`https://payhip.com/buy?link=REMPLACER_CLE_PAYHIP`
À remplacer par ta vraie clé produit Payhip.
Exemple :
- URL produit Payhip : `https://payhip.com/b/ean3D`
- Clé produit : `ean3D`
- Lien checkout direct : `https://payhip.com/buy?link=ean3D`
Dans `index.html`, cherche :
`REMPLACER_CLE_PAYHIP`
et remplace par ta clé.
Le prix affiché actuellement est `49 €`. Modifie-le dans `index.html` si tu veux un autre prix.
+1
View File
@@ -0,0 +1 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="96" height="96" rx="26" fill="url(#bg)"/><path d="M24 34C24 29.6 27.6 26 32 26H64C68.4 26 72 29.6 72 34V54C72 58.4 68.4 62 64 62H32C27.6 62 24 58.4 24 54V34Z" fill="#111827" stroke="white" stroke-opacity=".18" stroke-width="2"/><path d="M43 38.5C43 37 44.7 36 46 36.8L57 43.1C58.3 43.9 58.3 45.8 57 46.6L46 52.9C44.7 53.7 43 52.7 43 51.2V38.5Z" fill="url(#play)"/><path d="M35 70H61" stroke="white" stroke-opacity=".7" stroke-width="4" stroke-linecap="round"/><path d="M42 62L39 70M54 62L57 70" stroke="white" stroke-opacity=".45" stroke-width="3" stroke-linecap="round"/><defs><linearGradient id="bg" x1="0" y1="0" x2="96" y2="96"><stop stop-color="#FF7A18"/><stop offset=".55" stop-color="#E50914"/><stop offset="1" stop-color="#111827"/></linearGradient><linearGradient id="play" x1="42" y1="36" x2="60" y2="54"><stop stop-color="#FFB347"/><stop offset="1" stop-color="#FF7A18"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1020 B

+189
View File
@@ -0,0 +1,189 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>iMedia TV+ | Application IPTV Android TV</title>
<meta name="description" content="Application IPTV Android TV compatible Xtream et M3U avec LIVE, VOD, Séries, Recherche, Replay, YouTube, Continuer à regarder et tri intelligent.">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="header">
<nav class="nav">
<a class="brand" href="#top"><img src="assets/logo.svg" alt=""> iMedia TV+</a>
<button class="burger" id="burger" aria-label="Menu"><span></span><span></span><span></span></button>
<div class="links" id="links">
<a href="#features">Fonctionnalités</a>
<a href="#experience">Expérience TV</a>
<a href="#packs">Offres</a>
<a href="#faq">FAQ</a>
<a class="cta-small" href="#contact">Démo</a>
</div>
</nav>
</header>
<main id="top">
<section class="hero">
<div class="glow"></div>
<div class="container hero-grid">
<div>
<p class="eyebrow">Android TV • Google TV • Mi Box • Xtream • M3U</p>
<h1>Votre application IPTV professionnelle, rapide et prête à vendre.</h1>
<p class="lead">iMedia TV+ transforme une Android TV en plateforme IPTV complète : chaînes LIVE, VOD, séries, replay, recherche globale, YouTube, historique intelligent et navigation télécommande.</p>
<div class="actions">
<a class="btn primary" href="#contact">Demander une démo</a>
<a class="btn secondary" href="#features">Voir les fonctionnalités</a>
</div>
<div class="badges">
<span>Xtream Codes</span><span>M3U / M3U Plus</span><span>100% télécommande</span>
</div>
</div>
<div class="screen-card">
<div class="screen">
<div class="screen-bar"><span></span><span></span><span></span><b>iMedia TV+</b></div>
<div class="screen-body">
<div class="tiles">
<div class="tile active">TV<small>Chaînes en direct</small></div>
<div class="tile">VOD<small>Films</small></div>
<div class="tile">SÉRIES<small>Épisodes</small></div>
<div class="tile">SRC<small>Recherche</small></div>
<div class="tile">YT<small>YouTube</small></div>
</div>
<h4>Dernières chaînes LIVE</h4>
<div class="mini-row"><div>SPORT HD</div><div>NEWS 24</div><div>MOVIES</div></div>
<h4>Continuer à regarder</h4>
<div class="continue"><i></i><div><b>Série en cours</b><small>Épisode 04 • reprise instantanée</small><em><span></span></em></div></div>
</div>
</div>
<p>Interface pensée pour grands écrans, focus visible et télécommande.</p>
</div>
</div>
</section>
<section class="strip">
<div class="container strip-grid"><span>Android TV</span><span>Google TV</span><span>Mi Box</span><span>Xtream API</span><span>M3U</span></div>
</section>
<section id="features" class="section">
<div class="container">
<div class="section-title">
<p class="eyebrow">Fonctionnalités</p>
<h2>Tout ce qu’un lecteur IPTV moderne doit offrir.</h2>
<p>Une application complète, claire et rapide, au lieu d’une interface bricolée qui donne envie de jeter la télécommande par la fenêtre.</p>
</div>
<div class="grid features">
<article><strong>TV</strong><h3>LIVE rapide</h3><p>Chargement optimisé des chaînes, lecture plein écran, zapping et catégories.</p></article>
<article><strong>VOD</strong><h3>Films à la demande</h3><p>Catalogue VOD, affiches, lecture et reprise intelligente.</p></article>
<article><strong>SER</strong><h3>Séries & épisodes</h3><p>Gestion des saisons, épisodes et progression de visionnage.</p></article>
<article><strong>SRC</strong><h3>Recherche globale</h3><p>Recherche LIVE, VOD et séries sans bloquer l’interface.</p></article>
<article><strong>↺</strong><h3>Continuer à regarder</h3><p>Reprise des films et épisodes commencés avec barre de progression.</p></article>
<article><strong>★</strong><h3>Tri par usage</h3><p>Les catégories, chaînes et contenus les plus utilisés remontent automatiquement.</p></article>
<article><strong>⏱</strong><h3>Replay / timeshift</h3><p>Support du replay quand le fournisseur Xtream le permet.</p></article>
<article><strong>YT</strong><h3>YouTube intégré</h3><p>Ajout de vidéos YouTube par URL, miniatures et lecteur intégré.</p></article>
<article><strong>🔒</strong><h3>Stockage sécurisé</h3><p>Identifiants stockés localement avec chiffrement Android.</p></article>
</div>
</div>
</section>
<section id="experience" class="section dark">
<div class="container split">
<div>
<p class="eyebrow">Expérience TV</p>
<h2>Conçue pour la télécommande, pas pour martyriser l’utilisateur.</h2>
<p>iMedia TV+ propose une navigation claire : menu principal, dernières chaînes LIVE, continuer à regarder, scroll vertical, focus visible et saisie adaptée aux Mi Box.</p>
<ul class="checks">
<li>Menu TV, VOD, Séries, Recherche et YouTube.</li>
<li>Dernières chaînes LIVE au-dessus de l’historique.</li>
<li>Reprise automatique des VOD et épisodes.</li>
<li>Saisie via fenêtre native compatible Android TV.</li>
<li>Navigation DPAD / télécommande optimisée.</li>
</ul>
</div>
<div class="stats">
<div><b>2 sec</b><span>Chargement LIVE constaté sur base optimisée</span></div>
<div><b>3</b><span>Sources dans la recherche : LIVE, VOD, séries</span></div>
<div><b>100%</b><span>Interface pensée Android TV</span></div>
</div>
</div>
</section>
<section class="section">
<div class="container split">
<div class="card">
<h2>Compatible Xtream & M3U</h2>
<p>Connexion par base serveur Xtream, identifiant et mot de passe, ou par playlist M3U distante.</p>
<code>http://serveur.com:8080</code>
<code>https://exemple.com/playlist.m3u</code>
</div>
<div class="card highlight">
<h2>Version white label</h2>
<p>Nom, logo, couleurs, écran d’accueil et options peuvent être adaptés pour revendeurs, hôtels, résidences, intégrateurs ou projets média.</p>
<a class="btn primary" href="#contact">Créer ma version</a>
</div>
</div>
</section>
<section id="packs" class="section">
<div class="container">
<div class="section-title">
<p class="eyebrow">Offres</p>
<h2>Des formules simples à vendre.</h2>
<p>Remplace les prix par tes vrais tarifs. Oui, il faut choisir un prix, cet antique rituel commercial.</p>
</div>
<div class="grid pricing">
<article>
<h3>Starter</h3>
<b>49 €</b>
<p>Application standard, Xtream/M3U, APK prêt à installer. Paiement sécurisé via Payhip.</p>
<!-- Remplace REMPLACER_CLE_PAYHIP par la clé produit Payhip.
Exemple : si ton produit est https://payhip.com/b/ean3D,
le lien direct checkout est https://payhip.com/buy?link=ean3D -->
<a class="btn primary" href="https://payhip.com/buy?link=REMPLACER_CLE_PAYHIP" target="_blank" rel="noopener">Acheter Starter</a>
</article>
<article class="popular"><small>Populaire</small><h3>White Label</h3><b>Sur devis</b><p>Nom, logo, couleurs, personnalisation et aide installation Mi Box.</p><a class="btn primary" href="#contact">Démo</a></article>
<article><h3>Sur mesure</h3><b>Projet</b><p>Fonctionnalités spécifiques, API privée, portail client, maintenance.</p><a class="btn secondary" href="#contact">Discuter</a></article>
</div>
</div>
</section>
<section id="faq" class="section dark">
<div class="container narrow">
<div class="section-title"><p class="eyebrow">FAQ</p><h2>Questions fréquentes</h2></div>
<details><summary>L’application fournit-elle des chaînes ?</summary><p>Non. iMedia TV+ est un lecteur. Le client utilise ses propres accès ou playlists autorisés.</p></details>
<details><summary>Est-ce compatible Mi Box ?</summary><p>Oui. L’interface a été conçue et testée pour Android TV, Google TV et Mi Box.</p></details>
<details><summary>Peut-on personnaliser l’application ?</summary><p>Oui : nom, logo, couleurs, écran d’accueil et options selon le projet.</p></details>
<details><summary>Peut-on ajouter des fonctions ?</summary><p>Oui : profils, panneau admin, paiement, EPG avancé, portail client, etc.</p></details>
<details><summary>Comment se passe l’achat Starter ?</summary><p>Le bouton Starter envoie directement vers le checkout Payhip. Après paiement, Payhip livre le fichier numérique que tu as attaché au produit : APK, ZIP, guide d’installation ou pack complet.</p></details>
</div>
</section>
<section id="contact" class="section contact">
<div class="container split">
<div>
<p class="eyebrow">Contact</p>
<h2>Demandez une démo ou une version personnalisée.</h2>
<p>Remplace l’email du formulaire par ton vrai email, ajoute ton WhatsApp, et le site est prêt à publier.</p>
</div>
<form action="mailto:contact@votre-domaine.com" method="post" enctype="text/plain">
<label>Nom<input name="Nom" placeholder="Votre nom"></label>
<label>Email<input name="Email" type="email" placeholder="email@exemple.com"></label>
<label>Projet<select name="Projet"><option>Application standard</option><option>White label</option><option>Sur mesure</option></select></label>
<label>Message<textarea name="Message" rows="5" placeholder="Décrivez votre besoin..."></textarea></label>
<button class="btn primary" type="submit">Envoyer la demande</button>
</form>
</div>
</section>
</main>
<footer>
<div class="container footer-grid">
<div><a class="brand" href="#top"><img src="assets/logo.svg" alt=""> iMedia TV+</a><p>Application Android TV compatible Xtream et M3U.</p></div>
<div><b>Navigation</b><a href="#features">Fonctionnalités</a><a href="#packs">Offres</a><a href="#contact">Contact</a></div>
<div><b>Note légale</b><p>iMedia TV+ ne fournit aucun contenu, chaîne, film ou abonnement. C’est un lecteur pour sources autorisées.</p></div>
</div>
<p class="copy">© <span id="year"></span> iMedia TV+. Tous droits réservés.</p>
</footer>
<script src="script.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
const burger=document.getElementById('burger');const links=document.getElementById('links');if(burger&&links){burger.addEventListener('click',()=>links.classList.toggle('open'));links.querySelectorAll('a').forEach(a=>a.addEventListener('click',()=>links.classList.remove('open')))}const y=document.getElementById('year');if(y)y.textContent=new Date().getFullYear();
File diff suppressed because one or more lines are too long
+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)
}
}
```
+1471
View File
File diff suppressed because one or more lines are too long
+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()
}
}

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