Initial upload: iptv-android-tv-2

This commit is contained in:
Abderrahim-Mouhcine
2026-08-16 19:57:47 +01:00
commit 5c0540e727
90 changed files with 20580 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.gradle/
build/
local.properties
.idea/
*.iml
.DS_Store
app/build/
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
# Xtream Google TV Player
Projet Android TV / Google TV en Kotlin pour lire un accès Xtream compatible.
Ce ZIP contient un MVP propre : écran de connexion, appel `player_api.php`, chargement des chaînes Live et lecture vidéo avec Media3 ExoPlayer.
## Contenu
```text
XtreamGoogleTvPlayer/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle/libs.versions.toml
└── app/
├── build.gradle.kts
├── proguard-rules.pro
└── src/main/
├── AndroidManifest.xml
├── java/ma/tkgraph/xtreamtv/
│ ├── MainActivity.kt
│ ├── data/
│ │ ├── model/
│ │ ├── network/
│ │ └── repository/
│ ├── ui/
│ │ ├── screens/
│ │ └── theme/
│ └── util/
└── res/
```
## Fonctionnalités incluses
- Application Android TV reconnue par le launcher Google TV.
- Connexion via serveur, username et password Xtream.
- Vérification du compte via `player_api.php`.
- Récupération des chaînes Live via `get_live_streams`.
- Lecture des flux Live en `.m3u8` avec Media3 ExoPlayer.
- Navigation simple compatible télécommande.
- Dépendances prêtes dans Gradle Version Catalog.
## Prérequis
- Android Studio récent.
- JDK 17.
- SDK Android 36 installé.
- Un appareil Google TV / Android TV ou un émulateur Android TV.
## Lancement
1. Dézipper le projet.
2. Ouvrir le dossier dans Android Studio.
3. Laisser Gradle synchroniser.
4. Lancer sur un appareil Android TV / Google TV.
Commande possible :
```bash
./gradlew :app:assembleDebug
```
Sur Windows :
```powershell
gradlew.bat :app:assembleDebug
```
> Le wrapper Gradle complet n'est pas inclus dans ce ZIP. Android Studio peut utiliser son Gradle intégré. Pour générer un wrapper : `gradle wrapper`.
## Configuration Xtream attendue
Exemple :
```text
Serveur : http://exemple.com:8080
Username : mon_user
Password : mon_pass
```
L'application appelle :
```text
/player_api.php?username=USER&password=PASS
/player_api.php?username=USER&password=PASS&action=get_live_streams
```
Puis génère une URL de lecture :
```text
/live/USER/PASS/STREAM_ID.m3u8
```
Si ton fournisseur utilise `.ts` au lieu de `.m3u8`, modifie cette ligne dans `MainActivity.kt` :
```kotlin
extension = "m3u8"
```
par :
```kotlin
extension = "ts"
```
## Fichiers principaux
| Fichier | Rôle |
|---|---|
| `MainActivity.kt` | Navigation simple entre login, accueil et lecteur |
| `LoginScreen.kt` | Écran de connexion Xtream |
| `HomeScreen.kt` | Liste des chaînes Live |
| `PlayerScreen.kt` | Lecteur vidéo Media3/ExoPlayer |
| `XtreamApi.kt` | Endpoints API Xtream |
| `XtreamApiFactory.kt` | Création Retrofit dynamique selon le serveur |
| `XtreamRepository.kt` | Couche d'accès aux données |
| `XtreamUrlBuilder.kt` | Génération des URLs Live, VOD et séries |
## À améliorer ensuite
- Stockage sécurisé des identifiants.
- Favoris.
- Catégories Live.
- VOD.
- Séries.
- EPG XMLTV.
- Reprise de lecture.
- Recherche.
- Choix automatique `.m3u8` / `.ts`.
- Design TV plus avancé avec composants TV spécialisés.
## Note légale
Cette base technique sert à lire des flux pour lesquels l'utilisateur ou le fournisseur possède les droits nécessaires. Ne l'utilise pas pour contourner des accès, redistribuer du contenu protégé ou faire n'importe quoi que les avocats adorent transformer en facture.
+39
View File
@@ -0,0 +1,39 @@
# Mise à jour avancée Google TV
Cette mise à jour ajoute :
- design TV plus sophistiqué ;
- favoris chaînes ;
- retour depuis le lecteur vers la même catégorie et la même chaîne ;
- chaîne épinglée pour démarrage automatique ;
- affichage de la résolution détectée pendant la lecture ;
- recherche de catégorie ;
- recherche de chaîne ;
- zapping gauche/droite dans le lecteur.
## Installation
Depuis PowerShell :
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2"
Copy-Item "D:\07-Logiciels\11-IPTV Andoid TV 2" "D:\07-Logiciels\11-IPTV Andoid TV 2 - Backup avant update avancee" -Recurse
Expand-Archive -Path "$env:USERPROFILE\Downloads\xtream-google-tv-player-advanced-update.zip" -DestinationPath "D:\07-Logiciels\11-IPTV Andoid TV 2" -Force
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
## Utilisation
- Dans le menu, utilise la recherche à gauche pour filtrer les catégories.
- Utilise la recherche à droite pour filtrer les chaînes.
- Bouton Favori : ajoute ou retire la chaîne des favoris.
- Bouton Épingler : l’application ouvrira cette chaîne directement au prochain démarrage.
- Dans le lecteur, les touches gauche/droite changent de chaîne dans la liste filtrée actuelle.
- Retour revient au menu en gardant la même catégorie et la même chaîne sélectionnée.
+43
View File
@@ -0,0 +1,43 @@
# Mise à jour Xtream Google TV Player - Catégories Live
Cette archive contient uniquement les fichiers modifiés ou ajoutés pour :
- conserver la mémoire des identifiants Xtream ;
- ajouter l'appel API `get_live_categories` ;
- afficher les catégories Live à gauche ;
- filtrer les chaînes par catégorie ;
- garder l'activité lançable dans Google TV ;
- corriger la configuration Gradle déjà validée pendant les tests.
## Installation rapide
Depuis PowerShell :
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2"
Copy-Item "D:\07-Logiciels\11-IPTV Andoid TV 2" "D:\07-Logiciels\11-IPTV Andoid TV 2 - Backup avant categories" -Recurse
Expand-Archive -Path "$env:USERPROFILE\Downloads\xtream-google-tv-player-categories-update.zip" -DestinationPath "D:\07-Logiciels\11-IPTV Andoid TV 2" -Force
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
## Fichiers modifiés ou ajoutés
- `build.gradle.kts`
- `gradle/libs.versions.toml`
- `gradle.properties`
- `app/build.gradle.kts`
- `app/src/main/AndroidManifest.xml`
- `app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/model/LiveCategoryDto.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamApi.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/XtreamRepository.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/storage/ConfigStorage.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/LoginScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt`
+34
View File
@@ -0,0 +1,34 @@
# Mise à jour tri des catégories
Cette mise à jour trie les catégories dans l'ordre suivant :
1. Catégories françaises
2. Catégories arabes
3. Autres catégories
Les catégories internes de l'application restent en haut :
- Toutes les chaînes / Toutes les VOD / Toutes les séries
- Favoris
Puis les catégories réelles du serveur Xtream sont triées selon leur nom.
## Fichiers modifiés / ajoutés
- app/src/main/java/ma/tkgraph/xtreamtv/util/CategorySortUtils.kt
- app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt
- app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ProgramGuideScreen.kt
- app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/VodScreen.kt
- app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/SeriesScreen.kt
## Installation
Depuis le dossier du projet :
```powershell
Expand-Archive -Path "$env:USERPROFILE\Downloads\xtream-google-tv-player-category-sort-update.zip" -DestinationPath "D:\07-Logiciels\11-IPTV Andoid TV 2" -Force
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+40
View File
@@ -0,0 +1,40 @@
# Mise à jour EPG + Replay
Cette mise à jour ajoute une première version fonctionnelle de l'EPG et du replay/catch-up pour les serveurs Xtream compatibles.
## Ajouts
- Récupération EPG courte via `player_api.php?action=get_short_epg`.
- Affichage du programme en cours dans la liste des chaînes.
- Affichage du programme suivant quand le serveur le fournit.
- Bouton `Replay` dans la ligne de chaîne quand le replay est disponible.
- Lecture replay via l'URL Xtream `timeshift`.
- Affichage du programme dans l'overlay du lecteur.
- Conservation du zapping gauche/droite.
## Limites connues
Le replay dépend totalement de ce que le fournisseur Xtream renvoie :
- `tv_archive` dans la liste des chaînes ;
- `has_archive` dans l'EPG ;
- dates `start` / `end` valides ;
- endpoint `/timeshift/...` actif côté serveur.
Si le serveur ne fournit pas l'EPG ou bloque le timeshift, l'application affichera `EPG indisponible` ou le bouton Replay restera désactivé.
## Installation
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2"
Copy-Item "D:\07-Logiciels\11-IPTV Andoid TV 2" "D:\07-Logiciels\11-IPTV Andoid TV 2 - Backup avant EPG replay" -Recurse
Expand-Archive -Path "$env:USERPROFILE\Downloads\xtream-google-tv-player-epg-replay-update.zip" -DestinationPath "D:\07-Logiciels\11-IPTV Andoid TV 2" -Force
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+29
View File
@@ -0,0 +1,29 @@
# Mise à jour logos chaînes
Cette mise à jour ajoute l’affichage des logos de chaînes fournis par Xtream via le champ `stream_icon`.
## Changements
- Affichage du logo dans chaque ligne de chaîne.
- Fallback automatique avec initiales si le logo est absent ou impossible à charger.
- Affichage du logo dans l’overlay du lecteur avec le nom de la chaîne et la résolution.
- Ajout de Coil 3 pour charger les images distantes.
## Fichiers modifiés
- `app/build.gradle.kts`
- `app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/PlayerScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/components/ChannelLogo.kt`
## Installation
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2"
Expand-Archive -Path "$env:USERPROFILE\Downloads\xtream-google-tv-player-logos-update.zip" -DestinationPath "D:\07-Logiciels\11-IPTV Andoid TV 2" -Force
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+24
View File
@@ -0,0 +1,24 @@
# Mise à jour lecteur + retour + focus
Correctifs inclus :
- Overlay du lecteur masqué automatiquement après 3 secondes.
- Premier bouton Retour dans le lecteur : affiche les infos.
- Deuxième bouton Retour : revient au menu.
- Suppression des indications F / P.
- Confirmation de fermeture quand Retour est utilisé dans le menu principal.
- Au retour depuis une chaîne, le focus revient sur la ligne de la chaîne sélectionnée, pas dans les champs de recherche.
Compilation :
```powershell
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
```
Installation :
```powershell
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+32
View File
@@ -0,0 +1,32 @@
# Mise à jour Guide TV global + correction focus recherche
Cette mise à jour ajoute un écran **Guide des programmes** et corrige la perte de focus dans les zones de recherche.
## Ajouts
- Bouton **Guide TV** dans l’accueil.
- Écran avec :
- recherche de catégorie ;
- recherche de chaîne ;
- recherche d’émission ;
- liste des chaînes ;
- liste complète des programmes disponibles pour la chaîne sélectionnée ;
- bouton **Direct** ;
- bouton **Revoir** pour lancer un replay quand disponible.
- Correction du bug où le focus quittait la zone de recherche après une lettre.
- Retour vers l’accueil en gardant la chaîne sélectionnée.
## Installation
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2"
Expand-Archive -Path "$env:USERPROFILE\Downloads\xtream-google-tv-player-program-guide-update.zip" -DestinationPath "D:\07-Logiciels\11-IPTV Andoid TV 2" -Force
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
## Note
Le guide complet dépend de ce que le serveur Xtream renvoie avec `get_simple_data_table`.
+43
View File
@@ -0,0 +1,43 @@
# Mise à jour Replay Guide complet
Cette mise à jour transforme le bouton Replay en vrai guide replay par chaîne.
## Ajouts
- Chargement du programme complet de la chaîne avec `get_simple_data_table`.
- Nouvel écran Replay par chaîne.
- Liste des émissions groupées par jour.
- Recherche d’émission dans le guide replay.
- Bouton `Revoir` sur chaque émission disponible.
- Retour vers le menu avec conservation de la chaîne sélectionnée.
- Le replay actuel via `timeshift` reste utilisé pour lancer l’émission choisie.
## Fichiers modifiés / ajoutés
- `app/build.gradle.kts`
- `app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgProgramDto.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/model/EpgFullResponse.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/network/XtreamApi.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/data/repository/XtreamRepository.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/HomeScreen.kt`
- `app/src/main/java/ma/tkgraph/xtreamtv/ui/screens/ReplayGuideScreen.kt`
## Compilation
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2"
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
```
## Installation
```powershell
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
## Note
Le guide complet dépend du fournisseur Xtream. Si `get_simple_data_table` renvoie peu de jours ou rien du tout, l’application affichera ce que le serveur fournit, pas ce que l’univers aurait dû fournir.
+27
View File
@@ -0,0 +1,27 @@
# Mise à jour affichage épisodes séries
Cette mise à jour corrige l'affichage des épisodes dans l'écran Séries.
## Corrections
- Les noms d'épisodes sont affichés sur 2 lignes si nécessaire.
- Les épisodes qui arrivent du serveur avec un titre vide, '.', '-' ou '...' reçoivent un libellé lisible.
- Support de plusieurs champs Xtream possibles pour le titre : `title`, `name`, `episode_name`.
- Le bouton Lire est réduit pour laisser plus de place au titre.
- Les métadonnées affichent saison, extension, durée, date et ID si disponibles.
## Installation
Décompresser le ZIP à la racine du projet puis compiler :
```powershell
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
```
Puis installer :
```powershell
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+26
View File
@@ -0,0 +1,26 @@
# Mise à jour écran de démarrage iMedia TV+
Cette mise à jour remplace l'écran blanc de chargement par l'image fournie `iMedia TV+`.
## Fichiers modifiés / ajoutés
- `app/src/main/java/ma/tkgraph/xtreamtv/MainActivity.kt`
- `app/src/main/res/drawable-nodpi/imedia_tv_background.png`
- `app/src/main/res/drawable/launch_background.xml`
- `app/src/main/res/values/themes.xml`
## Installation
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2"
Copy-Item "D:\07-Logiciels\11-IPTV Andoid TV 2" "D:\07-Logiciels\11-IPTV Andoid TV 2 - Backup avant splash iMedia" -Recurse
Expand-Archive -Path "$env:USERPROFILE\Downloads\xtream-google-tv-player-splash-background-update.zip" -DestinationPath "D:\07-Logiciels\11-IPTV Andoid TV 2" -Force
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+32
View File
@@ -0,0 +1,32 @@
# Mise à jour UI compacte Google TV
Cette mise à jour corrige l'écran principal après test sur TV :
- textes réduits ;
- zones de recherche plus compactes ;
- texte des champs en blanc sur fond sombre ;
- liste des chaînes plus lisible ;
- noms des chaînes visibles ;
- suppression des gros boutons dans chaque ligne de chaîne ;
- barre d'action séparée pour regarder, mettre en favori ou épingler ;
- meilleur comportement avec télécommande ;
- catégories plus compactes ;
- interface moins chargée.
## Installation
Depuis le dossier du projet :
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2"
Copy-Item "D:\07-Logiciels\11-IPTV Andoid TV 2" "D:\07-Logiciels\11-IPTV Andoid TV 2 - Backup avant UI compacte" -Recurse
Expand-Archive -Path "$env:USERPROFILE\Downloads\xtream-google-tv-player-ui-compact-update.zip" -DestinationPath "D:\07-Logiciels\11-IPTV Andoid TV 2" -Force
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+25
View File
@@ -0,0 +1,25 @@
# Mise à jour UI Focus Actions
Cette mise à jour corrige l'accès aux actions Favori et Épingler sur Google TV.
## Changements
- Les boutons ne sont plus uniquement dans la barre supérieure.
- Chaque ligne de chaîne contient maintenant trois actions accessibles avec la télécommande :
- Voir
- ☆ / ★ Favori
- Pin / Ép.
- Navigation plus naturelle : depuis une chaîne, appuyer à droite permet d'atteindre les actions de la même ligne.
- Boutons et texte encore plus compacts.
- La barre d'information reste utile mais ne bloque plus l'accès aux actions.
## Installation
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2"
Expand-Archive -Path "$env:USERPROFILE\Downloads\xtream-google-tv-player-ui-focus-actions-update.zip" -DestinationPath "D:\07-Logiciels\11-IPTV Andoid TV 2" -Force
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+45
View File
@@ -0,0 +1,45 @@
# Mise à jour VOD + Séries
Cette mise à jour ajoute deux catalogues à l'application Google TV :
- VOD / films
- Séries avec saisons et épisodes
## Nouveaux endpoints Xtream utilisés
- `get_vod_categories`
- `get_vod_streams`
- `get_series_categories`
- `get_series`
- `get_series_info`
## Navigation
Depuis l'écran Live :
- bouton `VOD` pour ouvrir les films
- bouton `Séries` pour ouvrir le catalogue séries
- bouton `Live` dans les catalogues pour revenir aux chaînes
## Lecture
- VOD : `/movie/username/password/stream_id.extension`
- Épisode série : `/series/username/password/episode_id.extension`
Le format utilisé vient du champ `container_extension` quand il existe, sinon l'application utilise `mp4`.
## Fichiers principaux modifiés
- `MainActivity.kt`
- `HomeScreen.kt`
- `PlayerScreen.kt`
- `XtreamApi.kt`
- `XtreamRepository.kt`
## Fichiers ajoutés
- `VodStreamDto.kt`
- `SeriesDto.kt`
- `SeriesInfoResponse.kt`
- `VodScreen.kt`
- `SeriesScreen.kt`
+30
View File
@@ -0,0 +1,30 @@
# Mise à jour Favoris VOD + Séries
Cette mise à jour ajoute la gestion des favoris pour les catalogues VOD et Séries.
## Ajouts
- Bouton `☆ / ★` sur chaque film VOD.
- Bouton `☆ / ★` sur chaque série.
- Catégorie automatique `★ Favoris` dans le catalogue VOD.
- Catégorie automatique `★ Favoris` dans le catalogue Séries.
- Sauvegarde persistante des favoris dans `SharedPreferences`.
- Badge `Favori` dans les lignes VOD / Séries et dans l'overlay du lecteur quand le contenu est marqué favori.
## Fichiers modifiés
- `ConfigStorage.kt`
- `MainActivity.kt`
- `VodScreen.kt`
- `SeriesScreen.kt`
## Installation
```powershell
cd "D:\07-Logiciels\11-IPTV Andoid TV 2"
Expand-Archive -Path "$env:USERPROFILE\Downloads\xtream-google-tv-player-vod-series-favorites-update.zip" -DestinationPath "D:\07-Logiciels\11-IPTV Andoid TV 2" -Force
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
adb install -r ".\app\build\outputs\apk\debug\app-debug.apk"
adb shell am start -n ma.tkgraph.xtreamtv/.MainActivity
```
+62
View File
@@ -0,0 +1,62 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "ma.tkgraph.xtreamtv"
compileSdk = 36
defaultConfig {
applicationId = "ma.tkgraph.xtreamtv"
minSdk = 23
targetSdk = 36
versionCode = 8
versionName = "1.7.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.tv.material)
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.ui)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
debugImplementation(libs.androidx.compose.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
# MVP : pas de règles particulières.
# Ajouter des règles si vous activez minifyEnabled=true et si votre API utilise de la réflexion JSON avancée.
+34
View File
@@ -0,0 +1,34 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:allowBackup="true"
android:banner="@drawable/tv_banner"
android:icon="@drawable/ic_launcher_foreground"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_foreground"
android:supportsRtl="true"
android:theme="@style/Theme.XtreamTv"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,929 @@
package ma.tkgraph.xtreamtv
import android.app.Activity
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.SeriesEpisodeItem
import ma.tkgraph.xtreamtv.data.model.SeriesInfoResponse
import ma.tkgraph.xtreamtv.data.model.VodStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApiFactory
import ma.tkgraph.xtreamtv.data.repository.XtreamRepository
import ma.tkgraph.xtreamtv.data.storage.ConfigStorage
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.XtreamTvTheme
import ma.tkgraph.xtreamtv.util.XtreamUrlBuilder
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
XtreamTvTheme {
XtreamTvApp()
}
}
}
}
private const val FAVORITES_CATEGORY_ID = "__favorites__"
private const val UNCATEGORIZED_CATEGORY_ID = "__uncategorized__"
private enum class MediaReturnTarget {
HOME,
VOD,
SERIES
}
private sealed interface AppScreen {
data object Loading : AppScreen
data object Login : AppScreen
data object Home : AppScreen
data object Vod : AppScreen
data object Series : AppScreen
data object ProgramGuide : AppScreen
data class ReplayGuide(val stream: LiveStreamDto) : AppScreen
data class Player(
val stream: LiveStreamDto,
val title: String,
val url: String,
val isReplay: Boolean = false,
val programTitle: String? = null,
val programTime: String? = null
) : AppScreen
data class MediaPlayer(
val title: String,
val logoUrl: String?,
val url: String,
val returnTarget: MediaReturnTarget,
val subtitle: String? = null,
val favoriteId: String? = null
) : AppScreen
}
private data class XtreamContent(
val streams: List<LiveStreamDto>,
val categories: List<LiveCategoryDto>
)
@Composable
private fun XtreamTvApp() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val configStorage = remember { ConfigStorage(context) }
var screen by remember { mutableStateOf<AppScreen>(AppScreen.Loading) }
var config by remember { mutableStateOf<XtreamConfig?>(null) }
var liveStreams by remember { mutableStateOf<List<LiveStreamDto>>(emptyList()) }
var liveCategories by remember { mutableStateOf<List<LiveCategoryDto>>(emptyList()) }
var loginErrorMessage by remember { mutableStateOf<String?>(null) }
var selectedCategoryId by remember { mutableStateOf<String?>(null) }
var selectedStreamId by remember { mutableStateOf<String?>(null) }
var categorySearchQuery by remember { mutableStateOf("") }
var streamSearchQuery by remember { mutableStateOf("") }
var favoriteStreamIds by remember { mutableStateOf(configStorage.loadFavoriteStreamIds()) }
var pinnedStreamId by remember { mutableStateOf(configStorage.loadPinnedStreamId()) }
var showExitDialog by remember { mutableStateOf(false) }
var focusSelectedStreamRequest by remember { mutableStateOf(0) }
var epgByStreamId by remember { mutableStateOf<Map<String, List<EpgProgramDto>>>(emptyMap()) }
var loadingEpgStreamIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var fullEpgByStreamId by remember { mutableStateOf<Map<String, List<EpgProgramDto>>>(emptyMap()) }
var loadingFullEpgStreamIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var fullEpgErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
var vodMovies by remember { mutableStateOf<List<VodStreamDto>>(emptyList()) }
var vodCategories by remember { mutableStateOf<List<LiveCategoryDto>>(emptyList()) }
var isVodLoading by remember { mutableStateOf(false) }
var vodErrorMessage by remember { mutableStateOf<String?>(null) }
var selectedVodCategoryId by remember { mutableStateOf<String?>(null) }
var selectedVodMovieId by remember { mutableStateOf<String?>(null) }
var vodCategorySearchQuery by remember { mutableStateOf("") }
var vodMovieSearchQuery by remember { mutableStateOf("") }
var favoriteVodIds by remember { mutableStateOf(configStorage.loadFavoriteVodIds()) }
var seriesItems by remember { mutableStateOf<List<SeriesDto>>(emptyList()) }
var seriesCategories by remember { mutableStateOf<List<LiveCategoryDto>>(emptyList()) }
var isSeriesLoading by remember { mutableStateOf(false) }
var seriesErrorMessage by remember { mutableStateOf<String?>(null) }
var selectedSeriesCategoryId by remember { mutableStateOf<String?>(null) }
var selectedSeriesId by remember { mutableStateOf<String?>(null) }
var seriesCategorySearchQuery by remember { mutableStateOf("") }
var seriesSearchQuery by remember { mutableStateOf("") }
var episodeSearchQuery by remember { mutableStateOf("") }
var favoriteSeriesIds by remember { mutableStateOf(configStorage.loadFavoriteSeriesIds()) }
var seriesInfoById by remember { mutableStateOf<Map<String, SeriesInfoResponse>>(emptyMap()) }
var loadingSeriesInfoIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var seriesInfoErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
fun repository(): XtreamRepository? {
val currentConfig = config ?: return null
return XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl))
}
fun requestEpg(stream: LiveStreamDto) {
val currentConfig = config ?: return
val streamId = stream.streamId
if (epgByStreamId.containsKey(streamId) || loadingEpgStreamIds.contains(streamId)) return
loadingEpgStreamIds = loadingEpgStreamIds + streamId
scope.launch {
val programs = runCatching {
XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl)).loadShortEpg(
username = currentConfig.username,
password = currentConfig.password,
streamId = streamId,
limit = 8
)
}.getOrDefault(emptyList())
epgByStreamId = epgByStreamId + (streamId to programs)
loadingEpgStreamIds = loadingEpgStreamIds - streamId
}
}
fun requestFullEpg(stream: LiveStreamDto, forceRefresh: Boolean = false) {
val currentConfig = config ?: return
val streamId = stream.streamId
if (!forceRefresh && fullEpgByStreamId.containsKey(streamId)) return
if (loadingFullEpgStreamIds.contains(streamId)) return
loadingFullEpgStreamIds = loadingFullEpgStreamIds + streamId
fullEpgErrors = fullEpgErrors - streamId
scope.launch {
runCatching {
XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl)).loadFullEpg(
username = currentConfig.username,
password = currentConfig.password,
streamId = streamId
)
}.onSuccess { programs ->
fullEpgByStreamId = fullEpgByStreamId + (streamId to programs)
epgByStreamId = epgByStreamId + (streamId to programs.take(8))
fullEpgErrors = fullEpgErrors - streamId
}.onFailure { throwable ->
fullEpgErrors = fullEpgErrors + (streamId to (throwable.message ?: "Impossible de charger le guide replay complet."))
}
loadingFullEpgStreamIds = loadingFullEpgStreamIds - streamId
}
}
fun requestVodContent(forceRefresh: Boolean = false) {
val currentConfig = config ?: return
if (!forceRefresh && vodMovies.isNotEmpty()) return
if (isVodLoading) return
isVodLoading = true
vodErrorMessage = null
scope.launch {
runCatching {
val repo = XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl))
val categories = runCatching {
repo.loadVodCategories(
username = currentConfig.username,
password = currentConfig.password
)
}.getOrDefault(emptyList())
val movies = repo.loadVodStreams(
username = currentConfig.username,
password = currentConfig.password
)
categories to movies
}.onSuccess { (categories, movies) ->
vodCategories = categories
vodMovies = movies
vodErrorMessage = null
}.onFailure { throwable ->
vodErrorMessage = throwable.message ?: "Impossible de charger les VOD."
}
isVodLoading = false
}
}
fun requestSeriesContent(forceRefresh: Boolean = false) {
val currentConfig = config ?: return
if (!forceRefresh && seriesItems.isNotEmpty()) return
if (isSeriesLoading) return
isSeriesLoading = true
seriesErrorMessage = null
scope.launch {
runCatching {
val repo = XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl))
val categories = runCatching {
repo.loadSeriesCategories(
username = currentConfig.username,
password = currentConfig.password
)
}.getOrDefault(emptyList())
val items = repo.loadSeries(
username = currentConfig.username,
password = currentConfig.password
)
categories to items
}.onSuccess { (categories, items) ->
seriesCategories = categories
seriesItems = items
seriesErrorMessage = null
}.onFailure { throwable ->
seriesErrorMessage = throwable.message ?: "Impossible de charger les séries."
}
isSeriesLoading = false
}
}
fun requestSeriesInfo(series: SeriesDto, forceRefresh: Boolean = false) {
val currentConfig = config ?: return
val seriesId = series.seriesId
if (!forceRefresh && seriesInfoById.containsKey(seriesId)) return
if (loadingSeriesInfoIds.contains(seriesId)) return
loadingSeriesInfoIds = loadingSeriesInfoIds + seriesId
seriesInfoErrors = seriesInfoErrors - seriesId
scope.launch {
runCatching {
XtreamRepository(XtreamApiFactory.create(currentConfig.serverUrl)).loadSeriesInfo(
username = currentConfig.username,
password = currentConfig.password,
seriesId = seriesId
)
}.onSuccess { info ->
seriesInfoById = seriesInfoById + (seriesId to info)
seriesInfoErrors = seriesInfoErrors - seriesId
}.onFailure { throwable ->
seriesInfoErrors = seriesInfoErrors + (seriesId to (throwable.message ?: "Impossible de charger les épisodes."))
}
loadingSeriesInfoIds = loadingSeriesInfoIds - seriesId
}
}
fun openPlayer(stream: LiveStreamDto) {
val currentConfig = config ?: return
selectedStreamId = stream.streamId
requestEpg(stream)
screen = AppScreen.Player(
stream = stream,
title = stream.name.orEmpty().ifBlank { "Chaîne ${stream.streamId}" },
url = XtreamUrlBuilder.liveUrl(
config = currentConfig,
streamId = stream.streamId,
extension = "m3u8"
)
)
}
fun openVodPlayer(movie: VodStreamDto) {
val currentConfig = config ?: return
selectedVodMovieId = movie.streamId
screen = AppScreen.MediaPlayer(
title = movie.titleText(),
logoUrl = movie.streamIcon,
url = movie.directSource?.takeIf { it.isNotBlank() }
?: XtreamUrlBuilder.vodUrl(
config = currentConfig,
streamId = movie.streamId,
extension = movie.extensionText()
),
subtitle = "VOD • ${movie.extensionText().uppercase()}",
returnTarget = MediaReturnTarget.VOD,
favoriteId = movie.streamId
)
}
fun openSeriesEpisodePlayer(series: SeriesDto, item: SeriesEpisodeItem) {
val currentConfig = config ?: return
val episode = item.episode
val episodeId = episode.id?.takeIf { it.isNotBlank() } ?: return
selectedSeriesId = series.seriesId
screen = AppScreen.MediaPlayer(
title = "${series.titleText()} • ${item.seasonText()} • ${episode.titleText()}",
logoUrl = episode.info?.movieImage ?: series.cover,
url = XtreamUrlBuilder.seriesUrl(
config = currentConfig,
episodeId = episodeId,
extension = episode.extensionText()
),
subtitle = "${item.seasonText()} • Épisode ${episode.episodeNum ?: ""}".trim(),
returnTarget = MediaReturnTarget.SERIES,
favoriteId = series.seriesId
)
}
fun openReplayGuide(stream: LiveStreamDto) {
selectedStreamId = stream.streamId
requestFullEpg(stream)
screen = AppScreen.ReplayGuide(stream = stream)
}
fun openReplay(stream: LiveStreamDto, program: EpgProgramDto) {
val currentConfig = config ?: return
selectedStreamId = stream.streamId
val title = stream.name.orEmpty().ifBlank { "Chaîne ${stream.streamId}" }
screen = AppScreen.Player(
stream = stream,
title = title,
url = XtreamUrlBuilder.timeshiftUrl(
config = currentConfig,
streamId = stream.streamId,
start = program.start.orEmpty(),
durationMinutes = program.durationMinutes(),
extension = "ts"
),
isReplay = true,
programTitle = program.titleText(),
programTime = program.fullDateTimeText()
)
}
fun streamsForCurrentNavigation(): List<LiveStreamDto> {
return filterStreamsForNavigation(
streams = liveStreams,
selectedCategoryId = selectedCategoryId,
streamSearchQuery = streamSearchQuery,
favoriteStreamIds = favoriteStreamIds
)
}
fun zap(offset: Int) {
val currentConfig = config ?: return
val candidates = streamsForCurrentNavigation().ifEmpty { liveStreams }
if (candidates.isEmpty()) return
val currentId = selectedStreamId
val currentIndex = candidates.indexOfFirst { it.streamId == currentId }
val nextIndex = if (currentIndex < 0) {
0
} else {
(currentIndex + offset + candidates.size) % candidates.size
}
val nextStream = candidates[nextIndex]
selectedStreamId = nextStream.streamId
requestEpg(nextStream)
screen = AppScreen.Player(
stream = nextStream,
title = nextStream.name.orEmpty().ifBlank { "Chaîne ${nextStream.streamId}" },
url = XtreamUrlBuilder.liveUrl(
config = currentConfig,
streamId = nextStream.streamId,
extension = "m3u8"
)
)
}
fun clearCatalogStates() {
vodMovies = emptyList()
vodCategories = emptyList()
vodErrorMessage = null
isVodLoading = false
selectedVodCategoryId = null
selectedVodMovieId = null
vodCategorySearchQuery = ""
vodMovieSearchQuery = ""
seriesItems = emptyList()
seriesCategories = emptyList()
seriesErrorMessage = null
isSeriesLoading = false
selectedSeriesCategoryId = null
selectedSeriesId = null
seriesCategorySearchQuery = ""
seriesSearchQuery = ""
episodeSearchQuery = ""
seriesInfoById = emptyMap()
loadingSeriesInfoIds = emptySet()
seriesInfoErrors = emptyMap()
}
LaunchedEffect(Unit) {
val savedConfig = configStorage.load()
if (savedConfig == null) {
screen = AppScreen.Login
return@LaunchedEffect
}
runCatching {
val content = loadXtreamContentFromServer(savedConfig)
config = savedConfig
liveStreams = content.streams
liveCategories = content.categories
favoriteStreamIds = configStorage.loadFavoriteStreamIds()
favoriteVodIds = configStorage.loadFavoriteVodIds()
favoriteSeriesIds = configStorage.loadFavoriteSeriesIds()
pinnedStreamId = configStorage.loadPinnedStreamId()
loginErrorMessage = null
val pinnedStream = pinnedStreamId?.let { id ->
content.streams.firstOrNull { it.streamId == id }
}
if (pinnedStream != null) {
selectedCategoryId = pinnedStream.categoryId?.takeIf { it.isNotBlank() }
selectedStreamId = pinnedStream.streamId
requestEpg(pinnedStream)
screen = AppScreen.Player(
stream = pinnedStream,
title = pinnedStream.name.orEmpty().ifBlank { "Chaîne ${pinnedStream.streamId}" },
url = XtreamUrlBuilder.liveUrl(
config = savedConfig,
streamId = pinnedStream.streamId,
extension = "m3u8"
)
)
} else {
screen = AppScreen.Home
}
}.onFailure {
configStorage.clear()
config = null
liveStreams = emptyList()
liveCategories = emptyList()
epgByStreamId = emptyMap()
loadingEpgStreamIds = emptySet()
fullEpgByStreamId = emptyMap()
loadingFullEpgStreamIds = emptySet()
fullEpgErrors = emptyMap()
clearCatalogStates()
loginErrorMessage = "Connexion automatique impossible. Vérifie tes identifiants."
screen = AppScreen.Login
}
}
when (val currentScreen = screen) {
AppScreen.Loading -> LoadingScreen(
message = "Connexion automatique en cours..."
)
AppScreen.Login -> LoginScreen(
initialConfig = configStorage.load(),
initialErrorMessage = loginErrorMessage,
onLoginSuccess = { newConfig, streams, categories ->
configStorage.save(newConfig)
config = newConfig
liveStreams = streams
liveCategories = categories
favoriteStreamIds = configStorage.loadFavoriteStreamIds()
favoriteVodIds = configStorage.loadFavoriteVodIds()
favoriteSeriesIds = configStorage.loadFavoriteSeriesIds()
pinnedStreamId = configStorage.loadPinnedStreamId()
epgByStreamId = emptyMap()
loadingEpgStreamIds = emptySet()
fullEpgByStreamId = emptyMap()
loadingFullEpgStreamIds = emptySet()
fullEpgErrors = emptyMap()
clearCatalogStates()
loginErrorMessage = null
selectedCategoryId = null
selectedStreamId = null
screen = AppScreen.Home
}
)
AppScreen.Home -> {
BackHandler { showExitDialog = true }
HomeScreen(
streams = liveStreams,
categories = liveCategories,
epgByStreamId = epgByStreamId,
loadingEpgStreamIds = loadingEpgStreamIds,
selectedCategoryId = selectedCategoryId,
selectedStreamId = selectedStreamId,
favoriteStreamIds = favoriteStreamIds,
pinnedStreamId = pinnedStreamId,
categorySearchQuery = categorySearchQuery,
streamSearchQuery = streamSearchQuery,
focusSelectedStreamRequest = focusSelectedStreamRequest,
onCategorySearchChanged = { categorySearchQuery = it },
onStreamSearchChanged = { streamSearchQuery = it },
onCategorySelected = { categoryId -> selectedCategoryId = categoryId },
onStreamSelected = { stream -> openPlayer(stream) },
onRequestEpg = { stream -> requestEpg(stream) },
onOpenReplayGuide = { stream -> openReplayGuide(stream) },
onOpenProgramGuide = { screen = AppScreen.ProgramGuide },
onOpenVod = {
screen = AppScreen.Vod
requestVodContent()
},
onOpenSeries = {
screen = AppScreen.Series
requestSeriesContent()
},
onToggleFavorite = { stream ->
favoriteStreamIds = configStorage.toggleFavorite(stream.streamId)
},
onPinStream = { stream ->
if (pinnedStreamId == stream.streamId) {
configStorage.clearPinnedStreamId()
pinnedStreamId = null
} else {
configStorage.savePinnedStreamId(stream.streamId)
pinnedStreamId = stream.streamId
}
},
onClearPinned = {
configStorage.clearPinnedStreamId()
pinnedStreamId = null
},
onLogout = {
configStorage.clear()
config = null
liveStreams = emptyList()
liveCategories = emptyList()
epgByStreamId = emptyMap()
loadingEpgStreamIds = emptySet()
fullEpgByStreamId = emptyMap()
loadingFullEpgStreamIds = emptySet()
fullEpgErrors = emptyMap()
selectedCategoryId = null
selectedStreamId = null
categorySearchQuery = ""
streamSearchQuery = ""
clearCatalogStates()
loginErrorMessage = null
screen = AppScreen.Login
}
)
if (showExitDialog) {
ExitConfirmationDialog(
onDismiss = { showExitDialog = false },
onConfirm = {
showExitDialog = false
(context as? Activity)?.finish()
}
)
}
}
AppScreen.Vod -> {
VodScreen(
movies = vodMovies,
categories = vodCategories,
isLoading = isVodLoading,
errorMessage = vodErrorMessage,
selectedCategoryId = selectedVodCategoryId,
selectedMovieId = selectedVodMovieId,
favoriteMovieIds = favoriteVodIds,
categorySearchQuery = vodCategorySearchQuery,
movieSearchQuery = vodMovieSearchQuery,
onCategorySelected = { selectedVodCategoryId = it },
onCategorySearchChanged = { vodCategorySearchQuery = it },
onMovieSearchChanged = { vodMovieSearchQuery = it },
onMovieFocused = { movie -> selectedVodMovieId = movie.streamId },
onMovieSelected = { movie -> openVodPlayer(movie) },
onToggleFavorite = { movie ->
favoriteVodIds = configStorage.toggleFavoriteVod(movie.streamId)
},
onReload = { requestVodContent(forceRefresh = true) },
onBack = {
focusSelectedStreamRequest += 1
screen = AppScreen.Home
}
)
}
AppScreen.Series -> {
SeriesScreen(
series = seriesItems,
categories = seriesCategories,
isLoading = isSeriesLoading,
errorMessage = seriesErrorMessage,
selectedCategoryId = selectedSeriesCategoryId,
selectedSeriesId = selectedSeriesId,
favoriteSeriesIds = favoriteSeriesIds,
categorySearchQuery = seriesCategorySearchQuery,
seriesSearchQuery = seriesSearchQuery,
episodeSearchQuery = episodeSearchQuery,
seriesInfoById = seriesInfoById,
loadingSeriesInfoIds = loadingSeriesInfoIds,
seriesInfoErrors = seriesInfoErrors,
onCategorySelected = { selectedSeriesCategoryId = it },
onCategorySearchChanged = { seriesCategorySearchQuery = it },
onSeriesSearchChanged = { seriesSearchQuery = it },
onEpisodeSearchChanged = { episodeSearchQuery = it },
onSeriesFocused = { item ->
selectedSeriesId = item.seriesId
requestSeriesInfo(item)
},
onSeriesSelected = { item ->
selectedSeriesId = item.seriesId
requestSeriesInfo(item)
},
onToggleFavorite = { item ->
favoriteSeriesIds = configStorage.toggleFavoriteSeries(item.seriesId)
},
onPlayEpisode = { item, episode -> openSeriesEpisodePlayer(item, episode) },
onReload = { requestSeriesContent(forceRefresh = true) },
onRetryInfo = { item -> requestSeriesInfo(item, forceRefresh = true) },
onBack = {
focusSelectedStreamRequest += 1
screen = AppScreen.Home
}
)
}
AppScreen.ProgramGuide -> {
ProgramGuideScreen(
streams = liveStreams,
categories = liveCategories,
favoriteStreamIds = favoriteStreamIds,
selectedStreamId = selectedStreamId,
epgByStreamId = epgByStreamId,
loadingEpgStreamIds = loadingEpgStreamIds,
fullEpgByStreamId = fullEpgByStreamId,
loadingFullEpgStreamIds = loadingFullEpgStreamIds,
fullEpgErrors = fullEpgErrors,
onBack = {
focusSelectedStreamRequest += 1
screen = AppScreen.Home
},
onStreamFocused = { stream ->
selectedStreamId = stream.streamId
requestEpg(stream)
requestFullEpg(stream)
},
onOpenLive = { stream -> openPlayer(stream) },
onRetryFullGuide = { stream -> requestFullEpg(stream, forceRefresh = true) },
onProgramSelected = { stream, program -> openReplay(stream, program) }
)
}
is AppScreen.ReplayGuide -> {
LaunchedEffect(currentScreen.stream.streamId) {
requestFullEpg(currentScreen.stream)
}
ReplayGuideScreen(
stream = currentScreen.stream,
programs = fullEpgByStreamId[currentScreen.stream.streamId].orEmpty(),
isLoading = loadingFullEpgStreamIds.contains(currentScreen.stream.streamId),
errorMessage = fullEpgErrors[currentScreen.stream.streamId],
onBack = {
focusSelectedStreamRequest += 1
screen = AppScreen.Home
},
onRetry = { requestFullEpg(currentScreen.stream, forceRefresh = true) },
onProgramSelected = { program -> openReplay(currentScreen.stream, program) }
)
}
is AppScreen.Player -> {
LaunchedEffect(currentScreen.stream.streamId) {
requestEpg(currentScreen.stream)
}
val currentLiveProgram = epgByStreamId[currentScreen.stream.streamId]
.orEmpty()
.firstOrNull { it.isNowPlaying() }
?: epgByStreamId[currentScreen.stream.streamId].orEmpty().firstOrNull()
PlayerScreen(
title = currentScreen.title,
logoUrl = currentScreen.stream.streamIcon,
streamUrl = currentScreen.url,
isFavorite = favoriteStreamIds.contains(currentScreen.stream.streamId),
isPinned = pinnedStreamId == currentScreen.stream.streamId,
isReplay = currentScreen.isReplay,
programTitle = currentScreen.programTitle ?: currentLiveProgram?.titleText(),
programTime = currentScreen.programTime ?: currentLiveProgram?.timeRangeText(),
enableZapping = true,
onBack = {
focusSelectedStreamRequest += 1
screen = AppScreen.Home
},
onPrevious = { zap(offset = -1) },
onNext = { zap(offset = 1) },
onToggleFavorite = {
favoriteStreamIds = configStorage.toggleFavorite(currentScreen.stream.streamId)
},
onTogglePinned = {
if (pinnedStreamId == currentScreen.stream.streamId) {
configStorage.clearPinnedStreamId()
pinnedStreamId = null
} else {
configStorage.savePinnedStreamId(currentScreen.stream.streamId)
pinnedStreamId = currentScreen.stream.streamId
}
}
)
}
is AppScreen.MediaPlayer -> {
val isMediaFavorite = when (currentScreen.returnTarget) {
MediaReturnTarget.VOD -> currentScreen.favoriteId?.let { favoriteVodIds.contains(it) } == true
MediaReturnTarget.SERIES -> currentScreen.favoriteId?.let { favoriteSeriesIds.contains(it) } == true
MediaReturnTarget.HOME -> false
}
PlayerScreen(
title = currentScreen.title,
logoUrl = currentScreen.logoUrl,
streamUrl = currentScreen.url,
isFavorite = isMediaFavorite,
isPinned = false,
isReplay = false,
programTitle = currentScreen.subtitle,
programTime = null,
enableZapping = false,
onBack = {
screen = when (currentScreen.returnTarget) {
MediaReturnTarget.HOME -> AppScreen.Home
MediaReturnTarget.VOD -> AppScreen.Vod
MediaReturnTarget.SERIES -> AppScreen.Series
}
},
onPrevious = {},
onNext = {},
onToggleFavorite = {
val favoriteId = currentScreen.favoriteId ?: return@PlayerScreen
when (currentScreen.returnTarget) {
MediaReturnTarget.VOD -> favoriteVodIds = configStorage.toggleFavoriteVod(favoriteId)
MediaReturnTarget.SERIES -> favoriteSeriesIds = configStorage.toggleFavoriteSeries(favoriteId)
MediaReturnTarget.HOME -> Unit
}
},
onTogglePinned = {}
)
}
}
}
@Composable
private fun ExitConfirmationDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = "Quitter l’application ?") },
text = { Text(text = "Confirme si tu veux fermer Xtream TV Player.") },
confirmButton = {
TextButton(onClick = onConfirm) { Text(text = "Quitter") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(text = "Annuler") }
}
)
}
@Composable
private fun LoadingScreen(
message: String
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF020B22))
) {
Image(
painter = painterResource(id = R.drawable.imedia_tv_background),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0x66000B1F))
)
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 58.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
color = Color.White,
trackColor = Color.White.copy(alpha = 0.18f)
)
Text(
text = message,
color = Color.White.copy(alpha = 0.92f),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
private suspend fun loadXtreamContentFromServer(
config: XtreamConfig
): XtreamContent {
val repository = XtreamRepository(
XtreamApiFactory.create(config.serverUrl)
)
val account = repository.checkAccount(
username = config.username,
password = config.password
)
val isAuthorized = account.userInfo?.auth == "1"
val isActive = account.userInfo?.status.equals(
other = "Active",
ignoreCase = true
)
if (!isAuthorized || !isActive) {
error("Compte non autorisé ou abonnement inactif.")
}
val categories = runCatching {
repository.loadLiveCategories(
username = config.username,
password = config.password
)
}.getOrDefault(emptyList())
val streams = repository.loadLiveStreams(
username = config.username,
password = config.password
)
return XtreamContent(
streams = streams,
categories = categories
)
}
private fun filterStreamsForNavigation(
streams: List<LiveStreamDto>,
selectedCategoryId: String?,
streamSearchQuery: String,
favoriteStreamIds: Set<String>
): List<LiveStreamDto> {
val query = streamSearchQuery.trim()
return streams
.asSequence()
.filter { stream ->
when (selectedCategoryId) {
null -> true
FAVORITES_CATEGORY_ID -> favoriteStreamIds.contains(stream.streamId)
UNCATEGORIZED_CATEGORY_ID -> stream.categoryId.isNullOrBlank()
else -> stream.categoryId == selectedCategoryId
}
}
.filter { stream ->
query.isBlank() ||
stream.name.orEmpty().contains(query, ignoreCase = true) ||
stream.streamId.contains(query, ignoreCase = true) ||
stream.categoryId.orEmpty().contains(query, ignoreCase = true)
}
.toList()
}
@@ -0,0 +1,8 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class EpgFullResponse(
@SerializedName("epg_listings")
val listings: List<EpgProgramDto>? = emptyList()
)
@@ -0,0 +1,186 @@
package ma.tkgraph.xtreamtv.data.model
import android.util.Base64
import com.google.gson.annotations.SerializedName
import java.nio.charset.StandardCharsets
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
private const val MAX_REPLAY_DURATION_MINUTES = 720
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("lang")
val lang: String? = null,
@SerializedName("start")
val start: String? = null,
@SerializedName("end")
val end: String? = null,
@SerializedName("channel_id")
val channelId: String? = null,
@SerializedName("start_timestamp")
val startTimestamp: String? = null,
@SerializedName("stop_timestamp")
val stopTimestamp: String? = null,
@SerializedName("now_playing")
val nowPlaying: Int? = null,
@SerializedName("has_archive")
val hasArchive: Int? = null
) {
fun titleText(): String {
return decodeMaybeBase64(rawTitle).ifBlank { "Programme sans titre" }
}
fun descriptionText(): String {
return decodeMaybeBase64(rawDescription)
}
fun timeRangeText(): String {
val startLabel = start.orEmpty().takeLastTime()
val endLabel = end.orEmpty().takeLastTime()
return when {
startLabel.isNotBlank() && endLabel.isNotBlank() -> "$startLabel - $endLabel"
startLabel.isNotBlank() -> startLabel
else -> "Horaire inconnu"
}
}
fun fullDateTimeText(): String {
val dateLabel = startMillis()?.let { millis ->
SimpleDateFormat("EEE dd/MM", Locale.getDefault()).format(Date(millis))
}.orEmpty()
return listOf(dateLabel, timeRangeText())
.filter { it.isNotBlank() }
.joinToString(" • ")
.ifBlank { "Horaire inconnu" }
}
fun dayHeaderText(): String {
return startMillis()?.let { millis ->
SimpleDateFormat("EEEE dd/MM", Locale.getDefault()).format(Date(millis))
} ?: "Jour inconnu"
}
fun isNowPlaying(): Boolean {
return nowPlaying == 1
}
fun isReplayAvailable(): Boolean {
return hasArchive == 1 && !start.isNullOrBlank()
}
fun canPlayAsReplay(
streamArchiveEnabled: Boolean,
nowMillis: Long = System.currentTimeMillis()
): Boolean {
val startMillis = startMillis() ?: return false
val archiveAvailable = streamArchiveEnabled || hasArchive == 1
return archiveAvailable && startMillis <= nowMillis && !start.isNullOrBlank()
}
fun startMillis(): Long? {
startTimestamp?.toLongOrNull()?.let { seconds ->
return seconds * 1000L
}
val startText = start ?: return null
return parseDateTime(startText)?.time
}
fun stopMillis(): Long? {
stopTimestamp?.toLongOrNull()?.let { seconds ->
return seconds * 1000L
}
val stopText = end ?: return null
return parseDateTime(stopText)?.time
}
fun durationMinutes(): Int {
val timestampDuration = durationFromTimestamps()
if (timestampDuration > 0) return timestampDuration
val parsedDuration = durationFromDates()
if (parsedDuration > 0) return parsedDuration
return 60
}
private fun durationFromTimestamps(): Int {
val startSeconds = startTimestamp?.toLongOrNull() ?: return 0
val stopSeconds = stopTimestamp?.toLongOrNull() ?: return 0
val minutes = ((stopSeconds - startSeconds) / 60).toInt()
return minutes.clampDuration()
}
private fun durationFromDates(): Int {
val startMillis = startMillis() ?: return 0
val stopMillis = stopMillis() ?: return 0
val minutes = ((stopMillis - startMillis) / 60_000).toInt()
return minutes.clampDuration()
}
}
private fun String.takeLastTime(): String {
val match = Regex("""(\d{2}:\d{2})""").find(this)
return match?.value.orEmpty()
}
private fun parseDateTime(value: String): Date? {
val cleaned = value.trim().replace('T', ' ').take(19)
val patterns = listOf(
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd HH:mm",
"yyyy/MM/dd HH:mm:ss",
"yyyy/MM/dd HH:mm"
)
return patterns.firstNotNullOfOrNull { pattern ->
runCatching {
SimpleDateFormat(pattern, Locale.US).parse(cleaned)
}.getOrNull()
}
}
private fun Int.clampDuration(): Int {
if (this <= 0) return 0
return max(1, min(this, MAX_REPLAY_DURATION_MINUTES))
}
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() } &&
decoded.count { !it.isISOControl() }.toFloat() / decoded.length.toFloat() > 0.85f
}
?: text
}
@@ -0,0 +1,8 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class EpgShortResponse(
@SerializedName("epg_listings")
val listings: List<EpgProgramDto>? = emptyList()
)
@@ -0,0 +1,14 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class LiveCategoryDto(
@SerializedName("category_id")
val categoryId: String,
@SerializedName("category_name")
val categoryName: String? = null,
@SerializedName("parent_id")
val parentId: String? = null
)
@@ -0,0 +1,38 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class LiveStreamDto(
@SerializedName("num")
val number: Int? = null,
@SerializedName("name")
val name: String? = null,
@SerializedName("stream_type")
val streamType: String? = null,
@SerializedName("stream_id")
val streamId: String,
@SerializedName("stream_icon")
val streamIcon: String? = null,
@SerializedName("epg_channel_id")
val epgChannelId: String? = null,
@SerializedName("added")
val added: String? = null,
@SerializedName("category_id")
val categoryId: String? = null,
@SerializedName("custom_sid")
val customSid: String? = null,
@SerializedName("tv_archive")
val tvArchive: Int? = null,
@SerializedName("direct_source")
val directSource: String? = null
)
@@ -0,0 +1,46 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class SeriesDto(
@SerializedName("num")
val number: Int? = null,
@SerializedName("name")
val name: String? = null,
@SerializedName("series_id")
val seriesId: String,
@SerializedName("cover")
val cover: String? = null,
@SerializedName("plot")
val plot: String? = null,
@SerializedName("cast")
val cast: String? = null,
@SerializedName("director")
val director: String? = null,
@SerializedName("genre")
val genre: String? = null,
@SerializedName("releaseDate")
val releaseDate: String? = null,
@SerializedName("last_modified")
val lastModified: String? = null,
@SerializedName("rating")
val rating: String? = null,
@SerializedName("rating_5based")
val ratingFiveBased: Float? = null,
@SerializedName("category_id")
val categoryId: String? = null
) {
fun titleText(): String = name.orEmpty().ifBlank { "Série $seriesId" }
}
@@ -0,0 +1,121 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class SeriesInfoResponse(
@SerializedName("seasons")
val seasons: List<SeriesSeasonDto>? = null,
@SerializedName("info")
val info: SeriesDto? = null,
@SerializedName("episodes")
val episodes: Map<String, List<SeriesEpisodeDto>>? = null
) {
fun allEpisodes(): List<SeriesEpisodeItem> {
return episodes.orEmpty()
.flatMap { (seasonKey, episodeList) ->
episodeList.map { episode ->
SeriesEpisodeItem(
seasonKey = seasonKey,
episode = episode
)
}
}
.sortedWith(
compareBy<SeriesEpisodeItem> { it.seasonNumber() ?: Int.MAX_VALUE }
.thenBy { it.episode.episodeNum ?: Int.MAX_VALUE }
.thenBy { it.episode.title.orEmpty() }
)
}
}
data class SeriesSeasonDto(
@SerializedName("air_date")
val airDate: String? = null,
@SerializedName("episode_count")
val episodeCount: Int? = null,
@SerializedName("id")
val id: Int? = null,
@SerializedName("name")
val name: String? = null,
@SerializedName("overview")
val overview: String? = null,
@SerializedName("season_number")
val seasonNumber: Int? = null
)
data class SeriesEpisodeDto(
@SerializedName("id")
val id: String? = null,
@SerializedName("episode_num")
val episodeNum: Int? = null,
@SerializedName(value = "title", alternate = ["name", "episode_name"])
val title: String? = null,
@SerializedName(value = "container_extension", alternate = ["containerExtension", "extension"])
val containerExtension: String? = null,
@SerializedName("info")
val info: SeriesEpisodeInfoDto? = null,
@SerializedName("custom_sid")
val customSid: String? = null,
@SerializedName("added")
val added: String? = null
) {
fun titleText(): String {
val cleanTitle = title
.orEmpty()
.trim()
.takeIf { it.isNotBlank() && it != "." && it != "-" && it != "..." }
return cleanTitle ?: episodeNum?.let { "Épisode $it" } ?: "Épisode"
}
fun extensionText(): String {
return containerExtension
.orEmpty()
.trim()
.trim('.')
.takeIf { it.isNotBlank() && it != "-" }
?: "mp4"
}
}
data class SeriesEpisodeInfoDto(
@SerializedName("movie_image")
val movieImage: String? = null,
@SerializedName("plot")
val plot: String? = null,
@SerializedName("duration_secs")
val durationSeconds: Int? = null,
@SerializedName("duration")
val duration: String? = null,
@SerializedName("releasedate")
val releaseDate: String? = null,
@SerializedName("rating")
val rating: String? = null
)
data class SeriesEpisodeItem(
val seasonKey: String,
val episode: SeriesEpisodeDto
) {
fun seasonNumber(): Int? = seasonKey.toIntOrNull()
fun seasonText(): String = seasonNumber()?.let { "Saison $it" } ?: "Saison $seasonKey"
}
@@ -0,0 +1,45 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class VodStreamDto(
@SerializedName("num")
val number: Int? = null,
@SerializedName("name")
val name: String? = null,
@SerializedName("stream_type")
val streamType: String? = null,
@SerializedName("stream_id")
val streamId: String,
@SerializedName("stream_icon")
val streamIcon: String? = null,
@SerializedName("rating")
val rating: String? = null,
@SerializedName("rating_5based")
val ratingFiveBased: Float? = null,
@SerializedName("added")
val added: String? = null,
@SerializedName("category_id")
val categoryId: String? = null,
@SerializedName("container_extension")
val containerExtension: String? = null,
@SerializedName("custom_sid")
val customSid: String? = null,
@SerializedName("direct_source")
val directSource: String? = null
) {
fun titleText(): String = name.orEmpty().ifBlank { "VOD $streamId" }
fun extensionText(): String = containerExtension.orEmpty().ifBlank { "mp4" }
}
@@ -0,0 +1,60 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class XtreamAccountResponse(
@SerializedName("user_info")
val userInfo: XtreamUserInfo? = null,
@SerializedName("server_info")
val serverInfo: XtreamServerInfo? = null
)
data class XtreamUserInfo(
@SerializedName("username")
val username: String? = null,
@SerializedName("password")
val password: String? = null,
@SerializedName("message")
val message: String? = null,
@SerializedName("auth")
val auth: String? = null,
@SerializedName("status")
val status: String? = null,
@SerializedName("exp_date")
val expirationDate: String? = null,
@SerializedName("active_cons")
val activeConnections: String? = null,
@SerializedName("max_connections")
val maxConnections: String? = null
)
data class XtreamServerInfo(
@SerializedName("url")
val url: String? = null,
@SerializedName("port")
val port: String? = null,
@SerializedName("https_port")
val httpsPort: String? = null,
@SerializedName("server_protocol")
val serverProtocol: String? = null,
@SerializedName("timezone")
val timezone: String? = null,
@SerializedName("timestamp_now")
val timestampNow: Long? = null,
@SerializedName("time_now")
val timeNow: String? = null
)
@@ -0,0 +1,7 @@
package ma.tkgraph.xtreamtv.data.model
data class XtreamConfig(
val serverUrl: String,
val username: String,
val password: String
)
@@ -0,0 +1,87 @@
package ma.tkgraph.xtreamtv.data.network
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.SeriesDto
import ma.tkgraph.xtreamtv.data.model.SeriesInfoResponse
import ma.tkgraph.xtreamtv.data.model.VodStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamAccountResponse
import retrofit2.http.GET
import retrofit2.http.Query
interface XtreamApi {
@GET("player_api.php")
suspend fun getAccountInfo(
@Query("username") username: String,
@Query("password") password: String
): XtreamAccountResponse
@GET("player_api.php")
suspend fun getLiveStreams(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_live_streams"
): List<LiveStreamDto>
@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 getVodStreams(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_vod_streams"
): List<VodStreamDto>
@GET("player_api.php")
suspend fun getVodCategories(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_vod_categories"
): List<LiveCategoryDto>
@GET("player_api.php")
suspend fun getSeries(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_series"
): List<SeriesDto>
@GET("player_api.php")
suspend fun getSeriesCategories(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_series_categories"
): List<LiveCategoryDto>
@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: String
): SeriesInfoResponse
@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
}
@@ -0,0 +1,24 @@
package ma.tkgraph.xtreamtv.data.network
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object XtreamApiFactory {
fun create(serverUrl: String): XtreamApi {
return Retrofit.Builder()
.baseUrl(normalizeBaseUrl(serverUrl))
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(XtreamApi::class.java)
}
private fun normalizeBaseUrl(rawUrl: String): String {
val trimmed = rawUrl.trim()
val withProtocol = when {
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
else -> "http://$trimmed"
}
return withProtocol.trimEnd('/') + "/"
}
}
@@ -0,0 +1,82 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.SeriesInfoResponse
import ma.tkgraph.xtreamtv.data.model.VodStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamAccountResponse
import ma.tkgraph.xtreamtv.data.network.XtreamApi
class XtreamRepository(
private val api: XtreamApi
) {
suspend fun checkAccount(username: String, password: String): XtreamAccountResponse {
return api.getAccountInfo(username = username, password = password)
}
suspend fun loadLiveStreams(username: String, password: String): List<LiveStreamDto> {
return api.getLiveStreams(username = username, password = password)
}
suspend fun loadLiveCategories(username: String, password: String): List<LiveCategoryDto> {
return api.getLiveCategories(username = username, password = password)
}
suspend fun loadVodStreams(username: String, password: String): List<VodStreamDto> {
return api.getVodStreams(username = username, password = password)
}
suspend fun loadVodCategories(username: String, password: String): List<LiveCategoryDto> {
return api.getVodCategories(username = username, password = password)
}
suspend fun loadSeries(username: String, password: String): List<SeriesDto> {
return api.getSeries(username = username, password = password)
}
suspend fun loadSeriesCategories(username: String, password: String): List<LiveCategoryDto> {
return api.getSeriesCategories(username = username, password = password)
}
suspend fun loadSeriesInfo(
username: String,
password: String,
seriesId: String
): SeriesInfoResponse {
return api.getSeriesInfo(
username = username,
password = password,
seriesId = seriesId
)
}
suspend fun loadShortEpg(
username: String,
password: String,
streamId: String,
limit: Int = 8
): List<EpgProgramDto> {
return api.getShortEpg(
username = username,
password = password,
streamId = streamId,
limit = limit
).listings.orEmpty()
.sortedWith(compareBy<EpgProgramDto> { it.startMillis() ?: Long.MAX_VALUE })
}
suspend fun loadFullEpg(
username: String,
password: String,
streamId: String
): List<EpgProgramDto> {
return api.getFullEpg(
username = username,
password = password,
streamId = streamId
).listings.orEmpty()
.sortedWith(compareBy<EpgProgramDto> { it.startMillis() ?: Long.MAX_VALUE })
}
}
@@ -0,0 +1,139 @@
package ma.tkgraph.xtreamtv.data.storage
import android.content.Context
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
class ConfigStorage(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(
PREFERENCES_NAME,
Context.MODE_PRIVATE
)
fun save(config: XtreamConfig) {
preferences.edit()
.putString(KEY_SERVER_URL, config.serverUrl.trim())
.putString(KEY_USERNAME, config.username.trim())
.putString(KEY_PASSWORD, config.password.trim())
.apply()
}
fun load(): XtreamConfig? {
val serverUrl = preferences.getString(KEY_SERVER_URL, null)
?.trim()
?.takeIf { it.isNotBlank() }
?: return null
val username = preferences.getString(KEY_USERNAME, null)
?.trim()
?.takeIf { it.isNotBlank() }
?: return null
val password = preferences.getString(KEY_PASSWORD, null)
?.trim()
?.takeIf { it.isNotBlank() }
?: return null
return XtreamConfig(
serverUrl = serverUrl,
username = username,
password = password
)
}
fun clear() {
preferences.edit()
.remove(KEY_SERVER_URL)
.remove(KEY_USERNAME)
.remove(KEY_PASSWORD)
.apply()
}
fun loadFavoriteStreamIds(): Set<String> = loadStringSet(KEY_FAVORITE_STREAM_IDS)
fun toggleFavorite(streamId: String): Set<String> {
return toggleStringSetValue(
key = KEY_FAVORITE_STREAM_IDS,
value = streamId
)
}
fun loadFavoriteVodIds(): Set<String> = loadStringSet(KEY_FAVORITE_VOD_IDS)
fun toggleFavoriteVod(vodId: String): Set<String> {
return toggleStringSetValue(
key = KEY_FAVORITE_VOD_IDS,
value = vodId
)
}
fun loadFavoriteSeriesIds(): Set<String> = loadStringSet(KEY_FAVORITE_SERIES_IDS)
fun toggleFavoriteSeries(seriesId: String): Set<String> {
return toggleStringSetValue(
key = KEY_FAVORITE_SERIES_IDS,
value = seriesId
)
}
fun savePinnedStreamId(streamId: String) {
val cleanedStreamId = streamId.trim()
if (cleanedStreamId.isBlank()) return
preferences.edit()
.putString(KEY_PINNED_STREAM_ID, cleanedStreamId)
.apply()
}
fun loadPinnedStreamId(): String? {
return preferences.getString(KEY_PINNED_STREAM_ID, null)
?.trim()
?.takeIf { it.isNotBlank() }
}
fun clearPinnedStreamId() {
preferences.edit()
.remove(KEY_PINNED_STREAM_ID)
.apply()
}
private fun loadStringSet(key: String): Set<String> {
return preferences.getStringSet(key, emptySet())
?.map { it.trim() }
?.filter { it.isNotBlank() }
?.toSet()
.orEmpty()
}
private fun toggleStringSetValue(
key: String,
value: String
): Set<String> {
val cleanedValue = value.trim()
if (cleanedValue.isBlank()) return loadStringSet(key)
val items = loadStringSet(key).toMutableSet()
if (items.contains(cleanedValue)) {
items.remove(cleanedValue)
} else {
items.add(cleanedValue)
}
preferences.edit()
.putStringSet(key, items)
.apply()
return items.toSet()
}
companion object {
private const val PREFERENCES_NAME = "xtream_config"
private const val KEY_SERVER_URL = "server_url"
private const val KEY_USERNAME = "username"
private const val KEY_PASSWORD = "password"
private const val KEY_FAVORITE_STREAM_IDS = "favorite_stream_ids"
private const val KEY_FAVORITE_VOD_IDS = "favorite_vod_ids"
private const val KEY_FAVORITE_SERIES_IDS = "favorite_series_ids"
private const val KEY_PINNED_STREAM_ID = "pinned_stream_id"
}
}
@@ -0,0 +1,106 @@
package ma.tkgraph.xtreamtv.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
@Composable
fun ChannelLogo(
logoUrl: String?,
channelName: String?,
modifier: Modifier = Modifier
) {
val cleanedLogoUrl = logoUrl
?.trim()
?.takeIf { it.isNotBlank() }
var imageFailed by remember(cleanedLogoUrl) { mutableStateOf(false) }
val context = LocalContext.current
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.16f),
Color.Black.copy(alpha = 0.26f)
)
)
)
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.12f),
shape = RoundedCornerShape(12.dp)
)
.padding(3.dp),
contentAlignment = Alignment.Center
) {
if (cleanedLogoUrl != null && !imageFailed) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(cleanedLogoUrl)
.crossfade(false)
.build(),
contentDescription = channelName,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
onError = { imageFailed = true }
)
} else {
Text(
text = channelInitials(channelName),
color = Color.White.copy(alpha = 0.88f),
fontSize = 11.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.Black,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
private fun channelInitials(channelName: String?): String {
val cleanName = channelName
.orEmpty()
.replace(Regex("[^A-Za-z0-9À-ÿ ]"), " ")
.trim()
if (cleanName.isBlank()) return "TV"
val words = cleanName
.split(Regex("\\s+"))
.filter { it.isNotBlank() }
return when {
words.size >= 2 -> "${words[0].first()}${words[1].first()}".uppercase()
cleanName.length >= 2 -> cleanName.take(2).uppercase()
else -> cleanName.uppercase()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApiFactory
import ma.tkgraph.xtreamtv.data.repository.XtreamRepository
@Composable
fun LoginScreen(
initialConfig: XtreamConfig? = null,
initialErrorMessage: String? = null,
onLoginSuccess: (XtreamConfig, List<LiveStreamDto>, List<LiveCategoryDto>) -> Unit
) {
var serverUrl by remember(initialConfig?.serverUrl) {
mutableStateOf(initialConfig?.serverUrl.orEmpty())
}
var username by remember(initialConfig?.username) {
mutableStateOf(initialConfig?.username.orEmpty())
}
var password by remember(initialConfig?.password) {
mutableStateOf(initialConfig?.password.orEmpty())
}
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember(initialErrorMessage) {
mutableStateOf(initialErrorMessage)
}
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(48.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.widthIn(max = 720.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = "Xtream TV Player",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "Connecte ton accès Xtream compatible pour charger les chaînes Live et les catégories.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Serveur : http://exemple.com:8080") },
singleLine = true
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = username,
onValueChange = { username = it },
label = { Text("Utilisateur") },
singleLine = true
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = password,
onValueChange = { password = it },
label = { Text("Mot de passe") },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
Button(
enabled = !isLoading &&
serverUrl.isNotBlank() &&
username.isNotBlank() &&
password.isNotBlank(),
onClick = {
scope.launch {
isLoading = true
errorMessage = null
runCatching {
val config = XtreamConfig(
serverUrl = serverUrl.trim(),
username = username.trim(),
password = password.trim()
)
val repository = XtreamRepository(
XtreamApiFactory.create(config.serverUrl)
)
val account = repository.checkAccount(
username = config.username,
password = config.password
)
val isAuthorized = account.userInfo?.auth == "1"
val isActive = account.userInfo?.status.equals(
other = "Active",
ignoreCase = true
)
if (!isAuthorized || !isActive) {
error("Compte non autorisé ou abonnement inactif.")
}
val categories = runCatching {
repository.loadLiveCategories(
username = config.username,
password = config.password
)
}.getOrDefault(emptyList())
val streams = repository.loadLiveStreams(
username = config.username,
password = config.password
)
onLoginSuccess(config, streams, categories)
}.onFailure { throwable ->
errorMessage = throwable.message ?: "Connexion impossible au serveur."
}
isLoading = false
}
}
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.height(22.dp),
strokeWidth = 2.dp
)
} else {
Text("Se connecter")
}
}
errorMessage?.let { message ->
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@@ -0,0 +1,368 @@
package ma.tkgraph.xtreamtv.ui.screens
import android.view.KeyEvent as AndroidKeyEvent
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.fillMaxSize
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.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
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.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
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 ma.tkgraph.xtreamtv.ui.components.ChannelLogo
@Composable
fun PlayerScreen(
title: String,
logoUrl: String?,
streamUrl: String,
isFavorite: Boolean,
isPinned: Boolean,
isReplay: Boolean,
programTitle: String?,
programTime: String?,
enableZapping: Boolean = true,
onBack: () -> Unit,
onPrevious: () -> Unit,
onNext: () -> Unit,
onToggleFavorite: () -> Unit,
onTogglePinned: () -> Unit
) {
val context = LocalContext.current
val focusRequester = remember { FocusRequester() }
var errorMessage by remember(streamUrl) { mutableStateOf<String?>(null) }
var resolutionLabel by remember(streamUrl) { mutableStateOf("Résolution : détection...") }
var overlayVisible by remember(streamUrl) { mutableStateOf(true) }
var backIsArmed by remember(streamUrl) { mutableStateOf(false) }
fun showOverlayForBack() {
overlayVisible = true
backIsArmed = true
}
BackHandler {
if (backIsArmed) {
onBack()
} else {
showOverlayForBack()
}
}
LaunchedEffect(streamUrl, overlayVisible, backIsArmed) {
if (overlayVisible && errorMessage == null) {
delay(3_000)
overlayVisible = false
backIsArmed = false
}
}
val player = remember(streamUrl) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(streamUrl))
prepare()
playWhenReady = true
}
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
errorMessage = "Erreur de lecture : ${error.errorCodeName}"
overlayVisible = true
backIsArmed = true
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
resolutionLabel = if (videoSize.width > 0 && videoSize.height > 0) {
"${videoSize.width} × ${videoSize.height}"
} else {
"Résolution inconnue"
}
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(focusRequester)
.focusable()
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyUp) return@onPreviewKeyEvent false
when (event.key) {
Key.DirectionLeft -> {
if (!enableZapping) return@onPreviewKeyEvent false
overlayVisible = true
backIsArmed = false
onPrevious()
true
}
Key.DirectionRight -> {
if (!enableZapping) return@onPreviewKeyEvent false
overlayVisible = true
backIsArmed = false
onNext()
true
}
else -> false
}
}
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { viewContext ->
PlayerView(viewContext).apply {
this.player = player
useController = true
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
keepScreenOn = true
isFocusable = true
isFocusableInTouchMode = true
requestFocus()
}
},
update = { playerView ->
playerView.player = player
playerView.setOnKeyListener { _, keyCode, event ->
if (event.action != AndroidKeyEvent.ACTION_UP) return@setOnKeyListener false
when (keyCode) {
AndroidKeyEvent.KEYCODE_DPAD_LEFT,
AndroidKeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
if (!enableZapping) return@setOnKeyListener false
overlayVisible = true
backIsArmed = false
onPrevious()
true
}
AndroidKeyEvent.KEYCODE_DPAD_RIGHT,
AndroidKeyEvent.KEYCODE_MEDIA_NEXT -> {
if (!enableZapping) return@setOnKeyListener false
overlayVisible = true
backIsArmed = false
onNext()
true
}
else -> false
}
}
}
)
if (overlayVisible) {
PlayerInfoOverlay(
title = title,
logoUrl = logoUrl,
resolutionLabel = resolutionLabel,
isFavorite = isFavorite,
isPinned = isPinned,
isReplay = isReplay,
programTitle = programTitle,
programTime = programTime,
errorMessage = errorMessage,
modifier = Modifier.align(Alignment.TopStart)
)
ControlHintOverlay(
enableZapping = enableZapping,
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
}
@Composable
private fun PlayerInfoOverlay(
title: String,
logoUrl: String?,
resolutionLabel: String,
isFavorite: Boolean,
isPinned: Boolean,
isReplay: Boolean,
programTitle: String?,
programTime: String?,
errorMessage: String?,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier
.padding(28.dp)
.border(
width = 1.dp,
color = Color.White.copy(alpha = 0.1f),
shape = RoundedCornerShape(24.dp)
),
color = Color.Black.copy(alpha = 0.54f),
shape = RoundedCornerShape(24.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 22.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
ChannelLogo(
logoUrl = logoUrl,
channelName = title,
modifier = Modifier
.width(58.dp)
.height(58.dp)
)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
color = Color.White,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
InfoPill(text = resolutionLabel)
if (isReplay) InfoPill(text = "Replay")
if (isFavorite) InfoPill(text = "Favori")
if (isPinned) InfoPill(text = "Démarrage")
}
if (!programTitle.isNullOrBlank()) {
Text(
text = buildString {
append(programTitle)
if (!programTime.isNullOrBlank()) append(" • $programTime")
},
color = Color.White.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
errorMessage?.let { message ->
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
@Composable
private fun ControlHintOverlay(
enableZapping: Boolean,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.padding(28.dp),
color = Color.Black.copy(alpha = 0.42f),
shape = RoundedCornerShape(20.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (enableZapping) {
Text(
text = "← / →",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "zapper • Retour : afficher les infos, puis revenir au menu",
color = Color.White.copy(alpha = 0.72f),
style = MaterialTheme.typography.bodyMedium
)
} else {
Text(
text = "Retour",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "afficher les infos, puis revenir au catalogue",
color = Color.White.copy(alpha = 0.72f),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@Composable
private fun InfoPill(text: String) {
Surface(
color = Color.White.copy(alpha = 0.14f),
shape = RoundedCornerShape(999.dp)
) {
Text(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
text = text,
color = Color.White.copy(alpha = 0.9f),
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,511 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
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 kotlinx.coroutines.delay
import ma.tkgraph.xtreamtv.data.model.EpgProgramDto
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.ui.components.ChannelLogo
@Composable
fun ReplayGuideScreen(
stream: LiveStreamDto,
programs: List<EpgProgramDto>,
isLoading: Boolean,
errorMessage: String?,
onBack: () -> Unit,
onRetry: () -> Unit,
onProgramSelected: (EpgProgramDto) -> Unit
) {
BackHandler(onBack = onBack)
var searchQuery by remember(stream.streamId) { mutableStateOf("") }
val streamArchiveEnabled = stream.tvArchive == 1
val nowMillis = remember { System.currentTimeMillis() }
val visiblePrograms = remember(programs, searchQuery, streamArchiveEnabled) {
val query = searchQuery.trim()
programs
.asSequence()
.filter { program ->
query.isBlank() ||
program.titleText().contains(query, ignoreCase = true) ||
program.descriptionText().contains(query, ignoreCase = true) ||
program.timeRangeText().contains(query, ignoreCase = true) ||
program.dayHeaderText().contains(query, ignoreCase = true)
}
.sortedWith(compareBy<EpgProgramDto> { it.startMillis() ?: Long.MAX_VALUE })
.toList()
}
val guideItems = remember(visiblePrograms) {
buildReplayGuideItems(visiblePrograms)
}
val listState = rememberLazyListState()
val firstProgramFocusRequester = remember { FocusRequester() }
LaunchedEffect(stream.streamId, isLoading) {
if (!isLoading && guideItems.any { it is ReplayGuideItem.ProgramItem }) {
delay(150)
runCatching { firstProgramFocusRequester.requestFocus() }
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFF020617),
Color(0xFF071B35),
Color(0xFF07111F)
)
)
)
.padding(horizontal = 28.dp, vertical = 18.dp)
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
ReplayGuideHeader(
stream = stream,
programsCount = programs.size,
visibleProgramsCount = visiblePrograms.size,
searchQuery = searchQuery,
onSearchChanged = { searchQuery = it },
onBack = onBack
)
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.White.copy(alpha = 0.045f),
shape = RoundedCornerShape(22.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
when {
isLoading -> ReplayLoading(
modifier = Modifier.align(Alignment.Center)
)
!errorMessage.isNullOrBlank() -> ReplayError(
message = errorMessage,
onRetry = onRetry,
modifier = Modifier.align(Alignment.Center)
)
programs.isEmpty() -> ReplayEmpty(
text = "Aucun programme complet disponible pour cette chaîne. Le fournisseur ne renvoie probablement pas le guide détaillé. Charmant, mais au moins on sait où ça casse.",
modifier = Modifier.align(Alignment.Center)
)
visiblePrograms.isEmpty() -> ReplayEmpty(
text = "Aucune émission ne correspond à la recherche.",
modifier = Modifier.align(Alignment.Center)
)
else -> LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 24.dp)
) {
items(
items = guideItems,
key = { item -> item.stableKey }
) { item ->
when (item) {
is ReplayGuideItem.DayHeader -> DayHeaderRow(item.title)
is ReplayGuideItem.ProgramItem -> {
ReplayProgramRow(
program = item.program,
streamArchiveEnabled = streamArchiveEnabled,
nowMillis = nowMillis,
focusRequester = if (item.index == 0) firstProgramFocusRequester else null,
onClick = { onProgramSelected(item.program) }
)
}
}
}
}
}
}
}
}
}
}
@Composable
private fun ReplayGuideHeader(
stream: LiveStreamDto,
programsCount: Int,
visibleProgramsCount: Int,
searchQuery: String,
onSearchChanged: (String) -> Unit,
onBack: () -> Unit
) {
val title = stream.name.orEmpty().ifBlank { "Chaîne ${stream.streamId}" }
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White.copy(alpha = 0.055f),
shape = RoundedCornerShape(22.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
ChannelLogo(
logoUrl = stream.streamIcon,
channelName = title,
modifier = Modifier
.width(54.dp)
.height(54.dp)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Replay",
fontSize = 13.sp,
lineHeight = 16.sp,
color = Color(0xFFBAE6FD),
fontWeight = FontWeight.Bold,
maxLines = 1
)
Text(
text = title,
fontSize = 22.sp,
lineHeight = 26.sp,
color = Color.White,
fontWeight = FontWeight.ExtraBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "$visibleProgramsCount / $programsCount programmes affichés • choisis l’émission à revoir",
fontSize = 12.sp,
lineHeight = 15.sp,
color = Color.White.copy(alpha = 0.62f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
DarkReplaySearchField(
value = searchQuery,
onValueChange = onSearchChanged,
label = "Rechercher émission",
modifier = Modifier.widthIn(min = 260.dp, max = 390.dp)
)
Button(
modifier = Modifier.height(38.dp),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp),
onClick = onBack
) {
Text(
text = "Retour",
fontSize = 12.sp,
lineHeight = 15.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
@Composable
private fun DarkReplaySearchField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier
) {
OutlinedTextField(
modifier = modifier.height(50.dp),
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = TextStyle(
color = Color.White,
fontSize = 13.sp,
lineHeight = 16.sp
),
label = {
Text(
text = label,
color = Color.White.copy(alpha = 0.66f),
fontSize = 12.sp
)
}
)
}
@Composable
private fun DayHeaderRow(title: String) {
Text(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
text = title.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() },
color = Color(0xFFBAE6FD),
fontSize = 14.sp,
lineHeight = 18.sp,
fontWeight = FontWeight.ExtraBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@Composable
private fun ReplayProgramRow(
program: EpgProgramDto,
streamArchiveEnabled: Boolean,
nowMillis: Long,
focusRequester: FocusRequester?,
onClick: () -> Unit
) {
var focused by remember { mutableStateOf(false) }
val canPlay = program.canPlayAsReplay(
streamArchiveEnabled = streamArchiveEnabled,
nowMillis = nowMillis
)
val shape = RoundedCornerShape(16.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color(0xFF22D3EE) else Color.White.copy(alpha = 0.08f),
shape = shape
),
color = if (focused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.052f),
shape = shape
) {
Row(
modifier = Modifier.padding(horizontal = 13.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TimeBox(program.timeRangeText())
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { focused = it.isFocused }
.clickable(enabled = canPlay, onClick = onClick)
.focusable(enabled = canPlay)
.padding(horizontal = 8.dp, vertical = 5.dp),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
text = program.titleText(),
fontSize = 15.sp,
lineHeight = 18.sp,
color = Color.White,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val description = program.descriptionText()
Text(
text = if (description.isNotBlank()) description else program.fullDateTimeText(),
fontSize = 11.sp,
lineHeight = 14.sp,
color = Color.White.copy(alpha = 0.60f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Button(
modifier = Modifier
.width(86.dp)
.height(36.dp),
enabled = canPlay,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
colors = ButtonDefaults.buttonColors(
disabledContainerColor = Color.White.copy(alpha = 0.10f),
disabledContentColor = Color.White.copy(alpha = 0.34f)
),
onClick = onClick
) {
Text(
text = if (canPlay) "Revoir" else "Indispo",
fontSize = 12.sp,
lineHeight = 15.sp,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
}
}
@Composable
private fun TimeBox(text: String) {
Box(
modifier = Modifier
.width(96.dp)
.height(42.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color.Black.copy(alpha = 0.28f)),
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = Color.White.copy(alpha = 0.88f),
fontSize = 12.sp,
lineHeight = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun ReplayLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator()
Text(
text = "Chargement du guide replay complet...",
color = Color.White.copy(alpha = 0.82f),
fontSize = 15.sp,
lineHeight = 20.sp
)
}
}
@Composable
private fun ReplayError(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.widthIn(max = 620.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
fontSize = 15.sp,
lineHeight = 20.sp
)
Button(onClick = onRetry) {
Text(text = "Réessayer")
}
}
}
@Composable
private fun ReplayEmpty(
text: String,
modifier: Modifier = Modifier
) {
Text(
modifier = modifier.widthIn(max = 720.dp),
text = text,
color = Color.White.copy(alpha = 0.78f),
fontSize = 15.sp,
lineHeight = 20.sp
)
}
private sealed interface ReplayGuideItem {
val stableKey: String
data class DayHeader(
val title: String
) : ReplayGuideItem {
override val stableKey: String = "day_$title"
}
data class ProgramItem(
val program: EpgProgramDto,
val index: Int
) : ReplayGuideItem {
override val stableKey: String = "program_${program.id ?: program.start.orEmpty()}_${program.titleText()}"
}
}
private fun buildReplayGuideItems(programs: List<EpgProgramDto>): List<ReplayGuideItem> {
val items = mutableListOf<ReplayGuideItem>()
var lastDay: String? = null
var programIndex = 0
programs.forEach { program ->
val day = program.dayHeaderText()
if (day != lastDay) {
items += ReplayGuideItem.DayHeader(day)
lastDay = day
}
items += ReplayGuideItem.ProgramItem(
program = program,
index = programIndex
)
programIndex++
}
return items
}
@@ -0,0 +1,829 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
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 ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.SeriesDto
import ma.tkgraph.xtreamtv.data.model.SeriesEpisodeItem
import ma.tkgraph.xtreamtv.data.model.SeriesInfoResponse
import ma.tkgraph.xtreamtv.ui.components.ChannelLogo
import ma.tkgraph.xtreamtv.util.categorySortNormalizedName
import ma.tkgraph.xtreamtv.util.categorySortWeight
private const val ALL_CATEGORY_ID = "__all__"
private const val FAVORITES_SERIES_CATEGORY_ID = "__favorites_series__"
private const val UNCATEGORIZED_CATEGORY_ID = "__uncategorized__"
@Composable
fun SeriesScreen(
series: List<SeriesDto>,
categories: List<LiveCategoryDto>,
isLoading: Boolean,
errorMessage: String?,
selectedCategoryId: String?,
selectedSeriesId: String?,
favoriteSeriesIds: Set<String>,
categorySearchQuery: String,
seriesSearchQuery: String,
episodeSearchQuery: String,
seriesInfoById: Map<String, SeriesInfoResponse>,
loadingSeriesInfoIds: Set<String>,
seriesInfoErrors: Map<String, String>,
onCategorySelected: (String?) -> Unit,
onCategorySearchChanged: (String) -> Unit,
onSeriesSearchChanged: (String) -> Unit,
onEpisodeSearchChanged: (String) -> Unit,
onSeriesFocused: (SeriesDto) -> Unit,
onSeriesSelected: (SeriesDto) -> Unit,
onToggleFavorite: (SeriesDto) -> Unit,
onPlayEpisode: (SeriesDto, SeriesEpisodeItem) -> Unit,
onReload: () -> Unit,
onRetryInfo: (SeriesDto) -> Unit,
onBack: () -> Unit
) {
BackHandler(onBack = onBack)
val categoryFilters = remember(series, categories, favoriteSeriesIds) {
buildSeriesCategoryFilters(
series = series,
categories = categories,
favoriteSeriesIds = favoriteSeriesIds
)
}
val visibleCategories = remember(categoryFilters, categorySearchQuery) {
val query = categorySearchQuery.trim()
if (query.isBlank()) categoryFilters else categoryFilters.filter { category ->
category.name.contains(query, ignoreCase = true) || category.id.orEmpty().contains(query, ignoreCase = true)
}
}
val filteredSeries = remember(series, selectedCategoryId, seriesSearchQuery, favoriteSeriesIds) {
filterSeries(
series = series,
selectedCategoryId = selectedCategoryId,
favoriteSeriesIds = favoriteSeriesIds,
query = seriesSearchQuery
)
}
val selectedSeries = selectedSeriesId?.let { id -> series.firstOrNull { it.seriesId == id } }
?: filteredSeries.firstOrNull()
val selectedCategoryName = categoryFilters.firstOrNull { it.id == selectedCategoryId }?.name ?: "Toutes les séries"
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFF020617),
Color(0xFF0F172A),
Color(0xFF020617)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 28.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
SeriesHeader(
visibleCount = filteredSeries.size,
totalCount = series.size,
selectedCategoryName = selectedCategoryName,
isLoading = isLoading,
onReload = onReload,
onBack = onBack
)
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
SeriesCategoryPanel(
modifier = Modifier
.width(280.dp)
.fillMaxHeight(),
categorySearchQuery = categorySearchQuery,
onCategorySearchChanged = onCategorySearchChanged,
categories = visibleCategories,
selectedCategoryId = selectedCategoryId,
onCategorySelected = onCategorySelected
)
SeriesListPanel(
modifier = Modifier
.width(430.dp)
.fillMaxHeight(),
selectedCategoryName = selectedCategoryName,
seriesSearchQuery = seriesSearchQuery,
onSeriesSearchChanged = onSeriesSearchChanged,
series = filteredSeries,
selectedSeriesId = selectedSeries?.seriesId,
favoriteSeriesIds = favoriteSeriesIds,
isLoading = isLoading,
errorMessage = errorMessage,
onSeriesFocused = onSeriesFocused,
onSeriesSelected = onSeriesSelected,
onToggleFavorite = onToggleFavorite,
onReload = onReload
)
EpisodesPanel(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
selectedSeries = selectedSeries,
seriesInfo = selectedSeries?.seriesId?.let { seriesInfoById[it] },
isLoading = selectedSeries?.seriesId?.let { loadingSeriesInfoIds.contains(it) } == true,
errorMessage = selectedSeries?.seriesId?.let { seriesInfoErrors[it] },
episodeSearchQuery = episodeSearchQuery,
onEpisodeSearchChanged = onEpisodeSearchChanged,
onRetry = {
selectedSeries?.let(onRetryInfo)
},
onPlayEpisode = { item ->
selectedSeries?.let { onPlayEpisode(it, item) }
}
)
}
}
}
}
@Composable
private fun SeriesHeader(
visibleCount: Int,
totalCount: Int,
selectedCategoryName: String,
isLoading: Boolean,
onReload: () -> Unit,
onBack: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White.copy(alpha = 0.055f),
shape = RoundedCornerShape(22.dp),
tonalElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 22.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Séries",
fontSize = 26.sp,
lineHeight = 30.sp,
fontWeight = FontWeight.Black,
color = Color.White,
maxLines = 1
)
Text(
text = "$visibleCount / $totalCount séries • $selectedCategoryName",
fontSize = 14.sp,
lineHeight = 18.sp,
color = Color.White.copy(alpha = 0.70f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (isLoading) CircularProgressIndicator(modifier = Modifier.width(28.dp).height(28.dp), strokeWidth = 2.dp)
CompactOutlinedSeriesButton(text = "Actualiser", width = 112.dp, onClick = onReload)
CompactOutlinedSeriesButton(text = "Live", width = 82.dp, onClick = onBack)
}
}
}
@Composable
private fun SeriesCategoryPanel(
modifier: Modifier,
categorySearchQuery: String,
onCategorySearchChanged: (String) -> Unit,
categories: List<MediaCategoryFilter>,
selectedCategoryId: String?,
onCategorySelected: (String?) -> Unit
) {
Surface(
modifier = modifier,
color = Color.White.copy(alpha = 0.065f),
shape = RoundedCornerShape(22.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Catégories",
fontSize = 20.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
DarkSeriesSearchField(
value = categorySearchQuery,
onValueChange = onCategorySearchChanged,
label = "Rechercher catégorie",
modifier = Modifier.fillMaxWidth()
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(7.dp),
contentPadding = PaddingValues(bottom = 14.dp)
) {
items(
items = categories,
key = { it.id ?: ALL_CATEGORY_ID }
) { category ->
MediaCategoryRow(
category = category,
selected = category.id == selectedCategoryId,
onClick = { onCategorySelected(category.id) }
)
}
}
}
}
}
@Composable
private fun SeriesListPanel(
modifier: Modifier,
selectedCategoryName: String,
seriesSearchQuery: String,
onSeriesSearchChanged: (String) -> Unit,
series: List<SeriesDto>,
selectedSeriesId: String?,
favoriteSeriesIds: Set<String>,
isLoading: Boolean,
errorMessage: String?,
onSeriesFocused: (SeriesDto) -> Unit,
onSeriesSelected: (SeriesDto) -> Unit,
onToggleFavorite: (SeriesDto) -> Unit,
onReload: () -> Unit
) {
Surface(
modifier = modifier,
color = Color.White.copy(alpha = 0.045f),
shape = RoundedCornerShape(22.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = selectedCategoryName,
fontSize = 20.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
DarkSeriesSearchField(
value = seriesSearchQuery,
onValueChange = onSeriesSearchChanged,
label = "Rechercher série",
modifier = Modifier.fillMaxWidth()
)
when {
isLoading -> LoadingSeriesMessage(text = "Chargement des séries...")
errorMessage != null -> SeriesErrorBlock(message = errorMessage, onRetry = onReload)
series.isEmpty() -> EmptySeriesMessage("Aucune série trouvée.")
else -> LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 18.dp)
) {
items(
items = series,
key = { it.seriesId }
) { item ->
SeriesRow(
series = item,
selected = item.seriesId == selectedSeriesId,
isFavorite = favoriteSeriesIds.contains(item.seriesId),
onFocused = { onSeriesFocused(item) },
onClick = { onSeriesSelected(item) },
onToggleFavorite = { onToggleFavorite(item) }
)
}
}
}
}
}
}
@Composable
private fun SeriesRow(
series: SeriesDto,
selected: Boolean,
isFavorite: Boolean,
onFocused: () -> Unit,
onClick: () -> Unit,
onToggleFavorite: () -> Unit
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(14.dp)
val title = series.titleText()
Surface(
modifier = Modifier
.fillMaxWidth()
.border(
width = if (focused || selected) 2.dp else 1.dp,
color = when {
focused -> Color(0xFF22D3EE)
selected -> Color(0xFF38BDF8)
else -> Color.White.copy(alpha = 0.075f)
},
shape = shape
)
.clip(shape)
.onFocusChanged { state ->
focused = state.isFocused
if (state.isFocused) onFocused()
}
.clickable(onClick = onClick)
.focusable(),
color = when {
selected -> Color(0xFF7C3AED).copy(alpha = 0.25f)
focused -> Color.White.copy(alpha = 0.115f)
else -> Color.White.copy(alpha = 0.047f)
},
shape = shape
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
ChannelLogo(
logoUrl = series.cover,
channelName = title,
modifier = Modifier.width(46.dp).height(46.dp)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
fontSize = 15.sp,
lineHeight = 18.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = buildString {
series.number?.let { append("N° $it • ") }
append("ID ${series.seriesId}")
series.genre?.takeIf { it.isNotBlank() }?.let { append(" • $it") }
if (isFavorite) append(" • Favori")
},
fontSize = 11.sp,
lineHeight = 14.sp,
color = Color.White.copy(alpha = 0.58f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
OutlinedButton(
modifier = Modifier.width(48.dp).height(36.dp),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
onClick = onToggleFavorite
) {
Text(
text = if (isFavorite) "★" else "☆",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
}
}
}
@Composable
private fun EpisodesPanel(
modifier: Modifier,
selectedSeries: SeriesDto?,
seriesInfo: SeriesInfoResponse?,
isLoading: Boolean,
errorMessage: String?,
episodeSearchQuery: String,
onEpisodeSearchChanged: (String) -> Unit,
onRetry: () -> Unit,
onPlayEpisode: (SeriesEpisodeItem) -> Unit
) {
Surface(
modifier = modifier,
color = Color.White.copy(alpha = 0.045f),
shape = RoundedCornerShape(22.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = selectedSeries?.titleText() ?: "Sélectionne une série",
fontSize = 20.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
DarkSeriesSearchField(
value = episodeSearchQuery,
onValueChange = onEpisodeSearchChanged,
label = "Rechercher épisode",
modifier = Modifier.fillMaxWidth()
)
when {
selectedSeries == null -> EmptySeriesMessage("Choisis une série pour voir ses saisons et épisodes.")
isLoading -> LoadingSeriesMessage(text = "Chargement des épisodes...")
errorMessage != null -> SeriesErrorBlock(message = errorMessage, onRetry = onRetry)
seriesInfo == null -> EmptySeriesMessage("Aucun détail chargé pour cette série.")
else -> {
val episodes = remember(seriesInfo, episodeSearchQuery) {
val query = episodeSearchQuery.trim()
seriesInfo.allEpisodes().filter { item ->
query.isBlank() ||
item.episode.titleText().contains(query, ignoreCase = true) ||
item.seasonText().contains(query, ignoreCase = true) ||
item.episode.episodeNum?.toString().orEmpty().contains(query, ignoreCase = true)
}
}
if (episodes.isEmpty()) {
EmptySeriesMessage("Aucun épisode trouvé.")
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 18.dp)
) {
val grouped = episodes.groupBy { it.seasonText() }
grouped.forEach { (seasonName, items) ->
item(key = "header_$seasonName") {
Text(
text = seasonName,
color = Color(0xFFBAE6FD),
fontSize = 14.sp,
lineHeight = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 6.dp, bottom = 2.dp)
)
}
items(
items = items,
key = { item -> "${seasonName}_${item.episode.id ?: item.episode.episodeNum?.toString() ?: item.episode.titleText()}" }
) { item ->
EpisodeRow(
item = item,
seriesTitle = selectedSeries.titleText(),
onPlay = { onPlayEpisode(item) }
)
}
}
}
}
}
}
}
}
}
@Composable
private fun EpisodeRow(
item: SeriesEpisodeItem,
seriesTitle: String?,
onPlay: () -> Unit
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(14.dp)
val episode = item.episode
val title = buildEpisodeTitle(
item = item,
seriesTitle = seriesTitle
)
val meta = buildEpisodeMeta(item)
Surface(
modifier = Modifier
.fillMaxWidth()
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color(0xFF22D3EE) else Color.White.copy(alpha = 0.075f),
shape = shape
)
.clip(shape)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onPlay)
.focusable(),
color = if (focused) Color.White.copy(alpha = 0.135f) else Color.White.copy(alpha = 0.052f),
shape = shape
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
modifier = Modifier
.width(54.dp)
.height(42.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF0F172A).copy(alpha = 0.82f)),
contentAlignment = Alignment.Center
) {
Text(
text = episode.episodeNum?.let { "E$it" } ?: "EP",
fontSize = 13.sp,
lineHeight = 16.sp,
fontWeight = FontWeight.Black,
color = Color.White.copy(alpha = 0.92f),
maxLines = 1
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
text = title,
fontSize = 14.sp,
lineHeight = 17.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = meta,
fontSize = 10.sp,
lineHeight = 13.sp,
color = Color.White.copy(alpha = 0.62f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
modifier = Modifier.width(62.dp).height(34.dp),
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp),
onClick = onPlay
) {
Text(text = "Lire", fontSize = 11.sp, fontWeight = FontWeight.Bold, maxLines = 1)
}
}
}
}
private fun buildEpisodeTitle(
item: SeriesEpisodeItem,
seriesTitle: String?
): String {
val episode = item.episode
val rawTitle = episode.title
.orEmpty()
.trim()
.takeIf { it.isNotBlank() && it != "." && it != "-" && it != "..." }
val episodeLabel = episode.episodeNum?.let { "Épisode $it" } ?: "Épisode"
return when {
rawTitle != null && rawTitle != episode.episodeNum?.toString() -> "$episodeLabel • $rawTitle"
!seriesTitle.isNullOrBlank() -> "$episodeLabel • $seriesTitle"
else -> episodeLabel
}
}
private fun buildEpisodeMeta(item: SeriesEpisodeItem): String {
val episode = item.episode
val parts = mutableListOf<String>()
parts.add(item.seasonText())
parts.add(episode.extensionText().uppercase())
episode.info?.duration
?.trim()
?.takeIf { it.isNotBlank() && it != "." && it != "-" }
?.let { parts.add(it) }
episode.info?.releaseDate
?.trim()
?.takeIf { it.isNotBlank() && it != "." && it != "-" }
?.let { parts.add(it) }
episode.id
?.trim()
?.takeIf { it.isNotBlank() }
?.let { parts.add("ID $it") }
return parts.joinToString(" • ")
}
@Composable
private fun DarkSeriesSearchField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier
) {
OutlinedTextField(
modifier = modifier.height(52.dp),
value = value,
onValueChange = onValueChange,
label = {
Text(text = label, color = Color.White.copy(alpha = 0.68f), fontSize = 12.sp)
},
singleLine = true,
textStyle = TextStyle(color = Color.White, fontSize = 15.sp, lineHeight = 18.sp)
)
}
@Composable
private fun LoadingSeriesMessage(text: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator()
Text(text = text, color = Color.White.copy(alpha = 0.84f), fontSize = 15.sp)
}
}
@Composable
private fun SeriesErrorBlock(message: String, onRetry: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0xFF7F1D1D).copy(alpha = 0.34f)),
shape = RoundedCornerShape(18.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(modifier = Modifier.weight(1f), text = message, color = Color.White, style = MaterialTheme.typography.bodyMedium)
Button(onClick = onRetry) { Text("Réessayer") }
}
}
}
@Composable
private fun EmptySeriesMessage(text: String) {
Text(
modifier = Modifier.widthIn(max = 720.dp).padding(18.dp),
text = text,
color = Color.White.copy(alpha = 0.78f),
fontSize = 15.sp,
lineHeight = 20.sp
)
}
@Composable
private fun CompactOutlinedSeriesButton(
text: String,
width: androidx.compose.ui.unit.Dp,
onClick: () -> Unit
) {
OutlinedButton(
modifier = Modifier.width(width).height(36.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
onClick = onClick
) {
Text(text = text, fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
private fun buildSeriesCategoryFilters(
series: List<SeriesDto>,
categories: List<LiveCategoryDto>,
favoriteSeriesIds: Set<String>
): List<MediaCategoryFilter> {
val namesById = categories
.filter { it.categoryId.isNotBlank() }
.associate { category ->
category.categoryId to category.categoryName.orEmpty().ifBlank { "Catégorie ${category.categoryId}" }
}
val counts = series.groupingBy { item ->
item.categoryId?.takeIf { it.isNotBlank() } ?: UNCATEGORIZED_CATEGORY_ID
}.eachCount()
val filters = mutableListOf(
MediaCategoryFilter(id = null, name = "Toutes les séries", count = series.size)
)
val favoriteCount = series.count { favoriteSeriesIds.contains(it.seriesId) }
if (favoriteCount > 0) {
filters.add(MediaCategoryFilter(id = FAVORITES_SERIES_CATEGORY_ID, name = "★ Favoris", count = favoriteCount))
}
counts.keys
.filter { it != UNCATEGORIZED_CATEGORY_ID }
.sortedWith(
compareBy<String>(
{ id -> categorySortWeight(namesById[id] ?: "Catégorie $id") },
{ id -> categorySortNormalizedName(namesById[id] ?: "Catégorie $id") }
)
)
.forEach { id ->
filters.add(MediaCategoryFilter(id = id, name = namesById[id] ?: "Catégorie $id", count = counts[id] ?: 0))
}
val uncategorized = counts[UNCATEGORIZED_CATEGORY_ID] ?: 0
if (uncategorized > 0) {
filters.add(MediaCategoryFilter(id = UNCATEGORIZED_CATEGORY_ID, name = "Non classées", count = uncategorized))
}
return filters
}
private fun filterSeries(
series: List<SeriesDto>,
selectedCategoryId: String?,
favoriteSeriesIds: Set<String>,
query: String
): List<SeriesDto> {
val cleanQuery = query.trim()
return series
.asSequence()
.filter { item ->
when (selectedCategoryId) {
null -> true
FAVORITES_SERIES_CATEGORY_ID -> favoriteSeriesIds.contains(item.seriesId)
UNCATEGORIZED_CATEGORY_ID -> item.categoryId.isNullOrBlank()
else -> item.categoryId == selectedCategoryId
}
}
.filter { item ->
cleanQuery.isBlank() ||
item.name.orEmpty().contains(cleanQuery, ignoreCase = true) ||
item.seriesId.contains(cleanQuery, ignoreCase = true) ||
item.genre.orEmpty().contains(cleanQuery, ignoreCase = true)
}
.toList()
}
@@ -0,0 +1,661 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
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 ma.tkgraph.xtreamtv.data.model.LiveCategoryDto
import ma.tkgraph.xtreamtv.data.model.VodStreamDto
import ma.tkgraph.xtreamtv.ui.components.ChannelLogo
import ma.tkgraph.xtreamtv.util.categorySortNormalizedName
import ma.tkgraph.xtreamtv.util.categorySortWeight
private const val ALL_CATEGORY_ID = "__all__"
private const val FAVORITES_VOD_CATEGORY_ID = "__favorites_vod__"
private const val UNCATEGORIZED_CATEGORY_ID = "__uncategorized__"
@Composable
fun VodScreen(
movies: List<VodStreamDto>,
categories: List<LiveCategoryDto>,
isLoading: Boolean,
errorMessage: String?,
selectedCategoryId: String?,
selectedMovieId: String?,
favoriteMovieIds: Set<String>,
categorySearchQuery: String,
movieSearchQuery: String,
onCategorySelected: (String?) -> Unit,
onCategorySearchChanged: (String) -> Unit,
onMovieSearchChanged: (String) -> Unit,
onMovieFocused: (VodStreamDto) -> Unit,
onMovieSelected: (VodStreamDto) -> Unit,
onToggleFavorite: (VodStreamDto) -> Unit,
onReload: () -> Unit,
onBack: () -> Unit
) {
BackHandler(onBack = onBack)
val categoryFilters = remember(movies, categories, favoriteMovieIds) {
buildVodCategoryFilters(
movies = movies,
categories = categories,
favoriteMovieIds = favoriteMovieIds
)
}
val visibleCategories = remember(categoryFilters, categorySearchQuery) {
val query = categorySearchQuery.trim()
if (query.isBlank()) categoryFilters else categoryFilters.filter { category ->
category.name.contains(query, ignoreCase = true) || category.id.orEmpty().contains(query, ignoreCase = true)
}
}
val filteredMovies = remember(movies, selectedCategoryId, movieSearchQuery, favoriteMovieIds) {
filterVodMovies(
movies = movies,
selectedCategoryId = selectedCategoryId,
favoriteMovieIds = favoriteMovieIds,
query = movieSearchQuery
)
}
val selectedCategoryName = categoryFilters.firstOrNull { it.id == selectedCategoryId }?.name ?: "Toutes les VOD"
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color(0xFF020617),
Color(0xFF111827),
Color(0xFF020617)
)
)
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 28.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
VodHeader(
visibleCount = filteredMovies.size,
totalCount = movies.size,
selectedCategoryName = selectedCategoryName,
isLoading = isLoading,
onReload = onReload,
onBack = onBack
)
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
VodCategoryPanel(
modifier = Modifier
.width(310.dp)
.fillMaxHeight(),
categorySearchQuery = categorySearchQuery,
onCategorySearchChanged = onCategorySearchChanged,
categories = visibleCategories,
selectedCategoryId = selectedCategoryId,
onCategorySelected = onCategorySelected
)
Surface(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
color = Color.White.copy(alpha = 0.045f),
shape = RoundedCornerShape(22.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = selectedCategoryName,
fontSize = 22.sp,
lineHeight = 26.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Films et VOD disponibles sur ton serveur",
fontSize = 12.sp,
lineHeight = 15.sp,
color = Color.White.copy(alpha = 0.58f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
DarkVodSearchField(
value = movieSearchQuery,
onValueChange = onMovieSearchChanged,
label = "Rechercher VOD",
modifier = Modifier.widthIn(min = 300.dp, max = 430.dp)
)
}
when {
isLoading -> LoadingMessage(text = "Chargement des VOD...")
errorMessage != null -> ErrorBlock(message = errorMessage, onRetry = onReload)
filteredMovies.isEmpty() -> EmptyVodMessage("Aucune VOD trouvée. Change la catégorie ou la recherche, cette idée folle de filtrer fonctionne parfois trop bien.")
else -> LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 18.dp)
) {
items(
items = filteredMovies,
key = { it.streamId }
) { movie ->
VodMovieRow(
movie = movie,
selected = movie.streamId == selectedMovieId,
isFavorite = favoriteMovieIds.contains(movie.streamId),
onFocused = { onMovieFocused(movie) },
onPlay = { onMovieSelected(movie) },
onToggleFavorite = { onToggleFavorite(movie) }
)
}
}
}
}
}
}
}
}
}
@Composable
private fun VodHeader(
visibleCount: Int,
totalCount: Int,
selectedCategoryName: String,
isLoading: Boolean,
onReload: () -> Unit,
onBack: () -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White.copy(alpha = 0.055f),
shape = RoundedCornerShape(22.dp),
tonalElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 22.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "VOD",
fontSize = 26.sp,
lineHeight = 30.sp,
fontWeight = FontWeight.Black,
color = Color.White,
maxLines = 1
)
Text(
text = "$visibleCount / $totalCount films • $selectedCategoryName",
fontSize = 14.sp,
lineHeight = 18.sp,
color = Color.White.copy(alpha = 0.70f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (isLoading) CircularProgressIndicator(modifier = Modifier.width(28.dp).height(28.dp), strokeWidth = 2.dp)
CompactOutlinedMediaButton(text = "Actualiser", width = 112.dp, onClick = onReload)
CompactOutlinedMediaButton(text = "Live", width = 82.dp, onClick = onBack)
}
}
}
@Composable
private fun VodCategoryPanel(
modifier: Modifier,
categorySearchQuery: String,
onCategorySearchChanged: (String) -> Unit,
categories: List<MediaCategoryFilter>,
selectedCategoryId: String?,
onCategorySelected: (String?) -> Unit
) {
Surface(
modifier = modifier,
color = Color.White.copy(alpha = 0.065f),
shape = RoundedCornerShape(22.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Catégories VOD",
fontSize = 20.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
DarkVodSearchField(
value = categorySearchQuery,
onValueChange = onCategorySearchChanged,
label = "Rechercher catégorie",
modifier = Modifier.fillMaxWidth()
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(7.dp),
contentPadding = PaddingValues(bottom = 14.dp)
) {
items(
items = categories,
key = { it.id ?: ALL_CATEGORY_ID }
) { category ->
MediaCategoryRow(
category = category,
selected = category.id == selectedCategoryId,
onClick = { onCategorySelected(category.id) }
)
}
}
}
}
}
@Composable
private fun VodMovieRow(
movie: VodStreamDto,
selected: Boolean,
isFavorite: Boolean,
onFocused: () -> Unit,
onPlay: () -> Unit,
onToggleFavorite: () -> Unit
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(14.dp)
val title = movie.titleText()
Surface(
modifier = Modifier
.fillMaxWidth()
.border(
width = if (focused || selected) 2.dp else 1.dp,
color = when {
focused -> Color(0xFF22D3EE)
selected -> Color(0xFF38BDF8)
else -> Color.White.copy(alpha = 0.075f)
},
shape = shape
),
color = when {
selected -> Color(0xFF7C3AED).copy(alpha = 0.25f)
focused -> Color.White.copy(alpha = 0.115f)
else -> Color.White.copy(alpha = 0.047f)
},
shape = shape
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
ChannelLogo(
logoUrl = movie.streamIcon,
channelName = title,
modifier = Modifier
.width(52.dp)
.height(52.dp)
)
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp))
.onFocusChanged { state ->
focused = state.isFocused
if (state.isFocused) onFocused()
}
.clickable(onClick = onPlay)
.focusable()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
Text(
text = title,
fontSize = 16.sp,
lineHeight = 19.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = buildString {
movie.number?.let { append("N° $it • ") }
append("ID ${movie.streamId}")
append(" • ${movie.extensionText().uppercase()}")
movie.rating?.takeIf { it.isNotBlank() }?.let { append(" • Note $it") }
if (isFavorite) append(" • Favori")
},
fontSize = 12.sp,
lineHeight = 15.sp,
color = Color.White.copy(alpha = 0.60f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
OutlinedButton(
modifier = Modifier.width(52.dp).height(38.dp),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
onClick = onToggleFavorite
) {
Text(
text = if (isFavorite) "★" else "☆",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 1
)
}
Button(
modifier = Modifier.width(82.dp).height(38.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
onClick = onPlay
) {
Text(text = "Lire", fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1)
}
}
}
}
@Composable
private fun DarkVodSearchField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier
) {
OutlinedTextField(
modifier = modifier.height(52.dp),
value = value,
onValueChange = onValueChange,
label = {
Text(
text = label,
color = Color.White.copy(alpha = 0.68f),
fontSize = 12.sp
)
},
singleLine = true,
textStyle = TextStyle(
color = Color.White,
fontSize = 15.sp,
lineHeight = 18.sp
)
)
}
@Composable
internal fun MediaCategoryRow(
category: MediaCategoryFilter,
selected: Boolean,
onClick: () -> Unit
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(14.dp)
Surface(
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focused = it.isFocused }
.border(
width = if (focused || selected) 2.dp else 1.dp,
color = when {
focused -> Color(0xFF22D3EE)
selected -> Color(0xFF38BDF8)
else -> Color.White.copy(alpha = 0.08f)
},
shape = shape
)
.clip(shape)
.clickable(onClick = onClick)
.focusable(),
color = when {
selected -> Color(0xFF0EA5E9).copy(alpha = 0.32f)
focused -> Color.White.copy(alpha = 0.12f)
else -> Color.White.copy(alpha = 0.045f)
},
shape = shape
) {
Row(
modifier = Modifier.padding(horizontal = 13.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
modifier = Modifier.weight(1f),
text = category.name,
fontSize = 14.sp,
lineHeight = 18.sp,
color = Color.White,
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = category.count.toString(),
fontSize = 12.sp,
lineHeight = 14.sp,
color = Color.White.copy(alpha = 0.70f)
)
}
}
}
@Composable
private fun LoadingMessage(text: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator()
Text(text = text, color = Color.White.copy(alpha = 0.84f), fontSize = 15.sp)
}
}
@Composable
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0xFF7F1D1D).copy(alpha = 0.34f)),
shape = RoundedCornerShape(18.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
modifier = Modifier.weight(1f),
text = message,
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
Button(onClick = onRetry) { Text("Réessayer") }
}
}
}
@Composable
private fun EmptyVodMessage(text: String) {
Text(
modifier = Modifier.widthIn(max = 720.dp).padding(18.dp),
text = text,
color = Color.White.copy(alpha = 0.78f),
fontSize = 15.sp,
lineHeight = 20.sp
)
}
@Composable
private fun CompactOutlinedMediaButton(
text: String,
width: androidx.compose.ui.unit.Dp,
onClick: () -> Unit
) {
OutlinedButton(
modifier = Modifier.width(width).height(36.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
onClick = onClick
) {
Text(
text = text,
fontSize = 12.sp,
lineHeight = 15.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
internal data class MediaCategoryFilter(
val id: String?,
val name: String,
val count: Int
)
private fun buildVodCategoryFilters(
movies: List<VodStreamDto>,
categories: List<LiveCategoryDto>,
favoriteMovieIds: Set<String>
): List<MediaCategoryFilter> {
val namesById = categories
.filter { it.categoryId.isNotBlank() }
.associate { category ->
category.categoryId to category.categoryName.orEmpty().ifBlank { "Catégorie ${category.categoryId}" }
}
val counts = movies.groupingBy { movie ->
movie.categoryId?.takeIf { it.isNotBlank() } ?: UNCATEGORIZED_CATEGORY_ID
}.eachCount()
val filters = mutableListOf(
MediaCategoryFilter(id = null, name = "Toutes les VOD", count = movies.size)
)
val favoriteCount = movies.count { favoriteMovieIds.contains(it.streamId) }
if (favoriteCount > 0) {
filters.add(MediaCategoryFilter(id = FAVORITES_VOD_CATEGORY_ID, name = "★ Favoris", count = favoriteCount))
}
counts.keys
.filter { it != UNCATEGORIZED_CATEGORY_ID }
.sortedWith(
compareBy<String>(
{ id -> categorySortWeight(namesById[id] ?: "Catégorie $id") },
{ id -> categorySortNormalizedName(namesById[id] ?: "Catégorie $id") }
)
)
.forEach { id ->
filters.add(MediaCategoryFilter(id = id, name = namesById[id] ?: "Catégorie $id", count = counts[id] ?: 0))
}
val uncategorized = counts[UNCATEGORIZED_CATEGORY_ID] ?: 0
if (uncategorized > 0) {
filters.add(MediaCategoryFilter(id = UNCATEGORIZED_CATEGORY_ID, name = "Non classées", count = uncategorized))
}
return filters
}
private fun filterVodMovies(
movies: List<VodStreamDto>,
selectedCategoryId: String?,
favoriteMovieIds: Set<String>,
query: String
): List<VodStreamDto> {
val cleanQuery = query.trim()
return movies
.asSequence()
.filter { movie ->
when (selectedCategoryId) {
null -> true
FAVORITES_VOD_CATEGORY_ID -> favoriteMovieIds.contains(movie.streamId)
UNCATEGORIZED_CATEGORY_ID -> movie.categoryId.isNullOrBlank()
else -> movie.categoryId == selectedCategoryId
}
}
.filter { movie ->
cleanQuery.isBlank() ||
movie.name.orEmpty().contains(cleanQuery, ignoreCase = true) ||
movie.streamId.contains(cleanQuery, ignoreCase = true) ||
movie.rating.orEmpty().contains(cleanQuery, ignoreCase = true)
}
.toList()
}
@@ -0,0 +1,43 @@
package ma.tkgraph.xtreamtv.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColors = darkColorScheme(
primary = Color(0xFF60A5FA),
secondary = Color(0xFF93C5FD),
background = Color(0xFF050816),
surface = Color(0xFF101828),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White
)
private val LightColors = lightColorScheme(
primary = Color(0xFF2563EB),
secondary = Color(0xFF1D4ED8),
background = Color(0xFFF8FAFC),
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFF111827),
onSurface = Color(0xFF111827)
)
@Composable
fun XtreamTvTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = Typography(),
content = content
)
}
@@ -0,0 +1,73 @@
package ma.tkgraph.xtreamtv.util
import java.text.Normalizer
import java.util.Locale
/**
* Orders IPTV categories in a more useful way for this app:
* French categories first, Arabic categories second, everything else after.
* Special app categories such as "Toutes" and "Favoris" are still added before this sort
* by each screen, so they remain at the top.
*/
fun categorySortWeight(categoryName: String): Int {
val normalized = categorySortNormalizedName(categoryName)
return when {
isFrenchCategory(normalized) -> 0
isArabicCategory(categoryName, normalized) -> 1
else -> 2
}
}
fun categorySortNormalizedName(categoryName: String): String {
val withoutAccents = Normalizer.normalize(categoryName, Normalizer.Form.NFD)
.replace("\\p{Mn}+".toRegex(), "")
return withoutAccents
.lowercase(Locale.ROOT)
.replace("|", " ")
.replace("-", " ")
.replace("_", " ")
.replace("/", " ")
.replace("\\s+".toRegex(), " ")
.trim()
}
private fun isFrenchCategory(normalized: String): Boolean {
val padded = " $normalized "
return normalized.contains("france") ||
normalized.contains("francais") ||
normalized.contains("french") ||
normalized.contains("francia") ||
padded.contains(" fr ") ||
padded.contains(" fra ") ||
padded.contains(" french ") ||
padded.contains(" eu france ") ||
padded.contains(" eu fr ") ||
padded.startsWith(" fr ") ||
padded.startsWith(" fra ")
}
private fun isArabicCategory(original: String, normalized: String): Boolean {
val padded = " $normalized "
val containsArabicScript = original.any { char ->
Character.UnicodeBlock.of(char) == Character.UnicodeBlock.ARABIC ||
Character.UnicodeBlock.of(char) == Character.UnicodeBlock.ARABIC_PRESENTATION_FORMS_A ||
Character.UnicodeBlock.of(char) == Character.UnicodeBlock.ARABIC_PRESENTATION_FORMS_B ||
Character.UnicodeBlock.of(char) == Character.UnicodeBlock.ARABIC_SUPPLEMENT ||
Character.UnicodeBlock.of(char) == Character.UnicodeBlock.ARABIC_EXTENDED_A
}
return containsArabicScript ||
normalized.contains("arabic") ||
normalized.contains("arabe") ||
normalized.contains("arab") ||
normalized.contains("maghreb") ||
normalized.contains("maroc") ||
normalized.contains("morocco") ||
padded.contains(" ar ") ||
padded.startsWith(" ar ") ||
padded.contains(" mena ") ||
padded.contains(" middle east ")
}
@@ -0,0 +1,71 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
object XtreamUrlBuilder {
fun liveUrl(
config: XtreamConfig,
streamId: String,
extension: String = "m3u8"
): String {
val server = normalizeServer(config.serverUrl)
return "$server/live/${config.username}/${config.password}/$streamId.$extension"
}
fun vodUrl(
config: XtreamConfig,
streamId: String,
extension: String = "mp4"
): String {
val server = normalizeServer(config.serverUrl)
return "$server/movie/${config.username}/${config.password}/$streamId.$extension"
}
fun seriesUrl(
config: XtreamConfig,
episodeId: String,
extension: String = "mp4"
): String {
val server = normalizeServer(config.serverUrl)
return "$server/series/${config.username}/${config.password}/$episodeId.$extension"
}
fun timeshiftUrl(
config: XtreamConfig,
streamId: String,
start: String,
durationMinutes: Int,
extension: String = "ts"
): String {
val server = normalizeServer(config.serverUrl)
val formattedStart = formatTimeshiftStart(start)
val safeDuration = durationMinutes.coerceIn(1, 720)
return "$server/timeshift/${config.username}/${config.password}/$safeDuration/$formattedStart/$streamId.$extension"
}
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 fun normalizeServer(rawUrl: String): String {
val trimmed = rawUrl.trim()
val withProtocol = when {
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
else -> "http://$trimmed"
}
return withProtocol.trimEnd('/')
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#101828" android:pathData="M0,0h108v108h-108z" />
<path android:fillColor="#FFFFFF" android:pathData="M42,30 l34,24 l-34,24 z" />
<path android:fillColor="#2563EB" android:pathData="M18,82 h72 v8 h-72z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#020B22" />
</shape>
</item>
<item>
<bitmap
android:gravity="fill"
android:src="@drawable/imedia_tv_background" />
</item>
</layer-list>
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path android:fillColor="#101828" android:pathData="M0,0h320v180h-320z" />
<path android:fillColor="#2563EB" android:pathData="M0,120 C70,80 120,155 190,95 C240,52 280,68 320,38 L320,180 L0,180 Z" />
<path android:fillColor="#FFFFFF" android:pathData="M122,53 l76,44 l-76,44 z" />
</vector>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Xtream TV Player</string>
</resources>
+11
View File
@@ -0,0 +1,11 @@
<resources>
<style name="Theme.XtreamTv" parent="android:style/Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#4F8CFF</item>
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowDisablePreview">false</item>
</style>
</resources>
+13
View File
@@ -0,0 +1,13 @@
$ProjectPath = "D:\07-Logiciels\11-IPTV Andoid TV 2"
$BackupPath = "D:\07-Logiciels\11-IPTV Andoid TV 2 - Backup avant splash iMedia"
if (Test-Path $BackupPath) {
Write-Host "Backup already exists: $BackupPath"
} else {
Copy-Item $ProjectPath $BackupPath -Recurse
}
Write-Host "Compile project..."
Set-Location $ProjectPath
.\gradlew.bat clean --no-daemon
.\gradlew.bat :app:assembleDebug --no-daemon
+4
View File
@@ -0,0 +1,4 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+4
View File
@@ -0,0 +1,4 @@
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
+31
View File
@@ -0,0 +1,31 @@
[versions]
agp = "9.0.1"
kotlin = "2.4.0"
composeBom = "2026.04.01"
activityCompose = "1.13.0"
media3 = "1.10.1"
tvMaterial = "1.1.0"
tvFoundation = "1.0.0"
retrofit = "2.11.0"
coreKtx = "1.18.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
androidx-media3-exoplayer-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "XtreamGoogleTvPlayer"
include(":app")
+7
View File
@@ -0,0 +1,7 @@
.gradle/
build/
local.properties
.idea/
*.iml
.DS_Store
app/build/
+133
View File
@@ -0,0 +1,133 @@
# Xtream Google TV Player
Projet Android TV / Google TV en Kotlin pour lire un accès Xtream compatible.
Ce ZIP contient un MVP propre : écran de connexion, appel `player_api.php`, chargement des chaînes Live et lecture vidéo avec Media3 ExoPlayer.
## Contenu
```text
XtreamGoogleTvPlayer/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle/libs.versions.toml
└── app/
├── build.gradle.kts
├── proguard-rules.pro
└── src/main/
├── AndroidManifest.xml
├── java/ma/tkgraph/xtreamtv/
│ ├── MainActivity.kt
│ ├── data/
│ │ ├── model/
│ │ ├── network/
│ │ └── repository/
│ ├── ui/
│ │ ├── screens/
│ │ └── theme/
│ └── util/
└── res/
```
## Fonctionnalités incluses
- Application Android TV reconnue par le launcher Google TV.
- Connexion via serveur, username et password Xtream.
- Vérification du compte via `player_api.php`.
- Récupération des chaînes Live via `get_live_streams`.
- Lecture des flux Live en `.m3u8` avec Media3 ExoPlayer.
- Navigation simple compatible télécommande.
- Dépendances prêtes dans Gradle Version Catalog.
## Prérequis
- Android Studio récent.
- JDK 17.
- SDK Android 36 installé.
- Un appareil Google TV / Android TV ou un émulateur Android TV.
## Lancement
1. Dézipper le projet.
2. Ouvrir le dossier dans Android Studio.
3. Laisser Gradle synchroniser.
4. Lancer sur un appareil Android TV / Google TV.
Commande possible :
```bash
./gradlew :app:assembleDebug
```
Sur Windows :
```powershell
gradlew.bat :app:assembleDebug
```
> Le wrapper Gradle complet n'est pas inclus dans ce ZIP. Android Studio peut utiliser son Gradle intégré. Pour générer un wrapper : `gradle wrapper`.
## Configuration Xtream attendue
Exemple :
```text
Serveur : http://exemple.com:8080
Username : mon_user
Password : mon_pass
```
L'application appelle :
```text
/player_api.php?username=USER&password=PASS
/player_api.php?username=USER&password=PASS&action=get_live_streams
```
Puis génère une URL de lecture :
```text
/live/USER/PASS/STREAM_ID.m3u8
```
Si ton fournisseur utilise `.ts` au lieu de `.m3u8`, modifie cette ligne dans `MainActivity.kt` :
```kotlin
extension = "m3u8"
```
par :
```kotlin
extension = "ts"
```
## Fichiers principaux
| Fichier | Rôle |
|---|---|
| `MainActivity.kt` | Navigation simple entre login, accueil et lecteur |
| `LoginScreen.kt` | Écran de connexion Xtream |
| `HomeScreen.kt` | Liste des chaînes Live |
| `PlayerScreen.kt` | Lecteur vidéo Media3/ExoPlayer |
| `XtreamApi.kt` | Endpoints API Xtream |
| `XtreamApiFactory.kt` | Création Retrofit dynamique selon le serveur |
| `XtreamRepository.kt` | Couche d'accès aux données |
| `XtreamUrlBuilder.kt` | Génération des URLs Live, VOD et séries |
## À améliorer ensuite
- Stockage sécurisé des identifiants.
- Favoris.
- Catégories Live.
- VOD.
- Séries.
- EPG XMLTV.
- Reprise de lecture.
- Recherche.
- Choix automatique `.m3u8` / `.ts`.
- Design TV plus avancé avec composants TV spécialisés.
## Note légale
Cette base technique sert à lire des flux pour lesquels l'utilisateur ou le fournisseur possède les droits nécessaires. Ne l'utilise pas pour contourner des accès, redistribuer du contenu protégé ou faire n'importe quoi que les avocats adorent transformer en facture.
@@ -0,0 +1,65 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "ma.tkgraph.xtreamtv"
compileSdk = 36
defaultConfig {
applicationId = "ma.tkgraph.xtreamtv"
minSdk = 23
targetSdk = 36
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
jvmToolchain(17)
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
// Dépendances TV : utiles pour faire évoluer l'interface vers des composants TV spécialisés.
implementation(libs.androidx.tv.material)
implementation(libs.androidx.tv.foundation)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.exoplayer.hls)
implementation(libs.androidx.media3.exoplayer.dash)
implementation(libs.androidx.media3.ui)
implementation(libs.retrofit)
implementation(libs.retrofit.gson)
debugImplementation(libs.androidx.compose.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
# MVP : pas de règles particulières.
# Ajouter des règles si vous activez minifyEnabled=true et si votre API utilise de la réflexion JSON avancée.
@@ -0,0 +1,33 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:allowBackup="true"
android:banner="@drawable/tv_banner"
android:icon="@drawable/ic_launcher_foreground"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_foreground"
android:supportsRtl="true"
android:theme="@style/Theme.XtreamTv"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,78 @@
package ma.tkgraph.xtreamtv
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
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.theme.XtreamTvTheme
import ma.tkgraph.xtreamtv.util.XtreamUrlBuilder
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
XtreamTvTheme {
XtreamTvApp()
}
}
}
}
private sealed interface AppScreen {
data object Login : AppScreen
data object Home : AppScreen
data class Player(val title: String, val url: String) : AppScreen
}
@Composable
private fun XtreamTvApp() {
var screen by remember { mutableStateOf<AppScreen>(AppScreen.Login) }
var config by remember { mutableStateOf<XtreamConfig?>(null) }
var liveStreams by remember { mutableStateOf<List<LiveStreamDto>>(emptyList()) }
when (val currentScreen = screen) {
AppScreen.Login -> LoginScreen(
onLoginSuccess = { newConfig, streams ->
config = newConfig
liveStreams = streams
screen = AppScreen.Home
}
)
AppScreen.Home -> HomeScreen(
streams = liveStreams,
onStreamSelected = { stream ->
val currentConfig = config ?: return@HomeScreen
val url = XtreamUrlBuilder.liveUrl(
config = currentConfig,
streamId = stream.streamId,
extension = "m3u8"
)
screen = AppScreen.Player(
title = stream.name.orEmpty().ifBlank { "Chaîne ${stream.streamId}" },
url = url
)
},
onLogout = {
config = null
liveStreams = emptyList()
screen = AppScreen.Login
}
)
is AppScreen.Player -> PlayerScreen(
title = currentScreen.title,
streamUrl = currentScreen.url,
onBack = { screen = AppScreen.Home }
)
}
}
@@ -0,0 +1,38 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class LiveStreamDto(
@SerializedName("num")
val number: Int? = null,
@SerializedName("name")
val name: String? = null,
@SerializedName("stream_type")
val streamType: String? = null,
@SerializedName("stream_id")
val streamId: String,
@SerializedName("stream_icon")
val streamIcon: String? = null,
@SerializedName("epg_channel_id")
val epgChannelId: String? = null,
@SerializedName("added")
val added: String? = null,
@SerializedName("category_id")
val categoryId: String? = null,
@SerializedName("custom_sid")
val customSid: String? = null,
@SerializedName("tv_archive")
val tvArchive: Int? = null,
@SerializedName("direct_source")
val directSource: String? = null
)
@@ -0,0 +1,60 @@
package ma.tkgraph.xtreamtv.data.model
import com.google.gson.annotations.SerializedName
data class XtreamAccountResponse(
@SerializedName("user_info")
val userInfo: XtreamUserInfo? = null,
@SerializedName("server_info")
val serverInfo: XtreamServerInfo? = null
)
data class XtreamUserInfo(
@SerializedName("username")
val username: String? = null,
@SerializedName("password")
val password: String? = null,
@SerializedName("message")
val message: String? = null,
@SerializedName("auth")
val auth: String? = null,
@SerializedName("status")
val status: String? = null,
@SerializedName("exp_date")
val expirationDate: String? = null,
@SerializedName("active_cons")
val activeConnections: String? = null,
@SerializedName("max_connections")
val maxConnections: String? = null
)
data class XtreamServerInfo(
@SerializedName("url")
val url: String? = null,
@SerializedName("port")
val port: String? = null,
@SerializedName("https_port")
val httpsPort: String? = null,
@SerializedName("server_protocol")
val serverProtocol: String? = null,
@SerializedName("timezone")
val timezone: String? = null,
@SerializedName("timestamp_now")
val timestampNow: Long? = null,
@SerializedName("time_now")
val timeNow: String? = null
)
@@ -0,0 +1,7 @@
package ma.tkgraph.xtreamtv.data.model
data class XtreamConfig(
val serverUrl: String,
val username: String,
val password: String
)
@@ -0,0 +1,21 @@
package ma.tkgraph.xtreamtv.data.network
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamAccountResponse
import retrofit2.http.GET
import retrofit2.http.Query
interface XtreamApi {
@GET("player_api.php")
suspend fun getAccountInfo(
@Query("username") username: String,
@Query("password") password: String
): XtreamAccountResponse
@GET("player_api.php")
suspend fun getLiveStreams(
@Query("username") username: String,
@Query("password") password: String,
@Query("action") action: String = "get_live_streams"
): List<LiveStreamDto>
}
@@ -0,0 +1,24 @@
package ma.tkgraph.xtreamtv.data.network
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object XtreamApiFactory {
fun create(serverUrl: String): XtreamApi {
return Retrofit.Builder()
.baseUrl(normalizeBaseUrl(serverUrl))
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(XtreamApi::class.java)
}
private fun normalizeBaseUrl(rawUrl: String): String {
val trimmed = rawUrl.trim()
val withProtocol = when {
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
else -> "http://$trimmed"
}
return withProtocol.trimEnd('/') + "/"
}
}
@@ -0,0 +1,17 @@
package ma.tkgraph.xtreamtv.data.repository
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamAccountResponse
import ma.tkgraph.xtreamtv.data.network.XtreamApi
class XtreamRepository(
private val api: XtreamApi
) {
suspend fun checkAccount(username: String, password: String): XtreamAccountResponse {
return api.getAccountInfo(username = username, password = password)
}
suspend fun loadLiveStreams(username: String, password: String): List<LiveStreamDto> {
return api.getLiveStreams(username = username, password = password)
}
}
@@ -0,0 +1,152 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
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 ma.tkgraph.xtreamtv.data.model.LiveStreamDto
@Composable
fun HomeScreen(
streams: List<LiveStreamDto>,
onStreamSelected: (LiveStreamDto) -> Unit,
onLogout: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 48.dp, vertical = 32.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "Chaînes Live",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "${streams.size} chaîne(s) chargée(s)",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
)
}
Button(onClick = onLogout) {
Text("Déconnexion")
}
}
Spacer(modifier = Modifier.height(28.dp))
if (streams.isEmpty()) {
Text(
text = "Aucune chaîne trouvée. Le serveur a peut-être répondu vide, ce qui est une façon élégante de ne pas aider.",
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 48.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = streams,
key = { stream -> stream.streamId }
) { stream ->
StreamRow(
stream = stream,
onClick = { onStreamSelected(stream) }
)
}
}
}
}
}
@Composable
private fun StreamRow(
stream: LiveStreamDto,
onClick: () -> Unit
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(16.dp)
Card(
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focused = it.isFocused }
.border(
width = if (focused) 3.dp else 1.dp,
color = if (focused) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = shape
)
.clip(shape)
.clickable(onClick = onClick)
.focusable(),
colors = CardDefaults.cardColors(
containerColor = if (focused) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.22f)
} else {
MaterialTheme.colorScheme.surface
}
),
elevation = CardDefaults.cardElevation(defaultElevation = if (focused) 8.dp else 2.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = stream.name.orEmpty().ifBlank { "Chaîne ${stream.streamId}" },
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "ID ${stream.streamId} • Catégorie ${stream.categoryId ?: "non classée"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -0,0 +1,147 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ma.tkgraph.xtreamtv.data.model.LiveStreamDto
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
import ma.tkgraph.xtreamtv.data.network.XtreamApiFactory
import ma.tkgraph.xtreamtv.data.repository.XtreamRepository
@Composable
fun LoginScreen(
onLoginSuccess: (XtreamConfig, List<LiveStreamDto>) -> Unit
) {
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(48.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.widthIn(max = 720.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
horizontalAlignment = Alignment.Start
) {
Text(
text = "Xtream TV Player",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onBackground
)
Text(
text = "Connecte ton accès Xtream compatible pour charger les chaînes Live.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.75f)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Serveur : http://exemple.com:8080") },
singleLine = true
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = username,
onValueChange = { username = it },
label = { Text("Utilisateur") },
singleLine = true
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = password,
onValueChange = { password = it },
label = { Text("Mot de passe") },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
Button(
enabled = !isLoading && serverUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
onClick = {
scope.launch {
isLoading = true
errorMessage = null
runCatching {
val config = XtreamConfig(
serverUrl = serverUrl.trim(),
username = username.trim(),
password = password.trim()
)
val repository = XtreamRepository(XtreamApiFactory.create(config.serverUrl))
val account = repository.checkAccount(config.username, config.password)
val isAuthorized = account.userInfo?.auth == "1"
val isActive = account.userInfo?.status.equals("Active", ignoreCase = true)
if (!isAuthorized || !isActive) {
error("Compte non autorisé ou abonnement inactif.")
}
val streams = repository.loadLiveStreams(config.username, config.password)
onLoginSuccess(config, streams)
}.onFailure { throwable ->
errorMessage = throwable.message ?: "Connexion impossible au serveur."
}
isLoading = false
}
}
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.height(22.dp),
strokeWidth = 2.dp
)
} else {
Text("Se connecter")
}
}
errorMessage?.let { message ->
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@@ -0,0 +1,103 @@
package ma.tkgraph.xtreamtv.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
@Composable
fun PlayerScreen(
title: String,
streamUrl: String,
onBack: () -> Unit
) {
BackHandler(onBack = onBack)
val context = LocalContext.current
var errorMessage by remember(streamUrl) { mutableStateOf<String?>(null) }
val player = remember(streamUrl) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(streamUrl))
prepare()
playWhenReady = true
}
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
errorMessage = "Erreur de lecture : ${error.errorCodeName}"
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { viewContext ->
PlayerView(viewContext).apply {
this.player = player
useController = true
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
keepScreenOn = true
}
},
update = { playerView ->
playerView.player = player
}
)
Column(
modifier = Modifier
.align(Alignment.TopStart)
.padding(24.dp)
) {
Text(
text = title,
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.titleLarge
)
errorMessage?.let { message ->
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
@@ -0,0 +1,43 @@
package ma.tkgraph.xtreamtv.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColors = darkColorScheme(
primary = Color(0xFF60A5FA),
secondary = Color(0xFF93C5FD),
background = Color(0xFF050816),
surface = Color(0xFF101828),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White
)
private val LightColors = lightColorScheme(
primary = Color(0xFF2563EB),
secondary = Color(0xFF1D4ED8),
background = Color(0xFFF8FAFC),
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFF111827),
onSurface = Color(0xFF111827)
)
@Composable
fun XtreamTvTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = Typography(),
content = content
)
}
@@ -0,0 +1,42 @@
package ma.tkgraph.xtreamtv.util
import ma.tkgraph.xtreamtv.data.model.XtreamConfig
object XtreamUrlBuilder {
fun liveUrl(
config: XtreamConfig,
streamId: String,
extension: String = "m3u8"
): String {
val server = normalizeServer(config.serverUrl)
return "$server/live/${config.username}/${config.password}/$streamId.$extension"
}
fun vodUrl(
config: XtreamConfig,
streamId: String,
extension: String = "mp4"
): String {
val server = normalizeServer(config.serverUrl)
return "$server/movie/${config.username}/${config.password}/$streamId.$extension"
}
fun seriesUrl(
config: XtreamConfig,
episodeId: String,
extension: String = "mp4"
): String {
val server = normalizeServer(config.serverUrl)
return "$server/series/${config.username}/${config.password}/$episodeId.$extension"
}
private fun normalizeServer(rawUrl: String): String {
val trimmed = rawUrl.trim()
val withProtocol = when {
trimmed.startsWith("http://", ignoreCase = true) -> trimmed
trimmed.startsWith("https://", ignoreCase = true) -> trimmed
else -> "http://$trimmed"
}
return withProtocol.trimEnd('/')
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#101828" android:pathData="M0,0h108v108h-108z" />
<path android:fillColor="#FFFFFF" android:pathData="M42,30 l34,24 l-34,24 z" />
<path android:fillColor="#2563EB" android:pathData="M18,82 h72 v8 h-72z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path android:fillColor="#101828" android:pathData="M0,0h320v180h-320z" />
<path android:fillColor="#2563EB" android:pathData="M0,120 C70,80 120,155 190,95 C240,52 280,68 320,38 L320,180 L0,180 Z" />
<path android:fillColor="#FFFFFF" android:pathData="M122,53 l76,44 l-76,44 z" />
</vector>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Xtream TV Player</string>
</resources>
@@ -0,0 +1,9 @@
<resources>
<style name="Theme.XtreamTv" parent="android:style/Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#4F8CFF</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
@@ -0,0 +1,32 @@
[versions]
agp = "9.0.1"
kotlin = "2.4.0"
composeBom = "2026.04.01"
activityCompose = "1.13.0"
media3 = "1.10.1"
tvMaterial = "1.1.0"
tvFoundation = "1.0.0"
retrofit = "2.11.0"
coreKtx = "1.18.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
androidx-media3-exoplayer-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "media3" }
androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "XtreamGoogleTvPlayer"
include(":app")