Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7cffe91ee | ||
|
|
10a5b8d2bf | ||
|
|
03126d3b36 | ||
|
|
462bcc784c | ||
|
|
60c7f4cece | ||
|
|
9ce707a702 | ||
|
|
f105becd79 | ||
|
|
93d5741c0c | ||
|
|
2e88a56943 | ||
|
|
26bf240b76 | ||
|
|
186f9f89f9 | ||
|
|
3c37c6a6e6 | ||
|
|
c096532fec | ||
|
|
05ca6f85be | ||
|
|
04e9ab5730 | ||
|
|
9b6b7faf4a | ||
|
|
694b1257b9 | ||
|
|
8d74bbb320 |
No files matched your search
@@ -0,0 +1,85 @@
|
|||||||
|
# Construit l'APK release et publie une GitHub Release à chaque mise à jour
|
||||||
|
# de main (nouvelle version de l'app). Sur une pull request ou un lancement
|
||||||
|
# manuel (workflow_dispatch), l'APK est seulement construit et déposé en
|
||||||
|
# artifact — aucune release n'est publiée.
|
||||||
|
name: Build APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
flutter-version: 3.35.3
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Read app version from pubspec.yaml
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d+ -f1)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- run: flutter pub get
|
||||||
|
|
||||||
|
# Keystore fixe injecté via secrets : sans lui, Gradle signerait avec une
|
||||||
|
# clé debug générée sur le runner (différente à chaque build) et l'APK ne
|
||||||
|
# s'installerait pas par-dessus une version déjà présente sur l'appareil.
|
||||||
|
- name: Set up release signing
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
for v in KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do
|
||||||
|
if [ -z "${!v}" ]; then
|
||||||
|
echo "::error::Secret ANDROID_$v manquant — impossible de signer l'APK avec une clé stable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$KEYSTORE_BASE64" | base64 -d > android/app/release.jks
|
||||||
|
{
|
||||||
|
echo "storeFile=release.jks"
|
||||||
|
echo "storePassword=$KEYSTORE_PASSWORD"
|
||||||
|
echo "keyAlias=$KEY_ALIAS"
|
||||||
|
echo "keyPassword=$KEY_PASSWORD"
|
||||||
|
} > android/key.properties
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
run: flutter build apk --release
|
||||||
|
|
||||||
|
- name: Rename APK with version
|
||||||
|
run: cp build/app/outputs/flutter-apk/app-release.apk "xtremobile-v${{ steps.version.outputs.version }}.apk"
|
||||||
|
|
||||||
|
- name: Upload APK artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: xtremobile-v${{ steps.version.outputs.version }}-apk
|
||||||
|
path: xtremobile-v${{ steps.version.outputs.version }}.apk
|
||||||
|
|
||||||
|
# La release n'est créée que sur main : un même numéro de version met à
|
||||||
|
# jour la release existante au lieu d'en créer une seconde.
|
||||||
|
- name: Publish GitHub Release
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: v${{ steps.version.outputs.version }}
|
||||||
|
name: XtremMobile v${{ steps.version.outputs.version }}
|
||||||
|
files: xtremobile-v${{ steps.version.outputs.version }}.apk
|
||||||
|
generate_release_notes: true
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Construit un IPA *non signé* pour iPhone / iPad et le publie sur la GitHub
|
||||||
|
# Release de la version courante, à côté de l'APK Android.
|
||||||
|
#
|
||||||
|
# L'IPA n'est volontairement pas signé : la signature est faite au moment de
|
||||||
|
# l'installation par AltStore / SideStore / Sideloadly avec l'Apple ID de
|
||||||
|
# l'utilisateur (voir IOS_GUIDE.md). Aucun compte développeur n'est donc
|
||||||
|
# nécessaire pour produire ce fichier.
|
||||||
|
#
|
||||||
|
# Le job tourne sur un runner macOS (Xcode obligatoire) et consomme 10x plus
|
||||||
|
# de minutes GitHub Actions qu'un runner Linux : il n'est donc pas déclenché
|
||||||
|
# sur les pull requests.
|
||||||
|
name: Build iOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# Un run macOS coûte 10x en minutes : inutile d'en lancer un pour une
|
||||||
|
# modification qui ne touche que de la documentation.
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'docs/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
flutter-version: 3.35.3
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Read app version from pubspec.yaml
|
||||||
|
id: version
|
||||||
|
run: echo "version=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d+ -f1)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- run: flutter pub get
|
||||||
|
|
||||||
|
# Régénère ios/ (absent du dépôt) et applique les réglages IPTV :
|
||||||
|
# ATS, audio en arrière-plan, orientations iPad, bundle id.
|
||||||
|
- name: Prepare iOS project
|
||||||
|
run: ./scripts/prepare_ios.sh
|
||||||
|
|
||||||
|
- name: Generate iOS app icon
|
||||||
|
run: dart run flutter_launcher_icons -f flutter_launcher_icons-ios.yaml
|
||||||
|
|
||||||
|
- name: Build release app (unsigned)
|
||||||
|
run: flutter build ios --release --no-codesign
|
||||||
|
|
||||||
|
# Un IPA n'est qu'une archive zip contenant Payload/<App>.app.
|
||||||
|
- name: Package unsigned IPA
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
cd build/ios/iphoneos
|
||||||
|
rm -rf Payload
|
||||||
|
mkdir Payload
|
||||||
|
cp -R Runner.app Payload/
|
||||||
|
zip -qry "$GITHUB_WORKSPACE/xtremobile-v${VERSION}-unsigned.ipa" Payload
|
||||||
|
|
||||||
|
- name: Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: xtremobile-v${{ steps.version.outputs.version }}-ipa
|
||||||
|
path: xtremobile-v${{ steps.version.outputs.version }}-unsigned.ipa
|
||||||
|
|
||||||
|
# Même tag que le workflow APK : l'IPA est ajouté à la release existante.
|
||||||
|
- name: Publish GitHub Release
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: v${{ steps.version.outputs.version }}
|
||||||
|
name: XtremMobile v${{ steps.version.outputs.version }}
|
||||||
|
files: xtremobile-v${{ steps.version.outputs.version }}-unsigned.ipa
|
||||||
|
# Les notes sont déjà produites par le workflow APK ; les régénérer
|
||||||
|
# ici les ajouterait une seconde fois au corps de la release.
|
||||||
|
generate_release_notes: false
|
||||||
@@ -32,6 +32,19 @@ migrate_working_dir/
|
|||||||
|
|
||||||
# Web related
|
# Web related
|
||||||
|
|
||||||
|
# iOS related (le projet Xcode est régénéré par scripts/prepare_ios.sh)
|
||||||
|
**/ios/Flutter/App.framework
|
||||||
|
**/ios/Flutter/Flutter.framework
|
||||||
|
**/ios/Flutter/Flutter.podspec
|
||||||
|
**/ios/Flutter/Generated.xcconfig
|
||||||
|
**/ios/Flutter/ephemeral/
|
||||||
|
**/ios/Flutter/flutter_export_environment.sh
|
||||||
|
**/ios/Runner/GeneratedPluginRegistrant.*
|
||||||
|
**/ios/Pods/
|
||||||
|
**/ios/Podfile.lock
|
||||||
|
**/ios/.symlinks/
|
||||||
|
*.ipa
|
||||||
|
|
||||||
# Symbolication related
|
# Symbolication related
|
||||||
app.*.symbols
|
app.*.symbols
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Version iPhone / iPad — build et installation hors App Store
|
||||||
|
|
||||||
|
Ce guide explique comment produire XtremFlow pour iOS/iPadOS et l'installer sur
|
||||||
|
un appareil **sans passer par l'App Store**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ce qui a été mis en place
|
||||||
|
|
||||||
|
Le dossier `ios/` n'est pas versionné : il est **régénéré au moment du build**
|
||||||
|
par `scripts/prepare_ios.sh`, ce qui garantit qu'il correspond toujours à la
|
||||||
|
version de Flutter utilisée. Le script applique ensuite les réglages
|
||||||
|
indispensables à une app IPTV :
|
||||||
|
|
||||||
|
| Réglage | Pourquoi |
|
||||||
|
|---|---|
|
||||||
|
| `NSAllowsArbitraryLoads` | Les serveurs Xtream Codes sont presque toujours en `http://`. Sans cette exception, App Transport Security bloque **toutes** les requêtes API et tous les flux. |
|
||||||
|
| `UIBackgroundModes: audio` | Le son continue quand l'écran est verrouillé. |
|
||||||
|
| Orientations paysage + 4 sens sur iPad | Lecteur vidéo plein écran, rotation libre sur tablette. |
|
||||||
|
| `NSLocalNetworkUsageDescription` | Autorisation iOS 14+ si le serveur IPTV est sur le réseau local. |
|
||||||
|
| Cible iOS 13.0 | Minimum exigé par `media_kit` (libmpv/FFmpeg). |
|
||||||
|
| Bundle id `com.xtremflow.mobile` | Aligné sur l'`applicationId` Android. |
|
||||||
|
|
||||||
|
Le code Dart est déjà entièrement portable (aucun appel spécifique Android), et
|
||||||
|
`media_kit_libs_video` embarque déjà la variante iOS de libmpv : les codecs
|
||||||
|
AC3 / EAC3 / DTS restent donc gérés comme sur Android.
|
||||||
|
|
||||||
|
## 2. Produire l'IPA
|
||||||
|
|
||||||
|
### Option A — GitHub Actions (aucun Mac nécessaire) ✅ recommandé
|
||||||
|
|
||||||
|
Le workflow `.github/workflows/build-ios.yml` tourne sur un runner macOS et
|
||||||
|
publie un **IPA non signé** :
|
||||||
|
|
||||||
|
* automatiquement à chaque push sur `main`, joint à la GitHub Release de la
|
||||||
|
version (à côté de l'APK) ;
|
||||||
|
* à la demande via **Actions → Build iOS → Run workflow**, le fichier est alors
|
||||||
|
disponible en artifact.
|
||||||
|
|
||||||
|
> ⚠️ Les runners macOS comptent **10× plus** de minutes GitHub Actions que les
|
||||||
|
> runners Linux. C'est pourquoi le workflow ne se déclenche pas sur les pull
|
||||||
|
> requests.
|
||||||
|
|
||||||
|
### Option B — En local, sur un Mac
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
./scripts/prepare_ios.sh
|
||||||
|
dart run flutter_launcher_icons -f flutter_launcher_icons-ios.yaml
|
||||||
|
flutter build ios --release --no-codesign
|
||||||
|
|
||||||
|
cd build/ios/iphoneos
|
||||||
|
mkdir -p Payload && cp -R Runner.app Payload/
|
||||||
|
zip -qry ../../../xtremflow-unsigned.ipa Payload
|
||||||
|
```
|
||||||
|
|
||||||
|
Un IPA n'est rien d'autre qu'une archive zip contenant `Payload/Runner.app`.
|
||||||
|
|
||||||
|
## 3. Installer sans l'App Store
|
||||||
|
|
||||||
|
C'est ici que se situe la vraie contrainte : elle ne vient pas du code mais
|
||||||
|
d'Apple. Toute app iOS doit être **signée** par un certificat Apple avant de
|
||||||
|
pouvoir se lancer. Quatre voies possibles :
|
||||||
|
|
||||||
|
| Méthode | Coût | Validité | Matériel requis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **AltStore / SideStore** (Apple ID gratuit) | 0 € | **7 jours**, renouvelés automatiquement en Wi-Fi | Un PC/Mac pour l'installation initiale |
|
||||||
|
| **Sideloadly** (Apple ID gratuit) | 0 € | 7 jours, renouvellement manuel | Windows ou macOS, câble USB |
|
||||||
|
| **Compte Apple Developer** | 99 €/an | **1 an** | Idem, mais plus de limites gênantes |
|
||||||
|
| **TestFlight** | 99 €/an | 90 jours par build | Passe par App Store Connect (revue allégée) |
|
||||||
|
|
||||||
|
Limites du compte gratuit : **3 apps sideloadées maximum** et **10 App IDs par
|
||||||
|
semaine**. Avec un compte payant, la signature tient un an et les limites
|
||||||
|
disparaissent — c'est la meilleure option si l'app est utilisée au quotidien.
|
||||||
|
|
||||||
|
### Marche à suivre avec AltStore (gratuit, la plus courante)
|
||||||
|
|
||||||
|
1. Installer **AltServer** sur un PC Windows ou un Mac (altstore.io), plus
|
||||||
|
iTunes + iCloud (versions du site Apple, pas du Microsoft Store) sur Windows.
|
||||||
|
2. Brancher l'iPhone/iPad en USB, faire confiance à l'ordinateur.
|
||||||
|
3. Dans AltServer : *Install AltStore* → choisir l'appareil → saisir son Apple ID.
|
||||||
|
4. Sur l'appareil : **Réglages → Général → VPN et gestion de l'appareil** →
|
||||||
|
faire confiance au profil développeur.
|
||||||
|
5. Télécharger `xtremobile-vX.Y.Z-unsigned.ipa` depuis la GitHub Release.
|
||||||
|
6. Dans AltStore sur l'appareil : **My Apps → + → sélectionner l'IPA**. AltStore
|
||||||
|
signe l'app avec l'Apple ID et l'installe.
|
||||||
|
7. Garder AltServer allumé sur le même Wi-Fi : AltStore renouvelle la signature
|
||||||
|
avant l'expiration des 7 jours. **SideStore** fait la même chose sans PC une
|
||||||
|
fois configuré.
|
||||||
|
|
||||||
|
### Avec Sideloadly
|
||||||
|
|
||||||
|
Plus simple mais sans renouvellement automatique : installer Sideloadly, brancher
|
||||||
|
l'appareil, glisser l'IPA, saisir l'Apple ID, cliquer sur *Start*. À refaire tous
|
||||||
|
les 7 jours.
|
||||||
|
|
||||||
|
### Si l'appareil n'est pas toujours sur le même réseau
|
||||||
|
|
||||||
|
C'est la limite d'AltStore : son renouvellement automatique exige qu'AltServer
|
||||||
|
tourne sur **le même Wi-Fi** que l'appareil. Un iPad souvent hors du réseau
|
||||||
|
domestique verra l'app expirer au bout de 7 jours, loin de tout ordinateur.
|
||||||
|
|
||||||
|
Deux réponses :
|
||||||
|
|
||||||
|
* **SideStore** — variante d'AltStore qui renouvelle la signature *depuis
|
||||||
|
l'appareil*, sans AltServer ni réseau local. Un ordinateur reste nécessaire
|
||||||
|
une seule fois, pour générer le fichier de pairage. Ensuite l'iPad se
|
||||||
|
débrouille seul, en 4G comme en Wi-Fi. **C'est la seule voie gratuite adaptée
|
||||||
|
à un appareil nomade** ; la marche à suivre est détaillée ci-dessous.
|
||||||
|
* **Compte Apple Developer (99 €/an)** — la signature tient **un an** : il n'y a
|
||||||
|
plus rien à renouveler, donc plus aucune contrainte de réseau.
|
||||||
|
|
||||||
|
### Marche à suivre avec SideStore (gratuit, appareil nomade)
|
||||||
|
|
||||||
|
L'ordinateur ne sert qu'à l'étape 1 ; ensuite l'iPad est autonome.
|
||||||
|
|
||||||
|
1. **Fichier de pairage** — sur un PC Windows ou un Mac, installer le générateur
|
||||||
|
de pairage fourni par SideStore (`SideStore Pairing File Generator`,
|
||||||
|
sidestore.io), brancher l'iPad en USB, accepter « Faire confiance à cet
|
||||||
|
ordinateur », lancer l'outil : il produit un fichier `.mobiledevicepairing`.
|
||||||
|
2. **Installer SideStore** — toujours depuis l'ordinateur, installer l'app
|
||||||
|
SideStore sur l'iPad (l'outil d'installation de sidestore.io le fait avec ton
|
||||||
|
Apple ID). Sur l'iPad : **Réglages → Général → VPN et gestion de l'appareil**
|
||||||
|
→ faire confiance au profil développeur.
|
||||||
|
3. **Activer le tunnel** — installer **StosVPN** depuis l'App Store et l'activer.
|
||||||
|
SideStore l'utilise pour dialoguer avec le service d'installation d'iOS ; sans
|
||||||
|
lui, le renouvellement échoue.
|
||||||
|
4. **Configurer SideStore** — ouvrir SideStore sur l'iPad, importer le fichier de
|
||||||
|
pairage de l'étape 1, saisir l'Apple ID.
|
||||||
|
5. **Installer l'app** — télécharger `xtremobile-vX.Y.Z-unsigned.ipa` depuis la
|
||||||
|
GitHub Release *directement sur l'iPad*, puis dans SideStore : **My Apps → +**
|
||||||
|
→ sélectionner l'IPA. SideStore le signe et l'installe.
|
||||||
|
|
||||||
|
À partir de là, SideStore renouvelle la signature tout seul avant l'échéance des
|
||||||
|
7 jours, sur n'importe quel réseau — Wi-Fi public, partage de connexion ou 4G.
|
||||||
|
Il suffit que l'iPad ait accès à Internet au moins une fois par semaine.
|
||||||
|
|
||||||
|
Limites du compte Apple ID gratuit, qui subsistent : **3 apps sideloadées au
|
||||||
|
maximum** et **10 App IDs par semaine**.
|
||||||
|
|
||||||
|
### Avec un compte Apple Developer (99 €/an)
|
||||||
|
|
||||||
|
La signature tient **un an** et l'app cesse d'expirer toutes les semaines. Deux
|
||||||
|
approches :
|
||||||
|
|
||||||
|
* signer l'IPA avec Sideloadly/AltStore en utilisant l'Apple ID du compte payant
|
||||||
|
(le plus simple, rien à changer dans le projet) ;
|
||||||
|
* ou passer à une distribution **Ad Hoc** (jusqu'à 100 appareils enregistrés par
|
||||||
|
leur UDID) : il faut alors ajouter un certificat et un profil de provisioning
|
||||||
|
dans le workflow et remplacer `--no-codesign` par un `flutter build ipa
|
||||||
|
--export-options-plist`.
|
||||||
|
|
||||||
|
### Cas particulier de l'Union européenne
|
||||||
|
|
||||||
|
Depuis iOS 17.4, l'UE autorise les **magasins alternatifs** (AltStore PAL,
|
||||||
|
notamment). Y publier l'app exige tout de même un compte développeur payant et
|
||||||
|
la notarisation Apple : intéressant pour distribuer à d'autres personnes, inutile
|
||||||
|
pour un usage personnel.
|
||||||
|
|
||||||
|
### Ce qui n'est pas possible
|
||||||
|
|
||||||
|
Il n'existe **aucun** moyen d'installer une app iOS sans signature Apple sur un
|
||||||
|
appareil non jailbreaké. Un simple lien de téléchargement, comme pour l'APK
|
||||||
|
Android, n'existe pas sur iOS.
|
||||||
|
|
||||||
|
## 4. Points de vigilance
|
||||||
|
|
||||||
|
* **Mises à jour** : pas de mise à jour automatique. Il faut retélécharger l'IPA
|
||||||
|
de la nouvelle release et refaire l'installation.
|
||||||
|
* **Expiration** : une app signée avec un Apple ID gratuit s'ouvre encore après
|
||||||
|
7 jours mais refuse de se lancer ; une réinstallation conserve les données.
|
||||||
|
* **Personnalisation** : pour changer le nom affiché, le bundle id ou la cible
|
||||||
|
de déploiement, modifier les variables en tête de `scripts/prepare_ios.sh` —
|
||||||
|
ne pas éditer `ios/` à la main, il est régénéré à chaque build.
|
||||||
|
* **Projet Xcode versionné** : si vous préférez committer le dossier `ios/`
|
||||||
|
(pour le retoucher dans Xcode), générez-le une fois sur un Mac puis committez-le.
|
||||||
|
`scripts/prepare_ios.sh` détecte sa présence, ne le régénère pas et se contente
|
||||||
|
de réappliquer les réglages du tableau §1.
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
# XtremFlow - IPTV Web Application
|
# XtremFlow - IPTV Application
|
||||||
|
|
||||||
High-performance, containerized IPTV Web Application using Flutter Web and Xtream Codes API.
|
High-performance IPTV application built with Flutter and the Xtream Codes API,
|
||||||
|
running on Android, iPhone and iPad.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
- **Android** : télécharger l'APK depuis la [dernière release](../../releases) et l'installer.
|
||||||
|
- **iPhone / iPad** : un IPA non signé est publié sur la même release ; voir
|
||||||
|
[`IOS_GUIDE.md`](IOS_GUIDE.md) pour l'installer sans passer par l'App Store.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +7,18 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signature release stable : android/key.properties (ignoré par git) pointe vers
|
||||||
|
// un keystore fixe. Sans ce fichier, on retombe sur la clé debug locale pour que
|
||||||
|
// `flutter run --release` continue de marcher. La CI DOIT fournir key.properties,
|
||||||
|
// sinon chaque build serait signé avec une clé debug aléatoire et l'APK ne
|
||||||
|
// s'installerait pas par-dessus une version existante.
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val hasReleaseKeystore = keystorePropertiesFile.exists()
|
||||||
|
if (hasReleaseKeystore) {
|
||||||
|
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.xtremflow"
|
namespace = "com.example.xtremflow"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -30,11 +44,24 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (hasReleaseKeystore) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = if (hasReleaseKeystore) {
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfigs.getByName("release")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,17 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
|
||||||
|
<!-- Android TV / Fire TV : l'app reste installable sur téléphone
|
||||||
|
et apparaît dans le lanceur TV (pas d'écran tactile requis). -->
|
||||||
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="XtremFlow"
|
android:label="XtremFlow"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:banner="@drawable/banner"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -28,6 +35,7 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -1,12 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
<!-- Écran de lancement : fond graphite du thème + logo centré -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item android:drawable="@color/ic_launcher_background" />
|
||||||
|
<item>
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@drawable/ic_launcher_foreground" />
|
||||||
</item> -->
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 22 KiB |
@@ -1,12 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
<!-- Écran de lancement : fond graphite du thème + logo centré -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item android:drawable="@color/ic_launcher_background" />
|
||||||
|
<item>
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@drawable/ic_launcher_foreground" />
|
||||||
</item> -->
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Icône adaptative : le calque avant est déjà dessiné dans la zone sûre
|
||||||
|
(66dp sur 108dp), aucun inset supplémentaire n'est nécessaire.
|
||||||
|
Généré par scripts/generate_icons.py -->
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground>
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
<inset
|
|
||||||
android:drawable="@drawable/ic_launcher_foreground"
|
|
||||||
android:inset="16%" />
|
|
||||||
</foreground>
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Icône adaptative : le calque avant est déjà dessiné dans la zone sûre
|
||||||
|
(66dp sur 108dp), aucun inset supplémentaire n'est nécessaire.
|
||||||
|
Généré par scripts/generate_icons.py -->
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -1,4 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#1a1a2e</color>
|
<!-- baseLevel0 de lib/core/theme/app_colors.dart -->
|
||||||
|
<color name="ic_launcher_background">#1B1B1F</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2C2C33"/>
|
||||||
|
<stop offset="1" stop-color="#1B1B1F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2"/>
|
||||||
|
<stop offset="0.55" stop-color="#0A84FF"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(215.04 215.04) scale(5.9392)">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2C2C33"/>
|
||||||
|
<stop offset="1" stop-color="#1B1B1F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2"/>
|
||||||
|
<stop offset="0.55" stop-color="#0A84FF"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="512.0" cy="512.0" r="512.0" fill="url(#bg)"/>
|
||||||
|
<circle cx="512.0" cy="512.0" r="512.0" fill="url(#shine)"/>
|
||||||
|
|
||||||
|
<g transform="translate(194.56 194.56) scale(6.3488)">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2C2C33"/>
|
||||||
|
<stop offset="1" stop-color="#1B1B1F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2"/>
|
||||||
|
<stop offset="0.55" stop-color="#0A84FF"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1024" height="1024" rx="230.4" fill="url(#bg)"/>
|
||||||
|
<rect width="1024" height="1024" rx="230.4" fill="url(#shine)"/>
|
||||||
|
<rect x="1.5" y="1.5" width="1021" height="1021" rx="228.9"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
|
||||||
|
|
||||||
|
<g transform="translate(184.32 184.32) scale(6.5536)">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2C2C33"/>
|
||||||
|
<stop offset="1" stop-color="#1B1B1F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2"/>
|
||||||
|
<stop offset="0.55" stop-color="#0A84FF"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1024" height="1024" rx="0" fill="url(#bg)"/>
|
||||||
|
<rect width="1024" height="1024" rx="0" fill="url(#shine)"/>
|
||||||
|
<rect x="1.5" y="1.5" width="1021" height="1021" rx="0"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
|
||||||
|
|
||||||
|
<g transform="translate(184.32 184.32) scale(6.5536)">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2C2C33"/>
|
||||||
|
<stop offset="1" stop-color="#1B1B1F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2"/>
|
||||||
|
<stop offset="0.55" stop-color="#0A84FF"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="320" height="180" fill="url(#bg)"/>
|
||||||
|
<rect width="320" height="180" fill="url(#shine)"/>
|
||||||
|
|
||||||
|
<g transform="translate(29.60 39.60) scale(1.0080)">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text x="140.8" y="101.0" font-family="Inter" font-weight="bold"
|
||||||
|
font-size="32" fill="#F5F5F7" letter-spacing="-0.6">Xtrem<tspan fill="#8AB9FF">Flow</tspan></text>
|
||||||
|
<text x="141.8" y="124.0" font-family="Inter" font-weight="600"
|
||||||
|
font-size="12" fill="#98989F" letter-spacing="2.4">IPTV PLAYER</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 477 KiB After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
# Icônes iOS uniquement : le dossier ios/ étant régénéré à chaque build CI,
|
||||||
|
# l'AppIcon doit être reconstruite après `scripts/prepare_ios.sh`.
|
||||||
|
# Les icônes Android, elles, sont versionnées dans android/app/src/main/res.
|
||||||
|
# Usage : dart run flutter_launcher_icons -f flutter_launcher_icons-ios.yaml
|
||||||
|
flutter_launcher_icons:
|
||||||
|
ios: true
|
||||||
|
android: false
|
||||||
|
image_path: "assets/images/app_icon_ios.png"
|
||||||
|
# L'App Store et iOS refusent une icône avec canal alpha.
|
||||||
|
remove_alpha_ios: true
|
||||||
@@ -1,170 +1,299 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// XtremFlow Apple TV Grey Glossy Palette
|
/// XtremFlow — "Apple Glass Gray" Design System
|
||||||
///
|
///
|
||||||
/// Premium Apple TV design language:
|
/// Palette : graphite neutre + blanc cassé + bleu système Apple.
|
||||||
/// - Background: Deep dark grey (#1C1C1E) to black (#000000) gradient
|
/// Inspiration : verre dépoli iOS/visionOS — des gris francs, jamais un noir
|
||||||
/// - Surface cards: Glossy dark grey (#2C2C2E to #3A3A3C) with glass reflections
|
/// pur, pour une interface nettement moins sombre que l'ancien thème.
|
||||||
/// - Primary accent: Bright white (#FFFFFF) and light grey (#EBEBF5)
|
///
|
||||||
/// - Secondary accent: Apple Blue (#0A84FF)
|
/// Règle de perf : AUCUNE couleur de surface n'est translucide. La profondeur
|
||||||
/// - Borders: Subtle white 8% opacity for glass effect
|
/// vient des ombres et du contraste, pas du flou — pas de `BackdropFilter`,
|
||||||
/// - Glossy highlight: White 10-15% at top fading to transparent
|
/// coûteux à chaque frame sur les Android d'entrée de gamme. L'effet "glass"
|
||||||
|
/// est simulé par des gris élevés + liserés blancs à faible opacité.
|
||||||
class AppColors {
|
class AppColors {
|
||||||
AppColors._();
|
AppColors._();
|
||||||
|
|
||||||
|
// ============ BASE LEVEL 0 ============
|
||||||
|
/// Graphite profond — le niveau le plus bas, jamais un noir pur.
|
||||||
|
static const Color baseLevel0 = Color(0xFF1B1B1F);
|
||||||
|
|
||||||
// ============ BACKGROUNDS ============
|
// ============ BACKGROUNDS ============
|
||||||
/// Pure black — infinite depth, OLED-friendly
|
static const Color background = Color(0xFF26262C);
|
||||||
static const Color background = Color(0xFF000000);
|
static const Color onBackground = Color(0xFFF5F5F7);
|
||||||
|
|
||||||
/// Apple deep dark grey — primary app background (alias)
|
static const Color surface = Color(0xFF26262C);
|
||||||
static const Color backgroundSecondary = Color(0xFF1C1C1E);
|
static const Color surfaceDim = Color(0xFF1F1F24);
|
||||||
|
static const Color surfaceBright = Color(0xFF54545E);
|
||||||
|
|
||||||
/// Apple deep dark grey — primary surface
|
// Surface Containers (hiérarchie Material 3, toutes opaques)
|
||||||
static const Color surface = Color(0xFF1C1C1E);
|
static const Color surfaceContainerLowest = Color(0xFF1B1B1F);
|
||||||
|
static const Color surfaceContainerLow = Color(0xFF2C2C33);
|
||||||
|
static const Color surfaceContainer = Color(0xFF32323A);
|
||||||
|
static const Color surfaceContainerHigh = Color(0xFF3B3B44);
|
||||||
|
static const Color surfaceContainerHighest = Color(0xFF45454F);
|
||||||
|
static const Color surfaceVariant = Color(0xFF32323A);
|
||||||
|
|
||||||
/// Mid grey — elevated cards, sheets
|
// ============ SURFACE / ON SURFACE ============
|
||||||
static const Color surfaceVariant = Color(0xFF2C2C2E);
|
static const Color onSurface = Color(0xFFF5F5F7); // blanc Apple
|
||||||
|
static const Color onSurfaceVariant = Color(0xFFC7C7CE); // gris clair
|
||||||
|
|
||||||
/// Light grey surface — tertiary elevation
|
// ============ PRIMARY (Bleu système Apple) ============
|
||||||
static const Color surfaceElevated = Color(0xFF3A3A3C);
|
static const Color primary = Color(0xFF8AB9FF); // texte & icônes sur sombre
|
||||||
|
static const Color onPrimary = Color(0xFF00295C);
|
||||||
|
static const Color primaryContainer = Color(0xFF0A84FF); // remplissage bleu
|
||||||
|
static const Color onPrimaryContainer = Color(0xFFEAF2FF);
|
||||||
|
static const Color inversePrimary = Color(0xFF0066CC);
|
||||||
|
|
||||||
/// Focus/selected element background
|
/// Bleu profond — réservé aux **remplissages portant du texte**.
|
||||||
static const Color focusColor = Color(0xFFFFFFFF);
|
/// `primaryContainer` (#0A84FF) est trop lumineux : du blanc dessus plafonne
|
||||||
|
/// sous le seuil AA de 4,5:1. Cette variante passe le seuil tout en restant
|
||||||
|
/// la même couleur perçue.
|
||||||
|
static const Color primaryFill = Color(0xFF0A6FDC);
|
||||||
|
|
||||||
// ============ ACCENTS ============
|
/// Encre froide — remplace le noir pur pour du texte/des icônes posés sur un
|
||||||
/// Primary: Pure white — focus rings, active labels
|
/// remplissage clair (blanc cassé, bleu clair). Garde le contraste sans le
|
||||||
static const Color primary = Color(0xFFFFFFFF);
|
/// décalage d'un noir pur.
|
||||||
|
static const Color onPrimaryFixed = Color(0xFF002451);
|
||||||
|
static const Color primaryFixed = Color(0xFFD6E5FF);
|
||||||
|
static const Color primaryFixedDim = Color(0xFF8AB9FF);
|
||||||
|
static const Color onPrimaryFixedVariant = Color(0xFF0A5BB5);
|
||||||
|
|
||||||
/// Light grey — secondary text / inactive elements
|
// ============ SECONDARY (Argent / gris clair) ============
|
||||||
static const Color accent = Color(0xFFEBEBF5);
|
static const Color secondary = Color(0xFFD1D1D6);
|
||||||
|
static const Color onSecondary = Color(0xFF303036);
|
||||||
|
static const Color secondaryContainer = Color(0xFF44444D);
|
||||||
|
static const Color onSecondaryContainer = Color(0xFFE8E8ED);
|
||||||
|
|
||||||
/// Apple Blue — CTAs, links, active indicators
|
// ============ TERTIARY (Cyan glacé) ============
|
||||||
static const Color appleBlue = Color(0xFF0A84FF);
|
static const Color tertiary = Color(0xFF7DD3F2);
|
||||||
|
static const Color onTertiary = Color(0xFF003544);
|
||||||
|
static const Color tertiaryContainer = Color(0xFF1C4D60);
|
||||||
|
static const Color onTertiaryContainer = Color(0xFFC8ECFA);
|
||||||
|
|
||||||
|
// ============ ERROR ============
|
||||||
|
static const Color error = Color(0xFFFFB4AB);
|
||||||
|
static const Color onError = Color(0xFF690005);
|
||||||
|
static const Color errorContainer = Color(0xFF93000A);
|
||||||
|
static const Color onErrorContainer = Color(0xFFFFDAD6);
|
||||||
|
|
||||||
|
// ============ OUTLINE ============
|
||||||
|
static const Color outline = Color(0xFF8E8E93);
|
||||||
|
static const Color outlineVariant = Color(0xFF48484E);
|
||||||
|
|
||||||
|
// ============ INVERSE SURFACE ============
|
||||||
|
static const Color inverseSurface = Color(0xFFF2F2F7);
|
||||||
|
static const Color inverseOnSurface = Color(0xFF2C2C31);
|
||||||
|
static const Color surfaceTint = Color(0xFF8AB9FF);
|
||||||
|
|
||||||
|
// ============ TEXT / CONTENT ============
|
||||||
|
static const Color textPrimary = Color(0xFFF5F5F7);
|
||||||
|
static const Color textSecondary = Color(0xFFC7C7CE);
|
||||||
|
static const Color textTertiary = Color(0xFF98989F);
|
||||||
|
|
||||||
|
// ============ BLANC À OPACITÉ ============
|
||||||
|
// Blanc Apple (#F5F5F7) décliné en opacités pour rester cohérent partout.
|
||||||
|
static const Color onSurface06 = Color(0x0FF5F5F7);
|
||||||
|
static const Color onSurface12 = Color(0x1FF5F5F7);
|
||||||
|
static const Color onSurface24 = Color(0x3DF5F5F7);
|
||||||
|
static const Color onSurface38 = Color(0x61F5F5F7);
|
||||||
|
static const Color onSurface54 = Color(0x8AF5F5F7);
|
||||||
|
static const Color onSurface70 = Color(0xB3F5F5F7);
|
||||||
|
|
||||||
|
// ============ STATUS ============
|
||||||
|
static const Color success = Color(0xFF32D74B); // vert système
|
||||||
|
static const Color warning = Color(0xFFFFD60A); // jaune système
|
||||||
|
static const Color info = Color(0xFF64D2FF); // cyan système
|
||||||
|
|
||||||
|
// ============ CATÉGORIES ============
|
||||||
|
static const Color live = Color(0xFFFF453A); // rouge système
|
||||||
|
static const Color movies = Color(0xFF0A84FF); // bleu système
|
||||||
|
static const Color series = Color(0xFFBF5AF2); // violet système
|
||||||
|
|
||||||
|
// ============ RATING / PREMIUM ============
|
||||||
|
static const Color ratingGold = Color(0xFFFFD60A);
|
||||||
|
static const Color premiumGold = Color(0xFFE0B84C);
|
||||||
|
|
||||||
|
// ============ SHIMMER (placeholders de chargement) ============
|
||||||
|
static const Color shimmer = Color(0xFF32323A);
|
||||||
|
static const Color shimmerHighlight = Color(0xFF45454F);
|
||||||
|
|
||||||
|
// ============ BORDERS / FOCUS ============
|
||||||
|
static const Color border = outlineVariant;
|
||||||
|
static const Color focusColor = primaryContainer;
|
||||||
|
static const Color focusBorder = primaryContainer;
|
||||||
|
static const Color borderFocused = primaryContainer;
|
||||||
|
|
||||||
|
/// Liseré blanc 10 % — définit l'arête d'une surface "verre" sans la faire
|
||||||
|
/// flotter.
|
||||||
|
static const Color glassBorderColor = Color(0x1AFFFFFF);
|
||||||
|
|
||||||
|
// ============ SURFACES "GLASS" — OPAQUES ============
|
||||||
|
static const Color glassLevel1Bg = Color(0xFF2C2C33);
|
||||||
|
static const Color glassLevel1Border = Color(0x1AFFFFFF); // blanc 10 %
|
||||||
|
static const Color glassLevel2Bg = Color(0xFF32323A);
|
||||||
|
static const Color glassLevel2Border = Color(0x26FFFFFF); // blanc 15 %
|
||||||
|
static const Color glassLevel2InnerGlow = Color(0x3D0A84FF); // bleu 24 %
|
||||||
|
|
||||||
|
static const Color glassBackground = glassLevel2Bg;
|
||||||
|
static const Color glassBorder = glassLevel2Border;
|
||||||
|
|
||||||
// ============ GRADIENTS ============
|
// ============ GRADIENTS ============
|
||||||
|
/// Bleu système : clair vers profond.
|
||||||
/// Apple TV background: deep dark top → pure black bottom
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
static const LinearGradient backgroundGradient = LinearGradient(
|
colors: [Color(0xFF4DA2FF), Color(0xFF0A6FDC)],
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
|
||||||
Color(0xFF1C1C1E), // Deep dark grey
|
|
||||||
Color(0xFF000000), // Pure black
|
|
||||||
],
|
|
||||||
stops: [0.0, 1.0],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Legacy alias for backward compatibility
|
/// Fond "verre dépoli" : gris clair en haut, graphite en bas.
|
||||||
static const RadialGradient appleTvGradient = RadialGradient(
|
static const LinearGradient backgroundGradient = LinearGradient(
|
||||||
|
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Halo doux depuis le centre de l'écran.
|
||||||
|
static const RadialGradient projectorGradient = RadialGradient(
|
||||||
center: Alignment.center,
|
center: Alignment.center,
|
||||||
radius: 1.5,
|
radius: 1.5,
|
||||||
colors: [
|
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
|
||||||
Color(0xFF1A1A1C), // Slightly lighter than pure black
|
|
||||||
Color(0xFF000000), // Pure black edge
|
|
||||||
],
|
|
||||||
stops: [0.0, 1.0],
|
stops: [0.0, 1.0],
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Glossy card gradient: bright highlight at top fading to dark base
|
/// Carte : arête éclairée en haut, matière grise en bas.
|
||||||
static const LinearGradient glossyCardGradient = LinearGradient(
|
static const LinearGradient cardGradient = LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF45454F), Color(0xFF2C2C33)],
|
||||||
|
stops: [0.0, 1.0],
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Reflet posé sur le haut d'une carte — l'effet "verre".
|
||||||
|
static const LinearGradient cardHighlight = LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [Color(0x29FFFFFF), Color(0x00FFFFFF)],
|
||||||
Color(0xFF484848), // Lighter top for gloss highlight
|
stops: [0.0, 0.5],
|
||||||
Color(0xFF2C2C2E), // Dark mid
|
);
|
||||||
Color(0xFF1C1C1E), // Deeper bottom
|
|
||||||
],
|
/// Voile d'affiche : lecture du texte par-dessus une jaquette.
|
||||||
|
static const LinearGradient posterScrim = LinearGradient(
|
||||||
|
colors: [Color(0x001F1F24), Color(0xCC1F1F24), Color(0xFF1F1F24)],
|
||||||
|
stops: [0.0, 0.55, 1.0],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Voile latéral pour les héros pleine largeur.
|
||||||
|
static const LinearGradient heroScrim = LinearGradient(
|
||||||
|
colors: [Color(0xF21F1F24), Color(0x991F1F24), Color(0x001F1F24)],
|
||||||
stops: [0.0, 0.45, 1.0],
|
stops: [0.0, 0.45, 1.0],
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Glossy inner highlight (white shimmer layer, overlaid on top of card)
|
// ============ GLOW / OMBRES ============
|
||||||
static const LinearGradient glossyHighlight = LinearGradient(
|
static Color glowPrimary(double opacity) =>
|
||||||
begin: Alignment.topCenter,
|
const Color(0xFF0A84FF).withValues(alpha: opacity);
|
||||||
end: Alignment.bottomCenter,
|
static Color glowPrimaryDim(double opacity) =>
|
||||||
colors: [
|
const Color(0xFF8AB9FF).withValues(alpha: opacity);
|
||||||
Color(0x26FFFFFF), // White ~15% at top
|
|
||||||
Color(0x00FFFFFF), // Transparent at bottom
|
|
||||||
],
|
|
||||||
stops: [0.0, 0.5],
|
|
||||||
);
|
|
||||||
|
|
||||||
/// Card glossy gradient (Apple TV spec alias — dark base)
|
/// Ombre portée douce — moins dense qu'avant : le fond est plus clair.
|
||||||
static const LinearGradient cardGlossyGradient = LinearGradient(
|
static List<BoxShadow> lift({double intensity = 1.0}) => [
|
||||||
begin: Alignment.topCenter,
|
BoxShadow(
|
||||||
end: Alignment.bottomCenter,
|
color: const Color(0xFF000000).withValues(alpha: 0.40 * intensity),
|
||||||
colors: [Color(0xFF48484A), Color(0xFF2C2C2E)],
|
blurRadius: 28 * intensity,
|
||||||
);
|
spreadRadius: -8,
|
||||||
|
offset: Offset(0, 12 * intensity),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
/// Card glossy highlight (Apple TV spec alias — top-left shimmer)
|
/// Halo bleu pour l'élément focalisé (navigation TV / télécommande).
|
||||||
static const LinearGradient cardGlossyHighlight = LinearGradient(
|
/// Le nom historique `emberFocus` est conservé pour les sites d'appel.
|
||||||
begin: Alignment.topLeft,
|
static List<BoxShadow> emberFocus({double intensity = 1.0}) => [
|
||||||
end: Alignment.bottomRight,
|
BoxShadow(
|
||||||
colors: [Color(0x26FFFFFF), Color(0x00FFFFFF)],
|
color: const Color(0xFF0A84FF).withValues(alpha: 0.45 * intensity),
|
||||||
stops: [0.0, 0.5],
|
blurRadius: 32 * intensity,
|
||||||
);
|
spreadRadius: -4,
|
||||||
|
),
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF000000).withValues(alpha: 0.45),
|
||||||
|
blurRadius: 24,
|
||||||
|
spreadRadius: -10,
|
||||||
|
offset: const Offset(0, 14),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
/// Primary silver gradient (used for buttons/badges)
|
// ============ ALIAS DE COMPATIBILITÉ ============
|
||||||
static const LinearGradient primaryGradient = LinearGradient(
|
// Conservés pour ne pas casser les sites d'appel.
|
||||||
colors: [Color(0xFFFFFFFF), Color(0xFFCCCCCC)],
|
static const Color backgroundSecondary = surfaceContainerLow;
|
||||||
begin: Alignment.topLeft,
|
static const Color surfaceElevated = surfaceContainerHigh;
|
||||||
end: Alignment.bottomRight,
|
static const Color accent = secondary;
|
||||||
);
|
|
||||||
|
|
||||||
// ============ TEXT ============
|
/// Accent bleu Apple portant du texte blanc (contraste AA).
|
||||||
/// Pure white — headings, active labels
|
static const Color appleBlue = primaryFill;
|
||||||
static const Color textPrimary = Color(0xFFFFFFFF);
|
|
||||||
|
|
||||||
/// Light grey 60% — secondary info, captions
|
static const RadialGradient appleTvGradient = projectorGradient;
|
||||||
static const Color textSecondary = Color(0x99EBEBF5); // #EBEBF5 @ 60%
|
static const LinearGradient glossyCardGradient = cardGradient;
|
||||||
|
static const LinearGradient glossyHighlight = cardHighlight;
|
||||||
|
static const LinearGradient cardGlossyGradient = cardGradient;
|
||||||
|
static const LinearGradient cardGlossyHighlight = cardHighlight;
|
||||||
|
|
||||||
/// Darker grey — hints, placeholders
|
// ============ COLOR SCHEMES ============
|
||||||
static const Color textTertiary = Color(0xFF636366);
|
|
||||||
|
|
||||||
// ============ FUNCTIONAL COLORS ============
|
|
||||||
static const Color success = Color(0xFF30D158); // Apple Green
|
|
||||||
static const Color warning = Color(0xFFFF9F0A); // Apple Orange
|
|
||||||
static const Color error = Color(0xFFFF453A); // Apple Red
|
|
||||||
static const Color live = Color(0xFFFF453A); // Apple Red (live badge)
|
|
||||||
|
|
||||||
// ============ BORDERS ============
|
|
||||||
/// Invisible border — unfocused state
|
|
||||||
static const Color border = Color(0xFF2C2C2E);
|
|
||||||
|
|
||||||
/// Glass border: white at 8% opacity for subtle edge definition
|
|
||||||
static const Color glassBorderColor = Color(0x14FFFFFF); // 8% white
|
|
||||||
|
|
||||||
/// Focus border: pure white ring
|
|
||||||
static const Color focusBorder = Color(0xFFFFFFFF);
|
|
||||||
|
|
||||||
/// Alias: focused border — pure white (Apple TV spec)
|
|
||||||
static const Color borderFocused = Color(0xFFFFFFFF);
|
|
||||||
|
|
||||||
// ============ GLASSMORPHISM ============
|
|
||||||
static final Color glassBackground = const Color(0xFF1E1E1E).withValues(alpha: 0.75);
|
|
||||||
static final Color glassBorder = const Color(0xFFFFFFFF).withValues(alpha: 0.08);
|
|
||||||
|
|
||||||
// ============ THEME SCHEMES ============
|
|
||||||
static ColorScheme get darkColorScheme => const ColorScheme.dark(
|
static ColorScheme get darkColorScheme => const ColorScheme.dark(
|
||||||
primary: appleBlue,
|
primary: primary,
|
||||||
secondary: accent,
|
onPrimary: onPrimary,
|
||||||
surface: surface,
|
primaryContainer: primaryContainer,
|
||||||
|
onPrimaryContainer: onPrimaryContainer,
|
||||||
|
secondary: secondary,
|
||||||
|
onSecondary: onSecondary,
|
||||||
|
secondaryContainer: secondaryContainer,
|
||||||
|
onSecondaryContainer: onSecondaryContainer,
|
||||||
|
tertiary: tertiary,
|
||||||
|
onTertiary: onTertiary,
|
||||||
|
tertiaryContainer: tertiaryContainer,
|
||||||
|
onTertiaryContainer: onTertiaryContainer,
|
||||||
error: error,
|
error: error,
|
||||||
onPrimary: Colors.white,
|
onError: onError,
|
||||||
onSecondary: Colors.black,
|
errorContainer: errorContainer,
|
||||||
onSurface: textPrimary,
|
onErrorContainer: onErrorContainer,
|
||||||
onError: Colors.white,
|
surface: surface,
|
||||||
|
onSurface: onSurface,
|
||||||
|
onSurfaceVariant: onSurfaceVariant,
|
||||||
|
surfaceContainerLowest: surfaceContainerLowest,
|
||||||
|
surfaceContainerLow: surfaceContainerLow,
|
||||||
|
surfaceContainer: surfaceContainer,
|
||||||
|
surfaceContainerHigh: surfaceContainerHigh,
|
||||||
|
surfaceContainerHighest: surfaceContainerHighest,
|
||||||
|
surfaceTint: surfaceTint,
|
||||||
|
outline: outline,
|
||||||
|
outlineVariant: outlineVariant,
|
||||||
|
inverseSurface: inverseSurface,
|
||||||
|
inversePrimary: inversePrimary,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Thème clair : gris système iOS, même famille neutre.
|
||||||
static ColorScheme get lightColorScheme => const ColorScheme.light(
|
static ColorScheme get lightColorScheme => const ColorScheme.light(
|
||||||
primary: Colors.black,
|
primary: Color(0xFF0066CC),
|
||||||
secondary: Colors.grey,
|
|
||||||
surface: Color(0xFFF2F2F7),
|
|
||||||
error: error,
|
|
||||||
onPrimary: Colors.white,
|
onPrimary: Colors.white,
|
||||||
|
primaryContainer: Color(0xFFD6E5FF),
|
||||||
|
onPrimaryContainer: Color(0xFF002451),
|
||||||
|
secondary: Color(0xFF54545E),
|
||||||
onSecondary: Colors.white,
|
onSecondary: Colors.white,
|
||||||
onSurface: Colors.black,
|
secondaryContainer: Color(0xFFE3E3E8),
|
||||||
|
onSecondaryContainer: Color(0xFF1B1B1F),
|
||||||
|
tertiary: Color(0xFF00658E),
|
||||||
|
onTertiary: Colors.white,
|
||||||
|
error: Color(0xFFB3261E),
|
||||||
onError: Colors.white,
|
onError: Colors.white,
|
||||||
|
errorContainer: Color(0xFFFFDAD4),
|
||||||
|
onErrorContainer: Color(0xFF410E05),
|
||||||
|
surface: Color(0xFFF2F2F7),
|
||||||
|
onSurface: Color(0xFF1C1C1E),
|
||||||
|
onSurfaceVariant: Color(0xFF48484E),
|
||||||
|
outline: Color(0xFF8E8E93),
|
||||||
|
outlineVariant: Color(0xFFD1D1D6),
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Theme-aware decoration helpers.
|
import 'app_colors.dart';
|
||||||
/// Returns dark (Apple TV glossy) or light (iOS clean white) styles
|
|
||||||
/// based on current [BuildContext] brightness.
|
/// Theme-aware decoration helpers — "Warm Cinema" charter.
|
||||||
|
///
|
||||||
|
/// Depth comes from warm shadows and contrast, never from `BackdropFilter`:
|
||||||
|
/// a blur layer repaints every frame and is the first thing to drop frames on
|
||||||
|
/// low-end Android.
|
||||||
class AppDecorations {
|
class AppDecorations {
|
||||||
AppDecorations._();
|
AppDecorations._();
|
||||||
|
|
||||||
// Force Dark/Glossy mode for IPTV experience regardless of system theme
|
// Force Dark mode for the IPTV experience regardless of system theme.
|
||||||
static bool _isDark(BuildContext context) => true;
|
static bool _isDark(BuildContext context) => true;
|
||||||
|
|
||||||
// ─── Backgrounds ────────────────────────────────────────────────────────────
|
// ─── Backgrounds ────────────────────────────────────────────────────────────
|
||||||
@@ -14,20 +18,13 @@ class AppDecorations {
|
|||||||
/// Full-screen gradient background for tab content areas.
|
/// Full-screen gradient background for tab content areas.
|
||||||
static BoxDecoration background(BuildContext context) {
|
static BoxDecoration background(BuildContext context) {
|
||||||
if (_isDark(context)) {
|
if (_isDark(context)) {
|
||||||
return const BoxDecoration(
|
return const BoxDecoration(gradient: AppColors.backgroundGradient);
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0xFF1C1C1E), Color(0xFF000000)],
|
|
||||||
stops: [0.0, 1.0],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return const BoxDecoration(
|
return const BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Color(0xFFEAEAF0), Color(0xFFD8D8E0)],
|
colors: [Color(0xFFFBF3EC), Color(0xFFEFE2D6)],
|
||||||
stops: [0.0, 1.0],
|
stops: [0.0, 1.0],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -39,28 +36,20 @@ class AppDecorations {
|
|||||||
static BoxDecoration navPill(BuildContext context) {
|
static BoxDecoration navPill(BuildContext context) {
|
||||||
if (_isDark(context)) {
|
if (_isDark(context)) {
|
||||||
return BoxDecoration(
|
return BoxDecoration(
|
||||||
color: const Color(0xFF1C1C1E),
|
color: AppColors.surfaceContainerLow,
|
||||||
borderRadius: BorderRadius.circular(40),
|
borderRadius: BorderRadius.circular(40),
|
||||||
border: Border.all(color: const Color(0x20FFFFFF), width: 1),
|
border: Border.all(color: AppColors.glassLevel2Border, width: 1),
|
||||||
boxShadow: const [
|
boxShadow: AppColors.lift(),
|
||||||
BoxShadow(
|
|
||||||
color: Color(0x66000000),
|
|
||||||
blurRadius: 20,
|
|
||||||
spreadRadius: 0,
|
|
||||||
offset: Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return BoxDecoration(
|
return BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(40),
|
borderRadius: BorderRadius.circular(40),
|
||||||
border: Border.all(color: const Color(0x1A000000), width: 1),
|
border: Border.all(color: const Color(0x1A201A16), width: 1),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Color(0x1A000000),
|
color: Color(0x1A201A16),
|
||||||
blurRadius: 16,
|
blurRadius: 16,
|
||||||
spreadRadius: 0,
|
|
||||||
offset: Offset(0, 4),
|
offset: Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -69,59 +58,41 @@ class AppDecorations {
|
|||||||
|
|
||||||
// ─── Cards ───────────────────────────────────────────────────────────────────
|
// ─── Cards ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Glossy category / channel card — primary card style.
|
/// Category / channel card — primary card style.
|
||||||
static BoxDecoration glossyCard(
|
static BoxDecoration glossyCard(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
double radius = 16,
|
double radius = 16,
|
||||||
}) {
|
}) {
|
||||||
if (_isDark(context)) {
|
if (_isDark(context)) {
|
||||||
return BoxDecoration(
|
return BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: AppColors.cardGradient,
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xFF4A4A4E), Color(0xFF1C1C1E)],
|
|
||||||
stops: [0.0, 1.0],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
border: Border.all(color: const Color(0x28FFFFFF), width: 1),
|
border: Border.all(color: AppColors.glassLevel2Border, width: 1),
|
||||||
boxShadow: const [
|
boxShadow: AppColors.lift(intensity: 0.8),
|
||||||
BoxShadow(
|
|
||||||
color: Color(0xCC000000),
|
|
||||||
blurRadius: 16,
|
|
||||||
spreadRadius: 2,
|
|
||||||
offset: Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return BoxDecoration(
|
return BoxDecoration(
|
||||||
color: const Color(0xFFF2F2F7),
|
color: const Color(0xFFFBF3EC),
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
border: Border.all(color: const Color(0x1A000000), width: 1),
|
border: Border.all(color: const Color(0x1A201A16), width: 1),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Color(0x14000000),
|
color: Color(0x14201A16),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
spreadRadius: 0,
|
|
||||||
offset: Offset(0, 2),
|
offset: Offset(0, 2),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gloss shimmer overlay — placed on top-left of a card Stack.
|
/// Warm highlight overlay — placed on top of a card Stack.
|
||||||
static BoxDecoration glossShimmer(
|
static BoxDecoration glossShimmer(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
double radius = 16,
|
double radius = 16,
|
||||||
}) {
|
}) {
|
||||||
if (_isDark(context)) {
|
if (_isDark(context)) {
|
||||||
return BoxDecoration(
|
return BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: AppColors.cardHighlight,
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0x45FFFFFF), Color(0x00FFFFFF)],
|
|
||||||
stops: [0.0, 0.5],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,7 +100,7 @@ class AppDecorations {
|
|||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Color(0x0A000000), Color(0x00000000)],
|
colors: [Color(0x0A201A16), Color(0x00201A16)],
|
||||||
stops: [0.0, 0.55],
|
stops: [0.0, 0.55],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
|
||||||
@@ -139,20 +110,23 @@ class AppDecorations {
|
|||||||
/// Smaller channel card base (top area behind the logo).
|
/// Smaller channel card base (top area behind the logo).
|
||||||
static BoxDecoration channelCardBase(BuildContext context) {
|
static BoxDecoration channelCardBase(BuildContext context) {
|
||||||
if (_isDark(context)) {
|
if (_isDark(context)) {
|
||||||
return BoxDecoration(
|
return const BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Color(0xFF2C2C2E), Color(0xFF1C1C1E)],
|
colors: [
|
||||||
|
AppColors.surfaceContainerHigh,
|
||||||
|
AppColors.surfaceContainerLow,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||||
border: Border(
|
border: Border(
|
||||||
top: BorderSide(color: Colors.white.withOpacity(0.12), width: 0.5),
|
top: BorderSide(color: AppColors.glassLevel2Border, width: 0.5),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const BoxDecoration(
|
return const BoxDecoration(
|
||||||
color: Color(0xFFE4E4EC),
|
color: Color(0xFFEFE2D6),
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,33 +137,29 @@ class AppDecorations {
|
|||||||
static BoxDecoration searchBar(BuildContext context) {
|
static BoxDecoration searchBar(BuildContext context) {
|
||||||
if (_isDark(context)) {
|
if (_isDark(context)) {
|
||||||
return BoxDecoration(
|
return BoxDecoration(
|
||||||
color: const Color(0xFF2C2C2E),
|
color: AppColors.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: const Color(0x18FFFFFF), width: 1),
|
border: Border.all(color: AppColors.glassLevel2Border, width: 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return BoxDecoration(
|
return BoxDecoration(
|
||||||
color: const Color(0xFFEEEEF4),
|
color: const Color(0xFFF3E7DB),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: const Color(0x18000000), width: 1),
|
border: Border.all(color: const Color(0x18201A16), width: 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Colours ─────────────────────────────────────────────────────────────────
|
// ─── Colours ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static Color textPrimary(BuildContext context) =>
|
static Color textPrimary(BuildContext context) =>
|
||||||
_isDark(context) ? Colors.white : const Color(0xFF000000);
|
_isDark(context) ? AppColors.textPrimary : const Color(0xFF201A16);
|
||||||
|
|
||||||
static Color textSecondary(BuildContext context) =>
|
static Color textSecondary(BuildContext context) =>
|
||||||
_isDark(context)
|
_isDark(context) ? AppColors.textSecondary : const Color(0xFF52443B);
|
||||||
? const Color(0x99EBEBF5)
|
|
||||||
: const Color(0x993C3C43);
|
|
||||||
|
|
||||||
static Color iconMuted(BuildContext context) =>
|
static Color iconMuted(BuildContext context) =>
|
||||||
_isDark(context) ? Colors.white38 : Colors.black26;
|
_isDark(context) ? AppColors.onSurface38 : Colors.black26;
|
||||||
|
|
||||||
static Color divider(BuildContext context) =>
|
static Color divider(BuildContext context) =>
|
||||||
_isDark(context)
|
_isDark(context) ? AppColors.onSurface12 : const Color(0x1A201A16);
|
||||||
? const Color(0x1FFFFFFF)
|
|
||||||
: const Color(0x1A000000);
|
|
||||||
}
|
}
|
||||||
@@ -2,266 +2,393 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
|
|
||||||
/// XtremFlow Apple TV Theme
|
/// XtremFlow — "Apple Glass Gray" theme.
|
||||||
///
|
///
|
||||||
/// Focus-driven, immersive, minimal.
|
/// Typography: **Fraunces** (serif éditorial) for display/headline, **Karla**
|
||||||
|
/// (humaniste chaud) for UI and body — very legible at TV distance and on a
|
||||||
|
/// phone held at arm's length.
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
|
|
||||||
// ============ SPACING ============
|
// ============ SPACING (8pt base) ============
|
||||||
|
static const double spacing2 = 2.0;
|
||||||
static const double spacing4 = 4.0;
|
static const double spacing4 = 4.0;
|
||||||
static const double spacing8 = 8.0;
|
static const double spacing8 = 8.0;
|
||||||
static const double spacing12 = 12.0;
|
static const double spacing12 = 12.0;
|
||||||
static const double spacing16 = 16.0;
|
static const double spacing16 = 16.0;
|
||||||
|
static const double spacing20 = 20.0;
|
||||||
static const double spacing24 = 24.0;
|
static const double spacing24 = 24.0;
|
||||||
static const double spacing32 = 32.0;
|
static const double spacing32 = 32.0;
|
||||||
static const double spacing48 = 48.0; // Larger spacing for TV feel
|
static const double spacing48 = 48.0;
|
||||||
|
|
||||||
|
// Semantic aliases
|
||||||
|
static const double spacingXs = spacing4;
|
||||||
|
static const double spacingSm = spacing8;
|
||||||
|
static const double spacingMd = spacing16;
|
||||||
|
static const double spacingLg = spacing24;
|
||||||
|
static const double spacingXl = spacing32;
|
||||||
|
|
||||||
// ============ RADIUS ============
|
// ============ RADIUS ============
|
||||||
// ============ RADIUS ============
|
static const double radiusNone = 0.0;
|
||||||
static const double radiusSm = 8.0;
|
static const double radiusXs = 4.0;
|
||||||
static const double radiusMd = 12.0;
|
static const double radiusSm = 6.0;
|
||||||
static const double radiusLg = 20.0;
|
static const double radiusMd = 10.0;
|
||||||
static const double radiusXl = 32.0;
|
static const double radiusLg = 14.0;
|
||||||
|
static const double radiusXl = 20.0;
|
||||||
|
static const double radiusFull = 9999.0;
|
||||||
|
|
||||||
// ============ ANIMATION ============
|
// ============ ANIMATION ============
|
||||||
static const Duration durationFast = Duration(milliseconds: 200);
|
static const Duration durationXs = Duration(milliseconds: 120);
|
||||||
static const Duration durationNormal = Duration(milliseconds: 300);
|
static const Duration durationFast = Duration(milliseconds: 180);
|
||||||
static const Curve curveDefault =
|
static const Duration durationNormal = Duration(milliseconds: 260);
|
||||||
Curves.fastOutSlowIn; // Apple-like snappy curve
|
static const Duration durationMd = Duration(milliseconds: 260);
|
||||||
|
static const Duration durationLg = Duration(milliseconds: 380);
|
||||||
|
static const Duration durationXl = Duration(milliseconds: 520);
|
||||||
|
|
||||||
// ============ DARK THEME (TV Main) ============
|
static const Curve curveDefault = Curves.easeOutCubic;
|
||||||
|
static const Curve curveSmooth = Curves.easeOutCubic;
|
||||||
|
|
||||||
|
/// Ouverture de rideau — décélération très longue, "cinéma".
|
||||||
|
static const Curve curveCinema = Cubic(0.16, 1.0, 0.3, 1.0);
|
||||||
|
|
||||||
|
// ============ TYPOGRAPHY HELPERS ============
|
||||||
|
|
||||||
|
/// Display serif éditorial — titres, héros, chiffres marquants.
|
||||||
|
static TextStyle display({
|
||||||
|
required double fontSize,
|
||||||
|
required FontWeight fontWeight,
|
||||||
|
double? letterSpacing,
|
||||||
|
double? height,
|
||||||
|
Color color = AppColors.onSurface,
|
||||||
|
FontStyle? fontStyle,
|
||||||
|
}) {
|
||||||
|
return GoogleFonts.fraunces(
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
letterSpacing: letterSpacing,
|
||||||
|
height: height,
|
||||||
|
color: color,
|
||||||
|
fontStyle: fontStyle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corps / UI — humaniste chaud, très lisible à distance.
|
||||||
|
static TextStyle body({
|
||||||
|
required double fontSize,
|
||||||
|
required FontWeight fontWeight,
|
||||||
|
double? letterSpacing,
|
||||||
|
double? height,
|
||||||
|
Color color = AppColors.onSurface,
|
||||||
|
}) {
|
||||||
|
return GoogleFonts.karla(
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
letterSpacing: letterSpacing,
|
||||||
|
height: height,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Micro-label capitales espacées — « GÉNÉRIQUE DE FILM ».
|
||||||
|
static TextStyle eyebrow({
|
||||||
|
double fontSize = 11,
|
||||||
|
Color color = AppColors.textTertiary,
|
||||||
|
FontWeight fontWeight = FontWeight.w700,
|
||||||
|
}) {
|
||||||
|
return GoogleFonts.karla(
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
letterSpacing: 1.6,
|
||||||
|
height: 1.1,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static TextTheme _textTheme({
|
||||||
|
required Color primary,
|
||||||
|
required Color secondary,
|
||||||
|
}) {
|
||||||
|
return TextTheme(
|
||||||
|
displayLarge: display(
|
||||||
|
fontSize: 44,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -1.2,
|
||||||
|
height: 1.03,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
displayMedium: display(
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.8,
|
||||||
|
height: 1.08,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
displaySmall: display(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.4,
|
||||||
|
height: 1.15,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
headlineLarge: display(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.12,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
headlineMedium: display(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
height: 1.2,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
headlineSmall: display(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.25,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
titleLarge: body(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.3,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
titleMedium: body(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.35,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
titleSmall: body(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.35,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
bodyLarge: body(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.5,
|
||||||
|
color: secondary,
|
||||||
|
),
|
||||||
|
bodyMedium: body(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.5,
|
||||||
|
color: secondary,
|
||||||
|
),
|
||||||
|
bodySmall: body(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.45,
|
||||||
|
color: secondary,
|
||||||
|
),
|
||||||
|
labelLarge: body(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
labelMedium: body(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
color: primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ DARK THEME (principal) ============
|
||||||
static ThemeData get darkTheme {
|
static ThemeData get darkTheme {
|
||||||
final baseTheme = ThemeData.dark(useMaterial3: true);
|
final baseTheme = ThemeData.dark(useMaterial3: true);
|
||||||
|
|
||||||
return baseTheme.copyWith(
|
return baseTheme.copyWith(
|
||||||
colorScheme: AppColors.darkColorScheme,
|
colorScheme: AppColors.darkColorScheme,
|
||||||
scaffoldBackgroundColor: AppColors.background,
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
|
canvasColor: AppColors.background,
|
||||||
|
splashColor: AppColors.primaryContainer.withValues(alpha: 0.12),
|
||||||
|
highlightColor: AppColors.primaryContainer.withValues(alpha: 0.08),
|
||||||
|
|
||||||
|
textTheme: _textTheme(
|
||||||
|
primary: AppColors.textPrimary,
|
||||||
|
secondary: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
|
||||||
// Global Text Selection Theme (Fixes black cursor issues)
|
|
||||||
textSelectionTheme: const TextSelectionThemeData(
|
textSelectionTheme: const TextSelectionThemeData(
|
||||||
cursorColor: Colors.white,
|
cursorColor: AppColors.primary,
|
||||||
selectionColor: Color(0x4DFFFFFF), // 30% White
|
selectionColor: Color(0x590A84FF),
|
||||||
selectionHandleColor: Colors.white,
|
selectionHandleColor: AppColors.primaryContainer,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Typography: San Francisco style (using Inter as proxy)
|
// AppBar: transparente — le dégradé de fond passe au travers.
|
||||||
textTheme: TextTheme(
|
|
||||||
displayLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 56,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -1.0,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
displayMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
displaySmall: GoogleFonts.inter(
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
headlineMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
titleLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
titleMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
bodyLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
bodyMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
labelLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// AppBar: Transparent / Glass
|
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
foregroundColor: AppColors.textPrimary,
|
foregroundColor: AppColors.textPrimary,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
titleTextStyle: GoogleFonts.inter(
|
titleTextStyle: display(
|
||||||
fontSize: 17,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Inputs: Dark, minimal, no borders unless focused
|
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||||
|
backgroundColor: AppColors.surfaceContainerLow,
|
||||||
|
selectedItemColor: AppColors.primary,
|
||||||
|
unselectedItemColor: AppColors.textTertiary,
|
||||||
|
selectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w700),
|
||||||
|
unselectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w500),
|
||||||
|
type: BottomNavigationBarType.fixed,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppColors.surface,
|
fillColor: AppColors.surfaceContainer,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: spacing24,
|
horizontal: spacing16,
|
||||||
vertical: spacing16,
|
vertical: spacing16,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
borderSide: BorderSide.none,
|
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
borderSide: BorderSide.none,
|
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
borderSide: const BorderSide(color: AppColors.focusColor, width: 2),
|
borderSide: const BorderSide(
|
||||||
|
color: AppColors.primaryContainer,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
hintStyle: body(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
),
|
),
|
||||||
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// Buttons: White pill or minimal text
|
// Boutons : remplissage braise, texte crème (contraste AA).
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: AppColors.focusColor, // White buttons
|
backgroundColor: AppColors.primaryFill,
|
||||||
foregroundColor: Colors.black, // Black text
|
foregroundColor: AppColors.onSurface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: spacing24,
|
||||||
|
vertical: spacing16,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
),
|
),
|
||||||
textStyle:
|
textStyle: body(fontSize: 15, fontWeight: FontWeight.w700),
|
||||||
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
|
|
||||||
).copyWith(
|
|
||||||
overlayColor: WidgetStateProperty.resolveWith((states) {
|
|
||||||
if (states.contains(WidgetState.hovered)) {
|
|
||||||
return Colors.black.withOpacity(0.1);
|
|
||||||
}
|
|
||||||
if (states.contains(WidgetState.pressed)) {
|
|
||||||
return Colors.black.withOpacity(0.2);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Cards: Transparent by default (content defines look)
|
textButtonTheme: TextButtonThemeData(
|
||||||
// cardTheme: const CardTheme(
|
style: TextButton.styleFrom(
|
||||||
// color: AppColors.surface,
|
foregroundColor: AppColors.primary,
|
||||||
// elevation: 0,
|
textStyle: body(fontSize: 14, fontWeight: FontWeight.w700),
|
||||||
// margin: EdgeInsets.zero,
|
),
|
||||||
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(radiusMd))),
|
),
|
||||||
// ),
|
|
||||||
|
|
||||||
// Dialogs: Glass/Dark
|
cardTheme: CardThemeData(
|
||||||
// dialogTheme: DialogTheme(
|
color: AppColors.surfaceContainer,
|
||||||
// backgroundColor: const Color(0xFF1C1C1E), // Apple Dark Grey
|
elevation: 0,
|
||||||
// elevation: 24,
|
margin: EdgeInsets.zero,
|
||||||
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusLg)),
|
shape: RoundedRectangleBorder(
|
||||||
// titleTextStyle: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
|
borderRadius: BorderRadius.circular(radiusLg),
|
||||||
// contentTextStyle: GoogleFonts.inter(fontSize: 16, color: AppColors.textSecondary),
|
side: const BorderSide(color: AppColors.glassLevel2Border),
|
||||||
// ),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
dialogTheme: DialogThemeData(
|
||||||
|
backgroundColor: AppColors.surfaceContainerHigh,
|
||||||
|
elevation: 24,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(radiusXl),
|
||||||
|
side: const BorderSide(color: AppColors.glassLevel2Border),
|
||||||
|
),
|
||||||
|
titleTextStyle: display(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
contentTextStyle: body(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
sliderTheme: const SliderThemeData(
|
||||||
|
activeTrackColor: AppColors.primaryContainer,
|
||||||
|
inactiveTrackColor: AppColors.onSurface24,
|
||||||
|
thumbColor: AppColors.primary,
|
||||||
|
overlayColor: Color(0x290A84FF),
|
||||||
|
),
|
||||||
|
|
||||||
|
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||||
|
color: AppColors.primaryContainer,
|
||||||
|
linearTrackColor: AppColors.onSurface12,
|
||||||
|
),
|
||||||
|
|
||||||
|
switchTheme: SwitchThemeData(
|
||||||
|
thumbColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(WidgetState.selected)
|
||||||
|
? AppColors.primary
|
||||||
|
: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
trackColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(WidgetState.selected)
|
||||||
|
? AppColors.primaryFill
|
||||||
|
: AppColors.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
dividerTheme: const DividerThemeData(
|
||||||
|
color: AppColors.onSurface12,
|
||||||
|
thickness: 1,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static const double radiusFull = 999.0;
|
// ============ LIGHT THEME (gris système clair) ============
|
||||||
|
|
||||||
// ============ LIGHT THEME (Apple Clean White) ============
|
|
||||||
static ThemeData get lightTheme {
|
static ThemeData get lightTheme {
|
||||||
final baseTheme = ThemeData.light(useMaterial3: true);
|
final baseTheme = ThemeData.light(useMaterial3: true);
|
||||||
|
|
||||||
// Apple light palette
|
const bgColor = Color(0xFFF2F2F7);
|
||||||
const bgColor = Color(0xFFF2F2F7); // Apple system grey6
|
const surfaceColor = Color(0xFFFFFFFF);
|
||||||
const surfaceColor = Color(0xFFFFFFFF); // Pure white surfaces
|
const primaryColor = Color(0xFF0066CC);
|
||||||
const primaryColor = Color(0xFF007AFF); // Apple Blue (light)
|
const textPrimaryColor = Color(0xFF1C1C1E);
|
||||||
const textPrimaryColor = Color(0xFF000000);
|
const textSecondaryColor = Color(0xFF48484E);
|
||||||
const textSecondaryColor = Color(0x993C3C43); // #3C3C43 @ 60%
|
const textTertiaryColor = Color(0xFF8E8E93);
|
||||||
const textTertiaryColor = Color(0xFFC7C7CC);
|
const borderColor = Color(0xFFD8D8DE);
|
||||||
const borderColor = Color(0x1A000000); // #000000 @ 10%
|
|
||||||
|
|
||||||
return baseTheme.copyWith(
|
return baseTheme.copyWith(
|
||||||
colorScheme: AppColors.lightColorScheme.copyWith(
|
colorScheme: AppColors.lightColorScheme,
|
||||||
primary: primaryColor,
|
|
||||||
secondary: const Color(0xFF34C759), // Apple green
|
|
||||||
surface: surfaceColor,
|
|
||||||
onPrimary: Colors.white,
|
|
||||||
onSurface: textPrimaryColor,
|
|
||||||
brightness: Brightness.light,
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: bgColor,
|
scaffoldBackgroundColor: bgColor,
|
||||||
|
canvasColor: bgColor,
|
||||||
|
|
||||||
|
textTheme: _textTheme(
|
||||||
|
primary: textPrimaryColor,
|
||||||
|
secondary: textSecondaryColor,
|
||||||
|
),
|
||||||
|
|
||||||
// Text selection — blue cursor on light
|
|
||||||
textSelectionTheme: const TextSelectionThemeData(
|
textSelectionTheme: const TextSelectionThemeData(
|
||||||
cursorColor: primaryColor,
|
cursorColor: primaryColor,
|
||||||
selectionColor: Color(0x4D007AFF),
|
selectionColor: Color(0x4D0066CC),
|
||||||
selectionHandleColor: primaryColor,
|
selectionHandleColor: primaryColor,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Typography
|
|
||||||
textTheme: TextTheme(
|
|
||||||
displayLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 56,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -1.0,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
displayMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
displaySmall: GoogleFonts.inter(
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
headlineMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
titleLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
titleMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
bodyLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: textSecondaryColor,
|
|
||||||
),
|
|
||||||
bodyMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: textSecondaryColor,
|
|
||||||
),
|
|
||||||
labelLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// AppBar: White with subtle bottom shadow
|
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: surfaceColor,
|
backgroundColor: surfaceColor,
|
||||||
foregroundColor: textPrimaryColor,
|
foregroundColor: textPrimaryColor,
|
||||||
@@ -269,99 +396,97 @@ class AppTheme {
|
|||||||
scrolledUnderElevation: 1,
|
scrolledUnderElevation: 1,
|
||||||
shadowColor: borderColor,
|
shadowColor: borderColor,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
titleTextStyle: GoogleFonts.inter(
|
titleTextStyle: display(
|
||||||
fontSize: 17,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: textPrimaryColor,
|
color: textPrimaryColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom Navigation Bar: white, Apple Blue selected
|
|
||||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
||||||
backgroundColor: surfaceColor,
|
backgroundColor: surfaceColor,
|
||||||
selectedItemColor: primaryColor,
|
selectedItemColor: primaryColor,
|
||||||
unselectedItemColor: textSecondaryColor,
|
unselectedItemColor: textSecondaryColor,
|
||||||
selectedLabelStyle: GoogleFonts.inter(
|
selectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w700),
|
||||||
fontSize: 10,
|
unselectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w500),
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
unselectedLabelStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
type: BottomNavigationBarType.fixed,
|
type: BottomNavigationBarType.fixed,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Inputs: white fill, subtle border
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: surfaceColor,
|
fillColor: surfaceColor,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: spacing24,
|
horizontal: spacing16,
|
||||||
vertical: spacing16,
|
vertical: spacing16,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
borderSide: const BorderSide(color: borderColor, width: 1),
|
borderSide: const BorderSide(color: borderColor),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
borderSide: const BorderSide(color: borderColor, width: 1),
|
borderSide: const BorderSide(color: borderColor),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
borderSide: const BorderSide(color: primaryColor, width: 2),
|
borderSide: const BorderSide(color: primaryColor, width: 2),
|
||||||
),
|
),
|
||||||
hintStyle: GoogleFonts.inter(color: textTertiaryColor),
|
hintStyle: body(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: textTertiaryColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Buttons: Apple Blue pill
|
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: primaryColor,
|
backgroundColor: primaryColor,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: spacing24,
|
||||||
|
vertical: spacing16,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
borderRadius: BorderRadius.circular(radiusMd),
|
||||||
),
|
),
|
||||||
textStyle:
|
textStyle: body(fontSize: 15, fontWeight: FontWeight.w700),
|
||||||
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Cards: white with subtle shadow
|
cardTheme: CardThemeData(
|
||||||
cardTheme: const CardThemeData(
|
|
||||||
color: surfaceColor,
|
color: surfaceColor,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(radiusMd)),
|
borderRadius: BorderRadius.circular(radiusLg),
|
||||||
side: BorderSide(color: borderColor, width: 1),
|
side: const BorderSide(color: borderColor),
|
||||||
),
|
),
|
||||||
shadowColor: borderColor,
|
shadowColor: borderColor,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Dialogs: white, clean
|
|
||||||
dialogTheme: DialogThemeData(
|
dialogTheme: DialogThemeData(
|
||||||
backgroundColor: surfaceColor,
|
backgroundColor: surfaceColor,
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
shadowColor: borderColor,
|
shadowColor: borderColor,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(radiusLg),
|
borderRadius: BorderRadius.circular(radiusXl),
|
||||||
side: const BorderSide(color: borderColor, width: 1),
|
side: const BorderSide(color: borderColor),
|
||||||
),
|
),
|
||||||
titleTextStyle: GoogleFonts.inter(
|
titleTextStyle: display(
|
||||||
fontSize: 18,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w600,
|
||||||
color: textPrimaryColor,
|
color: textPrimaryColor,
|
||||||
),
|
),
|
||||||
contentTextStyle: GoogleFonts.inter(
|
contentTextStyle: body(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
color: textSecondaryColor,
|
color: textSecondaryColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
dividerTheme: const DividerThemeData(color: borderColor, thickness: 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ library;
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
/// Standard cache manager with 200MB size limit
|
/// Standard cache manager with 200MB size limit
|
||||||
class AppCacheManager {
|
class AppCacheManager {
|
||||||
@@ -103,7 +104,7 @@ class ImageCacheConfig {
|
|||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF2C2C2E),
|
color: AppColors.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -111,10 +112,10 @@ class ImageCacheConfig {
|
|||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF3A3A3C),
|
color: AppColors.surfaceContainerHigh,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.image_not_supported, color: Color(0xFF8E8E93)),
|
child: const Icon(Icons.image_not_supported, color: AppColors.textTertiary),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
|
|||||||
style: GoogleFonts.outfit(
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -161,7 +161,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
|
|||||||
item.subtitle!,
|
item.subtitle!,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Colors.white.withOpacity(0.8),
|
color: AppColors.onSurface.withOpacity(0.8),
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -227,8 +227,8 @@ class _HeroButtonState extends State<_HeroButton> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: widget.isPrimary
|
color: widget.isPrimary
|
||||||
? (_isHovered ? Colors.white : Colors.white.withOpacity(0.9))
|
? (_isHovered ? AppColors.onSurface : AppColors.onSurface.withOpacity(0.9))
|
||||||
: (_isHovered ? Colors.white.withOpacity(0.2) : Colors.white.withOpacity(0.1)),
|
: (_isHovered ? AppColors.onSurface.withOpacity(0.2) : AppColors.onSurface.withOpacity(0.1)),
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -236,14 +236,14 @@ class _HeroButtonState extends State<_HeroButton> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
widget.icon,
|
widget.icon,
|
||||||
color: widget.isPrimary ? Colors.black : Colors.white,
|
color: widget.isPrimary ? AppColors.onPrimaryFixed : AppColors.onSurface,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
widget.label,
|
widget.label,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
color: widget.isPrimary ? Colors.black : Colors.white,
|
color: widget.isPrimary ? AppColors.onPrimaryFixed : AppColors.onSurface,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ class _GradientButtonState extends State<GradientButton>
|
|||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: Colors.black,
|
color: AppColors.onPrimaryFixed,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Row(
|
: Row(
|
||||||
@@ -143,13 +143,13 @@ class _GradientButtonState extends State<GradientButton>
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (widget.icon != null) ...[
|
if (widget.icon != null) ...[
|
||||||
Icon(widget.icon, color: Colors.black, size: 20),
|
Icon(widget.icon, color: AppColors.onPrimaryFixed, size: 20),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
Text(
|
Text(
|
||||||
widget.label,
|
widget.label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.black,
|
color: AppColors.onPrimaryFixed,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
@@ -247,12 +247,12 @@ class LiveBadge extends StatelessWidget {
|
|||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.circle, size: 6, color: Colors.white),
|
Icon(Icons.circle, size: 6, color: AppColors.onSurface),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'LIVE',
|
'LIVE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
@@ -427,11 +427,11 @@ class _MediaCardState extends State<MediaCard>
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.star, size: 10, color: Color(0xFFFFD700)),
|
const Icon(Icons.star, size: 10, color: AppColors.ratingGold),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
widget.rating!,
|
widget.rating!,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
|
style: const TextStyle(color: AppColors.onSurface, fontSize: 10, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -449,7 +449,7 @@ class _MediaCardState extends State<MediaCard>
|
|||||||
color: AppColors.success,
|
color: AppColors.success,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.check, size: 12, color: Colors.white),
|
child: const Icon(Icons.check, size: 12, color: AppColors.onSurface),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
class DigitalClock extends StatefulWidget {
|
class DigitalClock extends StatefulWidget {
|
||||||
const DigitalClock({super.key});
|
const DigitalClock({super.key});
|
||||||
@@ -31,16 +32,16 @@ class _DigitalClockState extends State<DigitalClock> {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.1),
|
color: AppColors.onSurface.withOpacity(0.1),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: Colors.white.withOpacity(0.05)),
|
border: Border.all(color: AppColors.onSurface.withOpacity(0.05)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
timeString,
|
timeString,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
class ThemedLoading extends StatelessWidget {
|
class ThemedLoading extends StatelessWidget {
|
||||||
const ThemedLoading({super.key});
|
const ThemedLoading({super.key});
|
||||||
@@ -11,7 +12,7 @@ class ThemedLoading extends StatelessWidget {
|
|||||||
center: Alignment(0, -0.2),
|
center: Alignment(0, -0.2),
|
||||||
radius: 1.5,
|
radius: 1.5,
|
||||||
colors: [
|
colors: [
|
||||||
Color(0xFF2C2C2E),
|
AppColors.surfaceContainerHigh,
|
||||||
Color(0xFF000000),
|
Color(0xFF000000),
|
||||||
],
|
],
|
||||||
stops: [0.0, 1.0],
|
stops: [0.0, 1.0],
|
||||||
@@ -19,7 +20,7 @@ class ThemedLoading extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -72,10 +72,24 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
Timer? _controlsTimer;
|
Timer? _controlsTimer;
|
||||||
int _loadId = 0;
|
int _loadId = 0;
|
||||||
|
|
||||||
|
/// Set once the app is backgrounded, so a queued reload cannot restart the
|
||||||
|
/// stream behind the user.
|
||||||
|
bool _isReleased = false;
|
||||||
|
|
||||||
// Focus
|
// Focus
|
||||||
final FocusNode _playPauseFocusNode = FocusNode();
|
final FocusNode _playPauseFocusNode = FocusNode();
|
||||||
final FocusNode _prevFocusNode = FocusNode();
|
final FocusNode _prevFocusNode = FocusNode();
|
||||||
final FocusNode _nextFocusNode = FocusNode();
|
final FocusNode _nextFocusNode = FocusNode();
|
||||||
|
final FocusNode _channelListButtonFocusNode = FocusNode();
|
||||||
|
|
||||||
|
/// Focus D-pad de la chaîne active dans la sidebar : permet d'atterrir
|
||||||
|
/// directement dessus à l'ouverture de la liste.
|
||||||
|
final FocusNode _channelListItemFocusNode = FocusNode();
|
||||||
|
|
||||||
|
// [TiviMate] Channel List Sidebar
|
||||||
|
bool _showChannelList = false;
|
||||||
|
Timer? _channelListTimer;
|
||||||
|
final ScrollController _channelListScrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -98,17 +112,47 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (_controller == null) return;
|
// `inactive` is deliberately NOT handled: on Android it fires for transient
|
||||||
if (state == AppLifecycleState.paused ||
|
// interruptions (notification shade, call banner, permission dialog).
|
||||||
state == AppLifecycleState.inactive ||
|
final isBackgrounded = state == AppLifecycleState.paused ||
|
||||||
state == AppLifecycleState.detached) {
|
state == AppLifecycleState.hidden ||
|
||||||
_controller?.pause();
|
state == AppLifecycleState.detached;
|
||||||
if (mounted) {
|
if (!isBackgrounded) return;
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
_releasePlayback();
|
||||||
|
|
||||||
|
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop playback and release the decoder when the app leaves the foreground.
|
||||||
|
///
|
||||||
|
/// `pause()` alone is not enough: ExoPlayer keeps filling its buffer, so the
|
||||||
|
/// stream would still be pulled (and metered) in the background.
|
||||||
|
void _releasePlayback() {
|
||||||
|
if (_isReleased) return;
|
||||||
|
_isReleased = true;
|
||||||
|
|
||||||
|
_clockTimer?.cancel();
|
||||||
|
_clockTimer = null;
|
||||||
|
_epgTimer?.cancel();
|
||||||
|
_epgTimer = null;
|
||||||
|
_controlsTimer?.cancel();
|
||||||
|
_controlsTimer = null;
|
||||||
|
_stabilizationTimer?.cancel();
|
||||||
|
_stabilizationTimer = null;
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
_channelListTimer = null;
|
||||||
|
|
||||||
|
final controller = _controller;
|
||||||
|
_controller = null;
|
||||||
|
controller?.pause();
|
||||||
|
controller?.dispose();
|
||||||
|
|
||||||
|
WakelockPlus.disable();
|
||||||
|
}
|
||||||
|
|
||||||
void _startClock() {
|
void _startClock() {
|
||||||
_clockTimer?.cancel();
|
_clockTimer?.cancel();
|
||||||
_updateTime();
|
_updateTime();
|
||||||
@@ -132,6 +176,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
_xtreamService = XtreamServiceMobile(dir.path);
|
_xtreamService = XtreamServiceMobile(dir.path);
|
||||||
await _xtreamService!.setPlaylistAsync(widget.playlist);
|
await _xtreamService!.setPlaylistAsync(widget.playlist);
|
||||||
|
|
||||||
|
// Warm DNS + TCP/TLS to the panel host before the first request.
|
||||||
|
await _xtreamService!.prewarmHost().timeout(
|
||||||
|
const Duration(milliseconds: 1500),
|
||||||
|
onTimeout: () {},
|
||||||
|
);
|
||||||
|
|
||||||
_loadStream();
|
_loadStream();
|
||||||
|
|
||||||
if (widget.streamType == model.StreamType.live) {
|
if (widget.streamType == model.StreamType.live) {
|
||||||
@@ -160,6 +211,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
|
|
||||||
Future<void> _loadStream() async {
|
Future<void> _loadStream() async {
|
||||||
if (_xtreamService == null) return;
|
if (_xtreamService == null) return;
|
||||||
|
// Backgrounded while a (re)load was queued — do not resurrect the stream.
|
||||||
|
if (_isReleased || !mounted) return;
|
||||||
|
|
||||||
await _controller?.dispose();
|
await _controller?.dispose();
|
||||||
_controller = null;
|
_controller = null;
|
||||||
@@ -298,15 +351,95 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
|
|
||||||
void _switchChannel(int index) {
|
void _switchChannel(int index) {
|
||||||
if (widget.channels == null) return;
|
if (widget.channels == null) return;
|
||||||
setState(() => _currentIndex = index);
|
setState(() {
|
||||||
|
_currentIndex = index;
|
||||||
|
_showChannelList = false; // close sidebar on switch
|
||||||
|
});
|
||||||
|
_channelListTimer?.cancel();
|
||||||
_onUserInteraction(); // Ensure OSD shows up and timer resets on channel change
|
_onUserInteraction(); // Ensure OSD shows up and timer resets on channel change
|
||||||
_loadStream();
|
_loadStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open/close the channel list sidebar
|
||||||
|
void _toggleChannelList() {
|
||||||
|
setState(() => _showChannelList = !_showChannelList);
|
||||||
|
if (_showChannelList) {
|
||||||
|
_scrollToCurrentChannel();
|
||||||
|
_restartChannelListTimer();
|
||||||
|
// Focus direct sur la chaîne en cours : la télécommande navigue dans la
|
||||||
|
// liste sans étape intermédiaire. Second essai après l'animation de
|
||||||
|
// scroll (300 ms), l'item actif pouvant ne pas encore être construit.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted && _showChannelList) {
|
||||||
|
_channelListItemFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Future.delayed(const Duration(milliseconds: 400), () {
|
||||||
|
if (mounted &&
|
||||||
|
_showChannelList &&
|
||||||
|
!_channelListItemFocusNode.hasFocus) {
|
||||||
|
_channelListItemFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (Re)arme l'auto-fermeture de la sidebar. Relancé à chaque interaction
|
||||||
|
/// (focus D-pad, scroll) pour ne pas fermer la liste sous les doigts.
|
||||||
|
void _restartChannelListTimer() {
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
_channelListTimer = Timer(const Duration(seconds: 6), () {
|
||||||
|
if (mounted) setState(() => _showChannelList = false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scroll channel list to keep current channel visible
|
||||||
|
void _scrollToCurrentChannel() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!_channelListScrollController.hasClients) return;
|
||||||
|
const itemHeight = 64.0;
|
||||||
|
final offset = (_currentIndex * itemHeight) -
|
||||||
|
(_channelListScrollController.position.viewportDimension / 2) +
|
||||||
|
(itemHeight / 2);
|
||||||
|
_channelListScrollController.animateTo(
|
||||||
|
offset.clamp(0, _channelListScrollController.position.maxScrollExtent),
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
bool _handleKeyEvent(KeyEvent event) {
|
bool _handleKeyEvent(KeyEvent event) {
|
||||||
if (event is! KeyDownEvent) return false;
|
if (event is! KeyDownEvent) return false;
|
||||||
final key = event.logicalKey;
|
final key = event.logicalKey;
|
||||||
|
|
||||||
|
// Sidebar ouverte : Back/Menu la ferment, le reste part au système de
|
||||||
|
// Focus (navigation + OK sur un item). On relance juste l'auto-fermeture.
|
||||||
|
if (_showChannelList) {
|
||||||
|
if (key == LogicalKeyboardKey.escape ||
|
||||||
|
key == LogicalKeyboardKey.goBack ||
|
||||||
|
key == LogicalKeyboardKey.contextMenu ||
|
||||||
|
key == LogicalKeyboardKey.keyM) {
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
setState(() => _showChannelList = false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_restartChannelListTimer();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu key or 'M' → toggle channel list sidebar
|
||||||
|
if (key == LogicalKeyboardKey.contextMenu ||
|
||||||
|
key == LogicalKeyboardKey.keyM) {
|
||||||
|
if (widget.streamType == model.StreamType.live &&
|
||||||
|
widget.channels != null) {
|
||||||
|
_toggleChannelList();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (key == LogicalKeyboardKey.channelUp) {
|
if (key == LogicalKeyboardKey.channelUp) {
|
||||||
if (widget.channels != null) {
|
if (widget.channels != null) {
|
||||||
_switchChannel((_currentIndex + 1) % widget.channels!.length);
|
_switchChannel((_currentIndex + 1) % widget.channels!.length);
|
||||||
@@ -356,9 +489,18 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
_epgTimer?.cancel();
|
_epgTimer?.cancel();
|
||||||
_controlsTimer?.cancel();
|
_controlsTimer?.cancel();
|
||||||
_stabilizationTimer?.cancel();
|
_stabilizationTimer?.cancel();
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
_channelListScrollController.dispose();
|
||||||
_controller?.pause(); // Force stop audio immediately
|
_controller?.pause(); // Force stop audio immediately
|
||||||
_controller?.dispose();
|
_controller?.dispose();
|
||||||
_xtreamService?.dispose();
|
_xtreamService?.dispose();
|
||||||
|
|
||||||
|
_playPauseFocusNode.dispose();
|
||||||
|
_prevFocusNode.dispose();
|
||||||
|
_nextFocusNode.dispose();
|
||||||
|
_channelListButtonFocusNode.dispose();
|
||||||
|
_channelListItemFocusNode.dispose();
|
||||||
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,9 +512,26 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
: widget.title;
|
: widget.title;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
// True black behind the video surface, never the warm ink.
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: GestureDetector(
|
body: GestureDetector(
|
||||||
onTap: _onUserInteraction,
|
onTap: () {
|
||||||
|
if (_showChannelList) {
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
setState(() => _showChannelList = false);
|
||||||
|
} else {
|
||||||
|
_onUserInteraction();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Swipe from right edge → open channel list
|
||||||
|
onHorizontalDragEnd: (details) {
|
||||||
|
if (widget.streamType == model.StreamType.live &&
|
||||||
|
widget.channels != null &&
|
||||||
|
details.primaryVelocity != null &&
|
||||||
|
details.primaryVelocity! < -300) {
|
||||||
|
_toggleChannelList();
|
||||||
|
}
|
||||||
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Video Surface
|
// Video Surface
|
||||||
@@ -407,7 +566,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.arrow_back,
|
Icons.arrow_back,
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
size: 28,
|
size: 28,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -426,7 +585,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
child: Text(
|
child: Text(
|
||||||
_currentTime,
|
_currentTime,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -449,7 +608,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_errorMessage!,
|
_errorMessage!,
|
||||||
style: const TextStyle(color: Colors.red),
|
style: const TextStyle(color: AppColors.error),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -462,6 +621,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
|
|
||||||
// OSD (EPG + Controls)
|
// OSD (EPG + Controls)
|
||||||
if (_showControls) _buildOSD(title),
|
if (_showControls) _buildOSD(title),
|
||||||
|
|
||||||
|
// [TiviMate] Channel list sidebar
|
||||||
|
if (_showChannelList &&
|
||||||
|
widget.streamType == model.StreamType.live &&
|
||||||
|
widget.channels != null &&
|
||||||
|
widget.channels!.isNotEmpty)
|
||||||
|
_buildChannelListSidebar(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -508,7 +674,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withOpacity(0.7),
|
color: Colors.black.withOpacity(0.7),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: Colors.white10),
|
border: Border.all(color: AppColors.onSurface12),
|
||||||
),
|
),
|
||||||
child: IntrinsicHeight(
|
child: IntrinsicHeight(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -533,7 +699,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.skip_previous,
|
Icons.skip_previous,
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
size: 36,
|
size: 36,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -545,7 +711,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
_isPlaying
|
_isPlaying
|
||||||
? Icons.pause_circle_filled
|
? Icons.pause_circle_filled
|
||||||
: Icons.play_circle_filled,
|
: Icons.play_circle_filled,
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
size: 56,
|
size: 56,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -559,11 +725,25 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.skip_next,
|
Icons.skip_next,
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
size: 36,
|
size: 36,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// Channel list sidebar (liste des chaînes de la catégorie)
|
||||||
|
if (widget.streamType == model.StreamType.live &&
|
||||||
|
widget.channels != null &&
|
||||||
|
widget.channels!.isNotEmpty)
|
||||||
|
TVFocusable(
|
||||||
|
focusNode: _channelListButtonFocusNode,
|
||||||
|
onPressed: _toggleChannelList,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.playlist_play_rounded,
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Deinterlace Button (Switches to Native Player with forced Deinterlace)
|
// Deinterlace Button (Switches to Native Player with forced Deinterlace)
|
||||||
TVFocusable(
|
TVFocusable(
|
||||||
@@ -596,7 +776,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons
|
Icons
|
||||||
.grid_on, // Icon representing interlaced grid/mesh
|
.grid_on, // Icon representing interlaced grid/mesh
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -638,7 +818,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -664,7 +844,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: progress,
|
value: progress,
|
||||||
backgroundColor: Colors.white10,
|
backgroundColor: AppColors.onSurface12,
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
minHeight: 4,
|
minHeight: 4,
|
||||||
),
|
),
|
||||||
@@ -672,7 +852,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
next,
|
next,
|
||||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
style: const TextStyle(color: AppColors.onSurface54, fontSize: 12),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@@ -712,7 +892,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
min: 0,
|
min: 0,
|
||||||
max: _duration.inSeconds.toDouble(),
|
max: _duration.inSeconds.toDouble(),
|
||||||
activeColor: AppColors.primary,
|
activeColor: AppColors.primary,
|
||||||
inactiveColor: Colors.white24,
|
inactiveColor: AppColors.onSurface24,
|
||||||
onChanged: (v) => setState(() {
|
onChanged: (v) => setState(() {
|
||||||
_position = Duration(seconds: v.toInt());
|
_position = Duration(seconds: v.toInt());
|
||||||
_isSeeking = true;
|
_isSeeking = true;
|
||||||
@@ -727,4 +907,178 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [TiviMate] Channel list sidebar — slide-in panel on the right
|
||||||
|
Widget _buildChannelListSidebar() {
|
||||||
|
final channels = widget.channels!;
|
||||||
|
return Positioned(
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 280,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.centerRight,
|
||||||
|
end: Alignment.centerLeft,
|
||||||
|
colors: [Color(0xE6000000), Color(0x99000000)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: AppColors.onSurface12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.list,
|
||||||
|
color: AppColors.onSurface70, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Chaînes',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
setState(() => _showChannelList = false);
|
||||||
|
},
|
||||||
|
child: const Icon(Icons.close,
|
||||||
|
color: AppColors.onSurface54, size: 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Channel list
|
||||||
|
Expanded(
|
||||||
|
child: NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (_) {
|
||||||
|
_restartChannelListTimer();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _channelListScrollController,
|
||||||
|
itemCount: channels.length,
|
||||||
|
itemExtent: 64.0,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final ch = channels[index];
|
||||||
|
final isActive = index == _currentIndex;
|
||||||
|
return TVFocusable(
|
||||||
|
// Le focusNode externe n'est posé que sur la chaîne
|
||||||
|
// active ; la clé force un remontage quand isActive
|
||||||
|
// change (TVFocusable ne gère pas didUpdateWidget).
|
||||||
|
key: ValueKey('sidebar-ch-$index-$isActive'),
|
||||||
|
focusNode: isActive ? _channelListItemFocusNode : null,
|
||||||
|
scale: 1.0,
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
onFocus: _restartChannelListTimer,
|
||||||
|
onPressed: () {
|
||||||
|
_channelListTimer?.cancel();
|
||||||
|
_switchChannel(index);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive
|
||||||
|
? AppColors.primaryFill.withValues(alpha: 0.6)
|
||||||
|
: Colors.transparent,
|
||||||
|
border: isActive
|
||||||
|
? const Border(
|
||||||
|
left: BorderSide(
|
||||||
|
color: AppColors.primary,
|
||||||
|
width: 3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Logo
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
margin: const EdgeInsets.only(right: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.onSurface12,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: ch.streamIcon.isNotEmpty
|
||||||
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: ch.streamIcon,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
const Icon(Icons.tv,
|
||||||
|
color: AppColors.onSurface38,
|
||||||
|
size: 20),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.tv,
|
||||||
|
color: AppColors.onSurface38, size: 20),
|
||||||
|
),
|
||||||
|
// Name + num
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
ch.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isActive
|
||||||
|
? AppColors.onSurface
|
||||||
|
: AppColors.onSurface70,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isActive
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'CH ${index + 1}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.onSurface38,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Active indicator
|
||||||
|
if (isActive)
|
||||||
|
const Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
color: AppColors.primary,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
|
|||||||
? 'Playlist modifiée'
|
? 'Playlist modifiée'
|
||||||
: 'Playlist ajoutée',
|
: 'Playlist ajoutée',
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.green,
|
backgroundColor: AppColors.success,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class _MobilePlaylistSelectionScreenState
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Erreur de chargement',
|
'Erreur de chargement',
|
||||||
style: GoogleFonts.inter(color: Colors.white),
|
style: GoogleFonts.inter(color: AppColors.onSurface),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => ref.invalidate(playlistsProvider),
|
onPressed: () => ref.invalidate(playlistsProvider),
|
||||||
@@ -143,14 +143,14 @@ class _MobilePlaylistSelectionScreenState
|
|||||||
const Icon(
|
const Icon(
|
||||||
Icons.playlist_add,
|
Icons.playlist_add,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Colors.white54,
|
color: AppColors.onSurface54,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
'Aucune playlist configurée',
|
'Aucune playlist configurée',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: Colors.white70,
|
color: AppColors.onSurface70,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
@@ -163,18 +163,18 @@ class _MobilePlaylistSelectionScreenState
|
|||||||
vertical: 14,
|
vertical: 14,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF0A84FF),
|
color: AppColors.primaryFill,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.add, color: Colors.white, size: 20),
|
Icon(Icons.add, color: AppColors.onSurface, size: 20),
|
||||||
SizedBox(width: 10),
|
SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
'Ajouter une Playlist',
|
'Ajouter une Playlist',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
),
|
),
|
||||||
@@ -268,21 +268,21 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
title: Text(label, style: const TextStyle(color: Colors.white)),
|
title: Text(label, style: const TextStyle(color: AppColors.onSurface)),
|
||||||
content: TextField(
|
content: TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
obscureText: obscure,
|
obscureText: obscure,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: AppColors.onSurface),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelStyle: TextStyle(color: Colors.white70),
|
labelStyle: TextStyle(color: AppColors.onSurface70),
|
||||||
enabledBorder: UnderlineInputBorder(
|
enabledBorder: UnderlineInputBorder(
|
||||||
borderSide: BorderSide(color: Colors.white24),
|
borderSide: BorderSide(color: AppColors.onSurface24),
|
||||||
),
|
),
|
||||||
focusedBorder: UnderlineInputBorder(
|
focusedBorder: UnderlineInputBorder(
|
||||||
borderSide: BorderSide(color: Colors.white),
|
borderSide: BorderSide(color: AppColors.onSurface),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onSubmitted: (val) => Navigator.pop(ctx, val),
|
onSubmitted: (val) => Navigator.pop(ctx, val),
|
||||||
@@ -327,7 +327,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.07),
|
color: AppColors.onSurface.withOpacity(0.07),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -341,7 +341,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white70,
|
color: AppColors.onSurface70,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -350,7 +350,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
Text(
|
Text(
|
||||||
displayValue,
|
displayValue,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: value.isEmpty ? Colors.white38 : Colors.white,
|
color: value.isEmpty ? AppColors.onSurface38 : AppColors.onSurface,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -360,7 +360,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(Icons.edit, color: Colors.white38, size: 18),
|
const Icon(Icons.edit, color: AppColors.onSurface38, size: 18),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -374,7 +374,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
title: Text(
|
title: Text(
|
||||||
widget.isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
|
widget.isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
|
||||||
style: GoogleFonts.inter(color: Colors.white),
|
style: GoogleFonts.inter(color: AppColors.onSurface),
|
||||||
),
|
),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: 480,
|
width: 480,
|
||||||
@@ -435,8 +435,8 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
title: const Text('Confirmer', style: TextStyle(color: Colors.white)),
|
title: const Text('Confirmer', style: TextStyle(color: AppColors.onSurface)),
|
||||||
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: Colors.white70)),
|
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: AppColors.onSurface70)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
@@ -444,7 +444,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||||
child: const Text('Supprimer'),
|
child: const Text('Supprimer'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -458,7 +458,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
child: Text('Supprimer', style: TextStyle(color: Colors.red)),
|
child: Text('Supprimer', style: TextStyle(color: AppColors.error)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TVFocusable(
|
TVFocusable(
|
||||||
@@ -466,7 +466,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
child: Text('Annuler', style: TextStyle(color: Colors.white70)),
|
child: Text('Annuler', style: TextStyle(color: AppColors.onSurface70)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TVFocusable(
|
TVFocusable(
|
||||||
@@ -482,14 +482,14 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _name.isEmpty || _dns.isEmpty
|
color: _name.isEmpty || _dns.isEmpty
|
||||||
? Colors.white12
|
? AppColors.onSurface12
|
||||||
: AppColors.primary,
|
: AppColors.primary,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_isSaving ? '...' : 'Enregistrer',
|
_isSaving ? '...' : 'Enregistrer',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class _MobileSeriesDetailScreenState
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
icon: const Icon(Icons.arrow_back, color: AppColors.onSurface),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
tooltip: 'Back',
|
tooltip: 'Back',
|
||||||
),
|
),
|
||||||
@@ -149,7 +149,7 @@ class _MobileSeriesDetailScreenState
|
|||||||
maxHeightDiskCache: 720,
|
maxHeightDiskCache: 720,
|
||||||
cacheManager: AppCacheManager.instance,
|
cacheManager: AppCacheManager.instance,
|
||||||
errorWidget: (_, __, ___) =>
|
errorWidget: (_, __, ___) =>
|
||||||
Container(color: Colors.grey.shade900),
|
Container(color: AppColors.surfaceContainerLowest),
|
||||||
),
|
),
|
||||||
// Gradient Overlay
|
// Gradient Overlay
|
||||||
Container(
|
Container(
|
||||||
@@ -190,7 +190,7 @@ class _MobileSeriesDetailScreenState
|
|||||||
const Icon(
|
const Icon(
|
||||||
Icons.star,
|
Icons.star,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: Colors.amber,
|
color: AppColors.ratingGold,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -261,15 +261,15 @@ class _MobileSeriesDetailScreenState
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
? const Color(0xFF1C1C1E)
|
? AppColors.surfaceContainer
|
||||||
: Colors.grey.shade300,
|
: AppColors.surfaceContainerHigh,
|
||||||
selectedColor: isDark ? Colors.white : Colors.blue,
|
selectedColor: isDark ? AppColors.onSurface : AppColors.primaryFill,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? (isDark ? Colors.black : Colors.white)
|
? (isDark ? AppColors.onPrimaryFixed : AppColors.onSurface)
|
||||||
: (isDark
|
: (isDark
|
||||||
? Colors.grey.shade400
|
? AppColors.textSecondary
|
||||||
: Colors.grey.shade700),
|
: AppColors.outlineVariant),
|
||||||
fontWeight:
|
fontWeight:
|
||||||
isSelected ? FontWeight.w600 : FontWeight.w400,
|
isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||||
),
|
),
|
||||||
@@ -323,10 +323,10 @@ class _MobileSeriesDetailScreenState
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Force dark mode colors since background is hardcoded dark
|
// Force dark mode colors since background is hardcoded dark
|
||||||
const textColor = Colors.white;
|
const textColor = AppColors.onSurface;
|
||||||
final secondaryTextColor = Colors.grey.shade400;
|
final secondaryTextColor = AppColors.textSecondary;
|
||||||
const surfaceColor = Color(0xFF1C1C1E);
|
const surfaceColor = AppColors.surfaceContainer;
|
||||||
const primaryColor = Colors.white; // Or accent color if preferred
|
const primaryColor = AppColors.onSurface; // Or accent color if preferred
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -356,7 +356,7 @@ class _MobileSeriesDetailScreenState
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
border: Border(bottom: BorderSide(color: Colors.white10)),
|
border: Border(bottom: BorderSide(color: AppColors.onSurface12)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -374,7 +374,7 @@ class _MobileSeriesDetailScreenState
|
|||||||
child: Icon(
|
child: Icon(
|
||||||
isWatched ? Icons.check : Icons.play_arrow,
|
isWatched ? Icons.check : Icons.play_arrow,
|
||||||
color: isWatched
|
color: isWatched
|
||||||
? Colors.black // Checkmark on white bg
|
? AppColors.onPrimaryFixed // Checkmark on white bg
|
||||||
: primaryColor, // Play icon
|
: primaryColor, // Play icon
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
@@ -413,7 +413,7 @@ class _MobileSeriesDetailScreenState
|
|||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: progress,
|
value: progress,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
|
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.error),
|
||||||
minHeight: 2,
|
minHeight: 2,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,628 @@
|
|||||||
|
/// XMLTV EPG fallback service
|
||||||
|
///
|
||||||
|
/// Used when the IPTV provider's `get_short_epg` returns nothing for a channel.
|
||||||
|
/// Resolution order is decided by the caller (see [XtremServiceMobile]); this
|
||||||
|
/// service only knows how to fetch, parse, index and cache one XMLTV source.
|
||||||
|
///
|
||||||
|
/// Design constraints:
|
||||||
|
/// - XMLTV dumps are routinely 50-300 MB uncompressed. Parsing is done as a
|
||||||
|
/// **streaming** pass (`toXmlEvents`) so the whole document is never held in
|
||||||
|
/// memory, and only programmes inside a short time window are kept.
|
||||||
|
/// - The reduced index is persisted to disk, so a cold start costs one small
|
||||||
|
/// JSON read instead of a multi-megabyte download.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
import 'package:xml/xml_events.dart';
|
||||||
|
|
||||||
|
import 'package:xtremobile/core/models/iptv_models.dart' as model;
|
||||||
|
|
||||||
|
/// One XMLTV source to try, in priority order.
|
||||||
|
class XmltvSource {
|
||||||
|
/// Human-readable origin, surfaced in settings/diagnostics.
|
||||||
|
final String label;
|
||||||
|
final String url;
|
||||||
|
final Map<String, String>? headers;
|
||||||
|
|
||||||
|
const XmltvSource({
|
||||||
|
required this.label,
|
||||||
|
required this.url,
|
||||||
|
this.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single programme kept in the in-memory index.
|
||||||
|
class XmltvProgramme {
|
||||||
|
final DateTime start;
|
||||||
|
final DateTime end;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
const XmltvProgramme({
|
||||||
|
required this.start,
|
||||||
|
required this.end,
|
||||||
|
required this.title,
|
||||||
|
this.description = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
bool covers(DateTime at) => !at.isBefore(start) && at.isBefore(end);
|
||||||
|
|
||||||
|
List<dynamic> toCache() => [
|
||||||
|
start.toIso8601String(),
|
||||||
|
end.toIso8601String(),
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
];
|
||||||
|
|
||||||
|
static XmltvProgramme? fromCache(dynamic raw) {
|
||||||
|
if (raw is! List || raw.length < 4) return null;
|
||||||
|
final start = DateTime.tryParse(raw[0].toString());
|
||||||
|
final end = DateTime.tryParse(raw[1].toString());
|
||||||
|
if (start == null || end == null) return null;
|
||||||
|
return XmltvProgramme(
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
title: raw[2].toString(),
|
||||||
|
description: raw[3].toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class XmltvService {
|
||||||
|
XmltvService(this.cacheDir);
|
||||||
|
|
||||||
|
final String cacheDir;
|
||||||
|
|
||||||
|
/// How long a downloaded+indexed guide stays usable before a refetch.
|
||||||
|
static const Duration cacheTtl = Duration(hours: 6);
|
||||||
|
|
||||||
|
/// Only programmes overlapping [now - back, now + forward] are indexed.
|
||||||
|
/// Keeps a national guide down to a few hundred KB instead of hundreds of MB.
|
||||||
|
static const Duration windowBack = Duration(hours: 3);
|
||||||
|
static const Duration windowForward = Duration(hours: 18);
|
||||||
|
|
||||||
|
/// Hard ceiling on the download, so a hostile or broken URL can't fill
|
||||||
|
/// the device storage or spin forever.
|
||||||
|
static const int maxDownloadBytes = 220 * 1024 * 1024;
|
||||||
|
|
||||||
|
final Map<String, List<XmltvProgramme>> _byChannelId = {};
|
||||||
|
final Map<String, String> _normalizedNameToChannelId = {};
|
||||||
|
|
||||||
|
String? _loadedSourceLabel;
|
||||||
|
DateTime? _loadedAt;
|
||||||
|
Future<bool>? _inFlight;
|
||||||
|
|
||||||
|
/// True once at least one source has been indexed successfully.
|
||||||
|
bool get isLoaded => _byChannelId.isNotEmpty;
|
||||||
|
|
||||||
|
/// Label of the source currently backing the index (`null` if none).
|
||||||
|
String? get loadedSourceLabel => _loadedSourceLabel;
|
||||||
|
|
||||||
|
int get channelCount => _byChannelId.length;
|
||||||
|
|
||||||
|
bool get _isStale {
|
||||||
|
final at = _loadedAt;
|
||||||
|
if (at == null) return true;
|
||||||
|
return DateTime.now().difference(at) > cacheTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the first source that yields a usable guide.
|
||||||
|
///
|
||||||
|
/// Concurrent callers share a single in-flight attempt. Returns `false` when
|
||||||
|
/// every source failed — callers should then simply render "no EPG".
|
||||||
|
Future<bool> ensureLoaded(List<XmltvSource> sources) {
|
||||||
|
if (isLoaded && !_isStale) return Future.value(true);
|
||||||
|
return _inFlight ??= _loadFirstAvailable(sources).whenComplete(() {
|
||||||
|
_inFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _loadFirstAvailable(List<XmltvSource> sources) async {
|
||||||
|
for (final source in sources) {
|
||||||
|
if (source.url.trim().isEmpty) continue;
|
||||||
|
try {
|
||||||
|
if (await _loadFromDiskCache(source)) {
|
||||||
|
_loadedSourceLabel = source.label;
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
'✅ [XMLTV] Disk cache hit "${source.label}" '
|
||||||
|
'($channelCount channels)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await _download(source)) {
|
||||||
|
_loadedSourceLabel = source.label;
|
||||||
|
_loadedAt = DateTime.now();
|
||||||
|
unawaited(_writeDiskCache(source));
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(
|
||||||
|
'✅ [XMLTV] Indexed "${source.label}" '
|
||||||
|
'($channelCount channels)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ [XMLTV] "${source.label}" returned no usable programme');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) print('⚠️ [XMLTV] "${source.label}" failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lookup ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Find the programme airing at [at] for a channel.
|
||||||
|
///
|
||||||
|
/// Matching is tried on the Xtream `epg_channel_id` first (the reliable key),
|
||||||
|
/// then on a normalized channel name, which is what rescues providers that
|
||||||
|
/// ship empty or non-standard `epg_channel_id` values.
|
||||||
|
XmltvProgramme? programmeFor({
|
||||||
|
String? epgChannelId,
|
||||||
|
String? channelName,
|
||||||
|
DateTime? at,
|
||||||
|
}) {
|
||||||
|
final moment = at ?? DateTime.now();
|
||||||
|
final programmes = _programmesFor(
|
||||||
|
epgChannelId: epgChannelId,
|
||||||
|
channelName: channelName,
|
||||||
|
);
|
||||||
|
if (programmes == null || programmes.isEmpty) return null;
|
||||||
|
|
||||||
|
for (final programme in programmes) {
|
||||||
|
if (programme.covers(moment)) return programme;
|
||||||
|
}
|
||||||
|
// Nothing airing right now (guide gap): fall back to the next entry so the
|
||||||
|
// UI shows something meaningful rather than an empty slot.
|
||||||
|
for (final programme in programmes) {
|
||||||
|
if (programme.start.isAfter(moment)) return programme;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same lookup, shaped as the app's [model.ShortEPG].
|
||||||
|
model.ShortEPG? shortEpgFor({
|
||||||
|
required String streamId,
|
||||||
|
String? epgChannelId,
|
||||||
|
String? channelName,
|
||||||
|
DateTime? at,
|
||||||
|
}) {
|
||||||
|
final programme = programmeFor(
|
||||||
|
epgChannelId: epgChannelId,
|
||||||
|
channelName: channelName,
|
||||||
|
at: at,
|
||||||
|
);
|
||||||
|
if (programme == null || programme.title.isEmpty) return null;
|
||||||
|
|
||||||
|
return model.ShortEPG(
|
||||||
|
id: streamId,
|
||||||
|
title: programme.title,
|
||||||
|
start: _formatLocal(programme.start),
|
||||||
|
end: _formatLocal(programme.end),
|
||||||
|
description: programme.description,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<XmltvProgramme>? _programmesFor({
|
||||||
|
String? epgChannelId,
|
||||||
|
String? channelName,
|
||||||
|
}) {
|
||||||
|
final id = epgChannelId?.trim().toLowerCase();
|
||||||
|
if (id != null && id.isNotEmpty) {
|
||||||
|
final direct = _byChannelId[id];
|
||||||
|
if (direct != null && direct.isNotEmpty) return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalized = normalizeName(channelName ?? '');
|
||||||
|
if (normalized.isEmpty) return null;
|
||||||
|
final mapped = _normalizedNameToChannelId[normalized];
|
||||||
|
if (mapped == null) return null;
|
||||||
|
return _byChannelId[mapped];
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_byChannelId.clear();
|
||||||
|
_normalizedNameToChannelId.clear();
|
||||||
|
_loadedSourceLabel = null;
|
||||||
|
_loadedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Download & streaming parse ────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<bool> _download(XmltvSource source) async {
|
||||||
|
final uri = Uri.tryParse(source.url.trim());
|
||||||
|
if (uri == null || !uri.hasScheme) return false;
|
||||||
|
|
||||||
|
final client = HttpClient()
|
||||||
|
..connectionTimeout = const Duration(seconds: 20)
|
||||||
|
// `.xml.gz` bodies arrive as a gzip *payload*, not a gzip
|
||||||
|
// Content-Encoding, so decompression is handled explicitly below.
|
||||||
|
..autoUncompress = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final request = await client.getUrl(uri);
|
||||||
|
request.headers.set(HttpHeaders.acceptEncodingHeader, 'gzip');
|
||||||
|
request.headers.set(HttpHeaders.userAgentHeader, 'XtremFlow/1.0');
|
||||||
|
source.headers?.forEach(request.headers.set);
|
||||||
|
|
||||||
|
final response = await request.close().timeout(
|
||||||
|
const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ [XMLTV] HTTP ${response.statusCode} on ${source.label}');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexFromBytes(_capped(response));
|
||||||
|
} on TimeoutException {
|
||||||
|
if (kDebugMode) print('⏱️ [XMLTV] Timeout on ${source.label}');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
client.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse and index an XMLTV payload from a byte stream.
|
||||||
|
///
|
||||||
|
/// Split out of [_download] so the parsing path is testable without HTTP.
|
||||||
|
/// Gzip is detected from the payload itself, and only programmes overlapping
|
||||||
|
/// the current window are retained.
|
||||||
|
@visibleForTesting
|
||||||
|
Future<bool> indexFromBytes(
|
||||||
|
Stream<List<int>> bytes, {
|
||||||
|
DateTime? now,
|
||||||
|
}) async {
|
||||||
|
final window = _Window.around(now ?? DateTime.now());
|
||||||
|
final channelNames = <String, List<String>>{};
|
||||||
|
final programmes = <String, List<XmltvProgramme>>{};
|
||||||
|
|
||||||
|
final events = _decompressed(bytes)
|
||||||
|
.transform(const Utf8Decoder(allowMalformed: true))
|
||||||
|
.toXmlEvents()
|
||||||
|
.normalizeEvents()
|
||||||
|
.selectSubtreeEvents(
|
||||||
|
(event) => event.name == 'channel' || event.name == 'programme',
|
||||||
|
)
|
||||||
|
.toXmlNodes()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
await for (final node in events) {
|
||||||
|
if (node is! XmlElement) continue;
|
||||||
|
if (node.name.local == 'channel') {
|
||||||
|
_indexChannelElement(node, channelNames);
|
||||||
|
} else {
|
||||||
|
_indexProgrammeElement(node, window, programmes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (programmes.isEmpty) return false;
|
||||||
|
|
||||||
|
_commit(programmes: programmes, channelNames: channelNames);
|
||||||
|
_loadedAt = DateTime.now();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _indexChannelElement(
|
||||||
|
XmlElement node,
|
||||||
|
Map<String, List<String>> channelNames,
|
||||||
|
) {
|
||||||
|
final id = node.getAttribute('id')?.trim().toLowerCase();
|
||||||
|
if (id == null || id.isEmpty) return;
|
||||||
|
final names = node
|
||||||
|
.findElements('display-name')
|
||||||
|
.map((e) => e.innerText.trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (names.isEmpty) return;
|
||||||
|
channelNames.putIfAbsent(id, () => []).addAll(names);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _indexProgrammeElement(
|
||||||
|
XmlElement node,
|
||||||
|
_Window window,
|
||||||
|
Map<String, List<XmltvProgramme>> programmes,
|
||||||
|
) {
|
||||||
|
final channel = node.getAttribute('channel')?.trim().toLowerCase();
|
||||||
|
if (channel == null || channel.isEmpty) return;
|
||||||
|
|
||||||
|
final start = parseXmltvTime(node.getAttribute('start'));
|
||||||
|
final end = parseXmltvTime(node.getAttribute('stop'));
|
||||||
|
if (start == null || end == null) return;
|
||||||
|
if (!window.overlaps(start, end)) return;
|
||||||
|
|
||||||
|
final title = node
|
||||||
|
.findElements('title')
|
||||||
|
.map((e) => e.innerText.trim())
|
||||||
|
.firstWhere((e) => e.isNotEmpty, orElse: () => '')
|
||||||
|
.trim();
|
||||||
|
if (title.isEmpty) return;
|
||||||
|
|
||||||
|
final description = node
|
||||||
|
.findElements('desc')
|
||||||
|
.map((e) => e.innerText.trim())
|
||||||
|
.firstWhere((e) => e.isNotEmpty, orElse: () => '');
|
||||||
|
|
||||||
|
programmes.putIfAbsent(channel, () => []).add(
|
||||||
|
XmltvProgramme(
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _commit({
|
||||||
|
required Map<String, List<XmltvProgramme>> programmes,
|
||||||
|
required Map<String, List<String>> channelNames,
|
||||||
|
}) {
|
||||||
|
_byChannelId
|
||||||
|
..clear()
|
||||||
|
..addAll(programmes);
|
||||||
|
for (final list in _byChannelId.values) {
|
||||||
|
list.sort((a, b) => a.start.compareTo(b.start));
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizedNameToChannelId.clear();
|
||||||
|
// Channel ids are themselves readable labels in most community dumps
|
||||||
|
// (`France.2..fr`, `Trace.Toca.fr`), so they are indexed as name
|
||||||
|
// candidates too — this rescues channels that carry no <display-name>.
|
||||||
|
for (final id in _byChannelId.keys) {
|
||||||
|
final fromId = normalizeName(_labelFromChannelId(id));
|
||||||
|
if (fromId.isNotEmpty) {
|
||||||
|
_normalizedNameToChannelId.putIfAbsent(fromId, () => id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channelNames.forEach((id, names) {
|
||||||
|
if (!_byChannelId.containsKey(id)) return;
|
||||||
|
for (final name in names) {
|
||||||
|
final normalized = normalizeName(name);
|
||||||
|
if (normalized.isEmpty) continue;
|
||||||
|
_normalizedNameToChannelId[normalized] = id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforce [maxDownloadBytes] mid-stream.
|
||||||
|
Stream<List<int>> _capped(Stream<List<int>> input) async* {
|
||||||
|
var total = 0;
|
||||||
|
await for (final chunk in input) {
|
||||||
|
total += chunk.length;
|
||||||
|
if (total > maxDownloadBytes) {
|
||||||
|
throw StateError('XMLTV payload exceeds ${maxDownloadBytes ~/ 1048576}MB');
|
||||||
|
}
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transparently gunzip when the payload starts with the gzip magic number.
|
||||||
|
Stream<List<int>> _decompressed(Stream<List<int>> input) async* {
|
||||||
|
final iterator = StreamIterator(input);
|
||||||
|
try {
|
||||||
|
if (!await iterator.moveNext()) return;
|
||||||
|
|
||||||
|
var head = iterator.current;
|
||||||
|
while (head.length < 2 && await iterator.moveNext()) {
|
||||||
|
head = [...head, ...iterator.current];
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<List<int>> replayed() async* {
|
||||||
|
yield head;
|
||||||
|
while (await iterator.moveNext()) {
|
||||||
|
yield iterator.current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final isGzip = head.length >= 2 && head[0] == 0x1f && head[1] == 0x8b;
|
||||||
|
yield* isGzip ? gzip.decoder.bind(replayed()) : replayed();
|
||||||
|
} finally {
|
||||||
|
await iterator.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Disk cache ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
File _cacheFile(XmltvSource source) {
|
||||||
|
final key = source.url.hashCode.toUnsigned(32).toRadixString(16);
|
||||||
|
return File('$cacheDir${Platform.pathSeparator}xmltv_$key.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _loadFromDiskCache(XmltvSource source) async {
|
||||||
|
final file = _cacheFile(source);
|
||||||
|
if (!await file.exists()) return false;
|
||||||
|
|
||||||
|
final fetchedAt = await file.lastModified();
|
||||||
|
if (DateTime.now().difference(fetchedAt) > cacheTtl) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final decoded = json.decode(await file.readAsString());
|
||||||
|
if (decoded is! Map) return false;
|
||||||
|
|
||||||
|
final rawChannels = decoded['channels'];
|
||||||
|
if (rawChannels is! Map) return false;
|
||||||
|
|
||||||
|
final programmes = <String, List<XmltvProgramme>>{};
|
||||||
|
rawChannels.forEach((id, entries) {
|
||||||
|
if (entries is! List) return;
|
||||||
|
final parsed = entries
|
||||||
|
.map(XmltvProgramme.fromCache)
|
||||||
|
.whereType<XmltvProgramme>()
|
||||||
|
.toList();
|
||||||
|
if (parsed.isNotEmpty) programmes[id.toString()] = parsed;
|
||||||
|
});
|
||||||
|
if (programmes.isEmpty) return false;
|
||||||
|
|
||||||
|
final rawNames = decoded['names'];
|
||||||
|
final channelNames = <String, List<String>>{};
|
||||||
|
if (rawNames is Map) {
|
||||||
|
rawNames.forEach((normalized, id) {
|
||||||
|
channelNames
|
||||||
|
.putIfAbsent(id.toString(), () => [])
|
||||||
|
.add(normalized.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_commit(programmes: programmes, channelNames: channelNames);
|
||||||
|
_loadedAt = fetchedAt;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) print('⚠️ [XMLTV] Corrupt disk cache, ignoring: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeDiskCache(XmltvSource source) async {
|
||||||
|
try {
|
||||||
|
final payload = {
|
||||||
|
'source': source.label,
|
||||||
|
'url': source.url,
|
||||||
|
'fetchedAt': DateTime.now().toIso8601String(),
|
||||||
|
'channels': _byChannelId.map(
|
||||||
|
(id, list) => MapEntry(id, list.map((p) => p.toCache()).toList()),
|
||||||
|
),
|
||||||
|
'names': _normalizedNameToChannelId,
|
||||||
|
};
|
||||||
|
await _cacheFile(source).writeAsString(json.encode(payload), flush: true);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) print('⚠️ [XMLTV] Could not persist cache: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Parse an XMLTV timestamp: `20260811183000 +0200` (offset optional).
|
||||||
|
static DateTime? parseXmltvTime(String? raw) {
|
||||||
|
final value = raw?.trim();
|
||||||
|
if (value == null || value.length < 14) return null;
|
||||||
|
|
||||||
|
int? part(int start, int end) => int.tryParse(value.substring(start, end));
|
||||||
|
|
||||||
|
final year = part(0, 4);
|
||||||
|
final month = part(4, 6);
|
||||||
|
final day = part(6, 8);
|
||||||
|
final hour = part(8, 10);
|
||||||
|
final minute = part(10, 12);
|
||||||
|
final second = part(12, 14);
|
||||||
|
if (year == null ||
|
||||||
|
month == null ||
|
||||||
|
day == null ||
|
||||||
|
hour == null ||
|
||||||
|
minute == null ||
|
||||||
|
second == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final utc = DateTime.utc(year, month, day, hour, minute, second);
|
||||||
|
|
||||||
|
final offset = value.substring(14).trim();
|
||||||
|
if (offset.isEmpty) return utc.toLocal();
|
||||||
|
|
||||||
|
final match = RegExp(r'^([+-])(\d{2})(\d{2})$').firstMatch(offset);
|
||||||
|
if (match == null) return utc.toLocal();
|
||||||
|
|
||||||
|
final sign = match.group(1) == '-' ? -1 : 1;
|
||||||
|
final offsetDuration = Duration(
|
||||||
|
hours: int.parse(match.group(2)!),
|
||||||
|
minutes: int.parse(match.group(3)!),
|
||||||
|
);
|
||||||
|
return utc.subtract(offsetDuration * sign).toLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn an XMLTV channel id into the label it encodes.
|
||||||
|
///
|
||||||
|
/// `France.2..fr` → `France 2`, `Trace.Toca.fr` → `Trace Toca`.
|
||||||
|
static String _labelFromChannelId(String id) {
|
||||||
|
var value = id.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'\.(fr|uk|us|de|es|it|be|ch|ca|nl|pt|pl|ar|br|tr|ma|dz|tn)$',
|
||||||
|
caseSensitive: false,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
return value.replaceAll('.', ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduce a channel label to a comparable key.
|
||||||
|
///
|
||||||
|
/// Strips accents, quality suffixes (`HD`, `FHD`, `4K`, …) and any
|
||||||
|
/// punctuation, so `TF1 HD`, `tf1-fhd` and `TF1` all collapse to `tf1`.
|
||||||
|
static String normalizeName(String raw) {
|
||||||
|
var value = raw.toLowerCase().trim();
|
||||||
|
if (value.isEmpty) return '';
|
||||||
|
|
||||||
|
const accents = 'àáâãäåçèéêëìíîïñòóôõöùúûüýÿ';
|
||||||
|
const plain = 'aaaaaaceeeeiiiinooooouuuuyy';
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (final rune in value.runes) {
|
||||||
|
final char = String.fromCharCode(rune);
|
||||||
|
final index = accents.indexOf(char);
|
||||||
|
buffer.write(index >= 0 ? plain[index] : char);
|
||||||
|
}
|
||||||
|
value = buffer.toString();
|
||||||
|
|
||||||
|
// Drop the country prefix providers prepend.
|
||||||
|
// Bracketed form — "|FR| TF1", "[FR] TF1", "(FR) TF1".
|
||||||
|
value = value.replaceFirst(
|
||||||
|
RegExp(r'^\s*[\|\[\(][a-z]{2,4}[\|\]\)]\s*'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
// Bare form — "FR: TF1". A colon is required: accepting a dash too would
|
||||||
|
// eat legitimate names like "Ciné - Premier".
|
||||||
|
value = value.replaceFirst(RegExp(r'^\s*[a-z]{2,3}\s*:\s*'), '');
|
||||||
|
|
||||||
|
value = value.replaceAll(RegExp(r'[^a-z0-9+]'), '');
|
||||||
|
|
||||||
|
for (final suffix in const [
|
||||||
|
'fullhd',
|
||||||
|
'ultrahd',
|
||||||
|
'uhd',
|
||||||
|
'fhd',
|
||||||
|
'hdr',
|
||||||
|
'4k',
|
||||||
|
'1080p',
|
||||||
|
'720p',
|
||||||
|
'hd',
|
||||||
|
'sd',
|
||||||
|
]) {
|
||||||
|
if (value.length > suffix.length && value.endsWith(suffix)) {
|
||||||
|
value = value.substring(0, value.length - suffix.length);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _formatLocal(DateTime value) {
|
||||||
|
String two(int n) => n.toString().padLeft(2, '0');
|
||||||
|
return '${value.year}-${two(value.month)}-${two(value.day)} '
|
||||||
|
'${two(value.hour)}:${two(value.minute)}:${two(value.second)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Window {
|
||||||
|
final DateTime from;
|
||||||
|
final DateTime to;
|
||||||
|
|
||||||
|
const _Window(this.from, this.to);
|
||||||
|
|
||||||
|
factory _Window.around(DateTime now) => _Window(
|
||||||
|
now.subtract(XmltvService.windowBack),
|
||||||
|
now.add(XmltvService.windowForward),
|
||||||
|
);
|
||||||
|
|
||||||
|
bool overlaps(DateTime start, DateTime end) =>
|
||||||
|
end.isAfter(from) && start.isBefore(to);
|
||||||
|
}
|
||||||
@@ -7,8 +7,38 @@ library;
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import '../models/xtream_models.dart' as model;
|
import '../models/xtream_models.dart' as model;
|
||||||
|
import 'package:xtremobile/core/api/dns_resolver.dart';
|
||||||
import 'package:xtremobile/core/models/playlist_config.dart';
|
import 'package:xtremobile/core/models/playlist_config.dart';
|
||||||
|
import 'xmltv_service.dart';
|
||||||
|
|
||||||
|
/// Counting semaphore used to cap concurrent EPG requests.
|
||||||
|
class _Semaphore {
|
||||||
|
_Semaphore(this.maxConcurrent);
|
||||||
|
|
||||||
|
final int maxConcurrent;
|
||||||
|
int _current = 0;
|
||||||
|
final _waiting = <Completer<void>>[];
|
||||||
|
|
||||||
|
Future<void> acquire() {
|
||||||
|
if (_current < maxConcurrent) {
|
||||||
|
_current++;
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_waiting.add(completer);
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
void release() {
|
||||||
|
if (_waiting.isNotEmpty) {
|
||||||
|
_waiting.removeAt(0).complete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_current > 0) _current--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// EPG cache entry with TTL
|
/// EPG cache entry with TTL
|
||||||
class _EpgCacheEntry {
|
class _EpgCacheEntry {
|
||||||
@@ -22,19 +52,32 @@ class _EpgCacheEntry {
|
|||||||
DateTime.now().difference(timestamp).inSeconds > ttlSeconds;
|
DateTime.now().difference(timestamp).inSeconds > ttlSeconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Default community XMLTV guides, used only when the provider has no EPG of
|
||||||
|
/// its own. Ordered by preference; the first one that yields programmes wins.
|
||||||
|
const List<XmltvSource> kDefaultPublicXmltvSources = [
|
||||||
|
XmltvSource(
|
||||||
|
label: 'EPGShare FR1',
|
||||||
|
url: 'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
/// Xtream API Service for Mobile
|
/// Xtream API Service for Mobile
|
||||||
///
|
///
|
||||||
/// Optimized for Android with:
|
/// Optimized for Android with:
|
||||||
/// - Batch EPG loading (N+1 prevention)
|
/// - Batch EPG loading (N+1 prevention)
|
||||||
/// - Provider-level caching with TTL
|
/// - Provider-level caching with TTL
|
||||||
|
/// - XMLTV fallback when the provider serves no EPG
|
||||||
/// - Configurable timeouts and retries
|
/// - Configurable timeouts and retries
|
||||||
/// - Memory-efficient streaming
|
/// - Memory-efficient streaming
|
||||||
class XtreamServiceMobile {
|
class XtreamServiceMobile {
|
||||||
final String cacheDir;
|
final String cacheDir;
|
||||||
late Dio _dio;
|
|
||||||
late String _baseUrl;
|
/// Replaced with the configured instance in [setPlaylistAsync]; initialised
|
||||||
late String _username;
|
/// eagerly so [dispose] is safe even if the service was never configured.
|
||||||
late String _password;
|
Dio _dio = Dio();
|
||||||
|
String _baseUrl = '';
|
||||||
|
String _username = '';
|
||||||
|
String _password = '';
|
||||||
|
|
||||||
// Batch EPG cache to prevent N+1 queries
|
// Batch EPG cache to prevent N+1 queries
|
||||||
final Map<String, _EpgCacheEntry> _epgBatchCache = {};
|
final Map<String, _EpgCacheEntry> _epgBatchCache = {};
|
||||||
@@ -42,8 +85,62 @@ class XtreamServiceMobile {
|
|||||||
// In-flight batch requests to deduplicate concurrent calls
|
// In-flight batch requests to deduplicate concurrent calls
|
||||||
final Map<String, Future<Map<String, model.ShortEPG>>> _inFlightBatches = {};
|
final Map<String, Future<Map<String, model.ShortEPG>>> _inFlightBatches = {};
|
||||||
|
|
||||||
|
/// A channel grid asks for EPG one tile at a time, so a screenful fires ~30
|
||||||
|
/// simultaneous `player_api.php` calls. Many Xtream panels rate-limit past a
|
||||||
|
/// handful of parallel connections and answer with errors, which empties the
|
||||||
|
/// EPG of the whole grid. Requests are therefore funnelled in small packets.
|
||||||
|
static final _Semaphore _epgGate = _Semaphore(3);
|
||||||
|
|
||||||
|
/// streamId → channel, populated by [getLiveChannels].
|
||||||
|
///
|
||||||
|
/// The XMLTV fallback matches on `epg_channel_id` / channel name, neither of
|
||||||
|
/// which the per-stream EPG calls carry — this index supplies them without
|
||||||
|
/// changing any call site.
|
||||||
|
final Map<String, model.Channel> _channelIndex = {};
|
||||||
|
|
||||||
|
/// Last non-empty live lists, keyed by category id ('' = all channels).
|
||||||
|
///
|
||||||
|
/// The fetchers swallow errors and return `[]`, so a refresh attempted while
|
||||||
|
/// the network is down (typically right after a TV wakes from standby) would
|
||||||
|
/// otherwise replace a good list with an empty screen.
|
||||||
|
final Map<String, List<model.Channel>> _lastLiveChannels = {};
|
||||||
|
List<model.Category>? _lastLiveCategories;
|
||||||
|
|
||||||
|
late final XmltvService _xmltv = XmltvService(cacheDir);
|
||||||
|
|
||||||
|
/// User-provided XMLTV URL (empty = not configured).
|
||||||
|
String _customXmltvUrl = '';
|
||||||
|
|
||||||
|
/// Whether the community guides may be used as the last resort.
|
||||||
|
bool _allowPublicXmltv = true;
|
||||||
|
|
||||||
|
/// Set once every XMLTV source has failed, so a channel grid does not retry
|
||||||
|
/// a multi-megabyte download for each of its tiles.
|
||||||
|
bool _xmltvExhausted = false;
|
||||||
|
|
||||||
XtreamServiceMobile(this.cacheDir);
|
XtreamServiceMobile(this.cacheDir);
|
||||||
|
|
||||||
|
/// Label of the XMLTV source currently backing the fallback, if any.
|
||||||
|
String? get xmltvSourceLabel => _xmltv.loadedSourceLabel;
|
||||||
|
|
||||||
|
/// Configure the XMLTV fallback. Safe to call at any time.
|
||||||
|
void setXmltvConfig({String? customUrl, bool? allowPublicFallback}) {
|
||||||
|
final normalizedUrl = customUrl?.trim() ?? _customXmltvUrl;
|
||||||
|
final changed = normalizedUrl != _customXmltvUrl ||
|
||||||
|
(allowPublicFallback ?? _allowPublicXmltv) != _allowPublicXmltv;
|
||||||
|
|
||||||
|
_customXmltvUrl = normalizedUrl;
|
||||||
|
_allowPublicXmltv = allowPublicFallback ?? _allowPublicXmltv;
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
// Sources changed — drop the "nothing works" latch and any indexed guide
|
||||||
|
// so the new configuration is actually tried.
|
||||||
|
_xmltvExhausted = false;
|
||||||
|
_xmltv.clear();
|
||||||
|
_epgBatchCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize with playlist configuration
|
/// Initialize with playlist configuration
|
||||||
Future<void> setPlaylistAsync(PlaylistConfig config) async {
|
Future<void> setPlaylistAsync(PlaylistConfig config) async {
|
||||||
_baseUrl = config.dns;
|
_baseUrl = config.dns;
|
||||||
@@ -59,14 +156,70 @@ class XtreamServiceMobile {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Warm the DNS entry for the panel host now, so the first zap does not pay
|
||||||
|
// for resolution. Fire-and-forget: playback must never wait on this.
|
||||||
|
unawaited(prewarmHost());
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
print('✅ [XtreamServiceMobile] Initialized with URL: $_baseUrl');
|
print('✅ [XtreamServiceMobile] Initialized with URL: $_baseUrl');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve and pre-connect to the panel host.
|
||||||
|
///
|
||||||
|
/// Opens a bare TCP/TLS socket and closes it immediately: no HTTP request is
|
||||||
|
/// issued, so this never consumes one of the provider's connection slots,
|
||||||
|
/// while still leaving the DNS entry cached and the route warm.
|
||||||
|
Future<void> prewarmHost() async {
|
||||||
|
final uri = Uri.tryParse(_baseUrl);
|
||||||
|
if (uri == null || uri.host.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await DnsResolver.resolve(uri.host).timeout(
|
||||||
|
const Duration(seconds: 3),
|
||||||
|
onTimeout: () => null,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// DNS pre-resolution is best effort.
|
||||||
|
}
|
||||||
|
|
||||||
|
final isSecure = uri.scheme == 'https';
|
||||||
|
final port = uri.hasPort ? uri.port : (isSecure ? 443 : 80);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final socket = isSecure
|
||||||
|
? await SecureSocket.connect(
|
||||||
|
uri.host,
|
||||||
|
port,
|
||||||
|
timeout: const Duration(milliseconds: 1200),
|
||||||
|
onBadCertificate: (_) => true,
|
||||||
|
)
|
||||||
|
: await Socket.connect(
|
||||||
|
uri.host,
|
||||||
|
port,
|
||||||
|
timeout: const Duration(milliseconds: 1200),
|
||||||
|
);
|
||||||
|
socket.destroy();
|
||||||
|
if (kDebugMode) print('🔥 [Prewarm] ${uri.host}:$port ready');
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) print('⚠️ [Prewarm] ${uri.host}:$port failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get live channels for a category (with batch EPG support)
|
/// Get live channels for a category (with batch EPG support)
|
||||||
/// If categoryId is empty, fetches ALL channels from all categories
|
/// If categoryId is empty, fetches ALL channels from all categories
|
||||||
|
///
|
||||||
|
/// Falls back to the last good list for this category when the fetch fails.
|
||||||
Future<List<model.Channel>> getLiveChannels(String categoryId) async {
|
Future<List<model.Channel>> getLiveChannels(String categoryId) async {
|
||||||
|
final channels = await _fetchLiveChannels(categoryId);
|
||||||
|
if (channels.isNotEmpty) {
|
||||||
|
_lastLiveChannels[categoryId] = channels;
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
return _lastLiveChannels[categoryId] ?? channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<model.Channel>> _fetchLiveChannels(String categoryId) async {
|
||||||
try {
|
try {
|
||||||
final queryParams = {
|
final queryParams = {
|
||||||
'username': _username,
|
'username': _username,
|
||||||
@@ -98,6 +251,11 @@ class XtreamServiceMobile {
|
|||||||
.map((e) => model.Channel.fromJson(e))
|
.map((e) => model.Channel.fromJson(e))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
// Keep the EPG-matching keys reachable from a bare streamId.
|
||||||
|
for (final channel in channels) {
|
||||||
|
_channelIndex[channel.streamId] = channel;
|
||||||
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
final categories = channels.map((c) => c.categoryName).toSet();
|
final categories = channels.map((c) => c.categoryName).toSet();
|
||||||
print(
|
print(
|
||||||
@@ -195,6 +353,7 @@ class XtreamServiceMobile {
|
|||||||
|
|
||||||
// Load EPG for each stream_id individually (API doesn't support true batch)
|
// Load EPG for each stream_id individually (API doesn't support true batch)
|
||||||
for (final streamId in streamIds) {
|
for (final streamId in streamIds) {
|
||||||
|
await _epgGate.acquire();
|
||||||
try {
|
try {
|
||||||
final response = await _dio
|
final response = await _dio
|
||||||
.get(
|
.get(
|
||||||
@@ -244,6 +403,22 @@ class XtreamServiceMobile {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) print('⚠️ EPG failed for $streamId: $e');
|
if (kDebugMode) print('⚠️ EPG failed for $streamId: $e');
|
||||||
|
} finally {
|
||||||
|
_epgGate.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fallback: whatever the provider could not answer for ──────────────
|
||||||
|
final missing =
|
||||||
|
streamIds.where((id) => !result.containsKey(id)).toList(growable: false);
|
||||||
|
if (missing.isNotEmpty) {
|
||||||
|
final recovered = await _resolveFromXmltv(missing);
|
||||||
|
result.addAll(recovered);
|
||||||
|
if (kDebugMode && recovered.isNotEmpty) {
|
||||||
|
print(
|
||||||
|
'📺 XMLTV fallback recovered ${recovered.length}/${missing.length} '
|
||||||
|
'channels via "${_xmltv.loadedSourceLabel}"',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,8 +428,72 @@ class XtreamServiceMobile {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// XMLTV sources to try, in the order requested: the provider's own dump
|
||||||
|
/// first (it is often populated even when `get_short_epg` is not), then the
|
||||||
|
/// user's URL, then the community guides.
|
||||||
|
List<XmltvSource> _xmltvSources() {
|
||||||
|
return [
|
||||||
|
XmltvSource(
|
||||||
|
label: 'Fournisseur (xmltv.php)',
|
||||||
|
url: '$_baseUrl/xmltv.php?username=$_username&password=$_password',
|
||||||
|
),
|
||||||
|
if (_customXmltvUrl.isNotEmpty)
|
||||||
|
XmltvSource(label: 'URL personnalisée', url: _customXmltvUrl),
|
||||||
|
if (_allowPublicXmltv) ...kDefaultPublicXmltvSources,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve missing EPG entries from the XMLTV index, loading it on demand.
|
||||||
|
Future<Map<String, model.ShortEPG>> _resolveFromXmltv(
|
||||||
|
List<String> streamIds,
|
||||||
|
) async {
|
||||||
|
if (_xmltvExhausted) return {};
|
||||||
|
|
||||||
|
// Nothing to match on: the channel list has not been loaded in this
|
||||||
|
// session, so neither epg_channel_id nor the name is known.
|
||||||
|
final known = streamIds
|
||||||
|
.map((id) => _channelIndex[id])
|
||||||
|
.whereType<model.Channel>()
|
||||||
|
.toList();
|
||||||
|
if (known.isEmpty) return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final loaded = await _xmltv.ensureLoaded(_xmltvSources());
|
||||||
|
if (!loaded) {
|
||||||
|
_xmltvExhausted = true;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) print('⚠️ XMLTV load failed: $e');
|
||||||
|
_xmltvExhausted = true;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final resolved = <String, model.ShortEPG>{};
|
||||||
|
for (final channel in known) {
|
||||||
|
final epg = _xmltv.shortEpgFor(
|
||||||
|
streamId: channel.streamId,
|
||||||
|
epgChannelId: channel.epgChannelId,
|
||||||
|
channelName: channel.name,
|
||||||
|
);
|
||||||
|
if (epg != null) resolved[channel.streamId] = epg;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get live TV categories
|
/// Get live TV categories
|
||||||
|
///
|
||||||
|
/// Falls back to the last good list when the fetch fails.
|
||||||
Future<List<model.Category>> getLiveCategories() async {
|
Future<List<model.Category>> getLiveCategories() async {
|
||||||
|
final categories = await _fetchLiveCategories();
|
||||||
|
if (categories.isNotEmpty) {
|
||||||
|
_lastLiveCategories = categories;
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
return _lastLiveCategories ?? categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<model.Category>> _fetchLiveCategories() async {
|
||||||
try {
|
try {
|
||||||
if (kDebugMode) print('🔍 Loading live TV categories with 8s timeout...');
|
if (kDebugMode) print('🔍 Loading live TV categories with 8s timeout...');
|
||||||
|
|
||||||
@@ -530,6 +769,13 @@ class XtreamServiceMobile {
|
|||||||
_inFlightBatches.clear();
|
_inFlightBatches.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop the XMLTV index (keeps its disk cache) and allow a fresh attempt.
|
||||||
|
void resetXmltv() {
|
||||||
|
_xmltv.clear();
|
||||||
|
_xmltvExhausted = false;
|
||||||
|
_epgBatchCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get Live Stream URL
|
/// Get Live Stream URL
|
||||||
String getLiveStreamUrl(String streamId) {
|
String getLiveStreamUrl(String streamId) {
|
||||||
return '$_baseUrl/live/$_username/$_password/$streamId.ts';
|
return '$_baseUrl/live/$_username/$_password/$streamId.ts';
|
||||||
@@ -564,5 +810,8 @@ class XtreamServiceMobile {
|
|||||||
/// Dispose service resources
|
/// Dispose service resources
|
||||||
void dispose() {
|
void dispose() {
|
||||||
clearCache();
|
clearCache();
|
||||||
|
_channelIndex.clear();
|
||||||
|
_xmltv.clear();
|
||||||
|
_dio.close(force: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:xtremobile/core/services/ip_service.dart';
|
import 'package:xtremobile/core/services/ip_service.dart';
|
||||||
import 'package:xtremobile/core/models/iptv_models.dart' as model;
|
import 'package:xtremobile/core/models/iptv_models.dart' as model;
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
class MobileLiveTVTab extends ConsumerStatefulWidget {
|
class MobileLiveTVTab extends ConsumerStatefulWidget {
|
||||||
final PlaylistConfig playlist;
|
final PlaylistConfig playlist;
|
||||||
@@ -26,7 +27,11 @@ class MobileLiveTVTab extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||||
|
/// Coming back to the foreground after this long refetches the channel list,
|
||||||
|
/// so an app left running for days on a TV box picks up provider changes.
|
||||||
|
static const _autoRefreshAfter = Duration(hours: 4);
|
||||||
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
@@ -35,12 +40,16 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
bool _isSearchEditing = false;
|
bool _isSearchEditing = false;
|
||||||
bool _justReturnedFromPlayer =
|
bool _justReturnedFromPlayer =
|
||||||
false; // Flag to prevent PopScope interception after player
|
false; // Flag to prevent PopScope interception after player
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
|
||||||
Timer? _searchTimer;
|
Timer? _searchTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
// Anchor the auto-refresh clock to this first load.
|
||||||
|
ref.read(liveCatalogRefreshedAtProvider);
|
||||||
// [P1-1 FIX] Debounce search input to prevent per-keystroke rebuilds
|
// [P1-1 FIX] Debounce search input to prevent per-keystroke rebuilds
|
||||||
_searchTimer = null;
|
_searchTimer = null;
|
||||||
_searchController.addListener(() {
|
_searchController.addListener(() {
|
||||||
@@ -61,6 +70,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_searchFocusNode.dispose();
|
_searchFocusNode.dispose();
|
||||||
@@ -71,6 +81,39 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state != AppLifecycleState.resumed) return;
|
||||||
|
final lastRefresh = ref.read(liveCatalogRefreshedAtProvider);
|
||||||
|
if (DateTime.now().difference(lastRefresh) >= _autoRefreshAfter) {
|
||||||
|
_refreshChannels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refetch categories and the open category's channels. The current lists
|
||||||
|
/// stay on screen until the new ones arrive.
|
||||||
|
Future<void> _refreshChannels() async {
|
||||||
|
if (_isRefreshing) return;
|
||||||
|
setState(() => _isRefreshing = true);
|
||||||
|
|
||||||
|
refreshLiveCatalog(ref);
|
||||||
|
final categoryId = ref.read(mobileLiveTvUiStateProvider).selectedCategoryId;
|
||||||
|
try {
|
||||||
|
await Future.wait<Object>([
|
||||||
|
ref.read(mobileLiveCategoriesProvider(widget.playlist).future),
|
||||||
|
if (categoryId != null)
|
||||||
|
ref.read(
|
||||||
|
mobileLiveChannelsByCategoryProvider((widget.playlist, categoryId))
|
||||||
|
.future,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} catch (_) {
|
||||||
|
// Errors surface through the providers' own error states.
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isRefreshing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
@@ -130,14 +173,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
final shouldExit = await showDialog<bool>(
|
final shouldExit = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
backgroundColor: Colors.grey[900],
|
backgroundColor: AppColors.surfaceContainerLowest,
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"Quitter l'application",
|
"Quitter l'application",
|
||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: AppColors.onSurface),
|
||||||
),
|
),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
"Voulez-vous vraiment quitter l'application ?",
|
"Voulez-vous vraiment quitter l'application ?",
|
||||||
style: TextStyle(color: Colors.white70),
|
style: TextStyle(color: AppColors.onSurface70),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -145,14 +188,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
autofocus: true,
|
autofocus: true,
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Annuler',
|
'Annuler',
|
||||||
style: TextStyle(color: Colors.blueAccent),
|
style: TextStyle(color: AppColors.primary),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Quitter',
|
'Quitter',
|
||||||
style: TextStyle(color: Colors.redAccent),
|
style: TextStyle(color: AppColors.error),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -171,7 +214,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Erreur: $e',
|
'Erreur: $e',
|
||||||
style: const TextStyle(color: Colors.red),
|
style: const TextStyle(color: AppColors.error),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -209,197 +252,228 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: CustomScrollView(
|
child: RefreshIndicator(
|
||||||
controller: _scrollController,
|
onRefresh: _refreshChannels,
|
||||||
slivers: [
|
color: AppColors.primary,
|
||||||
// Floating Header (Search + Navigation)
|
child: CustomScrollView(
|
||||||
SliverAppBar(
|
controller: _scrollController,
|
||||||
floating: false,
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
pinned: true,
|
slivers: [
|
||||||
snap: false,
|
// Floating Header (Search + Navigation)
|
||||||
backgroundColor: const Color(0xFF000000), // Opaque black
|
SliverAppBar(
|
||||||
elevation: 0,
|
floating: false,
|
||||||
scrolledUnderElevation: 0,
|
pinned: true,
|
||||||
automaticallyImplyLeading: false,
|
snap: false,
|
||||||
toolbarHeight: _searchQuery.isNotEmpty ||
|
backgroundColor:
|
||||||
_showFavoritesOnly ||
|
const Color(0xFF000000), // Opaque black
|
||||||
!uiState.isCategoryView
|
elevation: 0,
|
||||||
? 120.0
|
scrolledUnderElevation: 0,
|
||||||
: 110.0,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
toolbarHeight: _searchQuery.isNotEmpty ||
|
||||||
background: Container(
|
_showFavoritesOnly ||
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
!uiState.isCategoryView
|
||||||
child: Column(
|
? 120.0
|
||||||
mainAxisSize: MainAxisSize.min,
|
: 110.0,
|
||||||
children: [
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
// Search Bar
|
background: Container(
|
||||||
TVFocusable(
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
scale: 1.0,
|
child: Column(
|
||||||
focusColor: Colors.white,
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPressed: () {
|
children: [
|
||||||
setState(() => _isSearchEditing = true);
|
// Search Bar
|
||||||
WidgetsBinding.instance
|
TVFocusable(
|
||||||
.addPostFrameCallback((_) {
|
scale: 1.0,
|
||||||
_searchFocusNode.requestFocus();
|
focusColor: AppColors.onSurface,
|
||||||
SystemChannels.textInput
|
onPressed: () {
|
||||||
.invokeMethod('TextInput.show');
|
setState(() => _isSearchEditing = true);
|
||||||
});
|
WidgetsBinding.instance
|
||||||
},
|
.addPostFrameCallback((_) {
|
||||||
borderRadius: BorderRadius.circular(12),
|
_searchFocusNode.requestFocus();
|
||||||
child: Container(
|
SystemChannels.textInput
|
||||||
height: 48,
|
.invokeMethod('TextInput.show');
|
||||||
decoration: AppDecorations.searchBar(context),
|
});
|
||||||
padding: const EdgeInsets.symmetric(
|
},
|
||||||
horizontal: 12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Row(
|
child: Container(
|
||||||
children: [
|
height: 48,
|
||||||
Icon(
|
decoration:
|
||||||
Icons.search,
|
AppDecorations.searchBar(context),
|
||||||
color: AppDecorations.textSecondary(
|
padding: const EdgeInsets.symmetric(
|
||||||
context),
|
horizontal: 12),
|
||||||
),
|
child: Row(
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
Expanded(
|
Icon(
|
||||||
child: ExcludeFocus(
|
Icons.search,
|
||||||
excluding: !_isSearchEditing,
|
color: AppDecorations.textSecondary(
|
||||||
child: TextField(
|
context),
|
||||||
cursorColor: Colors.white,
|
),
|
||||||
controller: _searchController,
|
const SizedBox(width: 8),
|
||||||
focusNode: _searchFocusNode,
|
Expanded(
|
||||||
readOnly: !_isSearchEditing,
|
child: ExcludeFocus(
|
||||||
style: TextStyle(
|
excluding: !_isSearchEditing,
|
||||||
color: AppDecorations.textPrimary(
|
child: TextField(
|
||||||
context),
|
cursorColor: AppColors.onSurface,
|
||||||
),
|
controller: _searchController,
|
||||||
decoration: const InputDecoration(
|
focusNode: _searchFocusNode,
|
||||||
hintText:
|
readOnly: !_isSearchEditing,
|
||||||
'Rechercher une chaîne...',
|
style: TextStyle(
|
||||||
border: InputBorder.none,
|
color:
|
||||||
focusedBorder: InputBorder.none,
|
AppDecorations.textPrimary(
|
||||||
enabledBorder: InputBorder.none,
|
context),
|
||||||
isDense: true,
|
),
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
onSubmitted: (_) => setState(
|
hintText:
|
||||||
() => _isSearchEditing = false,
|
'Rechercher une chaîne...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => setState(
|
||||||
|
() => _isSearchEditing = false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (_searchQuery.isNotEmpty)
|
||||||
if (_searchQuery.isNotEmpty)
|
GestureDetector(
|
||||||
GestureDetector(
|
onTap: () =>
|
||||||
onTap: () =>
|
_searchController.clear(),
|
||||||
_searchController.clear(),
|
child: Icon(
|
||||||
child: Icon(
|
Icons.close,
|
||||||
Icons.close,
|
color:
|
||||||
color: AppDecorations.textSecondary(
|
AppDecorations.textSecondary(
|
||||||
context),
|
context),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Title / Navigation Row
|
// Title / Navigation Row
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (!showGrid ||
|
if (!showGrid ||
|
||||||
_showFavoritesOnly ||
|
_showFavoritesOnly ||
|
||||||
_searchQuery.isNotEmpty) ...[
|
_searchQuery.isNotEmpty) ...[
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: AppDecorations.textPrimary(
|
||||||
|
context),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
_searchController.clear();
|
||||||
|
} else if (_showFavoritesOnly) {
|
||||||
|
setState(
|
||||||
|
() => _showFavoritesOnly = false,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
uiNotifier.state = uiState.copyWith(
|
||||||
|
isCategoryView: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_searchQuery.isNotEmpty
|
||||||
|
? 'Résultats de recherche'
|
||||||
|
: _showFavoritesOnly
|
||||||
|
? 'Favoris'
|
||||||
|
: showGrid
|
||||||
|
? 'Catégories'
|
||||||
|
: uiState
|
||||||
|
.selectedCategory ??
|
||||||
|
'Chaînes',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppDecorations.textPrimary(
|
||||||
|
context),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Actualiser les chaînes',
|
||||||
|
icon: _isRefreshing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.refresh,
|
||||||
|
color:
|
||||||
|
AppDecorations.textSecondary(
|
||||||
|
context),
|
||||||
|
),
|
||||||
|
onPressed: _isRefreshing
|
||||||
|
? null
|
||||||
|
: _refreshChannels,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.arrow_back,
|
_showFavoritesOnly
|
||||||
color:
|
? Icons.favorite
|
||||||
AppDecorations.textPrimary(context),
|
: Icons.favorite_border,
|
||||||
|
color: _showFavoritesOnly
|
||||||
|
? AppColors.live
|
||||||
|
: AppDecorations.textSecondary(
|
||||||
|
context),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () => setState(() {
|
||||||
if (_searchQuery.isNotEmpty) {
|
_showFavoritesOnly =
|
||||||
_searchController.clear();
|
!_showFavoritesOnly;
|
||||||
} else if (_showFavoritesOnly) {
|
if (_showFavoritesOnly) {
|
||||||
setState(
|
|
||||||
() => _showFavoritesOnly = false,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
uiNotifier.state = uiState.copyWith(
|
uiNotifier.state = uiState.copyWith(
|
||||||
isCategoryView: true,
|
isCategoryView: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
if (!_showFavoritesOnly) {
|
||||||
padding: EdgeInsets.zero,
|
uiNotifier.state = uiState.copyWith(
|
||||||
constraints: const BoxConstraints(),
|
isCategoryView: true);
|
||||||
|
}
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
],
|
],
|
||||||
Expanded(
|
),
|
||||||
child: Text(
|
],
|
||||||
_searchQuery.isNotEmpty
|
),
|
||||||
? 'Résultats de recherche'
|
|
||||||
: _showFavoritesOnly
|
|
||||||
? 'Favoris'
|
|
||||||
: showGrid
|
|
||||||
? 'Catégories'
|
|
||||||
: uiState.selectedCategory ??
|
|
||||||
'Chaînes',
|
|
||||||
style: TextStyle(
|
|
||||||
color:
|
|
||||||
AppDecorations.textPrimary(context),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
_showFavoritesOnly
|
|
||||||
? Icons.favorite
|
|
||||||
: Icons.favorite_border,
|
|
||||||
color: _showFavoritesOnly
|
|
||||||
? const Color(0xFFFF453A)
|
|
||||||
: AppDecorations.textSecondary(
|
|
||||||
context),
|
|
||||||
),
|
|
||||||
onPressed: () => setState(() {
|
|
||||||
_showFavoritesOnly = !_showFavoritesOnly;
|
|
||||||
if (_showFavoritesOnly) {
|
|
||||||
uiNotifier.state = uiState.copyWith(
|
|
||||||
isCategoryView: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!_showFavoritesOnly) {
|
|
||||||
uiNotifier.state = uiState.copyWith(
|
|
||||||
isCategoryView: true);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Main Content Sliver
|
// Main Content Sliver
|
||||||
if (showGrid)
|
if (showGrid)
|
||||||
_buildCategorySliver(categories, uiNotifier, uiState)
|
_buildCategorySliver(categories, uiNotifier, uiState)
|
||||||
else
|
else
|
||||||
_ChannelListView(
|
_ChannelListView(
|
||||||
playlist: widget.playlist,
|
playlist: widget.playlist,
|
||||||
uiState: uiState,
|
uiState: uiState,
|
||||||
searchQuery: _searchQuery,
|
searchQuery: _searchQuery,
|
||||||
showFavoritesOnly: _showFavoritesOnly,
|
showFavoritesOnly: _showFavoritesOnly,
|
||||||
onPlay: (channel, channels, index) => _playChannel(
|
onPlay: (channel, channels, index) => _playChannel(
|
||||||
context,
|
context,
|
||||||
channel,
|
channel,
|
||||||
channels,
|
channels,
|
||||||
widget.playlist,
|
widget.playlist,
|
||||||
index,
|
index,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -535,7 +609,7 @@ class _ChannelListView extends ConsumerWidget {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Aucune catégorie sélectionnée',
|
'Aucune catégorie sélectionnée',
|
||||||
style: TextStyle(color: Colors.white70),
|
style: TextStyle(color: AppColors.onSurface70),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -557,7 +631,7 @@ class _ChannelListView extends ConsumerWidget {
|
|||||||
showFavoritesOnly
|
showFavoritesOnly
|
||||||
? 'Sélectionnez une catégorie pour voir vos favoris'
|
? 'Sélectionnez une catégorie pour voir vos favoris'
|
||||||
: 'Sélectionnez une catégorie pour rechercher',
|
: 'Sélectionnez une catégorie pour rechercher',
|
||||||
style: const TextStyle(color: Colors.white70),
|
style: const TextStyle(color: AppColors.onSurface70),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -575,7 +649,7 @@ class _ChannelListView extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Erreur: $e',
|
'Erreur: $e',
|
||||||
style: const TextStyle(color: Colors.red),
|
style: const TextStyle(color: AppColors.error),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -600,7 +674,7 @@ class _ChannelListView extends ConsumerWidget {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Aucune chaîne trouvée',
|
'Aucune chaîne trouvée',
|
||||||
style: TextStyle(color: Colors.white70),
|
style: TextStyle(color: AppColors.onSurface70),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -754,12 +828,12 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.red,
|
color: AppColors.error,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.favorite,
|
Icons.favorite,
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
size: 10,
|
size: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -779,7 +853,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
|
|||||||
Text(
|
Text(
|
||||||
widget.channel.name,
|
widget.channel.name,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
@@ -792,7 +866,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
|
|||||||
Text(
|
Text(
|
||||||
_epgTitle!,
|
_epgTitle!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.yellow,
|
color: AppColors.ratingGold,
|
||||||
fontSize: 8,
|
fontSize: 8,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
|
|||||||
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
|
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
|
||||||
import 'package:xtremobile/core/models/iptv_models.dart' as model;
|
import 'package:xtremobile/core/models/iptv_models.dart' as model;
|
||||||
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
|
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
class MobileMoviesTab extends ConsumerStatefulWidget {
|
class MobileMoviesTab extends ConsumerStatefulWidget {
|
||||||
final PlaylistConfig playlist;
|
final PlaylistConfig playlist;
|
||||||
@@ -224,6 +225,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
|
||||||
final settings = ref.watch(mobileSettingsProvider);
|
final settings = ref.watch(mobileSettingsProvider);
|
||||||
final watchHistory = ref.watch(mobileWatchHistoryProvider);
|
final watchHistory = ref.watch(mobileWatchHistoryProvider);
|
||||||
|
|
||||||
@@ -285,14 +287,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
|||||||
final shouldExit = await showDialog<bool>(
|
final shouldExit = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
backgroundColor: Colors.grey[900],
|
backgroundColor: AppColors.surfaceContainerLowest,
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"Quitter l'application",
|
"Quitter l'application",
|
||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: AppColors.onSurface),
|
||||||
),
|
),
|
||||||
content: const Text(
|
content: const Text(
|
||||||
"Voulez-vous vraiment quitter l'application ?",
|
"Voulez-vous vraiment quitter l'application ?",
|
||||||
style: TextStyle(color: Colors.white70),
|
style: TextStyle(color: AppColors.onSurface70),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -300,14 +302,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
|||||||
autofocus: true,
|
autofocus: true,
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Annuler',
|
'Annuler',
|
||||||
style: TextStyle(color: Colors.blueAccent),
|
style: TextStyle(color: AppColors.primary),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Quitter',
|
'Quitter',
|
||||||
style: TextStyle(color: Colors.redAccent),
|
style: TextStyle(color: AppColors.error),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -346,7 +348,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
|||||||
// Search Bar
|
// Search Bar
|
||||||
TVFocusable(
|
TVFocusable(
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
focusColor: Colors.white,
|
focusColor: AppColors.onSurface,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() => _isSearchEditing = true);
|
setState(() => _isSearchEditing = true);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -367,7 +369,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
|||||||
child: ExcludeFocus(
|
child: ExcludeFocus(
|
||||||
excluding: !_isSearchEditing,
|
excluding: !_isSearchEditing,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
cursorColor: Colors.white,
|
cursorColor: AppColors.onSurface,
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
focusNode: _searchFocusNode,
|
focusNode: _searchFocusNode,
|
||||||
readOnly: !_isSearchEditing,
|
readOnly: !_isSearchEditing,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
|
|||||||
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
|
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
|
||||||
import 'package:xtremobile/core/models/iptv_models.dart' as model;
|
import 'package:xtremobile/core/models/iptv_models.dart' as model;
|
||||||
import 'package:xtremobile/features/iptv/screens/mobile_series_detail_screen.dart';
|
import 'package:xtremobile/features/iptv/screens/mobile_series_detail_screen.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
class MobileSeriesTab extends ConsumerStatefulWidget {
|
class MobileSeriesTab extends ConsumerStatefulWidget {
|
||||||
final PlaylistConfig playlist;
|
final PlaylistConfig playlist;
|
||||||
@@ -215,6 +216,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
|
||||||
final settings = ref.watch(mobileSettingsProvider);
|
final settings = ref.watch(mobileSettingsProvider);
|
||||||
|
|
||||||
if (_categories.isEmpty && !_isLoading) {
|
if (_categories.isEmpty && !_isLoading) {
|
||||||
@@ -269,18 +271,18 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
|
|||||||
final shouldExit = await showDialog<bool>(
|
final shouldExit = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
backgroundColor: Colors.grey[900],
|
backgroundColor: AppColors.surfaceContainerLowest,
|
||||||
title: const Text("Quitter l'application", style: TextStyle(color: Colors.white)),
|
title: const Text("Quitter l'application", style: TextStyle(color: AppColors.onSurface)),
|
||||||
content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: Colors.white70)),
|
content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: AppColors.onSurface70)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
child: const Text('Annuler', style: TextStyle(color: Colors.blueAccent)),
|
child: const Text('Annuler', style: TextStyle(color: AppColors.primary)),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
child: const Text('Quitter', style: TextStyle(color: Colors.redAccent)),
|
child: const Text('Quitter', style: TextStyle(color: AppColors.error)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -316,7 +318,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
|
|||||||
// Search Bar
|
// Search Bar
|
||||||
TVFocusable(
|
TVFocusable(
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
focusColor: Colors.white,
|
focusColor: AppColors.onSurface,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() => _isSearchEditing = true);
|
setState(() => _isSearchEditing = true);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -337,7 +339,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
|
|||||||
child: ExcludeFocus(
|
child: ExcludeFocus(
|
||||||
excluding: !_isSearchEditing,
|
excluding: !_isSearchEditing,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
cursorColor: Colors.white,
|
cursorColor: AppColors.onSurface,
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
focusNode: _searchFocusNode,
|
focusNode: _searchFocusNode,
|
||||||
readOnly: !_isSearchEditing,
|
readOnly: !_isSearchEditing,
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
// import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
// import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||||
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
|
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
|
||||||
|
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
|
||||||
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
||||||
import 'package:xtremobile/core/theme/app_decorations.dart';
|
import 'package:xtremobile/core/theme/app_decorations.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_theme.dart';
|
||||||
import 'package:xtremobile/core/providers/theme_provider.dart';
|
import 'package:xtremobile/core/providers/theme_provider.dart';
|
||||||
import 'package:xtremobile/features/iptv/screens/mobile_playlist_selection_screen.dart';
|
import 'package:xtremobile/features/iptv/screens/mobile_playlist_selection_screen.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
/// Simplified mobile settings tab - optimized for TV remote control
|
/// Simplified mobile settings tab - optimized for TV remote control
|
||||||
class MobileSettingsTab extends ConsumerStatefulWidget {
|
class MobileSettingsTab extends ConsumerStatefulWidget {
|
||||||
@@ -20,6 +23,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
late TextEditingController _liveTvController;
|
late TextEditingController _liveTvController;
|
||||||
late TextEditingController _moviesController;
|
late TextEditingController _moviesController;
|
||||||
late TextEditingController _seriesController;
|
late TextEditingController _seriesController;
|
||||||
|
late TextEditingController _xmltvController;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
bool _isRefreshingCache = false;
|
bool _isRefreshingCache = false;
|
||||||
|
|
||||||
@@ -29,6 +33,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
_liveTvController = TextEditingController();
|
_liveTvController = TextEditingController();
|
||||||
_moviesController = TextEditingController();
|
_moviesController = TextEditingController();
|
||||||
_seriesController = TextEditingController();
|
_seriesController = TextEditingController();
|
||||||
|
_xmltvController = TextEditingController();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,6 +41,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
_liveTvController.dispose();
|
_liveTvController.dispose();
|
||||||
_moviesController.dispose();
|
_moviesController.dispose();
|
||||||
_seriesController.dispose();
|
_seriesController.dispose();
|
||||||
|
_xmltvController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +50,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
_liveTvController.text = settings.liveTvKeywords.join(', ');
|
_liveTvController.text = settings.liveTvKeywords.join(', ');
|
||||||
_moviesController.text = settings.moviesKeywords.join(', ');
|
_moviesController.text = settings.moviesKeywords.join(', ');
|
||||||
_seriesController.text = settings.seriesKeywords.join(', ');
|
_seriesController.text = settings.seriesKeywords.join(', ');
|
||||||
|
_xmltvController.text = settings.xmltvUrl;
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +85,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
height: 48,
|
height: 48,
|
||||||
// No decoration for free-floating logo
|
// No decoration for free-floating logo
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/logo_xtremobile.png',
|
'assets/images/logo_xtremflow.png',
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
errorBuilder: (_, __, ___) => Icon(
|
||||||
Icons.play_circle_filled,
|
Icons.play_circle_filled,
|
||||||
@@ -100,7 +107,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Version 1.5.1',
|
'Version 1.7.3',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppDecorations.textSecondary(context),
|
color: AppDecorations.textSecondary(context),
|
||||||
@@ -167,6 +174,36 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// === GUIDE TV (EPG) ===
|
||||||
|
_buildSectionHeader('Guide TV (EPG)'),
|
||||||
|
|
||||||
|
_buildSettingItem(
|
||||||
|
icon: Icons.public,
|
||||||
|
title: 'EPG communautaire',
|
||||||
|
subtitle: 'Repli quand le fournisseur ne renvoie aucun programme',
|
||||||
|
value: settings.useCommunityEpg ? 'Activé' : 'Désactivé',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(mobileSettingsProvider.notifier)
|
||||||
|
.toggleCommunityEpg(!settings.useCommunityEpg),
|
||||||
|
),
|
||||||
|
|
||||||
|
_buildSettingItem(
|
||||||
|
icon: Icons.link,
|
||||||
|
title: 'URL XMLTV personnalisée',
|
||||||
|
subtitle: settings.xmltvUrl.isEmpty
|
||||||
|
? 'Aucune — xmltv.php du fournisseur, puis source communautaire'
|
||||||
|
: settings.xmltvUrl,
|
||||||
|
value: settings.xmltvUrl.isEmpty ? 'Ajouter' : 'Modifier',
|
||||||
|
onTap: () => _showKeyboardDialog(
|
||||||
|
'URL XMLTV',
|
||||||
|
_xmltvController,
|
||||||
|
(val) =>
|
||||||
|
ref.read(mobileSettingsProvider.notifier).setXmltvUrl(val),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// === FILTRES ===
|
// === FILTRES ===
|
||||||
_buildSectionHeader('Filtres de Contenu'),
|
_buildSectionHeader('Filtres de Contenu'),
|
||||||
|
|
||||||
@@ -216,8 +253,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
|
|
||||||
_buildSettingItem(
|
_buildSettingItem(
|
||||||
icon: _isRefreshingCache ? Icons.hourglass_empty : Icons.refresh,
|
icon: _isRefreshingCache ? Icons.hourglass_empty : Icons.refresh,
|
||||||
title: 'Actualiser le cache',
|
title: 'Forcer l\'actualisation',
|
||||||
subtitle: 'Recharge films, séries et EPG',
|
subtitle: 'Recharge chaînes, films, séries et EPG',
|
||||||
value: _isRefreshingCache ? '...' : 'Appuyer',
|
value: _isRefreshingCache ? '...' : 'Appuyer',
|
||||||
onTap: _isRefreshingCache ? null : _refreshCache,
|
onTap: _isRefreshingCache ? null : _refreshCache,
|
||||||
),
|
),
|
||||||
@@ -281,16 +318,16 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
setState(() => _isRefreshingCache = true);
|
setState(() => _isRefreshingCache = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// final dir = await getApplicationDocumentsDirectory();
|
refreshFullCatalog(ref);
|
||||||
// final cacheStore = HiveCacheStore(dir.path);
|
// Brief pause so the tap visibly registers on a TV remote.
|
||||||
// await cacheStore.clean();
|
await Future.delayed(const Duration(milliseconds: 600));
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Cache actualisé ! Relancez l\'app pour recharger.'),
|
content:
|
||||||
backgroundColor: Colors.green,
|
Text('Chaînes, films, séries et EPG en cours de rechargement'),
|
||||||
|
backgroundColor: AppColors.success,
|
||||||
duration: Duration(seconds: 3),
|
duration: Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -298,7 +335,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
SnackBar(
|
||||||
|
content: Text('Erreur: $e'), backgroundColor: AppColors.error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -352,54 +390,60 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
builder: (ctx, constraints) {
|
builder: (ctx, constraints) {
|
||||||
return Row(
|
return Row(
|
||||||
children: segments.map((seg) {
|
children: segments.map((seg) {
|
||||||
final (mode, icon, label) = seg;
|
final (mode, icon, label) = seg;
|
||||||
final selected = themeState.appThemeMode == mode;
|
final selected = themeState.appThemeMode == mode;
|
||||||
final accentColor = Theme.of(context).colorScheme.primary;
|
final accentColor =
|
||||||
return Expanded(
|
Theme.of(context).colorScheme.primary;
|
||||||
child: TVFocusable(
|
return Expanded(
|
||||||
onPressed: () => notifier.setThemeMode(mode),
|
child: TVFocusable(
|
||||||
borderRadius: BorderRadius.circular(8),
|
onPressed: () => notifier.setThemeMode(mode),
|
||||||
child: AnimatedContainer(
|
borderRadius: BorderRadius.circular(8),
|
||||||
duration: const Duration(milliseconds: 200),
|
child: AnimatedContainer(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
duration: const Duration(milliseconds: 200),
|
||||||
padding: const EdgeInsets.symmetric(
|
margin:
|
||||||
vertical: 7,
|
const EdgeInsets.symmetric(horizontal: 2),
|
||||||
),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(
|
vertical: 7,
|
||||||
color: selected
|
),
|
||||||
? accentColor
|
decoration: BoxDecoration(
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: selected
|
||||||
borderRadius: BorderRadius.circular(8),
|
? accentColor
|
||||||
),
|
: Theme.of(context)
|
||||||
child: Column(
|
.colorScheme
|
||||||
mainAxisSize: MainAxisSize.min,
|
.surfaceContainerHighest,
|
||||||
children: [
|
borderRadius: BorderRadius.circular(8),
|
||||||
Icon(
|
),
|
||||||
icon,
|
child: Column(
|
||||||
size: 16,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: selected
|
children: [
|
||||||
? Colors.white
|
Icon(
|
||||||
: AppDecorations.textSecondary(context),
|
icon,
|
||||||
),
|
size: 16,
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: selected
|
|
||||||
? FontWeight.w700
|
|
||||||
: FontWeight.w500,
|
|
||||||
color: selected
|
color: selected
|
||||||
? Colors.white
|
? AppColors.onSurface
|
||||||
: AppDecorations.textSecondary(context),
|
: AppDecorations.textSecondary(
|
||||||
|
context),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 3),
|
||||||
],
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: selected
|
||||||
|
? FontWeight.w700
|
||||||
|
: FontWeight.w500,
|
||||||
|
color: selected
|
||||||
|
? AppColors.onSurface
|
||||||
|
: AppDecorations.textSecondary(
|
||||||
|
context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}).toList(),
|
||||||
}).toList(),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -417,11 +461,9 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8),
|
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
title.toUpperCase(),
|
title.toUpperCase(),
|
||||||
style: GoogleFonts.inter(
|
style: AppTheme.eyebrow(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
letterSpacing: 1.2,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -447,7 +489,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: Theme.of(context).colorScheme.primary, size: 24),
|
Icon(icon,
|
||||||
|
color: Theme.of(context).colorScheme.primary, size: 24),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -478,7 +521,10 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
vertical: 6,
|
vertical: 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.15),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -526,7 +572,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: Theme.of(context).colorScheme.primary, size: 24),
|
Icon(icon,
|
||||||
|
color: Theme.of(context).colorScheme.primary, size: 24),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -556,7 +603,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(Icons.edit, color: AppDecorations.textSecondary(context), size: 20),
|
Icon(Icons.edit,
|
||||||
|
color: AppDecorations.textSecondary(context), size: 20),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ class MobileSettings {
|
|||||||
final List<String>
|
final List<String>
|
||||||
deinterlacedChannels; // IDs of channels with forced deinterlacing
|
deinterlacedChannels; // IDs of channels with forced deinterlacing
|
||||||
|
|
||||||
|
/// Custom XMLTV guide URL, tried after the provider's own `xmltv.php`.
|
||||||
|
final String xmltvUrl;
|
||||||
|
|
||||||
|
/// Allow the built-in community XMLTV guides as the last EPG resort.
|
||||||
|
final bool useCommunityEpg;
|
||||||
|
|
||||||
const MobileSettings({
|
const MobileSettings({
|
||||||
this.liveTvKeywords = const [],
|
this.liveTvKeywords = const [],
|
||||||
this.moviesKeywords = const [],
|
this.moviesKeywords = const [],
|
||||||
@@ -30,6 +36,8 @@ class MobileSettings {
|
|||||||
this.deinterlace = false,
|
this.deinterlace = false,
|
||||||
this.bufferDuration = 0,
|
this.bufferDuration = 0,
|
||||||
this.deinterlacedChannels = const [],
|
this.deinterlacedChannels = const [],
|
||||||
|
this.xmltvUrl = '',
|
||||||
|
this.useCommunityEpg = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
MobileSettings copyWith({
|
MobileSettings copyWith({
|
||||||
@@ -44,6 +52,8 @@ class MobileSettings {
|
|||||||
bool? deinterlace,
|
bool? deinterlace,
|
||||||
int? bufferDuration,
|
int? bufferDuration,
|
||||||
List<String>? deinterlacedChannels,
|
List<String>? deinterlacedChannels,
|
||||||
|
String? xmltvUrl,
|
||||||
|
bool? useCommunityEpg,
|
||||||
}) {
|
}) {
|
||||||
return MobileSettings(
|
return MobileSettings(
|
||||||
liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords,
|
liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords,
|
||||||
@@ -57,6 +67,8 @@ class MobileSettings {
|
|||||||
deinterlace: deinterlace ?? this.deinterlace,
|
deinterlace: deinterlace ?? this.deinterlace,
|
||||||
bufferDuration: bufferDuration ?? this.bufferDuration,
|
bufferDuration: bufferDuration ?? this.bufferDuration,
|
||||||
deinterlacedChannels: deinterlacedChannels ?? this.deinterlacedChannels,
|
deinterlacedChannels: deinterlacedChannels ?? this.deinterlacedChannels,
|
||||||
|
xmltvUrl: xmltvUrl ?? this.xmltvUrl,
|
||||||
|
useCommunityEpg: useCommunityEpg ?? this.useCommunityEpg,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +133,9 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
|
|||||||
final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?;
|
final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?;
|
||||||
final deinterlacedChannels =
|
final deinterlacedChannels =
|
||||||
_box?.get('deinterlacedChannels', defaultValue: <String>[]) as List?;
|
_box?.get('deinterlacedChannels', defaultValue: <String>[]) as List?;
|
||||||
|
final xmltvUrl = _box?.get('xmltvUrl', defaultValue: '') as String?;
|
||||||
|
final useCommunityEpg =
|
||||||
|
_box?.get('useCommunityEpg', defaultValue: true) as bool?;
|
||||||
|
|
||||||
state = MobileSettings(
|
state = MobileSettings(
|
||||||
liveTvKeywords: liveTv?.cast<String>() ?? [],
|
liveTvKeywords: liveTv?.cast<String>() ?? [],
|
||||||
@@ -134,6 +149,8 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
|
|||||||
deinterlace: deinterlace ?? false,
|
deinterlace: deinterlace ?? false,
|
||||||
bufferDuration: buffer ?? 0,
|
bufferDuration: buffer ?? 0,
|
||||||
deinterlacedChannels: deinterlacedChannels?.cast<String>() ?? [],
|
deinterlacedChannels: deinterlacedChannels?.cast<String>() ?? [],
|
||||||
|
xmltvUrl: xmltvUrl ?? '',
|
||||||
|
useCommunityEpg: useCommunityEpg ?? true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +204,17 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
|
|||||||
_box?.put('bufferDuration', seconds);
|
_box?.put('bufferDuration', seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setXmltvUrl(String url) {
|
||||||
|
final trimmed = url.trim();
|
||||||
|
state = state.copyWith(xmltvUrl: trimmed);
|
||||||
|
_box?.put('xmltvUrl', trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleCommunityEpg(bool value) {
|
||||||
|
state = state.copyWith(useCommunityEpg: value);
|
||||||
|
_box?.put('useCommunityEpg', value);
|
||||||
|
}
|
||||||
|
|
||||||
void toggleChannelDeinterlace(String streamId) {
|
void toggleChannelDeinterlace(String streamId) {
|
||||||
final currentList = List<String>.from(state.deinterlacedChannels);
|
final currentList = List<String>.from(state.deinterlacedChannels);
|
||||||
if (currentList.contains(streamId)) {
|
if (currentList.contains(streamId)) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/models/playlist_config.dart';
|
import '../../core/models/playlist_config.dart';
|
||||||
import '../../core/models/iptv_models.dart' as model;
|
import '../../core/models/iptv_models.dart' as model;
|
||||||
import '../../features/iptv/services/xtream_service_mobile.dart';
|
import '../../features/iptv/services/xtream_service_mobile.dart';
|
||||||
|
import 'mobile_settings_providers.dart';
|
||||||
|
|
||||||
/// Mobile-specific Xtream service provider
|
/// Mobile-specific Xtream service provider
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -13,9 +14,60 @@ final mobileXtreamServiceProvider =
|
|||||||
final service = XtreamServiceMobile(dir.path);
|
final service = XtreamServiceMobile(dir.path);
|
||||||
await service
|
await service
|
||||||
.setPlaylistAsync(playlist); // Async to resolve DNS before API calls
|
.setPlaylistAsync(playlist); // Async to resolve DNS before API calls
|
||||||
|
|
||||||
|
// EPG fallback configuration follows the settings live, without tearing the
|
||||||
|
// service (and its caches) down on every unrelated settings change.
|
||||||
|
void applyEpgSettings(MobileSettings settings) => service.setXmltvConfig(
|
||||||
|
customUrl: settings.xmltvUrl,
|
||||||
|
allowPublicFallback: settings.useCommunityEpg,
|
||||||
|
);
|
||||||
|
|
||||||
|
applyEpgSettings(ref.read(mobileSettingsProvider));
|
||||||
|
ref.listen<MobileSettings>(mobileSettingsProvider, (previous, next) {
|
||||||
|
if (previous?.xmltvUrl != next.xmltvUrl ||
|
||||||
|
previous?.useCommunityEpg != next.useCommunityEpg) {
|
||||||
|
applyEpgSettings(next);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A full catalogue refresh from the settings also drops the cached EPG.
|
||||||
|
ref.listen<int>(
|
||||||
|
fullCatalogRefreshTickProvider, (_, __) => service.clearCache());
|
||||||
|
|
||||||
|
ref.onDispose(service.dispose);
|
||||||
return service;
|
return service;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// When the live catalogue was last fetched or explicitly refreshed.
|
||||||
|
/// Drives the auto-refresh when the app comes back to the foreground.
|
||||||
|
final liveCatalogRefreshedAtProvider =
|
||||||
|
StateProvider<DateTime>((ref) => DateTime.now());
|
||||||
|
|
||||||
|
/// Bumped by the settings "force refresh". The movies and series tabs, which
|
||||||
|
/// hold their lists in local state, listen to it to reload.
|
||||||
|
final fullCatalogRefreshTickProvider = StateProvider<int>((ref) => 0);
|
||||||
|
|
||||||
|
/// Forces the live categories and channels to be refetched from the panel.
|
||||||
|
///
|
||||||
|
/// The Xtream service is kept: it falls back to its last good lists if the
|
||||||
|
/// refetch fails, so a flaky network never empties the screen.
|
||||||
|
void refreshLiveCatalog(WidgetRef ref) {
|
||||||
|
ref.invalidate(mobileLiveCategoriesProvider);
|
||||||
|
ref.invalidate(mobileLiveChannelsByCategoryProvider);
|
||||||
|
ref.invalidate(mobileLiveChannelsProvider);
|
||||||
|
ref.read(liveCatalogRefreshedAtProvider.notifier).state = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forces the whole catalogue (live, movies, series) and the EPG to reload.
|
||||||
|
void refreshFullCatalog(WidgetRef ref) {
|
||||||
|
refreshLiveCatalog(ref);
|
||||||
|
ref.invalidate(mobileMoviesProvider);
|
||||||
|
ref.invalidate(mobileSeriesCategoriesProvider);
|
||||||
|
ref.invalidate(mobileSeriesProvider);
|
||||||
|
ref.invalidate(mobileSeriesInfoByPlaylistProvider);
|
||||||
|
ref.read(fullCatalogRefreshTickProvider.notifier).state++;
|
||||||
|
}
|
||||||
|
|
||||||
/// Mobile-specific live categories provider (for Live TV category grid)
|
/// Mobile-specific live categories provider (for Live TV category grid)
|
||||||
final mobileLiveCategoriesProvider =
|
final mobileLiveCategoriesProvider =
|
||||||
FutureProvider.family<List<model.Category>, PlaylistConfig>(
|
FutureProvider.family<List<model.Category>, PlaylistConfig>(
|
||||||
@@ -25,9 +77,9 @@ final mobileLiveCategoriesProvider =
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Mobile-specific live channels provider (loads channels for a specific category)
|
/// Mobile-specific live channels provider (loads channels for a specific category)
|
||||||
final mobileLiveChannelsByCategoryProvider = FutureProvider.family<
|
final mobileLiveChannelsByCategoryProvider =
|
||||||
List<model.Channel>,
|
FutureProvider.family<List<model.Channel>, (PlaylistConfig, String)>(
|
||||||
(PlaylistConfig, String)>((ref, params) async {
|
(ref, params) async {
|
||||||
final (playlist, categoryId) = params;
|
final (playlist, categoryId) = params;
|
||||||
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||||
return service.getLiveChannels(categoryId);
|
return service.getLiveChannels(categoryId);
|
||||||
@@ -61,8 +113,9 @@ final mobileLiveChannelsProvider =
|
|||||||
|
|
||||||
for (int batchNum = 0; batchNum < totalBatches; batchNum++) {
|
for (int batchNum = 0; batchNum < totalBatches; batchNum++) {
|
||||||
int startIdx = batchNum * batchSize;
|
int startIdx = batchNum * batchSize;
|
||||||
int endIdx =
|
int endIdx = (startIdx + batchSize < categories.length)
|
||||||
(startIdx + batchSize < categories.length) ? startIdx + batchSize : categories.length;
|
? startIdx + batchSize
|
||||||
|
: categories.length;
|
||||||
final batch = categories.sublist(startIdx, endIdx);
|
final batch = categories.sublist(startIdx, endIdx);
|
||||||
|
|
||||||
print(
|
print(
|
||||||
@@ -102,7 +155,8 @@ final mobileLiveChannelsProvider =
|
|||||||
|
|
||||||
/// Mobile-specific movies provider
|
/// Mobile-specific movies provider
|
||||||
final mobileMoviesProvider =
|
final mobileMoviesProvider =
|
||||||
FutureProvider.family<List<model.VodItem>, PlaylistConfig>((ref, playlist) async {
|
FutureProvider.family<List<model.VodItem>, PlaylistConfig>(
|
||||||
|
(ref, playlist) async {
|
||||||
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||||
return service.getMoviesByCategory(""); // Fetch all or default
|
return service.getMoviesByCategory(""); // Fetch all or default
|
||||||
});
|
});
|
||||||
@@ -116,8 +170,9 @@ final mobileSeriesCategoriesProvider =
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Mobile-specific series pagination provider
|
/// Mobile-specific series pagination provider
|
||||||
final mobileSeriesProvider = FutureProvider.family<List<model.Series>,
|
final mobileSeriesProvider =
|
||||||
(PlaylistConfig, String?)>((ref, params) async {
|
FutureProvider.family<List<model.Series>, (PlaylistConfig, String?)>(
|
||||||
|
(ref, params) async {
|
||||||
final (playlist, categoryId) = params;
|
final (playlist, categoryId) = params;
|
||||||
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||||
return service.getSeriesPaginated(categoryId: categoryId);
|
return service.getSeriesPaginated(categoryId: categoryId);
|
||||||
@@ -143,7 +198,8 @@ class SeriesInfoRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final mobileSeriesInfoByPlaylistProvider =
|
final mobileSeriesInfoByPlaylistProvider =
|
||||||
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>((ref, request) async {
|
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>(
|
||||||
|
(ref, request) async {
|
||||||
final service =
|
final service =
|
||||||
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
|
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
|
||||||
return service.getSeriesInfo(request.seriesId);
|
return service.getSeriesInfo(request.seriesId);
|
||||||
|
|||||||
@@ -1,390 +1,80 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
|
||||||
import '../../core/theme/app_colors.dart';
|
|
||||||
|
|
||||||
/// XtremFlow Mobile Theme — Apple TV Grey Glossy Style (dark)
|
import '../../core/theme/app_colors.dart';
|
||||||
/// + Apple Clean White (light)
|
import '../../core/theme/app_theme.dart';
|
||||||
|
|
||||||
|
/// XtremFlow Mobile Theme — "Warm Cinema", phone-scaled.
|
||||||
|
///
|
||||||
|
/// Derived from [AppTheme] rather than redefined: the palette, typography and
|
||||||
|
/// component styling stay in one place, and this layer only shrinks the type
|
||||||
|
/// scale and tightens paddings for a handset.
|
||||||
class MobileTheme {
|
class MobileTheme {
|
||||||
MobileTheme._();
|
MobileTheme._();
|
||||||
|
|
||||||
// ============ SPACING ============
|
// ============ SPACING ============
|
||||||
static const double spacing4 = 4.0;
|
static const double spacing4 = AppTheme.spacing4;
|
||||||
static const double spacing8 = 8.0;
|
static const double spacing8 = AppTheme.spacing8;
|
||||||
static const double spacing12 = 12.0;
|
static const double spacing12 = AppTheme.spacing12;
|
||||||
static const double spacing16 = 16.0;
|
static const double spacing16 = AppTheme.spacing16;
|
||||||
static const double spacing24 = 24.0;
|
static const double spacing24 = AppTheme.spacing24;
|
||||||
|
|
||||||
// ============ RADIUS ============
|
// ============ RADIUS ============
|
||||||
static const double radiusSm = 8.0;
|
static const double radiusSm = AppTheme.radiusSm;
|
||||||
static const double radiusMd = 12.0;
|
static const double radiusMd = AppTheme.radiusMd;
|
||||||
static const double radiusLg = 16.0;
|
static const double radiusLg = AppTheme.radiusLg;
|
||||||
|
|
||||||
|
/// Phone type scale: display sizes come down ~20%, body stays legible.
|
||||||
|
static TextTheme _mobileTextTheme(TextTheme base) {
|
||||||
|
TextStyle? shrink(TextStyle? style, double factor) {
|
||||||
|
if (style?.fontSize == null) return style;
|
||||||
|
return style!.copyWith(fontSize: style.fontSize! * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
displayLarge: shrink(base.displayLarge, 0.70),
|
||||||
|
displayMedium: shrink(base.displayMedium, 0.76),
|
||||||
|
displaySmall: shrink(base.displaySmall, 0.84),
|
||||||
|
headlineLarge: shrink(base.headlineLarge, 0.78),
|
||||||
|
headlineMedium: shrink(base.headlineMedium, 0.86),
|
||||||
|
headlineSmall: shrink(base.headlineSmall, 0.92),
|
||||||
|
titleLarge: shrink(base.titleLarge, 0.94),
|
||||||
|
bodyLarge: shrink(base.bodyLarge, 0.90),
|
||||||
|
bodyMedium: shrink(base.bodyMedium, 0.92),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ThemeData _scaled(ThemeData base) {
|
||||||
|
return base.copyWith(
|
||||||
|
textTheme: _mobileTextTheme(base.textTheme),
|
||||||
|
inputDecorationTheme: base.inputDecorationTheme.copyWith(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: spacing16,
|
||||||
|
vertical: spacing12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: base.filledButtonTheme.style?.copyWith(
|
||||||
|
padding: const WidgetStatePropertyAll(
|
||||||
|
EdgeInsets.symmetric(horizontal: spacing16, vertical: spacing12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============ DARK THEME (Mobile Main) ============
|
// ============ DARK THEME (Mobile Main) ============
|
||||||
static ThemeData get darkTheme {
|
static ThemeData get darkTheme => _scaled(AppTheme.darkTheme);
|
||||||
final baseTheme = ThemeData.dark(useMaterial3: true);
|
|
||||||
|
|
||||||
return baseTheme.copyWith(
|
// ============ LIGHT THEME ============
|
||||||
colorScheme: AppColors.darkColorScheme,
|
static ThemeData get lightTheme => _scaled(AppTheme.lightTheme);
|
||||||
scaffoldBackgroundColor: AppColors.background,
|
|
||||||
|
|
||||||
// Global text selection — white cursor on dark
|
/// Returns the appropriate MobileTheme based on the current [BuildContext]
|
||||||
textSelectionTheme: const TextSelectionThemeData(
|
/// brightness.
|
||||||
cursorColor: Colors.white,
|
|
||||||
selectionColor: Color(0x4DFFFFFF),
|
|
||||||
selectionHandleColor: Colors.white,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Typography: San Francisco proxy via Inter — clean and modern
|
|
||||||
textTheme: TextTheme(
|
|
||||||
displayLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -1.0,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
displayMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
displaySmall: GoogleFonts.inter(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
headlineMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
titleLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
titleMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
bodyLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
bodyMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
labelLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// AppBar: Transparent — background gradient shows through
|
|
||||||
appBarTheme: AppBarTheme(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
foregroundColor: AppColors.textPrimary,
|
|
||||||
elevation: 0,
|
|
||||||
centerTitle: true,
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
titleTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Bottom Navigation Bar: glossy dark surface, Apple Blue selected
|
|
||||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
|
||||||
backgroundColor: AppColors.surface,
|
|
||||||
selectedItemColor: AppColors.appleBlue,
|
|
||||||
unselectedItemColor: AppColors.textSecondary,
|
|
||||||
selectedLabelStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
unselectedLabelStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
type: BottomNavigationBarType.fixed,
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Inputs: dark surface fill, white focus border
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
|
||||||
filled: true,
|
|
||||||
fillColor: AppColors.surfaceVariant,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: spacing16,
|
|
||||||
vertical: spacing12,
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
|
||||||
borderSide: const BorderSide(
|
|
||||||
color: AppColors.glassBorderColor,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
|
||||||
borderSide: const BorderSide(
|
|
||||||
color: AppColors.glassBorderColor,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
|
||||||
borderSide: const BorderSide(color: Colors.white, width: 1.5),
|
|
||||||
),
|
|
||||||
hintStyle: GoogleFonts.inter(
|
|
||||||
color: AppColors.textTertiary,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Buttons: Apple Blue fill — modern CTA style
|
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.appleBlue,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
|
||||||
),
|
|
||||||
textStyle: GoogleFonts.inter(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Cards: glossy dark surface with subtle glass border
|
|
||||||
cardTheme: const CardThemeData(
|
|
||||||
color: AppColors.surfaceVariant,
|
|
||||||
elevation: 0,
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(radiusMd)),
|
|
||||||
side: BorderSide(color: AppColors.glassBorderColor, width: 1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Dialogs: glass/dark
|
|
||||||
dialogTheme: DialogThemeData(
|
|
||||||
backgroundColor: AppColors.surface,
|
|
||||||
elevation: 24,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusLg),
|
|
||||||
side: const BorderSide(color: AppColors.glassBorderColor, width: 1),
|
|
||||||
),
|
|
||||||
titleTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
contentTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ LIGHT THEME (Apple Clean White) ============
|
|
||||||
static ThemeData get lightTheme {
|
|
||||||
final baseTheme = ThemeData.light(useMaterial3: true);
|
|
||||||
|
|
||||||
const bgColor = Color(0xFFEAEAF0);
|
|
||||||
const surfaceColor = Color(0xFFF2F2F7);
|
|
||||||
const primaryColor = Color(0xFF007AFF);
|
|
||||||
const textPrimaryColor = Color(0xFF000000);
|
|
||||||
const textSecondaryColor = Color(0x993C3C43);
|
|
||||||
const textTertiaryColor = Color(0xFFC7C7CC);
|
|
||||||
const borderColor = Color(0x1A000000);
|
|
||||||
|
|
||||||
return baseTheme.copyWith(
|
|
||||||
colorScheme: const ColorScheme.light(
|
|
||||||
primary: primaryColor,
|
|
||||||
secondary: Color(0xFF34C759),
|
|
||||||
surface: surfaceColor,
|
|
||||||
error: AppColors.error,
|
|
||||||
onPrimary: Colors.white,
|
|
||||||
onSecondary: Colors.white,
|
|
||||||
onSurface: textPrimaryColor,
|
|
||||||
onError: Colors.white,
|
|
||||||
brightness: Brightness.light,
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: bgColor,
|
|
||||||
|
|
||||||
textSelectionTheme: const TextSelectionThemeData(
|
|
||||||
cursorColor: primaryColor,
|
|
||||||
selectionColor: Color(0x4D007AFF),
|
|
||||||
selectionHandleColor: primaryColor,
|
|
||||||
),
|
|
||||||
|
|
||||||
textTheme: TextTheme(
|
|
||||||
displayLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -1.0,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
displayMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
displaySmall: GoogleFonts.inter(
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
headlineMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
titleLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
titleMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
bodyLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: textSecondaryColor,
|
|
||||||
),
|
|
||||||
bodyMedium: GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: textSecondaryColor,
|
|
||||||
),
|
|
||||||
labelLarge: GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
appBarTheme: AppBarTheme(
|
|
||||||
backgroundColor: surfaceColor,
|
|
||||||
foregroundColor: textPrimaryColor,
|
|
||||||
elevation: 0,
|
|
||||||
scrolledUnderElevation: 1,
|
|
||||||
shadowColor: borderColor,
|
|
||||||
centerTitle: true,
|
|
||||||
titleTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
|
||||||
backgroundColor: surfaceColor,
|
|
||||||
selectedItemColor: primaryColor,
|
|
||||||
unselectedItemColor: textSecondaryColor,
|
|
||||||
selectedLabelStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
unselectedLabelStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
type: BottomNavigationBarType.fixed,
|
|
||||||
elevation: 8,
|
|
||||||
),
|
|
||||||
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
|
||||||
filled: true,
|
|
||||||
fillColor: surfaceColor,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: spacing16,
|
|
||||||
vertical: spacing12,
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
|
||||||
borderSide: const BorderSide(color: borderColor, width: 1),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
|
||||||
borderSide: const BorderSide(color: borderColor, width: 1),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
|
||||||
borderSide: const BorderSide(color: primaryColor, width: 2),
|
|
||||||
),
|
|
||||||
hintStyle: GoogleFonts.inter(
|
|
||||||
color: textTertiaryColor,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
filledButtonTheme: FilledButtonThemeData(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: primaryColor,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
elevation: 0,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusMd),
|
|
||||||
),
|
|
||||||
textStyle: GoogleFonts.inter(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
cardTheme: const CardThemeData(
|
|
||||||
color: surfaceColor,
|
|
||||||
elevation: 0,
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(radiusMd)),
|
|
||||||
side: BorderSide(color: borderColor, width: 1),
|
|
||||||
),
|
|
||||||
shadowColor: borderColor,
|
|
||||||
),
|
|
||||||
|
|
||||||
dialogTheme: DialogThemeData(
|
|
||||||
backgroundColor: surfaceColor,
|
|
||||||
elevation: 8,
|
|
||||||
shadowColor: borderColor,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(radiusLg),
|
|
||||||
side: const BorderSide(color: borderColor, width: 1),
|
|
||||||
),
|
|
||||||
titleTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: textPrimaryColor,
|
|
||||||
),
|
|
||||||
contentTextStyle: GoogleFonts.inter(
|
|
||||||
fontSize: 14,
|
|
||||||
color: textSecondaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the appropriate MobileTheme based on the current [BuildContext] brightness.
|
|
||||||
static ThemeData themeOf(BuildContext context) {
|
static ThemeData themeOf(BuildContext context) {
|
||||||
final brightness = Theme.of(context).brightness;
|
final brightness = Theme.of(context).brightness;
|
||||||
return brightness == Brightness.dark ? darkTheme : lightTheme;
|
return brightness == Brightness.dark ? darkTheme : lightTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accent used for active/selected affordances on mobile.
|
||||||
|
static const Color accent = AppColors.primaryContainer;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:xtremobile/core/theme/app_decorations.dart';
|
import 'package:xtremobile/core/theme/app_decorations.dart';
|
||||||
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
class MobileCategoryCard extends StatelessWidget {
|
class MobileCategoryCard extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -13,7 +14,7 @@ class MobileCategoryCard extends StatelessWidget {
|
|||||||
required this.title,
|
required this.title,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.icon = Icons.folder_rounded,
|
this.icon = Icons.folder_rounded,
|
||||||
this.iconColor = const Color(0xFF0A84FF),
|
this.iconColor = AppColors.primary,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import 'package:xtremobile/core/theme/app_decorations.dart';
|
import 'package:xtremobile/core/theme/app_decorations.dart';
|
||||||
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
||||||
import 'package:xtremobile/core/utils/image_cache_config.dart';
|
import 'package:xtremobile/core/utils/image_cache_config.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
class MobilePosterCard extends StatelessWidget {
|
class MobilePosterCard extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -134,12 +135,12 @@ class MobilePosterCard extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.star, color: Colors.amber, size: 10),
|
const Icon(Icons.star, color: AppColors.ratingGold, size: 10),
|
||||||
const SizedBox(width: 2),
|
const SizedBox(width: 2),
|
||||||
Text(
|
Text(
|
||||||
rating!,
|
rating!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -157,13 +158,13 @@ class MobilePosterCard extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Color(0xFF30D158),
|
color: AppColors.success,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.check,
|
Icons.check,
|
||||||
size: 10,
|
size: 10,
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../core/theme/app_decorations.dart';
|
import '../../core/theme/app_decorations.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
class MobileScaffold extends ConsumerStatefulWidget {
|
class MobileScaffold extends ConsumerStatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -71,7 +72,7 @@ class _MobileScaffoldState extends ConsumerState<MobileScaffold> {
|
|||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, __, ___) => const Icon(
|
errorBuilder: (_, __, ___) => const Icon(
|
||||||
Icons.play_circle_filled,
|
Icons.play_circle_filled,
|
||||||
color: Colors.white,
|
color: AppColors.onSurface,
|
||||||
size: 40,
|
size: 40,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -201,12 +202,12 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const Color selectedColor = Color(0xFF0A84FF);
|
const Color selectedColor = AppColors.primaryFill;
|
||||||
final Color unselectedColor = AppDecorations.textSecondary(context);
|
final Color unselectedColor = AppDecorations.textSecondary(context);
|
||||||
final Color textColor =
|
final Color textColor =
|
||||||
widget.isSelected ? Colors.white : unselectedColor;
|
widget.isSelected ? AppColors.onSurface : unselectedColor;
|
||||||
final Color iconColor =
|
final Color iconColor =
|
||||||
widget.isSelected ? Colors.white : unselectedColor;
|
widget.isSelected ? AppColors.onSurface : unselectedColor;
|
||||||
final FontWeight fontWeight =
|
final FontWeight fontWeight =
|
||||||
widget.isSelected ? FontWeight.w700 : FontWeight.w500;
|
widget.isSelected ? FontWeight.w700 : FontWeight.w500;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||||
|
|
||||||
class TVFocusable extends StatefulWidget {
|
class TVFocusable extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -25,7 +26,7 @@ class TVFocusable extends StatefulWidget {
|
|||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
this.scale = 1.05, // Apple TV subtle lift
|
this.scale = 1.05, // Apple TV subtle lift
|
||||||
this.borderWidth = 2.0,
|
this.borderWidth = 2.0,
|
||||||
this.focusColor = Colors.white,
|
this.focusColor = AppColors.onSurface,
|
||||||
this.borderRadius,
|
this.borderRadius,
|
||||||
this.longPressDuration = const Duration(seconds: 3),
|
this.longPressDuration = const Duration(seconds: 3),
|
||||||
});
|
});
|
||||||
@@ -162,7 +163,7 @@ class _TVFocusableState extends State<TVFocusable> {
|
|||||||
offset: const Offset(0, 6),
|
offset: const Offset(0, 6),
|
||||||
),
|
),
|
||||||
const BoxShadow(
|
const BoxShadow(
|
||||||
color: Color(0x40FFFFFF), // White glow
|
color: Color(0x590A84FF), // Apple blue glow
|
||||||
blurRadius: 12,
|
blurRadius: 12,
|
||||||
spreadRadius: 0,
|
spreadRadius: 0,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: xtremobile
|
name: xtremobile
|
||||||
description: High-performance IPTV Android Application with Xtream Codes API
|
description: High-performance IPTV application (Android, iPhone, iPad) with Xtream Codes API
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.5.1+11
|
version: 1.7.3+17
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -24,6 +24,9 @@ dependencies:
|
|||||||
dio_cache_interceptor_hive_store: ^3.2.2
|
dio_cache_interceptor_hive_store: ^3.2.2
|
||||||
http: ^1.2.2
|
http: ^1.2.2
|
||||||
|
|
||||||
|
# EPG (XMLTV fallback parsing)
|
||||||
|
xml: ^6.5.0
|
||||||
|
|
||||||
# Routing
|
# Routing
|
||||||
go_router: ^13.2.5
|
go_router: ^13.2.5
|
||||||
|
|
||||||
@@ -31,6 +34,13 @@ dependencies:
|
|||||||
media_kit: ^1.1.11
|
media_kit: ^1.1.11
|
||||||
media_kit_video: ^1.2.5
|
media_kit_video: ^1.2.5
|
||||||
media_kit_libs_video: ^1.0.5
|
media_kit_libs_video: ^1.0.5
|
||||||
|
# Épinglé : tiré par media_kit_video (^3.0.2). À partir de 3.4.2, le plugin
|
||||||
|
# iOS utilise FlutterSceneLifeCycleDelegate / addSceneDelegate, des API
|
||||||
|
# apparues dans Flutter 3.38, sans relever sa contrainte `flutter:` — pub le
|
||||||
|
# sélectionne donc et la compilation Swift échoue sur le Flutter 3.35.3 des
|
||||||
|
# workflows. 3.4.1 est la dernière version sans ces appels.
|
||||||
|
# À relâcher le jour où les workflows passeront à Flutter >= 3.38.
|
||||||
|
volume_controller: 3.4.1
|
||||||
|
|
||||||
# UI Components
|
# UI Components
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
@@ -47,7 +57,11 @@ dependencies:
|
|||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
video_player: ^2.10.1
|
video_player: ^2.10.1
|
||||||
wakelock_plus: ^1.2.8
|
wakelock_plus: ^1.2.8
|
||||||
device_info_plus: ^12.3.0
|
# Épinglé : la 12.4.0 appelle NSProcessInfo.isiOSAppOnVision derrière un
|
||||||
|
# `@available(iOS 26.1, *)`, ce qui exige le SDK d'Xcode 26.1 — plus récent
|
||||||
|
# que celui du runner macOS. 12.3.0 est la dernière version sans cet appel.
|
||||||
|
# À relâcher le jour où le workflow iOS utilisera un Xcode >= 26.1.
|
||||||
|
device_info_plus: 12.3.0
|
||||||
shelf_router: ^1.1.4
|
shelf_router: ^1.1.4
|
||||||
shelf_static: ^1.1.3
|
shelf_static: ^1.1.3
|
||||||
flutter_cache_manager: ^3.4.1
|
flutter_cache_manager: ^3.4.1
|
||||||
@@ -65,11 +79,14 @@ dev_dependencies:
|
|||||||
riverpod_generator: ^2.4.0
|
riverpod_generator: ^2.4.0
|
||||||
hive_generator: ^2.0.1
|
hive_generator: ^2.0.1
|
||||||
|
|
||||||
|
# Les icônes Android (mipmap, adaptative, bannière TV) sont générées par
|
||||||
|
# scripts/generate_icons.py et versionnées : ne pas les écraser ici.
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: false
|
||||||
image_path: "assets/images/app-icon.png"
|
ios: true
|
||||||
adaptive_icon_background: "#1a1a2e"
|
image_path: "assets/images/app_icon_ios.png"
|
||||||
adaptive_icon_foreground: "assets/images/app-icon.png"
|
# iOS refuse une icône comportant un canal alpha.
|
||||||
|
remove_alpha_ios: true
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Génère toutes les icônes XtremFlow à partir d'un seul dessin vectoriel.
|
||||||
|
|
||||||
|
Palette alignée sur lib/core/theme/app_colors.dart (graphite + bleu Apple).
|
||||||
|
|
||||||
|
Sorties :
|
||||||
|
assets/branding/*.svg sources vectorielles
|
||||||
|
assets/images/logo_xtremflow.png logo in-app (fond transparent)
|
||||||
|
assets/images/app_icon_ios.png icône iOS (carré plein, sans alpha)
|
||||||
|
android/.../mipmap-*/ic_launcher.png icône legacy (tuile arrondie)
|
||||||
|
android/.../mipmap-*/ic_launcher_round.png icône legacy ronde
|
||||||
|
android/.../drawable-*/ic_launcher_foreground.png calque adaptatif
|
||||||
|
android/.../drawable-xhdpi/banner.png bannière Android TV / Fire TV
|
||||||
|
|
||||||
|
Usage : pip install cairosvg pillow && python3 scripts/generate_icons.py
|
||||||
|
La bannière utilise la police Inter (Bold) si elle est installée.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
import cairosvg
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
RES = os.path.join(ROOT, "android", "app", "src", "main", "res")
|
||||||
|
|
||||||
|
# Couleurs du thème (app_colors.dart)
|
||||||
|
BG_TOP = "#2C2C33" # surfaceContainerLow
|
||||||
|
BG_BOTTOM = "#1B1B1F" # baseLevel0
|
||||||
|
BLUE = "#0A84FF" # primaryContainer
|
||||||
|
BLUE_LIGHT = "#8AB9FF" # primary
|
||||||
|
CYAN = "#7DD3F2" # tertiary
|
||||||
|
|
||||||
|
DEFS = f"""
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="{BG_TOP}"/>
|
||||||
|
<stop offset="1" stop-color="{BG_BOTTOM}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="{BLUE}" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="{BLUE}" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="{CYAN}"/>
|
||||||
|
<stop offset="0.55" stop-color="{BLUE}"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="{CYAN}" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="{CYAN}" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>"""
|
||||||
|
|
||||||
|
|
||||||
|
def mark(cx, cy, size):
|
||||||
|
"""Le symbole : un bouton play fluide précédé de traînées de vitesse.
|
||||||
|
|
||||||
|
Dessiné dans une boîte de 100x100 centrée sur (cx, cy), mise à l'échelle
|
||||||
|
à `size` pixels.
|
||||||
|
"""
|
||||||
|
s = size / 100.0
|
||||||
|
tx, ty = cx - 50 * s, cy - 50 * s
|
||||||
|
return f"""
|
||||||
|
<g transform="translate({tx:.2f} {ty:.2f}) scale({s:.4f})">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>"""
|
||||||
|
|
||||||
|
|
||||||
|
def svg(w, h, body):
|
||||||
|
return (
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" '
|
||||||
|
f'viewBox="0 0 {w} {h}">{DEFS}{body}\n</svg>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tile(size, radius_ratio, mark_ratio=0.64):
|
||||||
|
"""Tuile graphite (arrondie ou pleine) avec le symbole centré."""
|
||||||
|
r = size * radius_ratio
|
||||||
|
body = f"""
|
||||||
|
<rect width="{size}" height="{size}" rx="{r}" fill="url(#bg)"/>
|
||||||
|
<rect width="{size}" height="{size}" rx="{r}" fill="url(#shine)"/>
|
||||||
|
<rect x="1.5" y="1.5" width="{size - 3}" height="{size - 3}" rx="{max(r - 1.5, 0)}"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
|
||||||
|
{mark(size / 2, size / 2, size * mark_ratio)}"""
|
||||||
|
return svg(size, size, body)
|
||||||
|
|
||||||
|
|
||||||
|
def round_tile(size):
|
||||||
|
body = f"""
|
||||||
|
<circle cx="{size / 2}" cy="{size / 2}" r="{size / 2}" fill="url(#bg)"/>
|
||||||
|
<circle cx="{size / 2}" cy="{size / 2}" r="{size / 2}" fill="url(#shine)"/>
|
||||||
|
{mark(size / 2, size / 2, size * 0.62)}"""
|
||||||
|
return svg(size, size, body)
|
||||||
|
|
||||||
|
|
||||||
|
def adaptive_foreground(size):
|
||||||
|
# 108dp de canevas, zone sûre de 66dp : le symbole occupe ~58% du canevas,
|
||||||
|
# soit l'intérieur du cercle sûr quelle que soit la forme du masque.
|
||||||
|
return svg(size, size, mark(size / 2, size / 2, size * 0.58))
|
||||||
|
|
||||||
|
|
||||||
|
def banner(w=320, h=180):
|
||||||
|
"""Bannière TV (Android TV / Fire TV) : 320x180 px en xhdpi."""
|
||||||
|
body = f"""
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#bg)"/>
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#shine)"/>
|
||||||
|
{mark(w * 0.25, h / 2, h * 0.56)}
|
||||||
|
<text x="{w * 0.44}" y="{h / 2 + 11}" font-family="Inter" font-weight="bold"
|
||||||
|
font-size="32" fill="#F5F5F7" letter-spacing="-0.6">Xtrem<tspan fill="{BLUE_LIGHT}">Flow</tspan></text>
|
||||||
|
<text x="{w * 0.44 + 1}" y="{h / 2 + 34}" font-family="Inter" font-weight="600"
|
||||||
|
font-size="12" fill="#98989F" letter-spacing="2.4">IPTV PLAYER</text>"""
|
||||||
|
return svg(w, h, body)
|
||||||
|
|
||||||
|
|
||||||
|
def render(svg_text, out_path, size=None, flatten=None):
|
||||||
|
png = cairosvg.svg2png(
|
||||||
|
bytestring=svg_text.encode(),
|
||||||
|
output_width=size[0] if size else None,
|
||||||
|
output_height=size[1] if size else None,
|
||||||
|
)
|
||||||
|
img = Image.open(io.BytesIO(png)).convert("RGBA")
|
||||||
|
if flatten:
|
||||||
|
bg = Image.new("RGBA", img.size, flatten)
|
||||||
|
bg.alpha_composite(img)
|
||||||
|
img = bg.convert("RGB")
|
||||||
|
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||||
|
img.save(out_path, optimize=True)
|
||||||
|
print(" ", os.path.relpath(out_path, ROOT), img.size)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
brand = os.path.join(ROOT, "assets", "branding")
|
||||||
|
os.makedirs(brand, exist_ok=True)
|
||||||
|
sources = {
|
||||||
|
"icon_rounded.svg": tile(1024, 0.225),
|
||||||
|
"icon_square.svg": tile(1024, 0),
|
||||||
|
"icon_round.svg": round_tile(1024),
|
||||||
|
"icon_foreground.svg": adaptive_foreground(1024),
|
||||||
|
"tv_banner.svg": banner(),
|
||||||
|
}
|
||||||
|
for name, text in sources.items():
|
||||||
|
with open(os.path.join(brand, name), "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
print("Assets Flutter :")
|
||||||
|
render(sources["icon_rounded.svg"], os.path.join(ROOT, "assets/images/logo_xtremflow.png"), (512, 512))
|
||||||
|
# iOS applique son propre masque et refuse l'alpha : carré plein.
|
||||||
|
render(sources["icon_square.svg"], os.path.join(ROOT, "assets/images/app_icon_ios.png"), (1024, 1024), flatten=BG_BOTTOM)
|
||||||
|
|
||||||
|
print("Android :")
|
||||||
|
densities = {"mdpi": 1, "hdpi": 1.5, "xhdpi": 2, "xxhdpi": 3, "xxxhdpi": 4}
|
||||||
|
for d, k in densities.items():
|
||||||
|
legacy = round(48 * k)
|
||||||
|
fg = round(108 * k)
|
||||||
|
render(sources["icon_rounded.svg"], f"{RES}/mipmap-{d}/ic_launcher.png", (legacy, legacy))
|
||||||
|
render(sources["icon_round.svg"], f"{RES}/mipmap-{d}/ic_launcher_round.png", (legacy, legacy))
|
||||||
|
render(sources["icon_foreground.svg"], f"{RES}/drawable-{d}/ic_launcher_foreground.png", (fg, fg))
|
||||||
|
# Bannière TV : 160x90dp -> 320x180 en xhdpi (taille de référence Google/Amazon).
|
||||||
|
render(sources["tv_banner.svg"], f"{RES}/drawable-xhdpi/banner.png", (320, 180), flatten=BG_BOTTOM)
|
||||||
|
render(sources["tv_banner.svg"], f"{RES}/drawable-xxhdpi/banner.png", (480, 270), flatten=BG_BOTTOM)
|
||||||
|
render(sources["tv_banner.svg"], f"{RES}/drawable-xxxhdpi/banner.png", (640, 360), flatten=BG_BOTTOM)
|
||||||
|
# Aperçu grand format pour les stores / README.
|
||||||
|
render(sources["tv_banner.svg"], os.path.join(brand, "tv_banner_1280x720.png"), (1280, 720), flatten=BG_BOTTOM)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Prépare le dossier ios/ pour un build iPhone / iPad.
|
||||||
|
#
|
||||||
|
# Le dépôt ne versionne pas le projet Xcode : il est régénéré par
|
||||||
|
# `flutter create` au moment du build (macOS uniquement), puis adapté aux
|
||||||
|
# besoins de l'app IPTV (flux HTTP en clair, audio en arrière-plan, iPad).
|
||||||
|
# Si un dossier ios/ est déjà présent dans le dépôt, il est conservé tel quel
|
||||||
|
# et seuls les réglages ci-dessous lui sont réappliqués — le script est
|
||||||
|
# idempotent et peut être relancé sans risque.
|
||||||
|
#
|
||||||
|
# Usage : ./scripts/prepare_ios.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUNDLE_ID="com.xtremflow.mobile" # identique à l'applicationId Android
|
||||||
|
DISPLAY_NAME="XtremFlow"
|
||||||
|
DEPLOYMENT_TARGET="13.0" # minimum exigé par media_kit sur iOS
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if [ "$(uname -s)" != "Darwin" ]; then
|
||||||
|
echo "::error::Un build iOS ne peut être préparé que sur macOS (Xcode requis)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PBXPROJ="ios/Runner.xcodeproj/project.pbxproj"
|
||||||
|
PLIST="ios/Runner/Info.plist"
|
||||||
|
PODFILE="ios/Podfile"
|
||||||
|
PLISTBUDDY=/usr/libexec/PlistBuddy
|
||||||
|
|
||||||
|
if [ ! -d "ios/Runner.xcodeproj" ]; then
|
||||||
|
echo "==> Aucun dossier ios/ : génération du projet Xcode"
|
||||||
|
# Garde-fou : `flutter create` ne doit recréer que la plateforme iOS. Si une
|
||||||
|
# version de l'outil venait à réécrire le point d'entrée de l'app, on le
|
||||||
|
# restaure — un main.dart écrasé produirait un IPA contenant le compteur
|
||||||
|
# d'exemple de Flutter, sans que le build échoue.
|
||||||
|
MAIN_BACKUP="$(mktemp)"
|
||||||
|
cp lib/main.dart "$MAIN_BACKUP"
|
||||||
|
flutter create --platforms=ios --org com.xtremflow --project-name xtremobile .
|
||||||
|
if ! cmp -s lib/main.dart "$MAIN_BACKUP"; then
|
||||||
|
echo "==> lib/main.dart réécrit par flutter create : restauration"
|
||||||
|
cp "$MAIN_BACKUP" lib/main.dart
|
||||||
|
fi
|
||||||
|
rm -f "$MAIN_BACKUP"
|
||||||
|
# `flutter create --org com.xtremflow` écrit com.xtremflow.xtremobile ; on
|
||||||
|
# aligne sur l'applicationId Android (la cible RunnerTests suit le suffixe).
|
||||||
|
sed -i '' "s/com\.xtremflow\.xtremobile/${BUNDLE_ID}/g" "$PBXPROJ"
|
||||||
|
else
|
||||||
|
echo "==> Dossier ios/ déjà présent : génération ignorée"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Cible de déploiement : iOS ${DEPLOYMENT_TARGET}"
|
||||||
|
perl -pi -e "s/IPHONEOS_DEPLOYMENT_TARGET = [0-9.]+;/IPHONEOS_DEPLOYMENT_TARGET = ${DEPLOYMENT_TARGET};/g" "$PBXPROJ"
|
||||||
|
perl -pi -e "s/^#?\s*platform :ios.*/platform :ios, '${DEPLOYMENT_TARGET}'/" "$PODFILE"
|
||||||
|
|
||||||
|
# Les pods (dont libmpv de media_kit) doivent viser la même version qu'ici,
|
||||||
|
# sinon CocoaPods refuse la résolution. Ajouté une seule fois.
|
||||||
|
if ! grep -q "IPHONEOS_DEPLOYMENT_TARGET" "$PODFILE"; then
|
||||||
|
perl -0pi -e "s/(flutter_additional_ios_build_settings\(target\))/\$1\n target.build_configurations.each do |config|\n config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${DEPLOYMENT_TARGET}'\n end/" "$PODFILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Info.plist"
|
||||||
|
|
||||||
|
plist_set() { # clé, type, valeur
|
||||||
|
$PLISTBUDDY -c "Set :$1 $3" "$PLIST" 2>/dev/null || $PLISTBUDDY -c "Add :$1 $2 $3" "$PLIST"
|
||||||
|
}
|
||||||
|
plist_reset() { # supprime une clé composite avant de la reconstruire
|
||||||
|
$PLISTBUDDY -c "Delete :$1" "$PLIST" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
plist_set CFBundleDisplayName string "$DISPLAY_NAME"
|
||||||
|
plist_set CFBundleName string "$DISPLAY_NAME"
|
||||||
|
|
||||||
|
# Les serveurs Xtream Codes sont très souvent en http:// simple : sans cette
|
||||||
|
# exception, App Transport Security bloque toutes les requêtes et tous les
|
||||||
|
# flux vidéo, sans message d'erreur exploitable côté Dart.
|
||||||
|
plist_reset NSAppTransportSecurity
|
||||||
|
$PLISTBUDDY -c "Add :NSAppTransportSecurity dict" "$PLIST"
|
||||||
|
$PLISTBUDDY -c "Add :NSAppTransportSecurity:NSAllowsArbitraryLoads bool true" "$PLIST"
|
||||||
|
|
||||||
|
# Lecture audio quand l'écran est verrouillé / l'app en arrière-plan.
|
||||||
|
plist_reset UIBackgroundModes
|
||||||
|
$PLISTBUDDY -c "Add :UIBackgroundModes array" "$PLIST"
|
||||||
|
$PLISTBUDDY -c "Add :UIBackgroundModes:0 string audio" "$PLIST"
|
||||||
|
|
||||||
|
# Un serveur Xtream hébergé sur le réseau local déclenche la demande
|
||||||
|
# d'autorisation « réseau local » d'iOS 14+ ; sans description, elle est refusée.
|
||||||
|
plist_set NSLocalNetworkUsageDescription string "Nécessaire pour joindre un serveur IPTV hébergé sur votre réseau local."
|
||||||
|
|
||||||
|
# Orientations : paysage indispensable pour le lecteur, les quatre sens sur iPad.
|
||||||
|
plist_reset UISupportedInterfaceOrientations
|
||||||
|
$PLISTBUDDY -c "Add :UISupportedInterfaceOrientations array" "$PLIST"
|
||||||
|
i=0
|
||||||
|
for o in UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight; do
|
||||||
|
$PLISTBUDDY -c "Add :UISupportedInterfaceOrientations:$i string $o" "$PLIST"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
plist_reset "UISupportedInterfaceOrientations~ipad"
|
||||||
|
$PLISTBUDDY -c "Add :UISupportedInterfaceOrientations~ipad array" "$PLIST"
|
||||||
|
i=0
|
||||||
|
for o in UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown \
|
||||||
|
UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight; do
|
||||||
|
$PLISTBUDDY -c "Add :UISupportedInterfaceOrientations~ipad:$i string $o" "$PLIST"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "==> Projet iOS prêt (bundle id : ${BUNDLE_ID})"
|
||||||
@@ -1,25 +1,80 @@
|
|||||||
# Task: Corrections Fire Stick & UI - TERMINÉ
|
# Task: Alignement sur xtremflow — EPG XMLTV, thème, stabilité des flux
|
||||||
|
|
||||||
## Bugs corrigés
|
## 1. EPG — repli XMLTV quand le fournisseur ne répond pas
|
||||||
|
|
||||||
### Session 1 (07h45)
|
- [x] Nouveau `lib/features/iptv/services/xmltv_service.dart` : téléchargement,
|
||||||
|
gunzip transparent, parse **en flux** (`toXmlEvents`), index réduit à une
|
||||||
|
fenêtre `-3 h / +18 h`, cache disque JSON (TTL 6 h)
|
||||||
|
- [x] Chaîne de repli dans `XtreamServiceMobile` :
|
||||||
|
`get_short_epg` → `{dns}/xmltv.php` du fournisseur → URL personnalisée →
|
||||||
|
source communautaire (`epgshare01` FR1)
|
||||||
|
- [x] Correspondance par `epg_channel_id`, puis par nom normalisé (accents,
|
||||||
|
suffixes `HD/FHD/4K`, préfixes `FR:` / `|FR|`)
|
||||||
|
- [x] Index `streamId → Channel` alimenté par `getLiveChannels` : aucun site
|
||||||
|
d'appel de `getShortEPG` n'a changé
|
||||||
|
- [x] Sémaphore EPG (3 requêtes max), comme xtremflow — une grille de 30 tuiles
|
||||||
|
ne noie plus le panel et ne vide plus l'EPG de tout l'écran
|
||||||
|
- [x] Réglages : « EPG communautaire » (on/off) + « URL XMLTV personnalisée »
|
||||||
|
|
||||||
1. ✅ **Mise en veille pendant lecture** - Ajout de `wakelock_plus` dans les deux players
|
## 2. Thème — port de la charte « Warm Cinema » de xtremflow
|
||||||
2. ✅ **Settings surbrillance invisible** - Toutes les box utilisent TVFocusable avec bordure blanche
|
|
||||||
3. ✅ **Player Lite bouton pause** - Descendu et rendu semi-transparent
|
|
||||||
|
|
||||||
### Session 2 (18h15)
|
- [x] `app_colors.dart` : palette ember/charbon/crème, alias de compatibilité
|
||||||
|
conservés pour les 21 symboles Apple TV déjà utilisés
|
||||||
|
- [x] `app_theme.dart` : typo **Fraunces** (display) + **Karla** (UI), tokens
|
||||||
|
spacing/radius/durations/curves alignés sur xtremflow
|
||||||
|
- [x] `mobile_theme.dart` : dérive désormais de `AppTheme` (échelle typo mobile)
|
||||||
|
au lieu de dupliquer 390 lignes
|
||||||
|
- [x] `app_decorations.dart` : hexs Apple codés en dur remplacés par la palette
|
||||||
|
- [x] **Passe 2 (v1.6.1)** — le thème ne suffisait pas : du bleu restait codé en
|
||||||
|
dur dans 19 fichiers d'écran/widget, hors palette. Balayage complet :
|
||||||
|
- `0xFF0A84FF` (bleu Apple) dans la **pilule de navigation sélectionnée**
|
||||||
|
(`mobile_scaffold`), les icônes de catégorie et le bouton « ajouter »
|
||||||
|
- `0xFF4FC3F7` / `0xFF1565C0` (bleus) dans les overlays du lecteur
|
||||||
|
- `Colors.blueAccent` / `Colors.blue` dans les boîtes de dialogue
|
||||||
|
- gris Apple `1C1C1E` / `2C2C2E` / `3A3A3C` / `8E8E93` → charbons chauds
|
||||||
|
- `Colors.white*` (128 occurrences) → paliers crème `onSurfaceNN`
|
||||||
|
- `Colors.red/green/amber/yellow/grey*` → `error` / `success` /
|
||||||
|
`ratingGold` / `outlineVariant`
|
||||||
|
- noir pur sur remplissage clair → encre chaude `onPrimaryFixed`
|
||||||
|
- halo de focus blanc → halo ember
|
||||||
|
- Conservés en noir vrai : fonds de surface vidéo et voiles (`scrims`)
|
||||||
|
- Vérification : plus **aucune** couleur Material/Apple codée en dur hors
|
||||||
|
de `lib/core/theme/`
|
||||||
|
|
||||||
4. ✅ **Retour depuis player TV** - Le bouton retour ramène maintenant à la liste des chaînes (pas aux catégories)
|
## 3. Lecture — arrêt en arrière-plan et tenue dans la durée
|
||||||
|
|
||||||
## Fichiers modifiés
|
- [x] `AppLifecycleState.inactive` retiré du déclencheur d'arrêt : il se
|
||||||
|
déclenchait au moindre volet de notifications
|
||||||
|
- [x] `_releasePlayback()` sur `paused` / `hidden` / `detached` — arrêt du flux,
|
||||||
|
libération du décodeur, annulation des timers, wakelock coupé
|
||||||
|
(les deux moteurs : `native_player_screen` et `lite_player_screen`)
|
||||||
|
- [x] Verrou `_isReleased` : watchdog et reconnexions ne peuvent plus relancer
|
||||||
|
un flux depuis l'arrière-plan
|
||||||
|
- [x] Abonnements aux `Stream` du player stockés et annulés au `dispose`
|
||||||
|
- [x] `demuxer-max-back-bytes` plafonné à 8 Mo (défaut mpv : 50 Mio) — évite la
|
||||||
|
dérive mémoire qui finissait en kill Android après plusieurs heures
|
||||||
|
- [x] `setState` de position limité aux moments où les contrôles sont visibles ;
|
||||||
|
horloge ne reconstruit qu'au changement de minute
|
||||||
|
- [x] FocusNodes du lecteur Lite libérés au `dispose`
|
||||||
|
|
||||||
- `pubspec.yaml` - Ajout de `wakelock_plus`
|
## 4. Vitesse de chargement
|
||||||
- `native_player_screen.dart` - WakeLock activé/désactivé
|
|
||||||
- `lite_player_screen.dart` - WakeLock + nouveau design boutons + seek réduit
|
|
||||||
- `mobile_settings_tab.dart` - TVFocusable sur toutes les options
|
|
||||||
- `mobile_live_tv_tab.dart` - Correction navigation retour depuis player
|
|
||||||
|
|
||||||
## APK généré
|
- [x] `prewarmHost()` : pré-résolution DNS + socket TCP/TLS ouvert puis fermé
|
||||||
|
avant l'ouverture du flux (aucune requête HTTP, donc aucun slot de
|
||||||
|
connexion consommé chez le fournisseur), plafonné à 1,5 s
|
||||||
|
|
||||||
`build\app\outputs\flutter-apk\app-release.apk` (92.3MB)
|
> Non retenu par l'utilisateur : décodage matériel VOD, réduction du probe,
|
||||||
|
> audio multicanal natif. Le profil de décodage reste inchangé.
|
||||||
|
|
||||||
|
## Vérifications
|
||||||
|
|
||||||
|
- [x] `flutter analyze` : 0 erreur / 0 warning sur les fichiers touchés
|
||||||
|
(3 warnings de code mort restants, déjà présents dans HEAD)
|
||||||
|
- [x] `flutter test` : 19/19 (18 unitaires + 1 sur un vrai dump)
|
||||||
|
- [x] Vrai dump `epg_ripper_FR1.xml.gz` (5,4 Mo gz / 46 Mo XML / 69 802
|
||||||
|
programmes) : 517 chaînes indexées en 1,1 s, 5/5 correspondances
|
||||||
|
- [x] `flutter build apk --release` → `app-release.apk` (94,3 Mo)
|
||||||
|
|
||||||
|
Non corrigé (pré-existant, hors périmètre) : erreurs d'analyse dans
|
||||||
|
`lib/core/utils/memory_profiler.dart` et `hive_encryption_benchmark.dart`
|
||||||
|
(orphelins, hors graphe d'import) et `test/widget_test.dart`.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:xtremobile/features/iptv/services/xmltv_service.dart';
|
||||||
|
|
||||||
|
/// Parses a real community XMLTV dump end to end.
|
||||||
|
///
|
||||||
|
/// Skipped unless `XMLTV_FIXTURE` points at a `.xml`/`.xml.gz` file, so CI and
|
||||||
|
/// day-to-day runs stay offline and fast:
|
||||||
|
///
|
||||||
|
/// flutter test test/xmltv_real_dump_test.dart \
|
||||||
|
/// --dart-define=XMLTV_FIXTURE=C:\path\epg_ripper_FR1.xml.gz
|
||||||
|
const String _fixturePath = String.fromEnvironment('XMLTV_FIXTURE');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'indexes a real XMLTV dump within a mobile memory budget',
|
||||||
|
() async {
|
||||||
|
final file = File(_fixturePath);
|
||||||
|
expect(file.existsSync(), isTrue, reason: 'fixture not found');
|
||||||
|
|
||||||
|
final service = XmltvService(Directory.systemTemp.path);
|
||||||
|
final started = DateTime.now();
|
||||||
|
|
||||||
|
final indexed = await service.indexFromBytes(file.openRead());
|
||||||
|
|
||||||
|
final elapsed = DateTime.now().difference(started);
|
||||||
|
final peakMb = ProcessInfo.maxRss / 1048576;
|
||||||
|
|
||||||
|
// ignore: avoid_print
|
||||||
|
print(
|
||||||
|
'channels=${service.channelCount} '
|
||||||
|
'time=${elapsed.inMilliseconds}ms '
|
||||||
|
'peakRss=${peakMb.toStringAsFixed(1)}MB',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(indexed, isTrue);
|
||||||
|
expect(service.channelCount, greaterThan(100));
|
||||||
|
|
||||||
|
// The whole point of the streaming parse: a ~46 MB document must never
|
||||||
|
// be resident. Generous ceiling to stay stable across VM versions.
|
||||||
|
expect(peakMb, lessThan(400));
|
||||||
|
|
||||||
|
// Spot-check the matching paths with labels shaped the way an Xtream
|
||||||
|
// provider reports them.
|
||||||
|
const probes = <List<String>>[
|
||||||
|
['', 'TF1 HD'],
|
||||||
|
['', 'FR: France 2'],
|
||||||
|
['', 'M6 FHD'],
|
||||||
|
['', '|FR| France 5'],
|
||||||
|
['', 'Arte'],
|
||||||
|
];
|
||||||
|
|
||||||
|
var hits = 0;
|
||||||
|
for (final probe in probes) {
|
||||||
|
final epg = service.shortEpgFor(
|
||||||
|
streamId: 'x',
|
||||||
|
epgChannelId: probe[0],
|
||||||
|
channelName: probe[1],
|
||||||
|
);
|
||||||
|
// ignore: avoid_print
|
||||||
|
print(' ${probe[1].padRight(16)} -> ${epg?.title ?? "NO MATCH"}');
|
||||||
|
if (epg != null) hits++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hits, greaterThanOrEqualTo(4),
|
||||||
|
reason: 'name matching should resolve mainstream French channels');
|
||||||
|
},
|
||||||
|
skip: _fixturePath.isEmpty ? 'set --dart-define=XMLTV_FIXTURE' : false,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:xtremobile/features/iptv/services/xmltv_service.dart';
|
||||||
|
|
||||||
|
/// Fixture shaped exactly like the community dumps the fallback consumes
|
||||||
|
/// (channel ids with dots, `+0200` offsets, base-less titles).
|
||||||
|
String _fixture(DateTime now) {
|
||||||
|
String stamp(DateTime value) {
|
||||||
|
String two(int n) => n.toString().padLeft(2, '0');
|
||||||
|
final utc = value.toUtc();
|
||||||
|
return '${utc.year}${two(utc.month)}${two(utc.day)}'
|
||||||
|
'${two(utc.hour)}${two(utc.minute)}${two(utc.second)} +0000';
|
||||||
|
}
|
||||||
|
|
||||||
|
final airingStart = now.subtract(const Duration(minutes: 20));
|
||||||
|
final airingEnd = now.add(const Duration(minutes: 40));
|
||||||
|
final nextEnd = airingEnd.add(const Duration(minutes: 50));
|
||||||
|
final farFuture = now.add(const Duration(days: 4));
|
||||||
|
|
||||||
|
return '''<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<tv generator-info-name="none">
|
||||||
|
<channel id="TF1.fr">
|
||||||
|
<display-name lang="fr">TF1</display-name>
|
||||||
|
</channel>
|
||||||
|
<channel id="France.3.-.Nord.Pas-de-Calais.fr">
|
||||||
|
<display-name lang="fr">France 3 - Nord Pas-de-Calais</display-name>
|
||||||
|
</channel>
|
||||||
|
<channel id="Cine.Premier.fr">
|
||||||
|
<display-name lang="fr">Ciné+ Premier</display-name>
|
||||||
|
</channel>
|
||||||
|
<programme channel="TF1.fr" start="${stamp(airingStart)}" stop="${stamp(airingEnd)}">
|
||||||
|
<title lang="fr">Journal de 20h</title>
|
||||||
|
<desc lang="fr">Le rendez-vous d'information.</desc>
|
||||||
|
</programme>
|
||||||
|
<programme channel="TF1.fr" start="${stamp(airingEnd)}" stop="${stamp(nextEnd)}">
|
||||||
|
<title lang="fr">Film du mardi</title>
|
||||||
|
</programme>
|
||||||
|
<programme channel="France.3.-.Nord.Pas-de-Calais.fr" start="${stamp(airingStart)}" stop="${stamp(airingEnd)}">
|
||||||
|
<title lang="fr">Ici 19/20</title>
|
||||||
|
</programme>
|
||||||
|
<programme channel="Cine.Premier.fr" start="${stamp(farFuture)}" stop="${stamp(farFuture.add(const Duration(hours: 1)))}">
|
||||||
|
<title lang="fr">Hors fenêtre</title>
|
||||||
|
</programme>
|
||||||
|
</tv>''';
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<List<int>> _bytes(String xml) => Stream.value(utf8.encode(xml));
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('parseXmltvTime', () {
|
||||||
|
test('applies the trailing UTC offset', () {
|
||||||
|
final parsed = XmltvService.parseXmltvTime('20260811183000 +0200');
|
||||||
|
expect(parsed, isNotNull);
|
||||||
|
expect(
|
||||||
|
parsed!.toUtc(),
|
||||||
|
DateTime.utc(2026, 8, 11, 16, 30),
|
||||||
|
reason: '18:30 at +0200 is 16:30 UTC',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles a negative offset', () {
|
||||||
|
final parsed = XmltvService.parseXmltvTime('20260811183000 -0430');
|
||||||
|
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 23, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats a missing offset as UTC', () {
|
||||||
|
final parsed = XmltvService.parseXmltvTime('20260811183000');
|
||||||
|
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 18, 30));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed input', () {
|
||||||
|
expect(XmltvService.parseXmltvTime(null), isNull);
|
||||||
|
expect(XmltvService.parseXmltvTime(''), isNull);
|
||||||
|
expect(XmltvService.parseXmltvTime('2026081118'), isNull);
|
||||||
|
expect(XmltvService.parseXmltvTime('not-a-timestamp!'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('normalizeName', () {
|
||||||
|
test('collapses quality suffixes', () {
|
||||||
|
expect(XmltvService.normalizeName('TF1 HD'), 'tf1');
|
||||||
|
expect(XmltvService.normalizeName('tf1-fhd'), 'tf1');
|
||||||
|
expect(XmltvService.normalizeName('TF1 4K'), 'tf1');
|
||||||
|
expect(XmltvService.normalizeName('TF1'), 'tf1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips country prefixes in both forms', () {
|
||||||
|
expect(XmltvService.normalizeName('FR: TF1'), 'tf1');
|
||||||
|
expect(XmltvService.normalizeName('|FR| TF1 HD'), 'tf1');
|
||||||
|
expect(XmltvService.normalizeName('[FR] TF1'), 'tf1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a dash-separated name intact', () {
|
||||||
|
// "Ciné - Premier" must not lose its first word to prefix stripping.
|
||||||
|
expect(XmltvService.normalizeName('Ciné - Premier'), 'cinepremier');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('folds accents and keeps the plus sign', () {
|
||||||
|
expect(XmltvService.normalizeName('Ciné+ Émotion'), 'cine+emotion');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is empty for a blank label', () {
|
||||||
|
expect(XmltvService.normalizeName(' '), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('indexFromBytes + lookup', () {
|
||||||
|
late XmltvService service;
|
||||||
|
late DateTime now;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
service = XmltvService(Directory.systemTemp.path);
|
||||||
|
now = DateTime.now();
|
||||||
|
final indexed = await service.indexFromBytes(
|
||||||
|
_bytes(_fixture(now)),
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
expect(indexed, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches on epg_channel_id', () {
|
||||||
|
final epg = service.shortEpgFor(
|
||||||
|
streamId: '123',
|
||||||
|
epgChannelId: 'TF1.fr',
|
||||||
|
channelName: 'anything',
|
||||||
|
);
|
||||||
|
expect(epg, isNotNull);
|
||||||
|
expect(epg!.title, 'Journal de 20h');
|
||||||
|
expect(epg.id, '123');
|
||||||
|
expect(epg.description, "Le rendez-vous d'information.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the channel name when the id is unknown', () {
|
||||||
|
final epg = service.shortEpgFor(
|
||||||
|
streamId: '456',
|
||||||
|
epgChannelId: '',
|
||||||
|
channelName: 'FR: TF1 HD',
|
||||||
|
);
|
||||||
|
expect(epg, isNotNull);
|
||||||
|
expect(epg!.title, 'Journal de 20h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches a name reconstructed from a dotted channel id', () {
|
||||||
|
final epg = service.shortEpgFor(
|
||||||
|
streamId: '789',
|
||||||
|
epgChannelId: 'not-in-guide',
|
||||||
|
channelName: 'France 3 - Nord Pas-de-Calais',
|
||||||
|
);
|
||||||
|
expect(epg, isNotNull);
|
||||||
|
expect(epg!.title, 'Ici 19/20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the currently airing programme, not the next one', () {
|
||||||
|
final programme = service.programmeFor(
|
||||||
|
epgChannelId: 'TF1.fr',
|
||||||
|
at: now,
|
||||||
|
);
|
||||||
|
expect(programme!.title, 'Journal de 20h');
|
||||||
|
expect(programme.covers(now), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls forward to the next entry when nothing is airing', () {
|
||||||
|
// 10 minutes into the gap-free schedule's second slot.
|
||||||
|
final during = now.add(const Duration(minutes: 50));
|
||||||
|
final programme = service.programmeFor(
|
||||||
|
epgChannelId: 'TF1.fr',
|
||||||
|
at: during,
|
||||||
|
);
|
||||||
|
expect(programme!.title, 'Film du mardi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops programmes outside the retention window', () {
|
||||||
|
// The Ciné+ entry is 4 days out — beyond windowForward, so the channel
|
||||||
|
// holds no programme at all.
|
||||||
|
final epg = service.shortEpgFor(
|
||||||
|
streamId: '999',
|
||||||
|
epgChannelId: 'Cine.Premier.fr',
|
||||||
|
);
|
||||||
|
expect(epg, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for a channel absent from the guide', () {
|
||||||
|
final epg = service.shortEpgFor(
|
||||||
|
streamId: '000',
|
||||||
|
epgChannelId: 'unknown.channel',
|
||||||
|
channelName: 'Chaîne Inconnue',
|
||||||
|
);
|
||||||
|
expect(epg, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses a gzipped payload transparently', () async {
|
||||||
|
final gzipped = XmltvService(Directory.systemTemp.path);
|
||||||
|
final compressed = gzip.encode(utf8.encode(_fixture(now)));
|
||||||
|
final indexed = await gzipped.indexFromBytes(
|
||||||
|
Stream.value(compressed),
|
||||||
|
now: now,
|
||||||
|
);
|
||||||
|
expect(indexed, isTrue);
|
||||||
|
expect(
|
||||||
|
gzipped.shortEpgFor(streamId: '1', epgChannelId: 'TF1.fr')?.title,
|
||||||
|
'Journal de 20h',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports failure on a payload with no programme', () async {
|
||||||
|
final empty = XmltvService(Directory.systemTemp.path);
|
||||||
|
final indexed = await empty.indexFromBytes(
|
||||||
|
_bytes('<?xml version="1.0"?><tv></tv>'),
|
||||||
|
);
|
||||||
|
expect(indexed, isFalse);
|
||||||
|
expect(empty.isLoaded, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||