Initial upload: iptv-android-tv-hermes
This commit is contained in:
+19
@@ -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
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
+37
@@ -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.** { *; }
|
||||
@@ -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("&", "&")
|
||||
.replace("\"", """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
|
||||
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
Reference in New Issue
Block a user