Initial upload: iptv-android-tv-hermes
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.TextInputAction
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.SourceType
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.ui.theme.DesignColors
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.LoginViewModel
|
||||
|
||||
/**
|
||||
* Écran de connexion / configuration.
|
||||
* Permet de choisir entre serveur Xtream et fichier M3U.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(viewModel: LoginViewModel = viewModel()) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val serverUrl = viewModel.serverUrl
|
||||
val username = viewModel.username
|
||||
val password = viewModel.password
|
||||
val m3uUrl = viewModel.m3uUrl
|
||||
val sourceType = viewModel.sourceType
|
||||
var showModeMenu by remember { mutableStateOf(false) }
|
||||
val serverFocusRequester = remember { FocusRequester() }
|
||||
val usernameFocusRequester = remember { FocusRequester() }
|
||||
val passwordFocusRequester = remember { FocusRequester() }
|
||||
val m3uFocusRequester = remember { FocusRequester() }
|
||||
val submitFocusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(sourceType) {
|
||||
runCatching {
|
||||
if (sourceType == SourceType.XTREAM) serverFocusRequester.requestFocus()
|
||||
else m3uFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier si des credentials sont déjà stockés
|
||||
val savedConfig = remember {
|
||||
XtreamApplication.instance.appContainer.secureStorage.getConfig()
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = DesignColors.bg1
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(DesignColors.bg1, DesignColors.bg2, DesignColors.bg3)
|
||||
)
|
||||
)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Spacer(Modifier.height(32.dp))
|
||||
|
||||
// Titre avec gradient orange
|
||||
Text(
|
||||
text = "iMedia TV+",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(DesignColors.orange, DesignColors.orange2)
|
||||
)
|
||||
),
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = (-0.055).sp
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Configuration",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF888888)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// ─── Sélecteur de mode ───
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color(0xFF1A1A1A),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("Source", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ModeButton(
|
||||
label = "Xtream",
|
||||
isSelected = sourceType == SourceType.XTREAM,
|
||||
onClick = { viewModel.switchSourceType(SourceType.XTREAM) }
|
||||
)
|
||||
ModeButton(
|
||||
label = "M3U",
|
||||
isSelected = sourceType == SourceType.M3U,
|
||||
onClick = { viewModel.switchSourceType(SourceType.M3U) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// ─── Champs selon le mode ───
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color(0xFF1A1A1A),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
if (sourceType == SourceType.XTREAM) {
|
||||
// ── Mode Xtream ──
|
||||
Text("Serveur Xtream", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { viewModel.updateServerUrl(it) },
|
||||
label = { Text("URL du serveur") },
|
||||
placeholder = { Text("http://serveur:8080") },
|
||||
singleLine = true,
|
||||
enabled = uiState !is UiState.Loading,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { usernameFocusRequester.requestFocus() }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(serverFocusRequester)
|
||||
.onFocusChanged { if (it.isFocused) keyboardController?.show() }
|
||||
.moveFocusOnDpad(
|
||||
down = { usernameFocusRequester.requestFocus() }
|
||||
),
|
||||
colors = fieldColors()
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { viewModel.updateUsername(it) },
|
||||
label = { Text("Identifiant") },
|
||||
singleLine = true,
|
||||
enabled = uiState !is UiState.Loading,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { passwordFocusRequester.requestFocus() }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(usernameFocusRequester)
|
||||
.onFocusChanged { if (it.isFocused) keyboardController?.show() }
|
||||
.moveFocusOnDpad(
|
||||
up = { serverFocusRequester.requestFocus() },
|
||||
down = { passwordFocusRequester.requestFocus() }
|
||||
),
|
||||
colors = fieldColors()
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { viewModel.updatePassword(it) },
|
||||
label = { Text("Mot de passe") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
enabled = uiState !is UiState.Loading,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { keyboardController?.hide(); viewModel.login() }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(passwordFocusRequester)
|
||||
.onFocusChanged { if (it.isFocused) keyboardController?.show() }
|
||||
.moveFocusOnDpad(
|
||||
up = { usernameFocusRequester.requestFocus() },
|
||||
down = { submitFocusRequester.requestFocus() }
|
||||
),
|
||||
colors = fieldColors()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.login() },
|
||||
enabled = uiState !is UiState.Loading
|
||||
&& serverUrl.isNotBlank()
|
||||
&& username.isNotBlank()
|
||||
&& password.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(48.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.focusRequester(submitFocusRequester)
|
||||
.moveFocusOnDpad(
|
||||
up = { passwordFocusRequester.requestFocus() }
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE50914))
|
||||
) {
|
||||
if (uiState is UiState.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text("Connexion", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ── Mode M3U ──
|
||||
Text("Playlist M3U", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = m3uUrl,
|
||||
onValueChange = { viewModel.updateM3uUrl(it) },
|
||||
label = { Text("URL du fichier M3U") },
|
||||
placeholder = { Text("https://exemple.com/playlist.m3u") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { keyboardController?.hide(); viewModel.saveM3uConfig() }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(m3uFocusRequester)
|
||||
.onFocusChanged { if (it.isFocused) keyboardController?.show() }
|
||||
.moveFocusOnDpad(
|
||||
down = { submitFocusRequester.requestFocus() }
|
||||
),
|
||||
colors = fieldColors()
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Entrez l'URL d'un fichier M3U distant",
|
||||
color = Color(0xFF666666),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.saveM3uConfig() },
|
||||
enabled = m3uUrl.isNotBlank(),
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(48.dp)
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.focusRequester(submitFocusRequester)
|
||||
.moveFocusOnDpad(
|
||||
up = { m3uFocusRequester.requestFocus() }
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50))
|
||||
) {
|
||||
Text("Appliquer", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message d'erreur
|
||||
if (uiState is UiState.Error) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(shape = RoundedCornerShape(10.dp), color = Color(0xFF4A1C1C)) {
|
||||
Text(
|
||||
text = (uiState as UiState.Error).message,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = Color(0xFFFF5252)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Si déjà connecté, afficher les infos
|
||||
if (savedConfig != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = Color(0xFF1A3A1A),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)
|
||||
) {
|
||||
Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Session active", color = Color(0xFF4CAF50), fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = { viewModel.logout() }) {
|
||||
Text("Déconnecter", color = Color(0xFFFF5252), fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.ModeButton(label: String, isSelected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (isSelected) Color(0xFFE50914) else Color(0xFF333333),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
color = if (isSelected) Color.White else Color(0xFFAAAAAA),
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.moveFocusOnDpad(
|
||||
up: (() -> Unit)? = null,
|
||||
down: (() -> Unit)? = null
|
||||
): Modifier = onPreviewKeyEvent { event ->
|
||||
if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false
|
||||
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> {
|
||||
up?.invoke()
|
||||
up != null
|
||||
}
|
||||
Key.DirectionDown -> {
|
||||
down?.invoke()
|
||||
down != null
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = Color(0xFFE50914),
|
||||
unfocusedBorderColor = Color(0xFF333333),
|
||||
focusedLabelColor = Color(0xFFE50914),
|
||||
unfocusedLabelColor = Color(0xFF888888),
|
||||
cursorColor = Color.White,
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.PlayerViewModel
|
||||
import ma.tkgraph.xtreamtv.ui.components.YoutubeMiniPlayer
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
|
||||
@Composable
|
||||
fun PlayerScreen(
|
||||
initialStream: LiveStreamDto,
|
||||
allStreams: List<LiveStreamDto>,
|
||||
initialIndex: Int,
|
||||
replayUrl: String? = null,
|
||||
viewModel: PlayerViewModel = viewModel(),
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var player by remember { mutableStateOf<ExoPlayer?>(null) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var youtubeVisible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(initialStream) {
|
||||
viewModel.initialize(initialStream, allStreams, initialIndex)
|
||||
}
|
||||
|
||||
// Use replay URL if provided, otherwise build live URL
|
||||
val streamUrl = remember(state.stream, replayUrl, state.restartTrigger) {
|
||||
replayUrl ?: viewModel.buildUrl(state.stream)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { player?.release() }
|
||||
}
|
||||
|
||||
// Auto-hide overlay after 3 seconds
|
||||
LaunchedEffect(state.showOverlay) {
|
||||
if (state.showOverlay) {
|
||||
delay(3000)
|
||||
viewModel.hideOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
// Update player when channel changes, with resolution listener
|
||||
LaunchedEffect(streamUrl) {
|
||||
if (streamUrl.isNotEmpty()) {
|
||||
val oldPlayer = player
|
||||
if (oldPlayer != null) {
|
||||
oldPlayer.stop()
|
||||
oldPlayer.release()
|
||||
}
|
||||
|
||||
val newPlayer = ExoPlayer.Builder(context).build().apply {
|
||||
volume = if (youtubeVisible) 0f else 1f
|
||||
setMediaItem(MediaItem.fromUri(streamUrl))
|
||||
addListener(object : Player.Listener {
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (videoSize.width > 0 && videoSize.height > 0) {
|
||||
val res = "${videoSize.width}x${videoSize.height}"
|
||||
viewModel.updateResolution(res)
|
||||
}
|
||||
}
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
viewModel.resetFreezeCounter()
|
||||
}
|
||||
}
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) {
|
||||
viewModel.resetFreezeCounter()
|
||||
}
|
||||
}
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
// Si le replay échoue, essayer le live en fallback
|
||||
if (replayUrl != null) {
|
||||
player?.stop()
|
||||
player?.setMediaItem(MediaItem.fromUri(viewModel.buildUrl(state.stream)))
|
||||
player?.prepare()
|
||||
player?.play()
|
||||
}
|
||||
}
|
||||
})
|
||||
prepare()
|
||||
play()
|
||||
}
|
||||
player = newPlayer
|
||||
|
||||
// Freeze detection : attend 15s de grâce au début,
|
||||
// puis ne redémarre qu'après 10s de buffering continu
|
||||
val startTime = System.currentTimeMillis()
|
||||
while (isActive && viewModel.canRestart()) {
|
||||
delay(5000)
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
if (elapsed < 15_000) continue // grâce de 15s au démarrage
|
||||
val p = player
|
||||
if (p != null && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) {
|
||||
delay(5000)
|
||||
if (isActive && player == p && p.playbackState == Player.STATE_BUFFERING && !p.isPlaying) {
|
||||
viewModel.requestRestart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player, youtubeVisible) {
|
||||
player?.volume = if (youtubeVisible) 0f else 1f
|
||||
}
|
||||
|
||||
// Request focus for remote-control key events
|
||||
LaunchedEffect(Unit) { runCatching { focusRequester.requestFocus() } }
|
||||
|
||||
val isLoadingStream by remember { derivedStateOf { streamUrl.isEmpty() } }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyUp) {
|
||||
if (youtubeVisible) {
|
||||
// IMPORTANT : quand le mini lecteur YouTube est ouvert,
|
||||
// le parent ne consomme plus aucune touche.
|
||||
// Le mini lecteur gère lui-même OK, Retour, Droite/Bas depuis le champ texte.
|
||||
// Cela évite les différences de focus entre modèles de Mi Box.
|
||||
false
|
||||
} else {
|
||||
when (event.key) {
|
||||
Key.DirectionUp -> { youtubeVisible = true; viewModel.hideOverlay(); true }
|
||||
Key.DirectionLeft -> { viewModel.zappingLeft(); true }
|
||||
Key.DirectionRight -> { viewModel.zappingRight(); true }
|
||||
Key.DirectionCenter, Key.Enter -> { viewModel.toggleOverlay(); true }
|
||||
Key.Back -> {
|
||||
if (state.showOverlay) {
|
||||
player?.stop()
|
||||
player?.release()
|
||||
player = null
|
||||
onBack()
|
||||
} else {
|
||||
viewModel.toggleOverlay()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
} else false
|
||||
}
|
||||
) {
|
||||
// Video player area
|
||||
if (!isLoadingStream) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PlayerView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
useController = false
|
||||
keepScreenOn = true
|
||||
}
|
||||
},
|
||||
update = { view -> view.player = player },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(state.stream.name, color = Color.White, style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Chargement du flux…", color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay : logo + nom + résolution + programme en cours/suivant
|
||||
AnimatedVisibility(
|
||||
visible = state.showOverlay,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.align(Alignment.TopStart).fillMaxWidth()
|
||||
) {
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.7f),
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp)
|
||||
) {
|
||||
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.Top) {
|
||||
// Logo
|
||||
if (state.stream.streamIcon != null) {
|
||||
coil3.compose.AsyncImage(
|
||||
model = state.stream.streamIcon,
|
||||
contentDescription = state.stream.name,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Nom de la chaîne
|
||||
Text(
|
||||
state.stream.name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
// Résolution
|
||||
if (state.resolution.isNotEmpty()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(state.resolution, color = Color(0xFFBBBBBB), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
||||
// Programme en cours
|
||||
if (state.currentProgram.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("En cours", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall)
|
||||
Text(state.currentProgram, color = Color.White, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
// Programme suivant
|
||||
if (state.nextProgram.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Suivant", color = Color(0xFF888888), style = MaterialTheme.typography.labelSmall)
|
||||
Text(state.nextProgram, color = Color(0xFFCCCCCC), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (youtubeVisible) {
|
||||
YoutubeMiniPlayer(
|
||||
onClose = { youtubeVisible = false },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Bottom controls hint
|
||||
if (state.showOverlay && allStreams.size > 1) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Text("▲ YouTube discret", color = Color.LightGray)
|
||||
Text("◀ Chaîne précédente", color = Color.LightGray)
|
||||
Text("Retour : menu", color = Color.LightGray)
|
||||
Text("Chaîne suivante ▶", color = Color.LightGray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.GuideViewModel
|
||||
import ma.tkgraph.xtreamtv.ui.navigation.NavState
|
||||
|
||||
@Composable
|
||||
fun ProgramGuideScreen(
|
||||
viewModel: GuideViewModel = viewModel(),
|
||||
onNavigate: (NavState) -> Unit = {},
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val streamsState by viewModel.streams.collectAsStateWithLifecycle()
|
||||
val epgState by viewModel.epg.collectAsStateWithLifecycle()
|
||||
val selectedStreamId by viewModel.selectedStreamId.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 8.dp
|
||||
)
|
||||
) { Text("\u25C0 Back") }
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text("TV Guide", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
|
||||
when (val strs = streamsState) {
|
||||
is UiState.Success -> {
|
||||
val streamList = strs.data
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
items(streamList) { stream ->
|
||||
Surface(
|
||||
onClick = { viewModel.selectStream(stream.streamId) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
cornerRadius = 10.dp
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = if (stream.streamId == selectedStreamId) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(stream.name, style = MaterialTheme.typography.bodyLarge)
|
||||
if (stream.streamId == selectedStreamId) {
|
||||
when (val epg = epgState) {
|
||||
is UiState.Success -> {
|
||||
epg.data.take(5).forEach { program ->
|
||||
Text(" ${program.start?.take(5) ?: ""} - ${program.titleText()}", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
is UiState.Loading -> Text("Loading EPG...", style = MaterialTheme.typography.bodySmall)
|
||||
else -> {}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SuggestionChip(
|
||||
onClick = { onNavigate(NavState.Player(stream, listOf(stream), 0)) },
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 16.dp
|
||||
),
|
||||
label = { Text("Live") }
|
||||
)
|
||||
SuggestionChip(
|
||||
onClick = { onNavigate(NavState.ReplayGuide(stream)) },
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 16.dp
|
||||
),
|
||||
label = { Text("Replay") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.ReplayViewModel
|
||||
|
||||
@Composable
|
||||
fun ReplayGuideScreen(
|
||||
stream: LiveStreamDto,
|
||||
viewModel: ReplayViewModel = viewModel(),
|
||||
onBack: () -> Unit = {},
|
||||
onPlayReplay: (String) -> Unit = {}
|
||||
) {
|
||||
val epgState by viewModel.epg.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(stream) { viewModel.loadReplay(stream) }
|
||||
BackHandler { onBack() }
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 8.dp
|
||||
)
|
||||
) { Text("\u25C0 Back") }
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Text("Replay - ${stream.name}", style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
|
||||
when (val epg = epgState) {
|
||||
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(epg.message) }
|
||||
is UiState.Success -> {
|
||||
val grouped = epg.data.groupBy { it.start?.take(10) ?: "" }
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
grouped.forEach { (day, programs) ->
|
||||
item {
|
||||
Text(day, style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
items(programs) { program ->
|
||||
Surface(
|
||||
onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
cornerRadius = 10.dp
|
||||
),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(program.titleText(), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("${program.start?.take(16) ?: ""} - ${program.end?.take(5) ?: ""}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
SuggestionChip(
|
||||
onClick = { viewModel.buildReplayUrl(program)?.let { onPlayReplay(it) } },
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 16.dp
|
||||
),
|
||||
label = { Text("Revoir") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.EpisodeDto
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.SeriesViewModel
|
||||
import ma.tkgraph.xtreamtv.ui.navigation.NavState
|
||||
|
||||
@Composable
|
||||
fun SeriesScreen(
|
||||
viewModel: SeriesViewModel = viewModel(),
|
||||
onNavigate: (NavState) -> Unit = {},
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val categoriesState by viewModel.categories.collectAsStateWithLifecycle()
|
||||
val seriesState by viewModel.series.collectAsStateWithLifecycle()
|
||||
val seriesInfoState by viewModel.seriesInfo.collectAsStateWithLifecycle()
|
||||
val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle()
|
||||
val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle()
|
||||
var selectedSeries by remember { mutableStateOf<SeriesDto?>(null) }
|
||||
|
||||
BackHandler { if (selectedSeries != null) selectedSeries = null else onBack() }
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (selectedSeries != null) selectedSeries = null
|
||||
else onBack()
|
||||
},
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = Color(0xFF4CAF50),
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 8.dp
|
||||
)
|
||||
) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (selectedSeries != null) selectedSeries!!.name else "Series",
|
||||
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
|
||||
color = if (selectedSeries != null) Color.White else Color(0xFF4CAF50))
|
||||
}
|
||||
|
||||
// Categories (hidden in detail view)
|
||||
if (selectedSeries == null) {
|
||||
val catData = categoriesState
|
||||
if (catData is UiState.Success) {
|
||||
val catList = catData.data
|
||||
LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item { Chip("All", selectedCatId == null, Color(0xFF4CAF50)) { viewModel.selectCategory(null) } }
|
||||
item { Chip("\u2605 Favorites", selectedCatId == -1, Color(0xFF4CAF50)) { viewModel.selectCategory(-1) } }
|
||||
catList.forEach { cat ->
|
||||
item(key = cat.categoryId) {
|
||||
Chip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1, Color(0xFF4CAF50)) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detail view
|
||||
val detailSeries = selectedSeries
|
||||
if (detailSeries != null) {
|
||||
val info = seriesInfoState
|
||||
when {
|
||||
info is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
|
||||
info is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(info.message, color = MaterialTheme.colorScheme.error) }
|
||||
info is UiState.Success -> {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
info.data.seasons.forEach { season ->
|
||||
item { Text("Season ${season.seasonNumber}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(vertical = 8.dp), color = Color(0xFF4CAF50)) }
|
||||
val episodes = info.data.episodes[season.seasonNumber.toString()] ?: emptyList()
|
||||
items(episodes, key = { it.id }) { ep ->
|
||||
Surface(onClick = {
|
||||
val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials()
|
||||
if (config != null) {
|
||||
val url = ma.tkgraph.xtreamtv.util.UrlBuilder(config).seriesUrl(ep.id, ep.containerExtension)
|
||||
onNavigate(NavState.Player(
|
||||
stream = ma.tkgraph.xtreamtv.data.model.LiveStreamDto(
|
||||
name = "${detailSeries.name} - Ep ${ep.episodeNum}",
|
||||
streamId = ep.id,
|
||||
containerExtension = ep.containerExtension
|
||||
),
|
||||
allStreams = emptyList(),
|
||||
currentIndex = 0,
|
||||
replayUrl = url,
|
||||
returnState = NavState.Series()
|
||||
))
|
||||
}
|
||||
}, modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 3.dp)
|
||||
.tvFocusHighlight(
|
||||
focusedBorderColor = Color(0xFF4CAF50),
|
||||
cornerRadius = 10.dp
|
||||
), shape = RoundedCornerShape(10.dp), color = Color(0xFF1A1A1A)) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Ep ${ep.episodeNum}: ${ep.title ?: ep.name ?: ep.episodeName ?: ""}", style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White, modifier = Modifier.weight(1f))
|
||||
Text("\u25B6", color = Color(0xFF4CAF50))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> { Box(Modifier.weight(1f)) } // Idle state
|
||||
}
|
||||
return@Surface
|
||||
}
|
||||
|
||||
// Grid
|
||||
val s = seriesState
|
||||
when {
|
||||
s is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
|
||||
s is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(s.message, color = MaterialTheme.colorScheme.error) }
|
||||
s is UiState.Success -> {
|
||||
val seriesList = if (selectedCatId == -1) s.data.filter { it.seriesId in favoriteIds } else s.data
|
||||
LazyVerticalGrid(columns = GridCells.Adaptive(160.dp), modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), contentPadding = PaddingValues(bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
items(seriesList, key = { it.seriesId }) { series ->
|
||||
Surface(onClick = { selectedSeries = series; viewModel.loadSeriesInfo(series.seriesId) }, modifier = Modifier.fillMaxWidth().tvFocusHighlight(focusedBorderColor = Color(0xFF4CAF50), cornerRadius = 12.dp), shape = RoundedCornerShape(12.dp), color = Color(0xFF1A1A1A), tonalElevation = 2.dp) {
|
||||
var favFocused by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Column {
|
||||
Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) {
|
||||
if (series.cover != null) coil3.compose.AsyncImage(model = series.cover, contentDescription = series.name, modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)))
|
||||
else Text(series.name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFF4CAF50), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(series.name, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleFavorite(series.seriesId) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(32.dp)
|
||||
.onFocusChanged { favFocused = it.isFocused }
|
||||
.border(
|
||||
width = if (favFocused) 2.dp else 0.dp,
|
||||
color = if (favFocused) Color(0xFFFFD700) else Color.Transparent,
|
||||
shape = RoundedCornerShape(50)
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (series.seriesId in favoriteIds) Color(0xFFFFD700) else Color(0xFF555555))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> { Box(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Chip(label: String, isSelected: Boolean, accentColor: Color, onClick: () -> Unit) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val bgColor = when {
|
||||
isSelected -> accentColor
|
||||
isFocused -> Color(0xFF1A3A1A)
|
||||
else -> Color(0xFF252525)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.border(
|
||||
width = 3.dp,
|
||||
color = when {
|
||||
isFocused -> accentColor
|
||||
else -> Color.Transparent
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = bgColor
|
||||
) {
|
||||
Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package ma.tkgraph.xtreamtv.ui.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.ui.components.tvFocusHighlight
|
||||
import ma.tkgraph.xtreamtv.ui.viewmodels.VodViewModel
|
||||
import ma.tkgraph.xtreamtv.ui.navigation.NavState
|
||||
|
||||
@Composable
|
||||
fun VodScreen(
|
||||
viewModel: VodViewModel = viewModel(),
|
||||
onNavigate: (NavState) -> Unit = {},
|
||||
onBack: () -> Unit = {}
|
||||
) {
|
||||
val categoriesState by viewModel.categories.collectAsStateWithLifecycle()
|
||||
val moviesState by viewModel.movies.collectAsStateWithLifecycle()
|
||||
val selectedCatId by viewModel.selectedCategoryId.collectAsStateWithLifecycle()
|
||||
val favoriteIds by viewModel.favoriteIds.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler { onBack() }
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = Color(0xFF0A0A0A)) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.tvFocusHighlight(
|
||||
focusedBorderColor = Color(0xFFE50914),
|
||||
borderWidth = 2.dp,
|
||||
cornerRadius = 8.dp
|
||||
)
|
||||
) { Text("\u25C0 Back", color = Color(0xFFCCCCCC)) }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Movies", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color(0xFFE50914))
|
||||
}
|
||||
|
||||
// Categories with ★ Favorites
|
||||
val catsState = categoriesState
|
||||
when (catsState) {
|
||||
is UiState.Success -> {
|
||||
val catList = catsState.data
|
||||
LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
item { CatChip("All", selectedCatId == null) { viewModel.selectCategory(null) } }
|
||||
item { CatChip("\u2605 Favorites", selectedCatId == -1) { viewModel.selectCategory(-1) } }
|
||||
catList.forEach { cat ->
|
||||
item(key = cat.categoryId) {
|
||||
CatChip(cat.categoryName, cat.categoryId.toIntOrNull() == selectedCatId && selectedCatId != -1) { viewModel.selectCategory(cat.categoryId.toIntOrNull()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
// Movies grid with posters
|
||||
when (val movies = moviesState) {
|
||||
is UiState.Loading -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text("Loading…", color = Color.Gray) }
|
||||
is UiState.Error -> Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text(movies.message, color = MaterialTheme.colorScheme.error) }
|
||||
is UiState.Success -> {
|
||||
val movieList = if (selectedCatId == -1) movies.data.filter { it.streamId in favoriteIds } else movies.data
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(160.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
items(movieList, key = { it.streamId }) { movie ->
|
||||
MovieCard(
|
||||
name = movie.name,
|
||||
posterUrl = movie.streamIcon,
|
||||
rating = movie.rating,
|
||||
isFavorite = movie.streamId in favoriteIds,
|
||||
onToggleFav = { viewModel.toggleFavorite(movie.streamId) },
|
||||
onPlay = {
|
||||
val config = ma.tkgraph.xtreamtv.XtreamApplication.instance.appContainer.secureStorage.getCredentials()
|
||||
if (config != null) {
|
||||
val url = ma.tkgraph.xtreamtv.util.UrlBuilder(config).vodUrl(movie.streamId, movie.containerExtension)
|
||||
onNavigate(NavState.Player(
|
||||
stream = ma.tkgraph.xtreamtv.data.model.LiveStreamDto(
|
||||
name = movie.name,
|
||||
streamId = movie.streamId,
|
||||
streamIcon = movie.streamIcon,
|
||||
containerExtension = movie.containerExtension
|
||||
),
|
||||
allStreams = emptyList(),
|
||||
currentIndex = 0,
|
||||
replayUrl = url,
|
||||
returnState = NavState.Vod()
|
||||
))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CatChip(label: String, isSelected: Boolean, onClick: () -> Unit) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val bgColor = when {
|
||||
isSelected -> Color(0xFFE50914)
|
||||
isFocused -> Color(0xFF3A1A1A)
|
||||
else -> Color(0xFF252525)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.border(
|
||||
width = 3.dp,
|
||||
color = when {
|
||||
isFocused -> Color(0xFFE50914)
|
||||
else -> Color.Transparent
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = bgColor
|
||||
) {
|
||||
Text(label, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), color = if (isSelected) Color.White else Color(0xFFCCCCCC), fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MovieCard(name: String, posterUrl: String?, rating: String?, isFavorite: Boolean, onToggleFav: () -> Unit, onPlay: () -> Unit) {
|
||||
var favFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
onClick = onPlay,
|
||||
modifier = Modifier.fillMaxWidth().tvFocusHighlight(focusedBorderColor = Color(0xFFE50914), cornerRadius = 12.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color(0xFF1A1A1A),
|
||||
tonalElevation = 2.dp
|
||||
) {
|
||||
Box {
|
||||
Column {
|
||||
// Poster
|
||||
Box(modifier = Modifier.fillMaxWidth().height(230.dp), contentAlignment = Alignment.Center) {
|
||||
if (posterUrl != null) {
|
||||
coil3.compose.AsyncImage(
|
||||
model = posterUrl,
|
||||
contentDescription = name,
|
||||
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
|
||||
)
|
||||
} else {
|
||||
Text(name.take(2).uppercase(), style = MaterialTheme.typography.displaySmall, color = Color(0xFFE50914), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
// Info bar
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Color.White)
|
||||
if (rating != null) Text(rating, style = MaterialTheme.typography.labelSmall, color = Color(0xFFAAAAAA))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Favorite icon in top-right corner (doesn't steal card focus)
|
||||
IconButton(
|
||||
onClick = onToggleFav,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp)
|
||||
.size(32.dp)
|
||||
.onFocusChanged { favFocused = it.isFocused }
|
||||
.border(
|
||||
width = if (favFocused) 2.dp else 0.dp,
|
||||
color = if (favFocused) Color(0xFFFFD700) else Color.Transparent,
|
||||
shape = RoundedCornerShape(50)
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Star, "Fav", modifier = Modifier.size(16.dp), tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF555555))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package ma.tkgraph.xtreamtv.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// ─── Couleurs du design iMedia TV+ ───
|
||||
object DesignColors {
|
||||
val bg1 = Color(0xFF020817)
|
||||
val bg2 = Color(0xFF071a3d)
|
||||
val bg3 = Color(0xFF2b0f53)
|
||||
val panel = Color(0xBD1A1C49) // rgba(26,28,73,0.74)
|
||||
val panel2 = Color(0xB3121D49) // rgba(18,29,73,0.70)
|
||||
val stroke = Color(0x1FFFFFFF) // rgba(255,255,255,0.12)
|
||||
val strokeStrong = Color(0x6B5EA1FF) // rgba(94,161,255,0.42)
|
||||
val text = Color(0xFFF7F8FF)
|
||||
val muted = Color(0xFFA9B2D8)
|
||||
val muted2 = Color(0xFF7F8AB6)
|
||||
val orange = Color(0xFFFF512F)
|
||||
val orange2 = Color(0xFFFFB000)
|
||||
val blue = Color(0xFF1469FF)
|
||||
val blue2 = Color(0xFF09B7FF)
|
||||
val red = Color(0xFFFF3D64)
|
||||
val green = Color(0xFF00D9B2)
|
||||
val purple = Color(0xFF875CFF)
|
||||
val liveRed = Color(0xFFFE3152)
|
||||
val liveRedDark = Color(0xFFBA1733)
|
||||
}
|
||||
|
||||
private val DesignColorScheme = darkColorScheme(
|
||||
primary = DesignColors.orange,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = DesignColors.orange.copy(alpha = 0.2f),
|
||||
secondary = DesignColors.blue,
|
||||
onSecondary = Color.White,
|
||||
surface = Color(0xBD1A1C49),
|
||||
surfaceVariant = Color(0x70121D49),
|
||||
onSurface = DesignColors.text,
|
||||
onSurfaceVariant = DesignColors.muted,
|
||||
background = DesignColors.bg1,
|
||||
onBackground = DesignColors.text,
|
||||
error = DesignColors.red,
|
||||
onError = Color.White,
|
||||
outline = DesignColors.stroke
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun XtreamTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = DesignColorScheme,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
|
||||
class GuideViewModel : ViewModel() {
|
||||
|
||||
private val liveRepo = XtreamApplication.instance.appContainer.liveRepository
|
||||
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
|
||||
|
||||
private val _streams = MutableStateFlow<UiState<List<LiveStreamDto>>>(UiState.Success(emptyList()))
|
||||
val streams: StateFlow<UiState<List<LiveStreamDto>>> = _streams.asStateFlow()
|
||||
|
||||
private val _selectedStreamId = MutableStateFlow<Int?>(null)
|
||||
val selectedStreamId: StateFlow<Int?> = _selectedStreamId.asStateFlow()
|
||||
|
||||
private val _epg = MutableStateFlow<UiState<List<EpgProgramDto>>>(UiState.Idle)
|
||||
val epg: StateFlow<UiState<List<EpgProgramDto>>> = _epg.asStateFlow()
|
||||
|
||||
init { loadStreams() }
|
||||
|
||||
fun loadStreams() {
|
||||
viewModelScope.launch {
|
||||
val r = liveRepo.getStreams()
|
||||
if (r is UiState.Success) _streams.value = r
|
||||
}
|
||||
}
|
||||
|
||||
fun selectStream(streamId: Int) {
|
||||
_selectedStreamId.value = streamId
|
||||
viewModelScope.launch {
|
||||
val r = epgRepo.getFullGuide(streamId)
|
||||
if (r is UiState.Success) _epg.value = r
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
|
||||
private val liveRepo = XtreamApplication.instance.appContainer.liveRepository
|
||||
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
|
||||
|
||||
private val _categories = MutableStateFlow<UiState<List<LiveCategoryDto>>>(
|
||||
UiState.Success(emptyList())
|
||||
)
|
||||
val categories: StateFlow<UiState<List<LiveCategoryDto>>> = _categories.asStateFlow()
|
||||
|
||||
private val _streams = MutableStateFlow<UiState<List<LiveStreamDto>>>(
|
||||
UiState.Success(emptyList())
|
||||
)
|
||||
val streams: StateFlow<UiState<List<LiveStreamDto>>> = _streams.asStateFlow()
|
||||
|
||||
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
|
||||
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
|
||||
|
||||
// État réactif pour les favoris et l'épinglage
|
||||
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteLiveIds())
|
||||
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
|
||||
|
||||
private val _pinnedChannelId = MutableStateFlow(favRepo.getPinnedChannelId())
|
||||
val pinnedChannelId: StateFlow<Int?> = _pinnedChannelId.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCategories()
|
||||
loadStreams()
|
||||
}
|
||||
|
||||
fun loadCategories() {
|
||||
viewModelScope.launch {
|
||||
val result = liveRepo.getCategories()
|
||||
if (result is UiState.Success) {
|
||||
_categories.value = result
|
||||
} else if (result is UiState.Error) {
|
||||
_categories.value = UiState.Success(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCategory(categoryId: Int?) {
|
||||
_selectedCategoryId.value = categoryId
|
||||
if (categoryId == -1) {
|
||||
// ★ Favorites : charger toutes les chaînes, filtrer dans l'UI
|
||||
loadStreams(null)
|
||||
} else {
|
||||
loadStreams(categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadStreams(categoryId: Int? = null) {
|
||||
viewModelScope.launch {
|
||||
val result = liveRepo.getStreams(categoryId)
|
||||
if (result is UiState.Success) {
|
||||
_streams.value = result
|
||||
} else if (result is UiState.Error) {
|
||||
_streams.value = UiState.Success(emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(streamId: Int) {
|
||||
favRepo.toggleFavoriteLive(streamId)
|
||||
_favoriteIds.value = favRepo.getFavoriteLiveIds()
|
||||
}
|
||||
|
||||
fun togglePin(streamId: Int) {
|
||||
val current = _pinnedChannelId.value
|
||||
if (current == streamId) {
|
||||
favRepo.setPinnedChannelId(null)
|
||||
_pinnedChannelId.value = null
|
||||
} else {
|
||||
favRepo.setPinnedChannelId(streamId)
|
||||
_pinnedChannelId.value = streamId
|
||||
}
|
||||
}
|
||||
|
||||
/** Déconnexion : efface config + cache M3U. */
|
||||
fun logout() {
|
||||
XtreamApplication.instance.appContainer.m3uRepository.invalidateCache()
|
||||
XtreamApplication.instance.appContainer.authRepository.logout()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.SourceType
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.repository.AuthRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.UserInfo
|
||||
|
||||
/**
|
||||
* ViewModel pour l'écran de connexion / configuration.
|
||||
* Gère la connexion Xtream et la configuration M3U.
|
||||
*/
|
||||
class LoginViewModel : ViewModel() {
|
||||
|
||||
private val authRepository: AuthRepository =
|
||||
XtreamApplication.instance.appContainer.authRepository
|
||||
private val m3uRepository = XtreamApplication.instance.appContainer.m3uRepository
|
||||
|
||||
private val _uiState = MutableStateFlow<UiState<UserInfo>>(UiState.Idle)
|
||||
val uiState: StateFlow<UiState<UserInfo>> = _uiState.asStateFlow()
|
||||
|
||||
/** Type de source (Compose-reactive). */
|
||||
var sourceType by mutableStateOf(SourceType.XTREAM)
|
||||
var serverUrl: String = ""
|
||||
private set
|
||||
var username: String = ""
|
||||
private set
|
||||
var password: String = ""
|
||||
private set
|
||||
var m3uUrl: String = ""
|
||||
private set
|
||||
|
||||
init {
|
||||
// Charger la config existante
|
||||
val config = XtreamApplication.instance.appContainer.secureStorage.getConfig()
|
||||
if (config != null) {
|
||||
sourceType = config.sourceType
|
||||
serverUrl = config.serverUrl
|
||||
username = config.username
|
||||
password = config.password
|
||||
m3uUrl = config.m3uUrl
|
||||
}
|
||||
}
|
||||
|
||||
fun switchSourceType(type: SourceType) {
|
||||
sourceType = type
|
||||
}
|
||||
|
||||
fun updateServerUrl(value: String) {
|
||||
serverUrl = value
|
||||
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
|
||||
}
|
||||
|
||||
fun updateUsername(value: String) {
|
||||
username = value
|
||||
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
|
||||
}
|
||||
|
||||
fun updatePassword(value: String) {
|
||||
password = value
|
||||
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
|
||||
}
|
||||
|
||||
fun updateM3uUrl(value: String) {
|
||||
m3uUrl = value
|
||||
if (_uiState.value is UiState.Error) _uiState.value = UiState.Idle
|
||||
}
|
||||
|
||||
/** Connexion au serveur Xtream. */
|
||||
fun login() {
|
||||
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_uiState.value = UiState.Loading
|
||||
val result = authRepository.login(serverUrl, username, password)
|
||||
_uiState.value = result
|
||||
}
|
||||
}
|
||||
|
||||
/** Sauvegarde la config M3U et invalide le cache. */
|
||||
fun saveM3uConfig() {
|
||||
if (m3uUrl.isBlank()) return
|
||||
m3uRepository.invalidateCache()
|
||||
authRepository.saveM3uConfig(m3uUrl)
|
||||
_uiState.value = UiState.Success(UserInfo(username = "M3U"))
|
||||
}
|
||||
|
||||
/** Déconnexion : efface la config et le cache M3U. */
|
||||
fun logout() {
|
||||
m3uRepository.invalidateCache()
|
||||
authRepository.logout()
|
||||
serverUrl = ""
|
||||
username = ""
|
||||
password = ""
|
||||
m3uUrl = ""
|
||||
sourceType = SourceType.XTREAM
|
||||
_uiState.value = UiState.Idle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SourceType
|
||||
import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository
|
||||
import ma.tkgraph.xtreamtv.util.UrlBuilder
|
||||
import timber.log.Timber
|
||||
|
||||
data class PlayerState(
|
||||
val stream: LiveStreamDto = LiveStreamDto(),
|
||||
val allStreams: List<LiveStreamDto> = emptyList(),
|
||||
val currentIndex: Int = 0,
|
||||
val showOverlay: Boolean = true,
|
||||
val resolution: String = "",
|
||||
val currentProgram: String = "",
|
||||
val nextProgram: String = "",
|
||||
val restartTrigger: Int = 0,
|
||||
val freezeRetries: Int = 0
|
||||
)
|
||||
|
||||
class PlayerViewModel : ViewModel() {
|
||||
|
||||
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
|
||||
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
|
||||
|
||||
private val _state = MutableStateFlow(PlayerState(showOverlay = true))
|
||||
val state: StateFlow<PlayerState> = _state.asStateFlow()
|
||||
|
||||
fun initialize(stream: LiveStreamDto, allStreams: List<LiveStreamDto>, index: Int) {
|
||||
_state.value = PlayerState(
|
||||
stream = stream,
|
||||
allStreams = allStreams,
|
||||
currentIndex = index,
|
||||
showOverlay = true
|
||||
)
|
||||
loadEpg(stream.streamId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit l'URL de lecture selon la source configurée.
|
||||
* - Mode Xtream : construit l'URL via [UrlBuilder]
|
||||
* - Mode M3U : utilise [LiveStreamDto.directUrl] directement
|
||||
*/
|
||||
fun buildUrl(stream: LiveStreamDto): String {
|
||||
// En mode M3U, utiliser l'URL directe du stream
|
||||
if (stream.directUrl.isNotBlank()) {
|
||||
return stream.directUrl
|
||||
}
|
||||
// Mode Xtream : construction via UrlBuilder
|
||||
val config = XtreamApplication.instance.appContainer.secureStorage.getConfig()
|
||||
?: return ""
|
||||
if (config.sourceType == SourceType.M3U && stream.directUrl.isNotBlank()) {
|
||||
return stream.directUrl
|
||||
}
|
||||
return UrlBuilder(config).liveUrl(stream.streamId, stream.containerExtension)
|
||||
}
|
||||
|
||||
fun updateResolution(resolution: String) {
|
||||
_state.value = _state.value.copy(resolution = resolution)
|
||||
}
|
||||
|
||||
private fun loadEpg(streamId: Int) {
|
||||
viewModelScope.launch {
|
||||
val result = epgRepo.getShortEpg(streamId)
|
||||
if (result is ma.tkgraph.xtreamtv.data.model.UiState.Success) {
|
||||
val programs = result.data
|
||||
if (programs.isNotEmpty()) {
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
val current = programs.firstOrNull {
|
||||
val start = it.startTimestamp?.toLongOrNull()
|
||||
val stop = it.stopTimestamp?.toLongOrNull()
|
||||
start != null && start <= now && (stop == null || stop >= now)
|
||||
} ?: programs.firstOrNull()
|
||||
val currentIdx = programs.indexOfFirst { it.titleText() == current?.titleText() }
|
||||
val next = if (currentIdx >= 0 && currentIdx + 1 < programs.size) programs[currentIdx + 1]
|
||||
else null
|
||||
_state.value = _state.value.copy(
|
||||
currentProgram = current?.titleText() ?: "",
|
||||
nextProgram = next?.titleText() ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun zappingLeft() {
|
||||
val current = _state.value
|
||||
if (current.allStreams.isEmpty()) return
|
||||
val newIndex = if (current.currentIndex > 0) current.currentIndex - 1
|
||||
else current.allStreams.lastIndex
|
||||
val newStream = current.allStreams[newIndex]
|
||||
_state.value = PlayerState(
|
||||
stream = newStream,
|
||||
allStreams = current.allStreams,
|
||||
currentIndex = newIndex,
|
||||
showOverlay = true
|
||||
)
|
||||
loadEpg(newStream.streamId)
|
||||
}
|
||||
|
||||
fun zappingRight() {
|
||||
val current = _state.value
|
||||
if (current.allStreams.isEmpty()) return
|
||||
val newIndex = if (current.currentIndex < current.allStreams.lastIndex) current.currentIndex + 1
|
||||
else 0
|
||||
val newStream = current.allStreams[newIndex]
|
||||
_state.value = PlayerState(
|
||||
stream = newStream,
|
||||
allStreams = current.allStreams,
|
||||
currentIndex = newIndex,
|
||||
showOverlay = true
|
||||
)
|
||||
loadEpg(newStream.streamId)
|
||||
}
|
||||
|
||||
fun toggleOverlay() {
|
||||
_state.value = _state.value.copy(showOverlay = !_state.value.showOverlay)
|
||||
}
|
||||
|
||||
fun hideOverlay() {
|
||||
_state.value = _state.value.copy(showOverlay = false)
|
||||
}
|
||||
|
||||
fun requestRestart() {
|
||||
val s = _state.value
|
||||
if (s.freezeRetries >= 3) return
|
||||
val retries = s.freezeRetries + 1
|
||||
Timber.i("Freeze détecté sur ${s.stream.name} — tentative $retries/3")
|
||||
_state.value = s.copy(
|
||||
restartTrigger = s.restartTrigger + 1,
|
||||
freezeRetries = retries
|
||||
)
|
||||
}
|
||||
|
||||
fun resetFreezeCounter() {
|
||||
val s = _state.value
|
||||
if (s.freezeRetries > 0) {
|
||||
_state.value = s.copy(freezeRetries = 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun canRestart(): Boolean = _state.value.freezeRetries < 3
|
||||
|
||||
fun isFavorite(streamId: Int): Boolean = favRepo.isFavoriteLive(streamId)
|
||||
fun toggleFavorite(streamId: Int) { favRepo.toggleFavoriteLive(streamId) }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
|
||||
class ReplayViewModel : ViewModel() {
|
||||
|
||||
private val epgRepo = XtreamApplication.instance.appContainer.epgRepository
|
||||
|
||||
private val _epg = MutableStateFlow<UiState<List<EpgProgramDto>>>(UiState.Idle)
|
||||
val epg: StateFlow<UiState<List<EpgProgramDto>>> = _epg.asStateFlow()
|
||||
|
||||
private var currentStream: LiveStreamDto? = null
|
||||
|
||||
fun loadReplay(stream: LiveStreamDto) {
|
||||
currentStream = stream
|
||||
viewModelScope.launch {
|
||||
_epg.value = UiState.Loading
|
||||
val result = epgRepo.getFullGuide(stream.streamId)
|
||||
if (result is UiState.Success) {
|
||||
// Filtrer : garder uniquement les programmes passés (déjà diffusés)
|
||||
val nowSeconds = System.currentTimeMillis() / 1000
|
||||
val pastPrograms = result.data.filter { prog ->
|
||||
val start = prog.startTimestamp?.toLongOrNull() ?: 0
|
||||
start > 0 && start <= nowSeconds
|
||||
}.sortedByDescending { it.startTimestamp?.toLongOrNull() ?: 0 }
|
||||
_epg.value = UiState.Success(pastPrograms)
|
||||
} else {
|
||||
_epg.value = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildReplayUrl(program: EpgProgramDto): String? {
|
||||
val config = XtreamApplication.instance.appContainer.secureStorage.getCredentials()
|
||||
?: return null
|
||||
val stream = currentStream ?: return null
|
||||
|
||||
val durationMinutes = program.durationMinutes()
|
||||
|
||||
return ma.tkgraph.xtreamtv.util.UrlBuilder(config)
|
||||
.replayUrl(
|
||||
streamId = stream.streamId,
|
||||
start = program.start.orEmpty(),
|
||||
durationMinutes = durationMinutes,
|
||||
extension = "ts"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesInfoDto
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
|
||||
class SeriesViewModel : ViewModel() {
|
||||
|
||||
private val seriesRepo = XtreamApplication.instance.appContainer.seriesRepository
|
||||
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
|
||||
|
||||
private val _categories = MutableStateFlow<UiState<List<SeriesCategoryDto>>>(UiState.Success(emptyList()))
|
||||
val categories: StateFlow<UiState<List<SeriesCategoryDto>>> = _categories.asStateFlow()
|
||||
|
||||
private val _series = MutableStateFlow<UiState<List<SeriesDto>>>(UiState.Success(emptyList()))
|
||||
val series: StateFlow<UiState<List<SeriesDto>>> = _series.asStateFlow()
|
||||
|
||||
private val _seriesInfo = MutableStateFlow<UiState<SeriesInfoDto>>(UiState.Idle)
|
||||
val seriesInfo: StateFlow<UiState<SeriesInfoDto>> = _seriesInfo.asStateFlow()
|
||||
|
||||
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
|
||||
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
|
||||
|
||||
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteSeriesIds())
|
||||
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCategories()
|
||||
loadSeries()
|
||||
}
|
||||
|
||||
fun loadCategories() {
|
||||
viewModelScope.launch {
|
||||
val r = seriesRepo.getCategories()
|
||||
if (r is UiState.Success) _categories.value = r
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSeries(categoryId: Int? = null) {
|
||||
viewModelScope.launch {
|
||||
val r = seriesRepo.getSeriesList(categoryId)
|
||||
if (r is UiState.Success) _series.value = r
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCategory(categoryId: Int?) {
|
||||
_selectedCategoryId.value = categoryId
|
||||
if (categoryId == -1) {
|
||||
loadSeries(null) // Load all, filter in UI
|
||||
} else {
|
||||
loadSeries(categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSeriesInfo(seriesId: Int) {
|
||||
viewModelScope.launch {
|
||||
val r = seriesRepo.getSeriesInfo(seriesId)
|
||||
if (r is UiState.Success) _seriesInfo.value = r
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(seriesId: Int) {
|
||||
favRepo.toggleFavoriteSeries(seriesId)
|
||||
_favoriteIds.value = favRepo.getFavoriteSeriesIds()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package ma.tkgraph.xtreamtv.ui.viewmodels
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ma.tkgraph.xtreamtv.XtreamApplication
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.VodDto
|
||||
|
||||
class VodViewModel : ViewModel() {
|
||||
|
||||
private val vodRepo = XtreamApplication.instance.appContainer.vodRepository
|
||||
private val favRepo = XtreamApplication.instance.appContainer.favoritesRepository
|
||||
|
||||
private val _categories = MutableStateFlow<UiState<List<VodCategoryDto>>>(UiState.Success(emptyList()))
|
||||
val categories: StateFlow<UiState<List<VodCategoryDto>>> = _categories.asStateFlow()
|
||||
|
||||
private val _movies = MutableStateFlow<UiState<List<VodDto>>>(UiState.Success(emptyList()))
|
||||
val movies: StateFlow<UiState<List<VodDto>>> = _movies.asStateFlow()
|
||||
|
||||
private val _selectedCategoryId = MutableStateFlow<Int?>(null)
|
||||
val selectedCategoryId: StateFlow<Int?> = _selectedCategoryId.asStateFlow()
|
||||
|
||||
private val _favoriteIds = MutableStateFlow(favRepo.getFavoriteVodIds())
|
||||
val favoriteIds: StateFlow<Set<Int>> = _favoriteIds.asStateFlow()
|
||||
|
||||
init {
|
||||
loadCategories()
|
||||
loadMovies()
|
||||
}
|
||||
|
||||
fun loadCategories() {
|
||||
viewModelScope.launch {
|
||||
val r = vodRepo.getCategories()
|
||||
if (r is UiState.Success) _categories.value = r
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMovies(categoryId: Int? = null) {
|
||||
viewModelScope.launch {
|
||||
val r = vodRepo.getMovies(categoryId)
|
||||
if (r is UiState.Success) _movies.value = r
|
||||
}
|
||||
}
|
||||
|
||||
fun selectCategory(categoryId: Int?) {
|
||||
_selectedCategoryId.value = categoryId
|
||||
if (categoryId == -1) {
|
||||
loadMovies(null) // Load all, filter in UI
|
||||
} else {
|
||||
loadMovies(categoryId)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleFavorite(streamId: Int) {
|
||||
favRepo.toggleFavoriteVod(streamId)
|
||||
_favoriteIds.value = favRepo.getFavoriteVodIds()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.SeriesCategoryDto
|
||||
import ma.tkgraph.xtreamtv.data.model.VodCategoryDto
|
||||
|
||||
/**
|
||||
* Trie les catégories dans l'ordre préféré :
|
||||
* 1. Catégories spéciales (Toutes, Favoris)
|
||||
* 2. Catégories prioritaires (Bein Sports, etc.)
|
||||
* 3. Catégories françaises
|
||||
* 4. Catégories arabes
|
||||
* 5. Autres catégories (ordre alphabétique)
|
||||
*/
|
||||
object CategorySortUtils {
|
||||
|
||||
private val PRIORITY_KEYWORDS = listOf(
|
||||
"bein", "beIN"
|
||||
)
|
||||
|
||||
private val FRENCH_KEYWORDS = listOf(
|
||||
"france", "français", "francaise", "française",
|
||||
"fr", "fra", "francais", "info", "infos"
|
||||
)
|
||||
|
||||
private val ARABIC_KEYWORDS = listOf(
|
||||
"arab", "arabic", "arabe", "arabia",
|
||||
"arabique", "maghreb", "moyen-orient",
|
||||
"عربي", "عربية", "العربية"
|
||||
)
|
||||
|
||||
private val SPECIAL_NAMES = listOf(
|
||||
"all channels", "toutes les chaînes",
|
||||
"all movies", "tous les films",
|
||||
"all series", "toutes les séries",
|
||||
"favorites", "favoris", "★"
|
||||
)
|
||||
|
||||
fun sortLiveCategories(categories: List<LiveCategoryDto>): List<LiveCategoryDto> {
|
||||
return categories.sortedWith(categoryComparator(
|
||||
nameSelector = { it.categoryName },
|
||||
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
|
||||
))
|
||||
}
|
||||
|
||||
fun sortVodCategories(categories: List<VodCategoryDto>): List<VodCategoryDto> {
|
||||
return categories.sortedWith(categoryComparator(
|
||||
nameSelector = { it.categoryName },
|
||||
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
|
||||
))
|
||||
}
|
||||
|
||||
fun sortSeriesCategories(categories: List<SeriesCategoryDto>): List<SeriesCategoryDto> {
|
||||
return categories.sortedWith(categoryComparator(
|
||||
nameSelector = { it.categoryName },
|
||||
isSpecial = { name -> name in SPECIAL_NAMES || name.startsWith("★") }
|
||||
))
|
||||
}
|
||||
|
||||
private fun <T> categoryComparator(
|
||||
nameSelector: (T) -> String,
|
||||
isSpecial: (String) -> Boolean
|
||||
): Comparator<T> = Comparator { a, b ->
|
||||
val nameA = nameSelector(a).lowercase().trim()
|
||||
val nameB = nameSelector(b).lowercase().trim()
|
||||
|
||||
when {
|
||||
isSpecial(nameA) && !isSpecial(nameB) -> -1
|
||||
!isSpecial(nameA) && isSpecial(nameB) -> 1
|
||||
isPriority(nameA) && !isPriority(nameB) -> -1
|
||||
!isPriority(nameA) && isPriority(nameB) -> 1
|
||||
isFrench(nameA) && !isFrench(nameB) -> -1
|
||||
!isFrench(nameA) && isFrench(nameB) -> 1
|
||||
isArabic(nameA) && !isArabic(nameB) -> -1
|
||||
!isArabic(nameA) && isArabic(nameB) -> 1
|
||||
else -> nameA.compareTo(nameB)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPriority(name: String): Boolean =
|
||||
PRIORITY_KEYWORDS.any { name.contains(it) }
|
||||
|
||||
private fun isFrench(name: String): Boolean =
|
||||
FRENCH_KEYWORDS.any { name.contains(it) }
|
||||
|
||||
private fun isArabic(name: String): Boolean =
|
||||
ARABIC_KEYWORDS.any { name.contains(it) } || name.any { it in '\u0600'..'\u06FF' }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
|
||||
/**
|
||||
* Vérifie l'état de la session au démarrage.
|
||||
*/
|
||||
class SessionManager(private val storage: SecureStorage) {
|
||||
|
||||
fun hasValidSession(): Boolean {
|
||||
val config = storage.getConfig() ?: return false
|
||||
return config.isValid
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
|
||||
class UrlBuilder(private val config: XtreamConfig) {
|
||||
|
||||
fun liveUrl(streamId: Int, extension: String = "m3u8"): String =
|
||||
"${baseUrl}/live/${config.username}/${config.password}/$streamId.$extension"
|
||||
|
||||
fun vodUrl(streamId: Int, extension: String = "mp4"): String =
|
||||
"${baseUrl}/movie/${config.username}/${config.password}/$streamId.$extension"
|
||||
|
||||
fun seriesUrl(episodeId: Int, extension: String = "mp4"): String =
|
||||
"${baseUrl}/series/${config.username}/${config.password}/$episodeId.$extension"
|
||||
|
||||
/**
|
||||
* Construit l'URL timeshift pour le replay.
|
||||
* Format : /timeshift/USER/PASS/DURATION_MINUTES/DATE:HH-MM/STREAM_ID.ts
|
||||
*
|
||||
* @param streamId ID de la chaîne
|
||||
* @param start Date de début au format "2026-06-11 20:00"
|
||||
* @param durationMinutes Durée en minutes (entre 1 et 720)
|
||||
* @param extension Extension du fichier (ts par défaut)
|
||||
*/
|
||||
fun replayUrl(
|
||||
streamId: Int,
|
||||
start: String,
|
||||
durationMinutes: Int = 120,
|
||||
extension: String = "ts"
|
||||
): String {
|
||||
val safeDuration = durationMinutes.coerceIn(1, 720)
|
||||
val formattedStart = formatTimeshiftStart(start)
|
||||
return "${baseUrl}/timeshift/${config.username}/${config.password}/$safeDuration/$formattedStart/$streamId.$extension"
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la date pour le timeshift.
|
||||
* "2026-06-11 20:00" → "2026-06-11:20-00"
|
||||
*/
|
||||
private fun formatTimeshiftStart(start: String): String {
|
||||
val match = Regex("""(\d{4}-\d{2}-\d{2})[ T](\d{2}):(\d{2})""").find(start.trim())
|
||||
if (match != null) {
|
||||
val date = match.groupValues[1]
|
||||
val hour = match.groupValues[2]
|
||||
val minute = match.groupValues[3]
|
||||
return "$date:$hour-$minute"
|
||||
}
|
||||
return start.trim().take(16).replace(" ", ":").replace(Regex(""":(\d{2})$"""), "-$1")
|
||||
}
|
||||
|
||||
private val baseUrl: String
|
||||
get() = config.serverUrl.trimEnd('/')
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
/**
|
||||
* Trie les éléments les plus utilisés en premier, puis conserve l'ordre serveur.
|
||||
* L'ordre serveur reste important : certains fournisseurs IPTV rangent déjà leurs listes, parfois volontairement.
|
||||
*/
|
||||
object UsageSortUtils {
|
||||
fun <T> sortByUsage(
|
||||
items: List<T>,
|
||||
usageCounts: Map<String, Int>,
|
||||
idSelector: (T) -> String
|
||||
): List<T> {
|
||||
if (items.size < 2 || usageCounts.isEmpty()) return items
|
||||
return items.withIndex()
|
||||
.sortedWith(
|
||||
compareByDescending<IndexedValue<T>> { indexed -> usageCounts[idSelector(indexed.value)] ?: 0 }
|
||||
.thenBy { indexed -> indexed.index }
|
||||
)
|
||||
.map { it.value }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Splash background for iMedia TV+
|
||||
Fond sombre avec le nom de l'app en logo textuel.
|
||||
Remplacez par l'image imedia_tv_background.png quand elle sera disponible.
|
||||
-->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Fond sombre -->
|
||||
<item android:drawable="@android:color/black" />
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,78 @@
|
||||
<resources>
|
||||
<string name="app_name">iMedia TV+</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="live_tv">البث المباشر</string>
|
||||
<string name="vod">الأفلام</string>
|
||||
<string name="series">المسلسلات</string>
|
||||
<string name="guide">دليل التلفاز</string>
|
||||
<string name="settings">الإعدادات</string>
|
||||
|
||||
<!-- Login -->
|
||||
<string name="server_url">رابط الخادم</string>
|
||||
<string name="username">اسم المستخدم</string>
|
||||
<string name="password">كلمة المرور</string>
|
||||
<string name="connect">اتصال</string>
|
||||
<string name="connecting">جارٍ الاتصال…</string>
|
||||
<string name="login_hint">مثال: http://server:8080</string>
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_network">تعذر الاتصال بالخادم. تحقق من شبكتك.</string>
|
||||
<string name="error_timeout">الخادم لا يستجيب. حاول مرة أخرى لاحقاً.</string>
|
||||
<string name="error_auth">بيانات الدخول غير صحيحة أو الخادم غير صالح.</string>
|
||||
<string name="error_stream">البث غير متاح لهذه القناة.</string>
|
||||
<string name="error_server">خطأ في الخادم (%d). اتصل بمزود الخدمة.</string>
|
||||
<string name="error_epg_empty">لم يتم العثور على برنامج لهذه القناة.</string>
|
||||
<string name="error_loading">فشل التحميل.</string>
|
||||
<string name="retry">إعادة المحاولة</string>
|
||||
|
||||
<!-- Live TV -->
|
||||
<string name="all_channels">جميع القنوات</string>
|
||||
<string name="favorites">★ المفضلة</string>
|
||||
<string name="search_channels">البحث عن قنوات…</string>
|
||||
<string name="no_channels">لم يتم العثور على قنوات</string>
|
||||
<string name="pinned_channel">مثبتة</string>
|
||||
<string name="pin_channel">تثبيت هذه القناة</string>
|
||||
<string name="unpin_channel">إلغاء التثبيت</string>
|
||||
<string name="add_favorite">إضافة إلى المفضلة</string>
|
||||
<string name="remove_favorite">إزالة من المفضلة</string>
|
||||
|
||||
<!-- Player -->
|
||||
<string name="now_playing">قيد التشغيل</string>
|
||||
<string name="next_channel">القناة التالية</string>
|
||||
<string name="previous_channel">القناة السابقة</string>
|
||||
<string name="current_program">البرنامج الحالي</string>
|
||||
<string name="next_program">التالي</string>
|
||||
<string name="replay">إعادة</string>
|
||||
<string name="live">مباشر</string>
|
||||
|
||||
<!-- Replay -->
|
||||
<string name="replay_guide">الإعادة — %s</string>
|
||||
<string name="watch_replay">مشاهدة الإعادة</string>
|
||||
<string name="no_replay">الإعادة غير متاحة</string>
|
||||
<string name="days_available">الأيام المتاحة</string>
|
||||
|
||||
<!-- VOD -->
|
||||
<string name="all_movies">جميع الأفلام</string>
|
||||
<string name="search_movies">البحث عن أفلام…</string>
|
||||
<string name="no_movies">لم يتم العثور على أفلام</string>
|
||||
<string name="watch_movie">مشاهدة الفيلم</string>
|
||||
|
||||
<!-- Series -->
|
||||
<string name="all_series">جميع المسلسلات</string>
|
||||
<string name="search_series">البحث عن مسلسلات…</string>
|
||||
<string name="no_series">لم يتم العثور على مسلسلات</string>
|
||||
<string name="season">الموسم %d</string>
|
||||
<string name="episode">الحلقة %d</string>
|
||||
<string name="episode_format">الحلقة %1$d • %2$s</string>
|
||||
|
||||
<!-- General -->
|
||||
<string name="exit_confirm">هل تريد الخروج من iMedia TV+؟</string>
|
||||
<string name="exit_confirm_desc">هل أنت متأكد من رغبتك في الخروج؟</string>
|
||||
<string name="yes">نعم</string>
|
||||
<string name="no">لا</string>
|
||||
<string name="loading">جارٍ التحميل…</string>
|
||||
<string name="no_data">لا توجد بيانات متاحة</string>
|
||||
<string name="disconnect">قطع الاتصال</string>
|
||||
<string name="version">الإصدار %s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,78 @@
|
||||
<resources>
|
||||
<string name="app_name">iMedia TV+</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="live_tv">Direct TV</string>
|
||||
<string name="vod">Films</string>
|
||||
<string name="series">Séries</string>
|
||||
<string name="guide">Guide TV</string>
|
||||
<string name="settings">Paramètres</string>
|
||||
|
||||
<!-- Login -->
|
||||
<string name="server_url">URL du serveur</string>
|
||||
<string name="username">Identifiant</string>
|
||||
<string name="password">Mot de passe</string>
|
||||
<string name="connect">Connexion</string>
|
||||
<string name="connecting">Connexion…</string>
|
||||
<string name="login_hint">ex: http://serveur:8080</string>
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_network">Impossible de se connecter au serveur. Vérifiez votre réseau.</string>
|
||||
<string name="error_timeout">Le serveur ne répond pas. Réessayez plus tard.</string>
|
||||
<string name="error_auth">Identifiants incorrects ou serveur invalide.</string>
|
||||
<string name="error_stream">Flux indisponible pour cette chaîne.</string>
|
||||
<string name="error_server">Erreur serveur (%d). Contactez votre fournisseur.</string>
|
||||
<string name="error_epg_empty">Aucun programme trouvé pour cette chaîne.</string>
|
||||
<string name="error_loading">Échec du chargement.</string>
|
||||
<string name="retry">Réessayer</string>
|
||||
|
||||
<!-- Live TV -->
|
||||
<string name="all_channels">Toutes les chaînes</string>
|
||||
<string name="favorites">★ Favoris</string>
|
||||
<string name="search_channels">Rechercher des chaînes…</string>
|
||||
<string name="no_channels">Aucune chaîne trouvée</string>
|
||||
<string name="pinned_channel">Épinglée</string>
|
||||
<string name="pin_channel">Épingler cette chaîne</string>
|
||||
<string name="unpin_channel">Désépingler</string>
|
||||
<string name="add_favorite">Ajouter aux favoris</string>
|
||||
<string name="remove_favorite">Retirer des favoris</string>
|
||||
|
||||
<!-- Player -->
|
||||
<string name="now_playing">En cours</string>
|
||||
<string name="next_channel">Chaîne suivante</string>
|
||||
<string name="previous_channel">Chaîne précédente</string>
|
||||
<string name="current_program">Programme en cours</string>
|
||||
<string name="next_program">Suivant</string>
|
||||
<string name="replay">Replay</string>
|
||||
<string name="live">Direct</string>
|
||||
|
||||
<!-- Replay -->
|
||||
<string name="replay_guide">Replay — %s</string>
|
||||
<string name="watch_replay">Revoir</string>
|
||||
<string name="no_replay">Replay non disponible</string>
|
||||
<string name="days_available">Jours disponibles</string>
|
||||
|
||||
<!-- VOD -->
|
||||
<string name="all_movies">Tous les films</string>
|
||||
<string name="search_movies">Rechercher des films…</string>
|
||||
<string name="no_movies">Aucun film trouvé</string>
|
||||
<string name="watch_movie">Regarder le film</string>
|
||||
|
||||
<!-- Series -->
|
||||
<string name="all_series">Toutes les séries</string>
|
||||
<string name="search_series">Rechercher des séries…</string>
|
||||
<string name="no_series">Aucune série trouvée</string>
|
||||
<string name="season">Saison %d</string>
|
||||
<string name="episode">Épisode %d</string>
|
||||
<string name="episode_format">Épisode %1$d • %2$s</string>
|
||||
|
||||
<!-- General -->
|
||||
<string name="exit_confirm">Quitter iMedia TV+ ?</string>
|
||||
<string name="exit_confirm_desc">Êtes-vous sûr de vouloir quitter ?</string>
|
||||
<string name="yes">Oui</string>
|
||||
<string name="no">Non</string>
|
||||
<string name="loading">Chargement…</string>
|
||||
<string name="no_data">Aucune donnée disponible</string>
|
||||
<string name="disconnect">Déconnexion</string>
|
||||
<string name="version">Version %s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary">#FFE50914</color>
|
||||
<color name="primary_dark">#FFB30000</color>
|
||||
<color name="on_primary">#FFFFFFFF</color>
|
||||
<color name="primary_container">#FF4A1C1C</color>
|
||||
<color name="secondary">#FF1A73E8</color>
|
||||
<color name="on_secondary">#FFFFFFFF</color>
|
||||
<color name="surface">#FF1A1A1A</color>
|
||||
<color name="surface_variant">#FF252525</color>
|
||||
<color name="on_surface">#FFFFFFFF</color>
|
||||
<color name="background">#FF0A0A0A</color>
|
||||
<color name="on_background">#FFEEEEEE</color>
|
||||
<color name="favorite">#FFFFD700</color>
|
||||
<color name="error">#FFFF5252</color>
|
||||
<color name="success">#FF4CAF50</color>
|
||||
<color name="replay_badge">#FF00BCD4</color>
|
||||
<color name="card_border">#FF333333</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<integer name="config_splash_timeout">3000</integer>
|
||||
<integer name="config_overlay_timeout">3000</integer>
|
||||
<integer name="config_connect_timeout">15000</integer>
|
||||
<integer name="config_read_timeout">30000</integer>
|
||||
<integer name="config_epg_cache_minutes">2</integer>
|
||||
<integer name="config_guide_cache_minutes">15</integer>
|
||||
</resources>
|
||||
@@ -0,0 +1,78 @@
|
||||
<resources>
|
||||
<string name="app_name">iMedia TV+</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="live_tv">Live TV</string>
|
||||
<string name="vod">Movies</string>
|
||||
<string name="series">Series</string>
|
||||
<string name="guide">TV Guide</string>
|
||||
<string name="settings">Settings</string>
|
||||
|
||||
<!-- Login -->
|
||||
<string name="server_url">Server URL</string>
|
||||
<string name="username">Username</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="connect">Connect</string>
|
||||
<string name="connecting">Connecting…</string>
|
||||
<string name="login_hint">e.g. http://server:8080</string>
|
||||
|
||||
<!-- Errors -->
|
||||
<string name="error_network">Cannot reach server. Check your network.</string>
|
||||
<string name="error_timeout">Server is not responding. Try again later.</string>
|
||||
<string name="error_auth">Invalid credentials or server.</string>
|
||||
<string name="error_stream">Stream unavailable for this channel.</string>
|
||||
<string name="error_server">Server error (%d). Contact your provider.</string>
|
||||
<string name="error_epg_empty">No program found for this channel.</string>
|
||||
<string name="error_loading">Failed to load content.</string>
|
||||
<string name="retry">Retry</string>
|
||||
|
||||
<!-- Live TV -->
|
||||
<string name="all_channels">All Channels</string>
|
||||
<string name="favorites">★ Favorites</string>
|
||||
<string name="search_channels">Search channels…</string>
|
||||
<string name="no_channels">No channels found</string>
|
||||
<string name="pinned_channel">Pinned</string>
|
||||
<string name="pin_channel">Pin this channel</string>
|
||||
<string name="unpin_channel">Unpin</string>
|
||||
<string name="add_favorite">Add to favorites</string>
|
||||
<string name="remove_favorite">Remove from favorites</string>
|
||||
|
||||
<!-- Player -->
|
||||
<string name="now_playing">Now playing</string>
|
||||
<string name="next_channel">Next channel</string>
|
||||
<string name="previous_channel">Previous channel</string>
|
||||
<string name="current_program">Current program</string>
|
||||
<string name="next_program">Next</string>
|
||||
<string name="replay">Replay</string>
|
||||
<string name="live">Live</string>
|
||||
|
||||
<!-- Replay -->
|
||||
<string name="replay_guide">Replay — %s</string>
|
||||
<string name="watch_replay">Watch replay</string>
|
||||
<string name="no_replay">No replay available</string>
|
||||
<string name="days_available">Available days</string>
|
||||
|
||||
<!-- VOD -->
|
||||
<string name="all_movies">All Movies</string>
|
||||
<string name="search_movies">Search movies…</string>
|
||||
<string name="no_movies">No movies found</string>
|
||||
<string name="watch_movie">Watch movie</string>
|
||||
|
||||
<!-- Series -->
|
||||
<string name="all_series">All Series</string>
|
||||
<string name="search_series">Search series…</string>
|
||||
<string name="no_series">No series found</string>
|
||||
<string name="season">Season %d</string>
|
||||
<string name="episode">Episode %d</string>
|
||||
<string name="episode_format">Episode %1$d • %2$s</string>
|
||||
|
||||
<!-- General -->
|
||||
<string name="exit_confirm">Exit iMedia TV+?</string>
|
||||
<string name="exit_confirm_desc">Are you sure you want to exit?</string>
|
||||
<string name="yes">Yes</string>
|
||||
<string name="no">No</string>
|
||||
<string name="loading">Loading…</string>
|
||||
<string name="no_data">No data available</string>
|
||||
<string name="disconnect">Disconnect</string>
|
||||
<string name="version">Version %s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.iMediaTVPlus" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:colorBackground">#FF0A0A0A</item>
|
||||
<item name="android:statusBarColor">#FF0A0A0A</item>
|
||||
<item name="android:navigationBarColor">#FF0A0A0A</item>
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
<style name="Theme.iMediaTVPlus.Splash" parent="Theme.iMediaTVPlus">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Xtream servers often use HTTP; we accept it explicitly -->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,74 @@
|
||||
package ma.tkgraph.xtreamtv.data.repository
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import ma.tkgraph.xtreamtv.data.model.AuthResponse
|
||||
import ma.tkgraph.xtreamtv.data.model.UiState
|
||||
import ma.tkgraph.xtreamtv.data.model.UserInfo
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApi
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AuthRepositoryTest {
|
||||
|
||||
private val api: XtreamApi = mockk()
|
||||
private val storage: SecureStorage = mockk()
|
||||
private lateinit var repo: AuthRepository
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repo = AuthRepository(api, storage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login with valid credentials returns Success`() = runTest {
|
||||
// Given
|
||||
coEvery { api.authenticate(any(), any()) } returns AuthResponse(
|
||||
userInfo = UserInfo(username = "testuser"),
|
||||
auth = "1",
|
||||
status = "Active"
|
||||
)
|
||||
|
||||
// When
|
||||
val result = repo.login("http://server:8080", "testuser", "pass123")
|
||||
|
||||
// Then
|
||||
assertTrue(result is UiState.Success)
|
||||
assertEquals("testuser", (result as UiState.Success).data.username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login with invalid auth returns Error`() = runTest {
|
||||
// Given
|
||||
coEvery { api.authenticate(any(), any()) } returns AuthResponse(
|
||||
auth = "0",
|
||||
status = "Active"
|
||||
)
|
||||
|
||||
// When
|
||||
val result = repo.login("http://server:8080", "baduser", "badpass")
|
||||
|
||||
// Then
|
||||
assertTrue(result is UiState.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `login with expired account returns Error`() = runTest {
|
||||
// Given
|
||||
coEvery { api.authenticate(any(), any()) } returns AuthResponse(
|
||||
auth = "1",
|
||||
status = "Expired"
|
||||
)
|
||||
|
||||
// When
|
||||
val result = repo.login("http://server:8080", "user", "pass")
|
||||
|
||||
// Then
|
||||
assertTrue(result is UiState.Error)
|
||||
assertTrue((result as UiState.Error).message.contains("expired", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CategorySortUtilsTest {
|
||||
|
||||
@Test
|
||||
fun `sortLiveCategories puts FR first, AR second, others last`() {
|
||||
// Given
|
||||
val categories = listOf(
|
||||
LiveCategoryDto(categoryId = "1", categoryName = "Sports"),
|
||||
LiveCategoryDto(categoryId = "2", categoryName = "France Info"),
|
||||
LiveCategoryDto(categoryId = "3", categoryName = "العربية"),
|
||||
LiveCategoryDto(categoryId = "4", categoryName = "French Cinema"),
|
||||
LiveCategoryDto(categoryId = "5", categoryName = "News")
|
||||
)
|
||||
|
||||
// When
|
||||
val sorted = CategorySortUtils.sortLiveCategories(categories)
|
||||
|
||||
// Then
|
||||
assertEquals("France Info", sorted[0].categoryName)
|
||||
assertEquals("French Cinema", sorted[1].categoryName)
|
||||
assertEquals("العربية", sorted[2].categoryName)
|
||||
assertEquals("News", sorted[3].categoryName)
|
||||
assertEquals("Sports", sorted[4].categoryName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `special categories always come first`() {
|
||||
// Given
|
||||
val categories = listOf(
|
||||
LiveCategoryDto(categoryId = "1", categoryName = "News"),
|
||||
LiveCategoryDto(categoryId = "2", categoryName = "★ Favorites"),
|
||||
LiveCategoryDto(categoryId = "3", categoryName = "All Channels"),
|
||||
LiveCategoryDto(categoryId = "4", categoryName = "France Info")
|
||||
)
|
||||
|
||||
// When
|
||||
val sorted = CategorySortUtils.sortLiveCategories(categories)
|
||||
|
||||
// Then
|
||||
assertEquals("All Channels", sorted[0].categoryName)
|
||||
assertEquals("★ Favorites", sorted[1].categoryName)
|
||||
assertEquals("France Info", sorted[2].categoryName)
|
||||
assertEquals("News", sorted[3].categoryName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package ma.tkgraph.xtreamtv.util
|
||||
|
||||
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class UrlBuilderTest {
|
||||
|
||||
private lateinit var builder: UrlBuilder
|
||||
private val config = XtreamConfig("http://server:8080", "user", "pass")
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
builder = UrlBuilder(config)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `liveUrl builds correct URL`() {
|
||||
val url = builder.liveUrl(42)
|
||||
assertEquals("http://server:8080/live/user/pass/42.m3u8", url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `liveUrl with custom extension`() {
|
||||
val url = builder.liveUrl(42, "ts")
|
||||
assertEquals("http://server:8080/live/user/pass/42.ts", url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vodUrl builds correct URL`() {
|
||||
val url = builder.vodUrl(123, "mp4")
|
||||
assertEquals("http://server:8080/movie/user/pass/123.mp4", url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seriesUrl builds correct URL`() {
|
||||
val url = builder.seriesUrl(456, "mkv")
|
||||
assertEquals("http://server:8080/series/user/pass/456.mkv", url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replayUrl builds correct URL`() {
|
||||
val url = builder.replayUrl(3600, "2026-06-10:12-00", 42)
|
||||
assertEquals(
|
||||
"http://server:8080/timeshift/user/pass/3600/2026-06-10:12-00/42.ts",
|
||||
url
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0"
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ma.tkgraph.xtreamtv"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ma.tkgraph.xtreamtv"
|
||||
minSdk = 30
|
||||
targetSdk = 36
|
||||
versionCode = 23
|
||||
versionName = "2.0.23-login-success-nav"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
buildConfigField("boolean", "ENABLE_LOGGING", "true")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
buildConfigField("boolean", "ENABLE_LOGGING", "false")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// --- Compose / TV ---
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
implementation(composeBom)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.tv:tv-material:1.0.0")
|
||||
implementation("androidx.tv:tv-foundation:1.0.0")
|
||||
|
||||
// --- Activity / Lifecycle ---
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
|
||||
// --- Media3 ExoPlayer ---
|
||||
implementation("androidx.media3:media3-exoplayer:1.5.1")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
|
||||
implementation("androidx.media3:media3-exoplayer-dash:1.5.1")
|
||||
implementation("androidx.media3:media3-ui:1.5.1")
|
||||
implementation("androidx.media3:media3-session:1.5.1")
|
||||
|
||||
// --- Network ---
|
||||
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// --- Images ---
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.0.4")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.0.4")
|
||||
|
||||
// --- Security (EncryptedSharedPreferences) ---
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// --- EPG Cache (Room) ---
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
annotationProcessor("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
// --- Logging ---
|
||||
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||
|
||||
// --- Core ---
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.leanback:leanback:1.2.0-alpha04")
|
||||
|
||||
// ========== TESTS ==========
|
||||
// Unit tests
|
||||
testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
testImplementation("io.mockk:mockk:1.13.14")
|
||||
testImplementation("app.cash.turbine:turbine:1.2.0")
|
||||
|
||||
// Android / UI tests
|
||||
androidTestImplementation(composeBom)
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
androidTestImplementation("androidx.test:runner:1.6.2")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
# Gestion d'erreurs — iMedia TV+
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
API[Serveur Xtream] -->|Timeout / 4xx / 5xx| Interceptor[OkHttp ErrorInterceptor]
|
||||
Interceptor -->|Exception typée| Repository
|
||||
Repository -->|Result / UiState.Error| ViewModel
|
||||
ViewModel -->|Message localisé| UI[Écran Compose]
|
||||
UI -->|Snackbar / Toast / Overlay| User[Utilisateur]
|
||||
```
|
||||
|
||||
## Hiérarchie d'erreurs
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Toutes les erreurs Xtream sont des sealed classes
|
||||
* pour un `when` exhaustif dans l'UI.
|
||||
*/
|
||||
sealed class XtreamException(message: String, cause: Throwable? = null) : Exception(message, cause) {
|
||||
class NetworkException(message: String) : XtreamException(message)
|
||||
class TimeoutException(message: String) : XtreamException(message)
|
||||
class AuthException(message: String) : XtreamException(message)
|
||||
class ServerException(val code: Int, message: String) : XtreamException(message)
|
||||
class ParseException(message: String, cause: Throwable) : XtreamException(message, cause)
|
||||
class StreamUnavailableException(message: String) : XtreamException(message)
|
||||
}
|
||||
```
|
||||
|
||||
## Table des erreurs
|
||||
|
||||
| N° | Condition | Exception | Code UI | Message FR | Message EN |
|
||||
|----|-----------|-----------|---------|------------|------------|
|
||||
| 1 | Serveur injoignable | `NetworkException` | Erreur réseau | "Impossible de se connecter au serveur. Vérifiez votre réseau." | "Cannot reach server. Check your network." |
|
||||
| 2 | Timeout dépassé (15s) | `TimeoutException` | Erreur réseau | "Le serveur ne répond pas. Réessayez plus tard." | "Server is not responding. Try again later." |
|
||||
| 3 | Auth invalide | `AuthException` | Login bloqué | "Identifiants incorrects ou serveur invalide." | "Invalid credentials or server." |
|
||||
| 4 | Flux vidéo cassé | `StreamUnavailableException` | Overlay erreur | "Flux indisponible pour cette chaîne." | "Stream unavailable for this channel." |
|
||||
| 5 | HTTP 4xx | `ServerException` | Toast | "Erreur serveur (code X). Contactez votre fournisseur." | "Server error (code X). Contact your provider." |
|
||||
| 6 | HTTP 5xx | `ServerException` | Toast | "Le serveur Xtream rencontre un problème." | "Xtream server is having issues." |
|
||||
| 7 | JSON mal formé | `ParseException` | Fallback silencieux | "Données reçues incorrectes." | "Received data is malformed." |
|
||||
| 8 | EPG vide | N/A (pas une erreur) | Texte vide | "Aucun programme trouvé pour cette chaîne." | "No program found for this channel." |
|
||||
| 9 | Cache corrompu | N/A | Suppression + reload | "Mise à jour des données..." | "Updating data..." |
|
||||
|
||||
## État loading global
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* État unique pour TOUS les écrans.
|
||||
* Permet un traitement uniforme dans les composants UI.
|
||||
*/
|
||||
sealed interface UiState<out T> {
|
||||
data object Idle : UiState<Nothing>
|
||||
data object Loading : UiState<Nothing>
|
||||
data class Success<T>(val data: T) : UiState<T>
|
||||
data class Error(
|
||||
val message: String, // Message affiché à l'utilisateur
|
||||
val retry: Boolean = true, // Afficher un bouton "Réessayer" ?
|
||||
val exception: XtreamException? = null // Pour logging uniquement
|
||||
) : UiState<Nothing>
|
||||
}
|
||||
```
|
||||
|
||||
## Intercepteur OkHttp
|
||||
|
||||
```kotlin
|
||||
class XtreamErrorInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
return try {
|
||||
val response = chain.proceed(request)
|
||||
if (!response.isSuccessful) {
|
||||
when (response.code) {
|
||||
401, 403 -> throw XtreamException.AuthException(
|
||||
"Authentication failed"
|
||||
)
|
||||
in 400..499 -> throw XtreamException.ServerException(
|
||||
response.code, "Client error"
|
||||
)
|
||||
in 500..599 -> throw XtreamException.ServerException(
|
||||
response.code, "Server error"
|
||||
)
|
||||
}
|
||||
}
|
||||
response
|
||||
} catch (e: SocketTimeoutException) {
|
||||
throw XtreamException.TimeoutException("Connection timed out")
|
||||
} catch (e: ConnectException) {
|
||||
throw XtreamException.NetworkException("Cannot connect")
|
||||
} catch (e: UnknownHostException) {
|
||||
throw XtreamException.NetworkException("DNS resolution failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comportement UI par erreur
|
||||
|
||||
### Écran de connexion
|
||||
```
|
||||
[Champs vides]
|
||||
❌ Bouton Connexion désactivé
|
||||
|
||||
[Erreur réseau]
|
||||
✅ Bouton actif, message rouge sous le formulaire
|
||||
✅ Champs conservés (pas de reset)
|
||||
|
||||
[Auth refusée]
|
||||
✅ Message : "Identifiants incorrects"
|
||||
✅ Focus revient sur le champ mot de passe
|
||||
```
|
||||
|
||||
### Écran Live TV
|
||||
```
|
||||
[Erreur chargement chaînes]
|
||||
✅ Toast 3s : "Impossible de charger les chaînes"
|
||||
✅ Cache affiché si disponible
|
||||
|
||||
[Chaîne sans flux]
|
||||
✅ Overlay : "Flux indisponible" + bouton "Chaîne suivante"
|
||||
```
|
||||
|
||||
### Lecteur vidéo
|
||||
```
|
||||
[Flux mort pendant lecture]
|
||||
✅ Remplacer par écran noir + message
|
||||
✅ Bouton "Chaîne suivante" visible
|
||||
✅ Retour possible vers le menu
|
||||
```
|
||||
|
||||
## Règles de logging
|
||||
|
||||
| Niveau | Usage | Exemple |
|
||||
|--------|-------|---------|
|
||||
| `Timber.d` | Debug uniquement (pas en prod) | "Loading 42 live channels" |
|
||||
| `Timber.i` | Événements importants | "User logged in: user123" |
|
||||
| `Timber.w` | Anomalies non bloquantes | "EPG cache expired for stream 99" |
|
||||
| `Timber.e` | Erreurs utilisateur | "Stream unavailable for stream 42" |
|
||||
|
||||
**❌ Interdit** : `Log.d("PASSWORD", password)` ou tout log contenant des credentials.
|
||||
|
||||
## Annexes
|
||||
|
||||
### Codes Xtream que l'on peut rencontrer
|
||||
|
||||
| Code | Signification | Action |
|
||||
|------|--------------|--------|
|
||||
| `auth = "0"` | Compte invalide | Message login |
|
||||
| `status = "Expired"` | Abonnement expiré | Message distinct |
|
||||
| `status = "Disabled"` | Compte désactivé | Message distinct |
|
||||
| `tv_archive = 0` | Pas d'archive | Cacher bouton Replay |
|
||||
| `has_archive = false` | Pas d'archive | Cacher bouton Replay |
|
||||
| Réponse vide | Serveur HS ou mauvaise URL | Erreur réseau générique |
|
||||
@@ -0,0 +1,159 @@
|
||||
# Sécurité — iMedia TV+
|
||||
|
||||
## Principes
|
||||
|
||||
1. **Aucun secret en clair sur le disque ou dans les logs**
|
||||
2. **Chiffrement de bout en bout** du stockage local des credentials
|
||||
3. **Isolation** : l'application ne partage pas les credentials avec d'autres apps
|
||||
4. **Principle of least privilege** : permissions Android minimales
|
||||
|
||||
## Stockage des credentials
|
||||
|
||||
### Implémentation
|
||||
|
||||
```kotlin
|
||||
// data/storage/SecureStorage.kt
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
class SecureStorage(context: Context) {
|
||||
private val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
private val prefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"imedia_secure_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
|
||||
fun saveCredentials(server: String, username: String, password: String) {
|
||||
prefs.edit()
|
||||
.putString("server_url", server)
|
||||
.putString("username", username)
|
||||
.putString("password", password)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getCredentials(): Credentials? {
|
||||
val server = prefs.getString("server_url", null)
|
||||
val username = prefs.getString("username", null)
|
||||
val password = prefs.getString("password", null)
|
||||
return if (server != null && username != null && password != null) {
|
||||
Credentials(server, username, password)
|
||||
} else null
|
||||
}
|
||||
|
||||
fun clearCredentials() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour les favoris et la chaîne épinglée (données non sensibles), la version 1 utilisait des `SharedPreferences` classiques — acceptable. Si on veut tout unifier, on utilise `EncryptedSharedPreferences` partout.
|
||||
|
||||
### Ce qui est chiffré
|
||||
|
||||
| Donnée | Niveau | Sensibilité |
|
||||
|--------|--------|-------------|
|
||||
| Server URL | AES-256 GCM | Haute |
|
||||
| Username | AES-256 GCM | Haute |
|
||||
| Password | AES-256 GCM | Critique |
|
||||
| Favoris | AES-256 SIV keys | Moyenne |
|
||||
| Chaîne épinglée | AES-256 SIV keys | Basse |
|
||||
|
||||
## Fichier de mapping des endpoints
|
||||
|
||||
**Important :** les URLs Xtream construites avec le builder ne doivent pas non plus être logguées.
|
||||
|
||||
```kotlin
|
||||
// util/XtreamUrlBuilder.kt
|
||||
class XtreamUrlBuilder(private val credentials: Credentials) {
|
||||
|
||||
fun liveUrl(streamId: Int): String =
|
||||
"${credentials.server}/live/${credentials.username}/${credentials.password}/$streamId.m3u8"
|
||||
|
||||
// ⚠️ Attention : cette méthode est appelée par PlayerScreen
|
||||
// — NE PAS logger son résultat
|
||||
}
|
||||
```
|
||||
|
||||
## Règles strictes
|
||||
|
||||
### ✅ À faire
|
||||
|
||||
- Utiliser `Timber.d` pour les logs de debug (supprimés en release par ProGuard)
|
||||
- Vérifier les inputs utilisateur avant de les envoyer (URL bien formée)
|
||||
- Timeout réseau configuré (15s connect, 30s read)
|
||||
- Nettoyer les credentials à la déconnexion
|
||||
- Minify + ProGuard en release (obfuscation)
|
||||
|
||||
### ❌ Interdit
|
||||
|
||||
```kotlin
|
||||
// JAMAIS :
|
||||
Log.d("Auth", "Login with $username / $password")
|
||||
Timber.d("Server URL: $serverUrl")
|
||||
println("Password is: $password")
|
||||
Log.e("API", "Stream URL: ${urlBuilder.liveUrl(42)}")
|
||||
|
||||
// En release, ces logs ne devraient pas survivre,
|
||||
// mais ne prenez pas le risque en debug non plus.
|
||||
```
|
||||
|
||||
## ProGuard
|
||||
|
||||
```pro
|
||||
# app/proguard-rules.pro
|
||||
# Sécurité : ne pas exposer les données chiffrées
|
||||
-keepclassmembers class ma.tkgraph.xtreamtv.data.storage.** { *; }
|
||||
|
||||
# OkHttp / Retrofit déjà couverts par leurs propres rules
|
||||
# Ne pas garder de logs en release
|
||||
-assumenosideeffects class timber.log.Timber {
|
||||
public static *** d(...);
|
||||
public static *** v(...);
|
||||
}
|
||||
```
|
||||
|
||||
## Permissions Android
|
||||
|
||||
```xml
|
||||
<!-- AndroidManifest.xml — le strict minimum -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- ⚠️ PAS de READ_EXTERNAL_STORAGE ni WRITE_EXTERNAL_STORAGE -->
|
||||
```
|
||||
|
||||
L'application ne lit pas de fichiers locaux et ne sauvegarde que des données dans son propre espace de stockage privé — pas besoin de permissions de stockage.
|
||||
|
||||
## HTTPS
|
||||
|
||||
Bien que le protocole Xtream utilise souvent HTTP (non chiffré), l'application doit :
|
||||
1. **Accepter HTTP** pour la compatibilité
|
||||
2. **Préférer HTTPS** si le serveur le supporte
|
||||
3. **Ne pas désactiver la validation** SSL/TLS (pas de `TrustAllCertificates`)
|
||||
|
||||
Si le fournisseur Xtream le permet, les credentials transitent en HTTPS, ce qui protège le mot de passe en transit.
|
||||
|
||||
## Audit
|
||||
|
||||
Une amélioration future possible : `Sentry` ou `Firebase Crashlytics` pour monitorer les crashs. Attention cependant à ne pas envoyer de données sensibles dans les rapports :
|
||||
|
||||
```kotlin
|
||||
// Dans Application.onCreate()
|
||||
FirebaseCrashlytics.getInstance().setCustomKey("screen", "login")
|
||||
// ❌ PAS FirebaseCrashlytics.getInstance().setCustomKey("username", username)
|
||||
```
|
||||
|
||||
## Checklist de sécurité avant release
|
||||
|
||||
- [ ] Aucun log de credentials dans le code (grep "password" dans les fichiers source)
|
||||
- [ ] EncryptedSharedPreferences en place, pas de fallback SharedPreferences
|
||||
- [ ] Timeouts réseau configurés
|
||||
- [ ] Permissions minimales (seulement INTERNET + ACCESS_NETWORK_STATE)
|
||||
- [ ] ProGuard activé en release
|
||||
- [ ] Pas de validation SSL désactivée
|
||||
- [ ] Gestion de la déconnexion efface TOUTES les données
|
||||
@@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 -XX:+UseG1GC
|
||||
org.gradle.parallel=false
|
||||
org.gradle.configuration-cache=false
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -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,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "iMediaTVPlus"
|
||||
include(":app")
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.software.leanback" android:required="true" />
|
||||
|
||||
<application
|
||||
android:name=".XtreamApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:banner="@drawable/ic_banner"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.iMediaTVPlus"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||
android:theme="@style/Theme.iMediaTVPlus.Splash">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,57 @@
|
||||
package ma.tkgraph.xtreamtv
|
||||
|
||||
import android.content.Context
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamApi
|
||||
import ma.tkgraph.xtreamtv.data.repository.AuthRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.EpgRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.FavoritesRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.LiveRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.M3uRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.RecentLiveRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.SeriesRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.UsageRepository
|
||||
import ma.tkgraph.xtreamtv.data.repository.VodRepository
|
||||
import ma.tkgraph.xtreamtv.data.storage.SecureStorage
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamBaseUrlInterceptor
|
||||
import ma.tkgraph.xtreamtv.data.network.XtreamErrorInterceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Container d'injection de dépendances manuel.
|
||||
* Pas de Hilt/Dagger pour garder le projet léger.
|
||||
*/
|
||||
class AppContainer(context: Context) {
|
||||
|
||||
val secureStorage = SecureStorage(context)
|
||||
|
||||
val m3uRepository = M3uRepository(secureStorage)
|
||||
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(XtreamBaseUrlInterceptor())
|
||||
.addInterceptor(XtreamErrorInterceptor())
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(8, TimeUnit.SECONDS)
|
||||
.writeTimeout(8, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val retrofit = Retrofit.Builder()
|
||||
.baseUrl("http://localhost") // Sera remplacé dynamiquement
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
val xtreamApi: XtreamApi = retrofit.create(XtreamApi::class.java)
|
||||
|
||||
// ─── Repositories ───
|
||||
val authRepository = AuthRepository(xtreamApi, secureStorage)
|
||||
val usageRepository = UsageRepository(secureStorage)
|
||||
val recentLiveRepository = RecentLiveRepository(context)
|
||||
val liveRepository = LiveRepository(xtreamApi, secureStorage, m3uRepository, usageRepository)
|
||||
val epgRepository = EpgRepository(xtreamApi, secureStorage)
|
||||
val vodRepository = VodRepository(xtreamApi, secureStorage, usageRepository)
|
||||
val seriesRepository = SeriesRepository(xtreamApi, secureStorage, usageRepository)
|
||||
val favoritesRepository = FavoritesRepository(secureStorage)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package ma.tkgraph.xtreamtv
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import ma.tkgraph.xtreamtv.ui.navigation.NavState
|
||||
import ma.tkgraph.xtreamtv.ui.screens.GlobalSearchScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.HomeScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.LoginScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.MainMenuScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.PlayerScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.ProgramGuideScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.ReplayGuideScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.SeriesScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.VodScreen
|
||||
import ma.tkgraph.xtreamtv.ui.screens.YoutubeScreen
|
||||
import ma.tkgraph.xtreamtv.ui.theme.XtreamTheme
|
||||
import ma.tkgraph.xtreamtv.util.SessionManager
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleIntentExtras()
|
||||
setContent {
|
||||
XtreamTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIntentExtras() {
|
||||
val serverUrl = intent.getStringExtra("server_url")
|
||||
val username = intent.getStringExtra("xtream_user")
|
||||
val password = intent.getStringExtra("xtream_pass")
|
||||
if (serverUrl != null && username != null && password != null) {
|
||||
XtreamApplication.instance.appContainer.secureStorage
|
||||
.saveConfig(ma.tkgraph.xtreamtv.data.model.XtreamConfig(
|
||||
serverUrl = serverUrl,
|
||||
username = username,
|
||||
password = password
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation principale.
|
||||
* L'auto-démarrage sur la chaîne épinglée est géré par HomeScreen
|
||||
* qui vérifie le pin au chargement des streams.
|
||||
*/
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val sessionManager = remember {
|
||||
SessionManager(XtreamApplication.instance.appContainer.secureStorage)
|
||||
}
|
||||
var hasSession by remember {
|
||||
mutableStateOf(sessionManager.hasValidSession())
|
||||
}
|
||||
var navState by remember { mutableStateOf<NavState>(NavState.MainMenu) }
|
||||
var hasAutoPlayedPin by remember { mutableStateOf(false) }
|
||||
|
||||
if (!hasSession) {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
hasSession = true
|
||||
navState = NavState.MainMenu
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
when (val state = navState) {
|
||||
is NavState.MainMenu -> MainMenuScreen(
|
||||
onOpenTv = { navState = NavState.Home() },
|
||||
onOpenVod = { navState = NavState.Vod() },
|
||||
onOpenSeries = { navState = NavState.Series() },
|
||||
onOpenSearch = { navState = NavState.GlobalSearch },
|
||||
onOpenYoutube = { navState = NavState.Youtube },
|
||||
onNavigate = { navState = it }
|
||||
)
|
||||
|
||||
is NavState.GlobalSearch -> GlobalSearchScreen(
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
|
||||
is NavState.Youtube -> YoutubeScreen(
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
|
||||
is NavState.Home -> HomeScreen(
|
||||
focusStreamId = state.focusStreamId,
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu },
|
||||
hasAutoPlayedPin = hasAutoPlayedPin,
|
||||
onAutoPlayPin = { hasAutoPlayedPin = true }
|
||||
)
|
||||
|
||||
is NavState.Player -> PlayerScreen(
|
||||
initialStream = state.stream,
|
||||
allStreams = state.allStreams,
|
||||
initialIndex = state.currentIndex,
|
||||
replayUrl = state.replayUrl,
|
||||
progressKey = state.progressKey,
|
||||
progressKeys = state.progressKeys,
|
||||
onBack = {
|
||||
navState = state.returnState ?: NavState.Home()
|
||||
}
|
||||
)
|
||||
is NavState.ProgramGuide -> ProgramGuideScreen(
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
|
||||
is NavState.ReplayGuide -> ReplayGuideScreen(
|
||||
stream = state.stream,
|
||||
onBack = { navState = NavState.Home(state.stream.streamId) },
|
||||
onPlayReplay = { url ->
|
||||
navState = NavState.Player(state.stream, listOf(state.stream), 0, replayUrl = url)
|
||||
}
|
||||
)
|
||||
|
||||
is NavState.Vod -> VodScreen(
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
|
||||
is NavState.Series -> SeriesScreen(
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
|
||||
is NavState.SeriesDetail -> SeriesScreen(
|
||||
onNavigate = { navState = it },
|
||||
onBack = { navState = NavState.MainMenu }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package ma.tkgraph.xtreamtv
|
||||
|
||||
import android.app.Application
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Point d'entrée de l'application iMedia TV+.
|
||||
* Configure le logging et l'injection de dépendances.
|
||||
*/
|
||||
class XtreamApplication : Application() {
|
||||
|
||||
/** Container d'injection de dépendances (manual DI). */
|
||||
lateinit var appContainer: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
appContainer = AppContainer(this)
|
||||
initLogging()
|
||||
}
|
||||
|
||||
private fun initLogging() {
|
||||
if (BuildConfig.ENABLE_LOGGING) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
} else {
|
||||
// En production : Crashlytics tree (à configurer)
|
||||
// Timber.plant(CrashReportingTree())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: XtreamApplication
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Réponse de l'API /player_api.php?username=...&password=...
|
||||
*/
|
||||
data class AuthResponse(
|
||||
@SerializedName("user_info") val userInfo: UserInfo? = null,
|
||||
val auth: String = "0",
|
||||
val status: String = ""
|
||||
)
|
||||
|
||||
data class UserInfo(
|
||||
val username: String = "",
|
||||
@SerializedName("auth") val auth: Int = 0,
|
||||
val status: String = "",
|
||||
@SerializedName("created_at") val createdAt: String = "",
|
||||
@SerializedName("exp_date") val expDate: String = "",
|
||||
@SerializedName("is_trial") val isTrial: String = "0",
|
||||
@SerializedName("max_connections") val maxConnections: String = "0",
|
||||
@SerializedName("allowed_output_formats") val allowedFormats: List<String> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import android.util.Base64
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
data class EpgProgramDto(
|
||||
@SerializedName("id") val id: String? = null,
|
||||
@SerializedName("epg_id") val epgId: String? = null,
|
||||
@SerializedName("title") val rawTitle: String? = null,
|
||||
@SerializedName("description") val rawDescription: String? = null,
|
||||
@SerializedName("start") val start: String? = null,
|
||||
@SerializedName("end") val end: String? = null,
|
||||
@SerializedName("start_timestamp") val startTimestamp: String? = null,
|
||||
@SerializedName("stop_timestamp") val stopTimestamp: String? = null,
|
||||
@SerializedName("has_archive") val hasArchive: Int? = null,
|
||||
@SerializedName("now_playing") val nowPlaying: Int? = null,
|
||||
@SerializedName("lang") val lang: String? = null
|
||||
) {
|
||||
fun titleText(): String {
|
||||
return decodeMaybeBase64(rawTitle).ifBlank { "" }
|
||||
}
|
||||
|
||||
fun startMillis(): Long? {
|
||||
startTimestamp?.toLongOrNull()?.let { return it * 1000L }
|
||||
return null
|
||||
}
|
||||
|
||||
fun stopMillis(): Long? {
|
||||
stopTimestamp?.toLongOrNull()?.let { return it * 1000L }
|
||||
return null
|
||||
}
|
||||
|
||||
/** Durée en minutes, calculée depuis les timestamps ou la plage horaire. */
|
||||
fun durationMinutes(): Int {
|
||||
// Depuis les timestamps Unix (en secondes)
|
||||
val startSec = startTimestamp?.toLongOrNull() ?: return 120
|
||||
val stopSec = stopTimestamp?.toLongOrNull() ?: return 120
|
||||
return ((stopSec - startSec) / 60).toInt().coerceIn(1, 720)
|
||||
}
|
||||
}
|
||||
|
||||
/** Décode si Base64, sinon retourne tel quel. */
|
||||
private fun decodeMaybeBase64(value: String?): String {
|
||||
val text = value.orEmpty().trim()
|
||||
if (text.isBlank()) return ""
|
||||
return runCatching {
|
||||
val decoded = Base64.decode(text, Base64.DEFAULT)
|
||||
String(decoded, StandardCharsets.UTF_8).trim()
|
||||
}.getOrNull()
|
||||
?.takeIf { decoded ->
|
||||
decoded.isNotBlank() && decoded.any { it.isLetterOrDigit() }
|
||||
}
|
||||
?: text
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class EpgShortResponse(
|
||||
@SerializedName("epg_listings") val listings: List<EpgProgramDto>? = emptyList()
|
||||
)
|
||||
|
||||
data class EpgFullResponse(
|
||||
@SerializedName("epg_listings") val listings: List<EpgProgramDto>? = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* DTO pour une catégorie Live.
|
||||
*/
|
||||
data class LiveCategoryDto(
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("category_name") val categoryName: String = "",
|
||||
@SerializedName("parent_id") val parentId: Int = 0
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* DTO pour un flux Live (chaîne TV).
|
||||
*/
|
||||
data class LiveStreamDto(
|
||||
val num: Int = 0,
|
||||
val name: String = "",
|
||||
@SerializedName("stream_id") val streamId: Int = 0,
|
||||
@SerializedName("stream_type") val streamType: String = "",
|
||||
@SerializedName("stream_icon") val streamIcon: String? = null,
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("epg_channel_id") val epgChannelId: String? = null,
|
||||
@SerializedName("tv_archive") val tvArchive: Int = 0,
|
||||
@SerializedName("has_archive") val hasArchive: Boolean = false,
|
||||
@SerializedName("container_extension") val containerExtension: String = "m3u8",
|
||||
val added: String = "",
|
||||
@SerializedName("custom_sid") val customSid: String? = null,
|
||||
@SerializedName("tv_archive_duration") val archiveDuration: Int = 0,
|
||||
/** URL directe du flux (utilisé en mode M3U). */
|
||||
val directUrl: String = ""
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* DTO pour une série.
|
||||
*/
|
||||
data class SeriesDto(
|
||||
val num: Int = 0,
|
||||
val name: String = "",
|
||||
@SerializedName("series_id") val seriesId: Int = 0,
|
||||
val cover: String? = null,
|
||||
val plot: String? = null,
|
||||
val cast: String? = null,
|
||||
val rating: String? = null,
|
||||
val genre: String? = null,
|
||||
val year: String? = null,
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("last_modified") val lastModified: String? = null
|
||||
)
|
||||
|
||||
data class SeriesCategoryDto(
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("category_name") val categoryName: String = "",
|
||||
@SerializedName("parent_id") val parentId: Int = 0
|
||||
)
|
||||
|
||||
/**
|
||||
* Détail d'une série (saisons + épisodes).
|
||||
*/
|
||||
data class SeriesInfoDto(
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
val episodes: Map<String, List<EpisodeDto>> = emptyMap()
|
||||
)
|
||||
|
||||
data class SeasonDto(
|
||||
val id: Int = 0,
|
||||
@SerializedName("season_number") val seasonNumber: Int = 0,
|
||||
val name: String = ""
|
||||
)
|
||||
|
||||
data class EpisodeDto(
|
||||
val id: Int = 0,
|
||||
@SerializedName("episode_num") val episodeNum: Int = 0,
|
||||
val title: String? = null,
|
||||
val name: String? = null,
|
||||
@SerializedName("episode_name") val episodeName: String? = null,
|
||||
@SerializedName("container_extension") val containerExtension: String = "mp4",
|
||||
val info: EpisodeInfoDto? = null
|
||||
)
|
||||
|
||||
data class EpisodeInfoDto(
|
||||
@SerializedName("movie_image") val movieImage: String? = null,
|
||||
val plot: String? = null,
|
||||
val duration: String? = null,
|
||||
val rating: String? = null
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
/**
|
||||
* État générique pour tous les écrans.
|
||||
* Usage : ViewModel expose `StateFlow<UiState<T>>`
|
||||
*/
|
||||
sealed interface UiState<out T> {
|
||||
data object Idle : UiState<Nothing>
|
||||
data object Loading : UiState<Nothing>
|
||||
data class Success<T>(val data: T) : UiState<T>
|
||||
data class Error(
|
||||
val message: String,
|
||||
val retry: Boolean = true,
|
||||
val exception: XtreamException? = null
|
||||
) : UiState<Nothing>
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* DTO pour un film VOD.
|
||||
*/
|
||||
data class VodDto(
|
||||
val num: Int = 0,
|
||||
val name: String = "",
|
||||
@SerializedName("stream_id") val streamId: Int = 0,
|
||||
@SerializedName("stream_type") val streamType: String = "movie",
|
||||
@SerializedName("stream_icon") val streamIcon: String? = null,
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("container_extension") val containerExtension: String = "mp4",
|
||||
val rating: String? = null,
|
||||
val added: String = "",
|
||||
val description: String? = null,
|
||||
val year: String? = null,
|
||||
val genre: String? = null,
|
||||
val director: String? = null,
|
||||
val cast: String? = null
|
||||
)
|
||||
|
||||
data class VodCategoryDto(
|
||||
@SerializedName("category_id") val categoryId: String = "",
|
||||
@SerializedName("category_name") val categoryName: String = "",
|
||||
@SerializedName("parent_id") val parentId: Int = 0
|
||||
)
|
||||