Initial upload: iptv-android-tv-2
This commit is contained in:
@@ -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
@@ -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,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.
|
||||
@@ -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`
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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`
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+2
@@ -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,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>
|
||||
@@ -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,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>
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+93
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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")
|
||||
@@ -0,0 +1,7 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
app/build/
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
)
|
||||
+60
@@ -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
|
||||
)
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ma.tkgraph.xtreamtv.data.model
|
||||
|
||||
data class XtreamConfig(
|
||||
val serverUrl: String,
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
+21
@@ -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>
|
||||
}
|
||||
+24
@@ -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('/') + "/"
|
||||
}
|
||||
}
|
||||
+17
@@ -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)
|
||||
}
|
||||
}
|
||||
+152
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -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
|
||||
)
|
||||
}
|
||||
+42
@@ -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>
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user