mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
462bcc784c | ||
|
|
60c7f4cece | ||
|
|
9ce707a702 | ||
|
|
f105becd79 | ||
|
|
93d5741c0c | ||
|
|
2e88a56943 | ||
|
|
26bf240b76 | ||
|
|
186f9f89f9 | ||
|
|
3c37c6a6e6 | ||
|
|
c096532fec | ||
|
|
05ca6f85be | ||
|
|
04e9ab5730 | ||
|
|
9b6b7faf4a | ||
|
|
694b1257b9 |
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
|
||||
+13
@@ -32,6 +32,19 @@ migrate_working_dir/
|
||||
|
||||
# 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
|
||||
app.*.symbols
|
||||
|
||||
|
||||
+178
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,6 +7,18 @@ plugins {
|
||||
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 {
|
||||
namespace = "com.example.xtremflow"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -30,11 +44,24 @@ android {
|
||||
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 {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = if (hasReleaseKeystore) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/logo_xtremflow.png"
|
||||
# L'App Store et iOS refusent une icône avec canal alpha.
|
||||
remove_alpha_ios: true
|
||||
+124
-117
@@ -1,115 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// XtremFlow — "Warm Cinema" Design System (ported from the web app)
|
||||
/// XtremFlow — "Apple Glass Gray" Design System
|
||||
///
|
||||
/// Palette: charbon chaud + crème + orange brûlé (ember).
|
||||
/// Inspiration: salle de projection, pellicule, affiches 70s.
|
||||
/// Palette : graphite neutre + blanc cassé + bleu système Apple.
|
||||
/// Inspiration : verre dépoli iOS/visionOS — des gris francs, jamais un noir
|
||||
/// pur, pour une interface nettement moins sombre que l'ancien thème.
|
||||
///
|
||||
/// Règle de perf : AUCUNE couleur de surface n'est translucide. La profondeur
|
||||
/// vient des ombres et du contraste, pas du flou — pas de `BackdropFilter`,
|
||||
/// coûteux à chaque frame sur les Android d'entrée de gamme.
|
||||
///
|
||||
/// Les noms hérités de l'ancienne charte Apple TV (`appleBlue`,
|
||||
/// `glossyCardGradient`, `appleTvGradient`, …) sont conservés en alias pour ne
|
||||
/// pas casser les sites d'appel existants ; ils pointent désormais vers les
|
||||
/// teintes chaudes.
|
||||
/// 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 {
|
||||
AppColors._();
|
||||
|
||||
// ============ BASE LEVEL 0 ============
|
||||
/// Noir chaud — le "noir de salle", jamais un noir pur bleuté.
|
||||
static const Color baseLevel0 = Color(0xFF0B0908);
|
||||
/// Graphite profond — le niveau le plus bas, jamais un noir pur.
|
||||
static const Color baseLevel0 = Color(0xFF1B1B1F);
|
||||
|
||||
// ============ BACKGROUNDS ============
|
||||
static const Color background = Color(0xFF100C0A);
|
||||
static const Color onBackground = Color(0xFFF3E9DF);
|
||||
static const Color background = Color(0xFF26262C);
|
||||
static const Color onBackground = Color(0xFFF5F5F7);
|
||||
|
||||
static const Color surface = Color(0xFF100C0A);
|
||||
static const Color surfaceDim = Color(0xFF0B0908);
|
||||
static const Color surfaceBright = Color(0xFF3C322C);
|
||||
static const Color surface = Color(0xFF26262C);
|
||||
static const Color surfaceDim = Color(0xFF1F1F24);
|
||||
static const Color surfaceBright = Color(0xFF54545E);
|
||||
|
||||
// Surface Containers (hiérarchie Material 3, toutes opaques)
|
||||
static const Color surfaceContainerLowest = Color(0xFF070505);
|
||||
static const Color surfaceContainerLow = Color(0xFF181310);
|
||||
static const Color surfaceContainer = Color(0xFF1F1815);
|
||||
static const Color surfaceContainerHigh = Color(0xFF2A211C);
|
||||
static const Color surfaceContainerHighest = Color(0xFF352A24);
|
||||
static const Color surfaceVariant = Color(0xFF1F1815);
|
||||
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);
|
||||
|
||||
// ============ SURFACE / ON SURFACE ============
|
||||
static const Color onSurface = Color(0xFFF3E9DF); // crème
|
||||
static const Color onSurfaceVariant = Color(0xFFC5B3A5); // crème atténué
|
||||
static const Color onSurface = Color(0xFFF5F5F7); // blanc Apple
|
||||
static const Color onSurfaceVariant = Color(0xFFC7C7CE); // gris clair
|
||||
|
||||
// ============ PRIMARY (Ember / orange brûlé) ============
|
||||
static const Color primary = Color(0xFFFFB68C); // texte & icônes sur sombre
|
||||
static const Color onPrimary = Color(0xFF521B00);
|
||||
static const Color primaryContainer = Color(0xFFD9541F); // remplissage ember
|
||||
static const Color onPrimaryContainer = Color(0xFF2B0A00);
|
||||
static const Color inversePrimary = Color(0xFFA63B10);
|
||||
// ============ PRIMARY (Bleu système Apple) ============
|
||||
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);
|
||||
|
||||
/// Braise profonde — réservée aux **remplissages portant du texte**.
|
||||
/// `primaryContainer` (#D9541F) est trop lumineux : du blanc dessus plafonne
|
||||
/// vers 3,9:1, 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(0xFFA8360C);
|
||||
/// Bleu profond — réservé aux **remplissages portant du texte**.
|
||||
/// `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);
|
||||
|
||||
// ============ SECONDARY (Terre cuite / argile) ============
|
||||
static const Color secondary = Color(0xFFE7C3AC);
|
||||
static const Color onSecondary = Color(0xFF442A1B);
|
||||
static const Color secondaryContainer = Color(0xFF5D4030);
|
||||
static const Color onSecondaryContainer = Color(0xFFFFDCC8);
|
||||
/// Encre froide — remplace le noir pur pour du texte/des icônes posés sur un
|
||||
/// remplissage clair (blanc cassé, bleu clair). Garde le contraste sans le
|
||||
/// 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);
|
||||
|
||||
// ============ TERTIARY (Laiton / doré patiné) ============
|
||||
static const Color tertiary = Color(0xFFD9C88C);
|
||||
static const Color onTertiary = Color(0xFF3A3016);
|
||||
static const Color tertiaryContainer = Color(0xFF52472A);
|
||||
static const Color onTertiaryContainer = Color(0xFFF6E4A6);
|
||||
// ============ SECONDARY (Argent / gris clair) ============
|
||||
static const Color secondary = Color(0xFFD1D1D6);
|
||||
static const Color onSecondary = Color(0xFF303036);
|
||||
static const Color secondaryContainer = Color(0xFF44444D);
|
||||
static const Color onSecondaryContainer = Color(0xFFE8E8ED);
|
||||
|
||||
// ============ TERTIARY (Cyan glacé) ============
|
||||
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(0xFFFFB4A6);
|
||||
static const Color onError = Color(0xFF5F1409);
|
||||
static const Color errorContainer = Color(0xFF8C2416);
|
||||
static const Color onErrorContainer = Color(0xFFFFDAD4);
|
||||
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(0xFF9C8878);
|
||||
static const Color outlineVariant = Color(0xFF4A3C33);
|
||||
static const Color outline = Color(0xFF8E8E93);
|
||||
static const Color outlineVariant = Color(0xFF48484E);
|
||||
|
||||
// ============ INVERSE SURFACE ============
|
||||
static const Color inverseSurface = Color(0xFFF3E9DF);
|
||||
static const Color inverseOnSurface = Color(0xFF2A211C);
|
||||
static const Color surfaceTint = Color(0xFFFFB68C);
|
||||
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(0xFFF3E9DF);
|
||||
static const Color textSecondary = Color(0xFFC5B3A5);
|
||||
static const Color textTertiary = Color(0xFF9C8878);
|
||||
static const Color textPrimary = Color(0xFFF5F5F7);
|
||||
static const Color textSecondary = Color(0xFFC7C7CE);
|
||||
static const Color textTertiary = Color(0xFF98989F);
|
||||
|
||||
// ============ CRÈME À OPACITÉ ============
|
||||
// Un blanc pur sur fond chaud tire vers le bleu : on décline la crème.
|
||||
static const Color onSurface06 = Color(0x0FF3E9DF);
|
||||
static const Color onSurface12 = Color(0x1FF3E9DF);
|
||||
static const Color onSurface24 = Color(0x3DF3E9DF);
|
||||
static const Color onSurface38 = Color(0x61F3E9DF);
|
||||
static const Color onSurface54 = Color(0x8AF3E9DF);
|
||||
// ============ 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(0xFF9BC46A); // olive lumineux
|
||||
static const Color warning = Color(0xFFE9B23C); // ambre
|
||||
static const Color info = Color(0xFFA3B8C4); // bleu-gris fumé
|
||||
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(0xFFE5484D); // rouge enseigne
|
||||
static const Color movies = Color(0xFFD9541F); // ember
|
||||
static const Color series = Color(0xFFD9C88C); // laiton
|
||||
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(0xFFE9B23C);
|
||||
static const Color premiumGold = Color(0xFFC9922A);
|
||||
static const Color ratingGold = Color(0xFFFFD60A);
|
||||
static const Color premiumGold = Color(0xFFE0B84C);
|
||||
|
||||
// ============ SHIMMER (placeholders de chargement) ============
|
||||
static const Color shimmer = Color(0xFF2A211C);
|
||||
static const Color shimmerHighlight = Color(0xFF3C302A);
|
||||
static const Color shimmer = Color(0xFF32323A);
|
||||
static const Color shimmerHighlight = Color(0xFF45454F);
|
||||
|
||||
// ============ BORDERS / FOCUS ============
|
||||
static const Color border = outlineVariant;
|
||||
@@ -117,61 +123,62 @@ class AppColors {
|
||||
static const Color focusBorder = primaryContainer;
|
||||
static const Color borderFocused = primaryContainer;
|
||||
|
||||
/// Liseré crème 8 % — définit l'arête d'une surface sans la faire flotter.
|
||||
static const Color glassBorderColor = Color(0x14F3E9DF);
|
||||
/// 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(0xFF181310);
|
||||
static const Color glassLevel1Border = Color(0x14F3E9DF); // crème 8 %
|
||||
static const Color glassLevel2Bg = Color(0xFF1F1815);
|
||||
static const Color glassLevel2Border = Color(0x1FF3E9DF); // crème 12 %
|
||||
static const Color glassLevel2InnerGlow = Color(0x3DD9541F); // ember 24 %
|
||||
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 ============
|
||||
/// Braise : orange brûlé vers rouge profond.
|
||||
/// Bleu système : clair vers profond.
|
||||
static const LinearGradient primaryGradient = LinearGradient(
|
||||
colors: [Color(0xFFE9701F), Color(0xFFC13A1C)],
|
||||
colors: [Color(0xFF4DA2FF), Color(0xFF0A6FDC)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
);
|
||||
|
||||
/// Fond de salle : projecteur chaud en haut, obscurité en bas.
|
||||
/// Fond "verre dépoli" : gris clair en haut, graphite en bas.
|
||||
static const LinearGradient backgroundGradient = LinearGradient(
|
||||
colors: [Color(0xFF1A1310), Color(0xFF0B0908)],
|
||||
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
);
|
||||
|
||||
/// Halo de projection depuis le centre de l'écran.
|
||||
/// Halo doux depuis le centre de l'écran.
|
||||
static const RadialGradient projectorGradient = RadialGradient(
|
||||
center: Alignment.center,
|
||||
radius: 1.5,
|
||||
colors: [Color(0xFF1A1310), Color(0xFF0B0908)],
|
||||
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
|
||||
stops: [0.0, 1.0],
|
||||
);
|
||||
|
||||
/// Carte : arête éclairée en haut, matière sombre en bas.
|
||||
/// Carte : arête éclairée en haut, matière grise en bas.
|
||||
static const LinearGradient cardGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF352A24), Color(0xFF181310)],
|
||||
colors: [Color(0xFF45454F), Color(0xFF2C2C33)],
|
||||
stops: [0.0, 1.0],
|
||||
);
|
||||
|
||||
/// Reflet chaud posé sur le haut d'une carte.
|
||||
/// Reflet posé sur le haut d'une carte — l'effet "verre".
|
||||
static const LinearGradient cardHighlight = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x1FF3E9DF), Color(0x00F3E9DF)],
|
||||
colors: [Color(0x29FFFFFF), Color(0x00FFFFFF)],
|
||||
stops: [0.0, 0.5],
|
||||
);
|
||||
|
||||
/// Voile d'affiche : lecture du texte par-dessus une jaquette.
|
||||
static const LinearGradient posterScrim = LinearGradient(
|
||||
colors: [Color(0x000B0908), Color(0xCC0B0908), Color(0xFF0B0908)],
|
||||
colors: [Color(0x001F1F24), Color(0xCC1F1F24), Color(0xFF1F1F24)],
|
||||
stops: [0.0, 0.55, 1.0],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
@@ -179,7 +186,7 @@ class AppColors {
|
||||
|
||||
/// Voile latéral pour les héros pleine largeur.
|
||||
static const LinearGradient heroScrim = LinearGradient(
|
||||
colors: [Color(0xF20B0908), Color(0x990B0908), Color(0x000B0908)],
|
||||
colors: [Color(0xF21F1F24), Color(0x991F1F24), Color(0x001F1F24)],
|
||||
stops: [0.0, 0.45, 1.0],
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
@@ -187,43 +194,43 @@ class AppColors {
|
||||
|
||||
// ============ GLOW / OMBRES ============
|
||||
static Color glowPrimary(double opacity) =>
|
||||
const Color(0xFFD9541F).withValues(alpha: opacity);
|
||||
const Color(0xFF0A84FF).withValues(alpha: opacity);
|
||||
static Color glowPrimaryDim(double opacity) =>
|
||||
const Color(0xFFFFB68C).withValues(alpha: opacity);
|
||||
const Color(0xFF8AB9FF).withValues(alpha: opacity);
|
||||
|
||||
/// Ombre portée « projecteur » : profonde, chaude, jamais grise.
|
||||
/// Ombre portée douce — moins dense qu'avant : le fond est plus clair.
|
||||
static List<BoxShadow> lift({double intensity = 1.0}) => [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF000000).withValues(alpha: 0.55 * intensity),
|
||||
color: const Color(0xFF000000).withValues(alpha: 0.40 * intensity),
|
||||
blurRadius: 28 * intensity,
|
||||
spreadRadius: -8,
|
||||
offset: Offset(0, 12 * intensity),
|
||||
),
|
||||
];
|
||||
|
||||
/// Halo ember pour l'élément focalisé (navigation TV / télécommande).
|
||||
/// Halo bleu pour l'élément focalisé (navigation TV / télécommande).
|
||||
/// Le nom historique `emberFocus` est conservé pour les sites d'appel.
|
||||
static List<BoxShadow> emberFocus({double intensity = 1.0}) => [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFD9541F).withValues(alpha: 0.45 * intensity),
|
||||
color: const Color(0xFF0A84FF).withValues(alpha: 0.45 * intensity),
|
||||
blurRadius: 32 * intensity,
|
||||
spreadRadius: -4,
|
||||
),
|
||||
BoxShadow(
|
||||
color: const Color(0xFF000000).withValues(alpha: 0.6),
|
||||
color: const Color(0xFF000000).withValues(alpha: 0.45),
|
||||
blurRadius: 24,
|
||||
spreadRadius: -10,
|
||||
offset: const Offset(0, 14),
|
||||
),
|
||||
];
|
||||
|
||||
// ============ ALIAS DE COMPATIBILITÉ (ancienne charte Apple TV) ============
|
||||
// Conservés pour ne pas casser les sites d'appel ; remappés sur les teintes
|
||||
// chaudes. À retirer au fil des réécritures d'écrans.
|
||||
// ============ ALIAS DE COMPATIBILITÉ ============
|
||||
// Conservés pour ne pas casser les sites d'appel.
|
||||
static const Color backgroundSecondary = surfaceContainerLow;
|
||||
static const Color surfaceElevated = surfaceContainerHigh;
|
||||
static const Color accent = secondary;
|
||||
|
||||
/// Ancien accent bleu Apple → braise portant du texte blanc (contraste AA).
|
||||
/// Accent bleu Apple portant du texte blanc (contraste AA).
|
||||
static const Color appleBlue = primaryFill;
|
||||
|
||||
static const RadialGradient appleTvGradient = projectorGradient;
|
||||
@@ -266,27 +273,27 @@ class AppColors {
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
|
||||
/// Thème clair : « papier kraft » — même famille chaude, inversée.
|
||||
/// Thème clair : gris système iOS, même famille neutre.
|
||||
static ColorScheme get lightColorScheme => const ColorScheme.light(
|
||||
primary: Color(0xFFA63B10),
|
||||
primary: Color(0xFF0066CC),
|
||||
onPrimary: Colors.white,
|
||||
primaryContainer: Color(0xFFFFDBC8),
|
||||
onPrimaryContainer: Color(0xFF360F00),
|
||||
secondary: Color(0xFF765847),
|
||||
primaryContainer: Color(0xFFD6E5FF),
|
||||
onPrimaryContainer: Color(0xFF002451),
|
||||
secondary: Color(0xFF54545E),
|
||||
onSecondary: Colors.white,
|
||||
secondaryContainer: Color(0xFFFFDCC8),
|
||||
onSecondaryContainer: Color(0xFF2B160A),
|
||||
tertiary: Color(0xFF6A5F3F),
|
||||
secondaryContainer: Color(0xFFE3E3E8),
|
||||
onSecondaryContainer: Color(0xFF1B1B1F),
|
||||
tertiary: Color(0xFF00658E),
|
||||
onTertiary: Colors.white,
|
||||
error: Color(0xFFB3261E),
|
||||
onError: Colors.white,
|
||||
errorContainer: Color(0xFFFFDAD4),
|
||||
onErrorContainer: Color(0xFF410E05),
|
||||
surface: Color(0xFFFBF3EC),
|
||||
onSurface: Color(0xFF201A16),
|
||||
onSurfaceVariant: Color(0xFF52443B),
|
||||
outline: Color(0xFF847369),
|
||||
outlineVariant: Color(0xFFD6C3B7),
|
||||
surface: Color(0xFFF2F2F7),
|
||||
onSurface: Color(0xFF1C1C1E),
|
||||
onSurfaceVariant: Color(0xFF48484E),
|
||||
outline: Color(0xFF8E8E93),
|
||||
outlineVariant: Color(0xFFD1D1D6),
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// XtremFlow — "Warm Cinema" theme (ported from the web app).
|
||||
/// XtremFlow — "Apple Glass Gray" theme.
|
||||
///
|
||||
/// Typography: **Fraunces** (serif éditorial) for display/headline, **Karla**
|
||||
/// (humaniste chaud) for UI and body — very legible at TV distance and on a
|
||||
@@ -217,10 +217,9 @@ class AppTheme {
|
||||
secondary: AppColors.textSecondary,
|
||||
),
|
||||
|
||||
// Curseur crème — un curseur blanc pur jure sur fond chaud.
|
||||
textSelectionTheme: const TextSelectionThemeData(
|
||||
cursorColor: AppColors.primary,
|
||||
selectionColor: Color(0x59D9541F),
|
||||
selectionColor: Color(0x590A84FF),
|
||||
selectionHandleColor: AppColors.primaryContainer,
|
||||
),
|
||||
|
||||
@@ -334,7 +333,7 @@ class AppTheme {
|
||||
activeTrackColor: AppColors.primaryContainer,
|
||||
inactiveTrackColor: AppColors.onSurface24,
|
||||
thumbColor: AppColors.primary,
|
||||
overlayColor: Color(0x29D9541F),
|
||||
overlayColor: Color(0x290A84FF),
|
||||
),
|
||||
|
||||
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
||||
@@ -362,17 +361,17 @@ class AppTheme {
|
||||
);
|
||||
}
|
||||
|
||||
// ============ LIGHT THEME (« papier kraft ») ============
|
||||
// ============ LIGHT THEME (gris système clair) ============
|
||||
static ThemeData get lightTheme {
|
||||
final baseTheme = ThemeData.light(useMaterial3: true);
|
||||
|
||||
const bgColor = Color(0xFFFBF3EC);
|
||||
const bgColor = Color(0xFFF2F2F7);
|
||||
const surfaceColor = Color(0xFFFFFFFF);
|
||||
const primaryColor = Color(0xFFA63B10);
|
||||
const textPrimaryColor = Color(0xFF201A16);
|
||||
const textSecondaryColor = Color(0xFF52443B);
|
||||
const textTertiaryColor = Color(0xFF847369);
|
||||
const borderColor = Color(0xFFD6C3B7);
|
||||
const primaryColor = Color(0xFF0066CC);
|
||||
const textPrimaryColor = Color(0xFF1C1C1E);
|
||||
const textSecondaryColor = Color(0xFF48484E);
|
||||
const textTertiaryColor = Color(0xFF8E8E93);
|
||||
const borderColor = Color(0xFFD8D8DE);
|
||||
|
||||
return baseTheme.copyWith(
|
||||
colorScheme: AppColors.lightColorScheme,
|
||||
@@ -386,7 +385,7 @@ class AppTheme {
|
||||
|
||||
textSelectionTheme: const TextSelectionThemeData(
|
||||
cursorColor: primaryColor,
|
||||
selectionColor: Color(0x4DA63B10),
|
||||
selectionColor: Color(0x4D0066CC),
|
||||
selectionHandleColor: primaryColor,
|
||||
),
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ library;
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.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
|
||||
class AppCacheManager {
|
||||
@@ -103,7 +104,7 @@ class ImageCacheConfig {
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2C2C2E),
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
@@ -111,10 +112,10 @@ class ImageCacheConfig {
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF3A3A3C),
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
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(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -161,7 +161,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
|
||||
item.subtitle!,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
color: AppColors.onSurface.withOpacity(0.8),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -227,8 +227,8 @@ class _HeroButtonState extends State<_HeroButton> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isPrimary
|
||||
? (_isHovered ? Colors.white : Colors.white.withOpacity(0.9))
|
||||
: (_isHovered ? Colors.white.withOpacity(0.2) : Colors.white.withOpacity(0.1)),
|
||||
? (_isHovered ? AppColors.onSurface : AppColors.onSurface.withOpacity(0.9))
|
||||
: (_isHovered ? AppColors.onSurface.withOpacity(0.2) : AppColors.onSurface.withOpacity(0.1)),
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||
),
|
||||
child: Row(
|
||||
@@ -236,14 +236,14 @@ class _HeroButtonState extends State<_HeroButton> {
|
||||
children: [
|
||||
Icon(
|
||||
widget.icon,
|
||||
color: widget.isPrimary ? Colors.black : Colors.white,
|
||||
color: widget.isPrimary ? AppColors.onPrimaryFixed : AppColors.onSurface,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.label,
|
||||
style: GoogleFonts.inter(
|
||||
color: widget.isPrimary ? Colors.black : Colors.white,
|
||||
color: widget.isPrimary ? AppColors.onPrimaryFixed : AppColors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -134,7 +134,7 @@ class _GradientButtonState extends State<GradientButton>
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.black,
|
||||
color: AppColors.onPrimaryFixed,
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
@@ -143,13 +143,13 @@ class _GradientButtonState extends State<GradientButton>
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (widget.icon != null) ...[
|
||||
Icon(widget.icon, color: Colors.black, size: 20),
|
||||
Icon(widget.icon, color: AppColors.onPrimaryFixed, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
widget.label,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
color: AppColors.onPrimaryFixed,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
@@ -247,12 +247,12 @@ class LiveBadge extends StatelessWidget {
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle, size: 6, color: Colors.white),
|
||||
Icon(Icons.circle, size: 6, color: AppColors.onSurface),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'LIVE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
@@ -427,11 +427,11 @@ class _MediaCardState extends State<MediaCard>
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.star, size: 10, color: Color(0xFFFFD700)),
|
||||
const Icon(Icons.star, size: 10, color: AppColors.ratingGold),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
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,
|
||||
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:google_fonts/google_fonts.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||
|
||||
class DigitalClock extends StatefulWidget {
|
||||
const DigitalClock({super.key});
|
||||
@@ -31,16 +32,16 @@ class _DigitalClockState extends State<DigitalClock> {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
color: AppColors.onSurface.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.05)),
|
||||
border: Border.all(color: AppColors.onSurface.withOpacity(0.05)),
|
||||
),
|
||||
child: Text(
|
||||
timeString,
|
||||
style: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||
|
||||
class ThemedLoading extends StatelessWidget {
|
||||
const ThemedLoading({super.key});
|
||||
@@ -11,7 +12,7 @@ class ThemedLoading extends StatelessWidget {
|
||||
center: Alignment(0, -0.2),
|
||||
radius: 1.5,
|
||||
colors: [
|
||||
Color(0xFF2C2C2E),
|
||||
AppColors.surfaceContainerHigh,
|
||||
Color(0xFF000000),
|
||||
],
|
||||
stops: [0.0, 1.0],
|
||||
@@ -19,7 +20,7 @@ class ThemedLoading extends StatelessWidget {
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -80,6 +80,16 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
final FocusNode _playPauseFocusNode = FocusNode();
|
||||
final FocusNode _prevFocusNode = 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
|
||||
void initState() {
|
||||
@@ -132,6 +142,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
_controlsTimer = null;
|
||||
_stabilizationTimer?.cancel();
|
||||
_stabilizationTimer = null;
|
||||
_channelListTimer?.cancel();
|
||||
_channelListTimer = null;
|
||||
|
||||
final controller = _controller;
|
||||
_controller = null;
|
||||
@@ -339,15 +351,95 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
|
||||
void _switchChannel(int index) {
|
||||
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
|
||||
_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) {
|
||||
if (event is! KeyDownEvent) return false;
|
||||
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 (widget.channels != null) {
|
||||
_switchChannel((_currentIndex + 1) % widget.channels!.length);
|
||||
@@ -397,6 +489,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
_epgTimer?.cancel();
|
||||
_controlsTimer?.cancel();
|
||||
_stabilizationTimer?.cancel();
|
||||
_channelListTimer?.cancel();
|
||||
_channelListScrollController.dispose();
|
||||
_controller?.pause(); // Force stop audio immediately
|
||||
_controller?.dispose();
|
||||
_xtreamService?.dispose();
|
||||
@@ -404,6 +498,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
_playPauseFocusNode.dispose();
|
||||
_prevFocusNode.dispose();
|
||||
_nextFocusNode.dispose();
|
||||
_channelListButtonFocusNode.dispose();
|
||||
_channelListItemFocusNode.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
@@ -416,9 +512,26 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
: widget.title;
|
||||
|
||||
return Scaffold(
|
||||
// True black behind the video surface, never the warm ink.
|
||||
backgroundColor: Colors.black,
|
||||
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(
|
||||
children: [
|
||||
// Video Surface
|
||||
@@ -453,7 +566,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
@@ -472,7 +585,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
child: Text(
|
||||
_currentTime,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -495,7 +608,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
style: const TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -508,6 +621,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
|
||||
// OSD (EPG + Controls)
|
||||
if (_showControls) _buildOSD(title),
|
||||
|
||||
// [TiviMate] Channel list sidebar
|
||||
if (_showChannelList &&
|
||||
widget.streamType == model.StreamType.live &&
|
||||
widget.channels != null &&
|
||||
widget.channels!.isNotEmpty)
|
||||
_buildChannelListSidebar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -554,7 +674,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white10),
|
||||
border: Border.all(color: AppColors.onSurface12),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
@@ -579,7 +699,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.skip_previous,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
@@ -591,7 +711,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
_isPlaying
|
||||
? Icons.pause_circle_filled
|
||||
: Icons.play_circle_filled,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 56,
|
||||
),
|
||||
),
|
||||
@@ -605,11 +725,25 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.skip_next,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
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),
|
||||
// Deinterlace Button (Switches to Native Player with forced Deinterlace)
|
||||
TVFocusable(
|
||||
@@ -642,7 +776,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
child: const Icon(
|
||||
Icons
|
||||
.grid_on, // Icon representing interlaced grid/mesh
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
@@ -684,7 +818,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -710,7 +844,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: Colors.white10,
|
||||
backgroundColor: AppColors.onSurface12,
|
||||
color: AppColors.primary,
|
||||
minHeight: 4,
|
||||
),
|
||||
@@ -718,7 +852,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
next,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
style: const TextStyle(color: AppColors.onSurface54, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -758,7 +892,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
min: 0,
|
||||
max: _duration.inSeconds.toDouble(),
|
||||
activeColor: AppColors.primary,
|
||||
inactiveColor: Colors.white24,
|
||||
inactiveColor: AppColors.onSurface24,
|
||||
onChanged: (v) => setState(() {
|
||||
_position = Duration(seconds: v.toInt());
|
||||
_isSeeking = true;
|
||||
@@ -773,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 ajoutée',
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ class _MobilePlaylistSelectionScreenState
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur de chargement',
|
||||
style: GoogleFonts.inter(color: Colors.white),
|
||||
style: GoogleFonts.inter(color: AppColors.onSurface),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(playlistsProvider),
|
||||
@@ -143,14 +143,14 @@ class _MobilePlaylistSelectionScreenState
|
||||
const Icon(
|
||||
Icons.playlist_add,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
color: AppColors.onSurface54,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Aucune playlist configurée',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 18,
|
||||
color: Colors.white70,
|
||||
color: AppColors.onSurface70,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
@@ -163,18 +163,18 @@ class _MobilePlaylistSelectionScreenState
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A84FF),
|
||||
color: AppColors.primaryFill,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add, color: Colors.white, size: 20),
|
||||
Icon(Icons.add, color: AppColors.onSurface, size: 20),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Ajouter une Playlist',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
@@ -268,21 +268,21 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: Text(label, style: const TextStyle(color: Colors.white)),
|
||||
title: Text(label, style: const TextStyle(color: AppColors.onSurface)),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
obscureText: obscure,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: TextInputAction.done,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
style: const TextStyle(color: AppColors.onSurface),
|
||||
decoration: const InputDecoration(
|
||||
labelStyle: TextStyle(color: Colors.white70),
|
||||
labelStyle: TextStyle(color: AppColors.onSurface70),
|
||||
enabledBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.white24),
|
||||
borderSide: BorderSide(color: AppColors.onSurface24),
|
||||
),
|
||||
focusedBorder: UnderlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.white),
|
||||
borderSide: BorderSide(color: AppColors.onSurface),
|
||||
),
|
||||
),
|
||||
onSubmitted: (val) => Navigator.pop(ctx, val),
|
||||
@@ -327,7 +327,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.07),
|
||||
color: AppColors.onSurface.withOpacity(0.07),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
@@ -341,7 +341,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
color: AppColors.onSurface70,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -350,7 +350,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
||||
Text(
|
||||
displayValue,
|
||||
style: TextStyle(
|
||||
color: value.isEmpty ? Colors.white38 : Colors.white,
|
||||
color: value.isEmpty ? AppColors.onSurface38 : AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
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,
|
||||
title: Text(
|
||||
widget.isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
|
||||
style: GoogleFonts.inter(color: Colors.white),
|
||||
style: GoogleFonts.inter(color: AppColors.onSurface),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 480,
|
||||
@@ -435,8 +435,8 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
title: const Text('Confirmer', style: TextStyle(color: Colors.white)),
|
||||
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: Colors.white70)),
|
||||
title: const Text('Confirmer', style: TextStyle(color: AppColors.onSurface)),
|
||||
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: AppColors.onSurface70)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
@@ -444,7 +444,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
@@ -458,7 +458,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text('Supprimer', style: TextStyle(color: Colors.red)),
|
||||
child: Text('Supprimer', style: TextStyle(color: AppColors.error)),
|
||||
),
|
||||
),
|
||||
TVFocusable(
|
||||
@@ -466,7 +466,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text('Annuler', style: TextStyle(color: Colors.white70)),
|
||||
child: Text('Annuler', style: TextStyle(color: AppColors.onSurface70)),
|
||||
),
|
||||
),
|
||||
TVFocusable(
|
||||
@@ -482,14 +482,14 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: _name.isEmpty || _dns.isEmpty
|
||||
? Colors.white12
|
||||
? AppColors.onSurface12
|
||||
: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_isSaving ? '...' : 'Enregistrer',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -129,7 +129,7 @@ class _MobileSeriesDetailScreenState
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.onSurface),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
tooltip: 'Back',
|
||||
),
|
||||
@@ -149,7 +149,7 @@ class _MobileSeriesDetailScreenState
|
||||
maxHeightDiskCache: 720,
|
||||
cacheManager: AppCacheManager.instance,
|
||||
errorWidget: (_, __, ___) =>
|
||||
Container(color: Colors.grey.shade900),
|
||||
Container(color: AppColors.surfaceContainerLowest),
|
||||
),
|
||||
// Gradient Overlay
|
||||
Container(
|
||||
@@ -190,7 +190,7 @@ class _MobileSeriesDetailScreenState
|
||||
const Icon(
|
||||
Icons.star,
|
||||
size: 16,
|
||||
color: Colors.amber,
|
||||
color: AppColors.ratingGold,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
@@ -261,15 +261,15 @@ class _MobileSeriesDetailScreenState
|
||||
}
|
||||
},
|
||||
backgroundColor: isDark
|
||||
? const Color(0xFF1C1C1E)
|
||||
: Colors.grey.shade300,
|
||||
selectedColor: isDark ? Colors.white : Colors.blue,
|
||||
? AppColors.surfaceContainer
|
||||
: AppColors.surfaceContainerHigh,
|
||||
selectedColor: isDark ? AppColors.onSurface : AppColors.primaryFill,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected
|
||||
? (isDark ? Colors.black : Colors.white)
|
||||
? (isDark ? AppColors.onPrimaryFixed : AppColors.onSurface)
|
||||
: (isDark
|
||||
? Colors.grey.shade400
|
||||
: Colors.grey.shade700),
|
||||
? AppColors.textSecondary
|
||||
: AppColors.outlineVariant),
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
@@ -323,10 +323,10 @@ class _MobileSeriesDetailScreenState
|
||||
}
|
||||
|
||||
// Force dark mode colors since background is hardcoded dark
|
||||
const textColor = Colors.white;
|
||||
final secondaryTextColor = Colors.grey.shade400;
|
||||
const surfaceColor = Color(0xFF1C1C1E);
|
||||
const primaryColor = Colors.white; // Or accent color if preferred
|
||||
const textColor = AppColors.onSurface;
|
||||
final secondaryTextColor = AppColors.textSecondary;
|
||||
const surfaceColor = AppColors.surfaceContainer;
|
||||
const primaryColor = AppColors.onSurface; // Or accent color if preferred
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
@@ -356,7 +356,7 @@ class _MobileSeriesDetailScreenState
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: Colors.white10)),
|
||||
border: Border(bottom: BorderSide(color: AppColors.onSurface12)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -374,7 +374,7 @@ class _MobileSeriesDetailScreenState
|
||||
child: Icon(
|
||||
isWatched ? Icons.check : Icons.play_arrow,
|
||||
color: isWatched
|
||||
? Colors.black // Checkmark on white bg
|
||||
? AppColors.onPrimaryFixed // Checkmark on white bg
|
||||
: primaryColor, // Play icon
|
||||
size: 20,
|
||||
),
|
||||
@@ -413,7 +413,7 @@ class _MobileSeriesDetailScreenState
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
backgroundColor: Colors.transparent,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.error),
|
||||
minHeight: 2,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -73,6 +73,11 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
final FocusNode _sliderFocusNode = FocusNode();
|
||||
final FocusNode _backFocusNode = FocusNode();
|
||||
final FocusNode _audioFocusNode = 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();
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isFirstLoad = true; // true = black overlay; false = silent spinner
|
||||
@@ -135,6 +140,35 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_resetControlsTimer();
|
||||
}
|
||||
|
||||
/// Dernier contrôle de l'OSD qui avait le focus télécommande.
|
||||
///
|
||||
/// Quand l'OSD se masque (auto-hide 5s), ses FocusNodes sont démontés et le
|
||||
/// focus part « on ne sait où » : à la réouverture l'utilisateur devait
|
||||
/// re-naviguer depuis un point aléatoire jusqu'au bouton de zapping. On
|
||||
/// mémorise donc le dernier bouton utilisé pour le re-focaliser d'office.
|
||||
FocusNode? _lastFocusedControl;
|
||||
|
||||
void _noteFocus(FocusNode node) {
|
||||
_lastFocusedControl = node;
|
||||
_resetControlsTimer();
|
||||
}
|
||||
|
||||
/// Réaffiche l'OSD en redonnant le focus au dernier contrôle utilisé
|
||||
/// (Play/Pause par défaut) — un seul appui suffit alors pour re-zapper.
|
||||
void _showControlsRestoringFocus() {
|
||||
setState(() => _showControls = true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_showControls) return;
|
||||
final target = _lastFocusedControl ?? _playPauseFocusNode;
|
||||
if (target.canRequestFocus) {
|
||||
target.requestFocus();
|
||||
} else {
|
||||
_playPauseFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
_resetControlsTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -189,10 +223,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
// Principe TiviMate : jouer à partir de là où on est, JAMAIS chasser le live edge.
|
||||
// ExoPlayer n'utilise pas les PTS du stream pour se recaler — on fait pareil.
|
||||
|
||||
// --- Buffering stable (TiviMate Medium ~ 8-10s) ---
|
||||
(_player.platform as dynamic)?.setProperty('cache-secs', '8');
|
||||
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '150000000'); // 150MB RAM max
|
||||
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '10');
|
||||
// --- Buffering basse latence (TiviMate Low ~ 3-5s) ---
|
||||
// 8s/10s de readahead ajoutaient autant de retard sur le direct : 3s de
|
||||
// cache + 5s de readahead suffisent à absorber la gigue réseau tout en
|
||||
// restant nettement plus près du live edge.
|
||||
(_player.platform as dynamic)?.setProperty('cache-secs', '3');
|
||||
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '60000000'); // 60MB RAM max
|
||||
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '5');
|
||||
(_player.platform as dynamic)?.setProperty('demuxer-thread', 'yes');
|
||||
// --- Plafond du buffer ARRIÈRE (stabilité longue durée) ---
|
||||
// mpv garde par défaut 50 Mio de flux déjà lu pour permettre un retour
|
||||
@@ -234,15 +271,19 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
(_player.platform as dynamic)?.setProperty('video-sync', 'audio');
|
||||
}
|
||||
|
||||
// [V11.0 Perfect Restoration] Recovering settings from working commit a1628a6b
|
||||
// Forced software re-sampling handles ANY stubborn 5.1/LATM/24-bit audio track.
|
||||
// [Audio Fix] L'ancienne chaîne `af=lavrresample,format=channels=stereo:
|
||||
// sample_fmts=s16` mélangeait la syntaxe FFmpeg et mpv : `lavrresample`
|
||||
// n'existe plus dans les builds récents et `sample_fmts` n'est pas une
|
||||
// option du filtre `format` de mpv. Quand la chaîne échoue à s'initialiser
|
||||
// pour un codec donné (AC3/EAC3/LATM selon la chaîne TV), mpv coupe la
|
||||
// sortie audio ⇒ « certaines chaînes n'ont pas de son ».
|
||||
// Le downmix natif `audio-channels=stereo` fait la même chose (5.1 → 2.0)
|
||||
// sans chaîne de filtres fragile, et l'AO négocie lui-même le format.
|
||||
(_player.platform as dynamic)?.setProperty('ad', 'lavc:*');
|
||||
(_player.platform as dynamic)?.setProperty('af', 'lavrresample,format=channels=stereo:sample_fmts=s16');
|
||||
(_player.platform as dynamic)?.setProperty('audio-format', 's16');
|
||||
(_player.platform as dynamic)?.setProperty('audio-channels', 'stereo');
|
||||
(_player.platform as dynamic)?.setProperty('ao', 'audiotrack');
|
||||
(_player.platform as dynamic)?.setProperty('audio-pitch-correction', 'no');
|
||||
(_player.platform as dynamic)?.setProperty('audio-buffer', '0.2'); // Live: buffer audio court (réduit le désync pendant les cache-pauses)
|
||||
(_player.platform as dynamic)?.setProperty('audio-samplerate', '48000');
|
||||
(_player.platform as dynamic)?.setProperty('aid', 'auto');
|
||||
|
||||
// --- Seeking & Sync globaux ---
|
||||
@@ -270,11 +311,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
);
|
||||
|
||||
// --- Probing du flux (une seule entrée, consolidée) ---
|
||||
// Précédent : 2 setProperty('demuxer-lavf-o') en conflit
|
||||
// fflags=+discardcorrupt supprimé : peut causer des skips sur certains streams IPTV
|
||||
// Ces valeurs sont des PLAFONDS : FFmpeg s'arrête dès que toutes les pistes
|
||||
// sont identifiées, donc les relever ne ralentit pas les flux sains.
|
||||
// 1MB/2s ratait la piste audio (AAC-LATM déclaré tard) sur certaines
|
||||
// chaînes ⇒ pas de son. 2MB/3s les récupère sans pénaliser le zapping.
|
||||
(_player.platform as dynamic)?.setProperty(
|
||||
'demuxer-lavf-o',
|
||||
'analyzeduration=2000000,probesize=1000000',
|
||||
'analyzeduration=3000000,probesize=2000000',
|
||||
);
|
||||
|
||||
// --- Décodeur lavc : threads libres, sans fast-mode (qualité prioritaire) ---
|
||||
@@ -566,6 +610,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_sliderFocusNode.dispose();
|
||||
_backFocusNode.dispose();
|
||||
_audioFocusNode.dispose();
|
||||
_channelListButtonFocusNode.dispose();
|
||||
_channelListItemFocusNode.dispose();
|
||||
|
||||
// Restore normal orientation
|
||||
SystemChrome.setPreferredOrientations([
|
||||
@@ -654,7 +700,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
'Reprise à ${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: Colors.green.shade700,
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -997,6 +1043,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_showChannelList = false; // close sidebar on switch
|
||||
_showControls = true; // show regular OSD
|
||||
});
|
||||
// Garde le focus sur le bouton de zap qui vient d'être pressé : l'appui
|
||||
// suivant sur OK rezappe immédiatement, sans re-navigation dans l'OSD.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_showControls) return;
|
||||
final target = _lastFocusedControl;
|
||||
if (target != null && target.canRequestFocus && !target.hasFocus) {
|
||||
target.requestFocus();
|
||||
}
|
||||
});
|
||||
_resetControlsTimer();
|
||||
_loadStream();
|
||||
}
|
||||
@@ -1006,15 +1061,36 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
setState(() => _showChannelList = !_showChannelList);
|
||||
if (_showChannelList) {
|
||||
_scrollToCurrentChannel();
|
||||
_channelListTimer?.cancel();
|
||||
_channelListTimer = Timer(const Duration(seconds: 6), () {
|
||||
if (mounted) setState(() => _showChannelList = false);
|
||||
_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((_) {
|
||||
@@ -1049,12 +1125,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
void _toggleControls() {
|
||||
// Always show controls on tap, then start auto-hide timer
|
||||
if (!_showControls) {
|
||||
setState(() => _showControls = true);
|
||||
// Request focus on Play/Pause button when opening
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// If controls opened, default to Play/Pause
|
||||
_playPauseFocusNode.requestFocus();
|
||||
});
|
||||
// Reopen on the last focused control (falls back to Play/Pause)
|
||||
_showControlsRestoringFocus();
|
||||
return;
|
||||
}
|
||||
_resetControlsTimer();
|
||||
}
|
||||
@@ -1066,6 +1139,21 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
// Explicit Play
|
||||
if (key == LogicalKeyboardKey.mediaPlay) {
|
||||
if (!_isPlaying) _player.play();
|
||||
@@ -1087,8 +1175,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
key == LogicalKeyboardKey.numpadEnter ||
|
||||
key == LogicalKeyboardKey.gameButtonA ||
|
||||
key == LogicalKeyboardKey.mediaPlayPause) {
|
||||
// If controls are hidden, toggle them + play/pause
|
||||
if (!_showControls) {
|
||||
// Live : OK réaffiche seulement l'OSD (focus restauré sur le dernier
|
||||
// bouton utilisé) — surtout ne PAS mettre le direct en pause.
|
||||
if (widget.streamType == model.StreamType.live &&
|
||||
key != LogicalKeyboardKey.mediaPlayPause) {
|
||||
_showControlsRestoringFocus();
|
||||
return true;
|
||||
}
|
||||
// VOD : comportement historique — toggle play/pause + OSD
|
||||
_togglePlayPause();
|
||||
_toggleControls();
|
||||
return true;
|
||||
@@ -1116,8 +1211,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
if (widget.streamType != model.StreamType.live) {
|
||||
final newPos = _position - const Duration(seconds: 10);
|
||||
_player.seek(newPos < Duration.zero ? Duration.zero : newPos);
|
||||
_onUserInteraction();
|
||||
} else {
|
||||
_showControlsRestoringFocus();
|
||||
}
|
||||
_onUserInteraction();
|
||||
return true;
|
||||
}
|
||||
return false; // Let Focus handle navigation
|
||||
@@ -1131,8 +1228,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
if (newPos < _duration) {
|
||||
_player.seek(newPos);
|
||||
}
|
||||
_onUserInteraction();
|
||||
} else {
|
||||
_showControlsRestoringFocus();
|
||||
}
|
||||
_onUserInteraction();
|
||||
return true;
|
||||
}
|
||||
return false; // Let Focus handle navigation
|
||||
@@ -1142,7 +1241,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
if (key == LogicalKeyboardKey.arrowUp ||
|
||||
key == LogicalKeyboardKey.arrowDown) {
|
||||
if (!_showControls) {
|
||||
_onUserInteraction();
|
||||
_showControlsRestoringFocus();
|
||||
return true;
|
||||
}
|
||||
return false; // Let Focus handle navigation
|
||||
@@ -1168,16 +1267,6 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
}
|
||||
}
|
||||
|
||||
// OK / Select when channel list is open → switch to focused channel
|
||||
if (_showChannelList &&
|
||||
(key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.select ||
|
||||
key == LogicalKeyboardKey.numpadEnter)) {
|
||||
// Channel list handles its own tap, this closes it
|
||||
setState(() => _showChannelList = false);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Menu key or 'M' → toggle channel list sidebar
|
||||
if (key == LogicalKeyboardKey.contextMenu ||
|
||||
key == LogicalKeyboardKey.keyM) {
|
||||
@@ -1293,6 +1382,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
// Loading Overlay — black only on FIRST load
|
||||
if (_isLoading && _errorMessage == null)
|
||||
Container(
|
||||
// True black, not the warm ink: this sits over the video surface.
|
||||
color: Colors.black,
|
||||
child: _buildLoadingView(),
|
||||
),
|
||||
@@ -1313,7 +1403,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white70,
|
||||
color: AppColors.onSurface70,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1349,7 +1439,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
@@ -1380,7 +1470,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
@@ -1392,7 +1482,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Text(
|
||||
_currentTime,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
color: AppColors.onSurface70,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -1485,7 +1575,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white10),
|
||||
border: Border.all(color: AppColors.onSurface12),
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
@@ -1503,10 +1593,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
TVFocusable(
|
||||
focusNode: _prevFocusNode,
|
||||
onPressed: _playPrevious,
|
||||
onFocus: _resetControlsTimer,
|
||||
onFocus: () => _noteFocus(_prevFocusNode),
|
||||
child: const Icon(
|
||||
Icons.skip_previous,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
@@ -1516,12 +1606,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
TVFocusable(
|
||||
focusNode: _playPauseFocusNode,
|
||||
onPressed: _togglePlayPause,
|
||||
onFocus: _resetControlsTimer,
|
||||
onFocus: () => _noteFocus(_playPauseFocusNode),
|
||||
child: Icon(
|
||||
_isPlaying
|
||||
? Icons.pause_circle_filled
|
||||
: Icons.play_circle_filled,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 56,
|
||||
),
|
||||
),
|
||||
@@ -1531,15 +1621,28 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
TVFocusable(
|
||||
focusNode: _nextFocusNode,
|
||||
onPressed: _playNext,
|
||||
onFocus: _resetControlsTimer,
|
||||
onFocus: () => _noteFocus(_nextFocusNode),
|
||||
child: const Icon(
|
||||
Icons.skip_next,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Channel list sidebar (liste des chaînes de la catégorie)
|
||||
if (widget.channels != null && widget.channels!.isNotEmpty)
|
||||
TVFocusable(
|
||||
focusNode: _channelListButtonFocusNode,
|
||||
onPressed: _toggleChannelList,
|
||||
onFocus: () => _noteFocus(_channelListButtonFocusNode),
|
||||
child: const Icon(
|
||||
Icons.playlist_play_rounded,
|
||||
color: AppColors.onSurface,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
|
||||
// Disable Deinterlace Button
|
||||
if (widget.forceDeinterlace) ...[
|
||||
const SizedBox(width: 16),
|
||||
@@ -1570,7 +1673,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
onFocus: _resetControlsTimer,
|
||||
child: const Icon(
|
||||
Icons.grid_off,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
@@ -1618,7 +1721,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
imageUrl: widget.channels![_currentIndex].streamIcon,
|
||||
fit: BoxFit.contain,
|
||||
errorWidget: (_, __, ___) =>
|
||||
const Icon(Icons.tv, color: Colors.white38),
|
||||
const Icon(Icons.tv, color: AppColors.onSurface38),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@@ -1628,7 +1731,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
color: AppColors.onSurface70,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -1639,7 +1742,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Text(
|
||||
nowPlaying,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -1654,7 +1757,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Text(
|
||||
startTime,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54, fontSize: 11),
|
||||
color: AppColors.onSurface54, fontSize: 11),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
@@ -1663,9 +1766,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
child: LinearProgressIndicator(
|
||||
value: epgProgress.clamp(0.0, 1.0),
|
||||
minHeight: 4,
|
||||
backgroundColor: Colors.white24,
|
||||
backgroundColor: AppColors.onSurface24,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFF4FC3F7),
|
||||
AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1674,7 +1777,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Text(
|
||||
endTime,
|
||||
style: const TextStyle(
|
||||
color: Colors.white54, fontSize: 11),
|
||||
color: AppColors.onSurface54, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1714,7 +1817,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.82),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white12),
|
||||
border: Border.all(color: AppColors.onSurface12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -1728,7 +1831,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.white10,
|
||||
color: AppColors.onSurface12,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -1736,7 +1839,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
imageUrl: ch.streamIcon,
|
||||
fit: BoxFit.contain,
|
||||
errorWidget: (_, __, ___) =>
|
||||
const Icon(Icons.tv, color: Colors.white38, size: 32),
|
||||
const Icon(Icons.tv, color: AppColors.onSurface38, size: 32),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1747,9 +1850,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.white10,
|
||||
color: AppColors.onSurface12,
|
||||
),
|
||||
child: const Icon(Icons.tv, color: Colors.white38, size: 32),
|
||||
child: const Icon(Icons.tv, color: AppColors.onSurface38, size: 32),
|
||||
),
|
||||
|
||||
// Channel info
|
||||
@@ -1765,13 +1868,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
horizontal: 6, vertical: 2),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1565C0),
|
||||
color: const AppColors.primaryFill,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'CH $chNum',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -1781,7 +1884,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
child: Text(
|
||||
ch.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -1797,9 +1900,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
child: SizedBox(
|
||||
height: 3,
|
||||
child: LinearProgressIndicator(
|
||||
backgroundColor: Colors.white24,
|
||||
backgroundColor: AppColors.onSurface24,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFF4FC3F7)),
|
||||
AppColors.primary),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1808,7 +1911,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Text(
|
||||
nowPlaying,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
color: AppColors.onSurface70,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -1820,9 +1923,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
child: LinearProgressIndicator(
|
||||
value: epgProgress.clamp(0.0, 1.0),
|
||||
minHeight: 3,
|
||||
backgroundColor: Colors.white24,
|
||||
backgroundColor: AppColors.onSurface24,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
Color(0xFF4FC3F7)),
|
||||
AppColors.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1861,18 +1964,18 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.white12),
|
||||
bottom: BorderSide(color: AppColors.onSurface12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.list, color: Colors.white70, size: 18),
|
||||
const Icon(Icons.list, color: AppColors.onSurface70, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Chaînes',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -1881,34 +1984,47 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _showChannelList = false),
|
||||
child: const Icon(Icons.close,
|
||||
color: Colors.white54, size: 18),
|
||||
color: AppColors.onSurface54, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Channel list
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
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 GestureDetector(
|
||||
onTap: () {
|
||||
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
|
||||
? const Color(0xFF1565C0).withOpacity(0.6)
|
||||
? AppColors.primaryFill.withValues(alpha: 0.6)
|
||||
: Colors.transparent,
|
||||
border: isActive
|
||||
? const Border(
|
||||
left: BorderSide(
|
||||
color: Color(0xFF4FC3F7),
|
||||
color: AppColors.primary,
|
||||
width: 3,
|
||||
),
|
||||
)
|
||||
@@ -1924,7 +2040,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
height: 40,
|
||||
margin: const EdgeInsets.only(right: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
color: AppColors.onSurface12,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: ch.streamIcon.isNotEmpty
|
||||
@@ -1935,12 +2051,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
fit: BoxFit.contain,
|
||||
errorWidget: (_, __, ___) =>
|
||||
const Icon(Icons.tv,
|
||||
color: Colors.white38,
|
||||
color: AppColors.onSurface38,
|
||||
size: 20),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.tv,
|
||||
color: Colors.white38, size: 20),
|
||||
color: AppColors.onSurface38, size: 20),
|
||||
),
|
||||
// Name + num
|
||||
Expanded(
|
||||
@@ -1952,8 +2068,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
ch.name,
|
||||
style: TextStyle(
|
||||
color: isActive
|
||||
? Colors.white
|
||||
: Colors.white70,
|
||||
? AppColors.onSurface
|
||||
: AppColors.onSurface70,
|
||||
fontSize: 13,
|
||||
fontWeight: isActive
|
||||
? FontWeight.bold
|
||||
@@ -1965,7 +2081,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Text(
|
||||
'CH ${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
color: AppColors.onSurface38,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
@@ -1976,7 +2092,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
if (isActive)
|
||||
const Icon(
|
||||
Icons.play_arrow,
|
||||
color: Color(0xFF4FC3F7),
|
||||
color: AppColors.primary,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
@@ -1984,6 +2100,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -2016,12 +2133,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_player.seek(pos - const Duration(seconds: 10));
|
||||
_resetControlsTimer();
|
||||
},
|
||||
onFocus: _resetControlsTimer,
|
||||
onFocus: () => _noteFocus(_prevFocusNode),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.replay_10,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 48,
|
||||
),
|
||||
onPressed: () async {
|
||||
@@ -2037,7 +2154,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
TVFocusable(
|
||||
focusNode: _playPauseFocusNode,
|
||||
onPressed: _togglePlayPause,
|
||||
onFocus: _resetControlsTimer,
|
||||
onFocus: () => _noteFocus(_playPauseFocusNode),
|
||||
scale: 1.1,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: IconButton(
|
||||
@@ -2046,7 +2163,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_isPlaying
|
||||
? Icons.pause_circle_filled
|
||||
: Icons.play_circle_filled,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
onPressed: _togglePlayPause,
|
||||
),
|
||||
@@ -2062,12 +2179,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_player.seek(pos + const Duration(seconds: 10));
|
||||
_resetControlsTimer();
|
||||
},
|
||||
onFocus: _resetControlsTimer,
|
||||
onFocus: () => _noteFocus(_nextFocusNode),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
child: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.forward_10,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 48,
|
||||
),
|
||||
onPressed: () async {
|
||||
@@ -2103,7 +2220,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Text(
|
||||
_formatDuration(_position),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
@@ -2157,14 +2274,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
_resetControlsTimer();
|
||||
},
|
||||
activeColor: AppColors.primary,
|
||||
inactiveColor: Colors.white24,
|
||||
inactiveColor: AppColors.onSurface24,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(_duration),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
@@ -2199,7 +2316,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
height: 72,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white10,
|
||||
color: AppColors.onSurface12,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ClipRRect(
|
||||
@@ -2208,7 +2325,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
imageUrl: ch.streamIcon,
|
||||
fit: BoxFit.contain,
|
||||
errorWidget: (_, __, ___) =>
|
||||
const Icon(Icons.tv, color: Colors.white38, size: 40),
|
||||
const Icon(Icons.tv, color: AppColors.onSurface38, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -2217,7 +2334,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
Text(
|
||||
ch != null ? ch.name : 'Chargement...',
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
color: AppColors.onSurface70,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -2232,18 +2349,18 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 64, color: Colors.red),
|
||||
const Icon(Icons.error_outline, size: 64, color: AppColors.error),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Erreur de lecture',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18),
|
||||
style: TextStyle(color: AppColors.onSurface, fontSize: 18),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
_errorMessage ?? 'Une erreur inconnue est survenue',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
style: const TextStyle(color: AppColors.onSurface70),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -2256,8 +2373,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('Retour'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.black,
|
||||
backgroundColor: AppColors.onSurface,
|
||||
foregroundColor: AppColors.onPrimaryFixed,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -2267,7 +2384,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.black,
|
||||
foregroundColor: AppColors.onPrimaryFixed,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -2328,7 +2445,7 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.7),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.green.withOpacity(0.5)),
|
||||
border: Border.all(color: AppColors.success.withOpacity(0.5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -2337,7 +2454,7 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
|
||||
const Text(
|
||||
'STATS FOR NERDS 🤓',
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
color: AppColors.success,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
@@ -2345,11 +2462,11 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Buffer: ${_buffer.inSeconds}s',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
'Decoder: $_decoder',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -98,6 +98,14 @@ class XtreamServiceMobile {
|
||||
/// 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).
|
||||
@@ -200,7 +208,18 @@ class XtreamServiceMobile {
|
||||
|
||||
/// Get live channels for a category (with batch EPG support)
|
||||
/// 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 {
|
||||
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 {
|
||||
final queryParams = {
|
||||
'username': _username,
|
||||
@@ -463,7 +482,18 @@ class XtreamServiceMobile {
|
||||
}
|
||||
|
||||
/// Get live TV categories
|
||||
///
|
||||
/// Falls back to the last good list when the fetch fails.
|
||||
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 {
|
||||
if (kDebugMode) print('🔍 Loading live TV categories with 8s timeout...');
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:xtremobile/core/services/ip_service.dart';
|
||||
import 'package:xtremobile/core/models/iptv_models.dart' as model;
|
||||
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||
|
||||
class MobileLiveTVTab extends ConsumerStatefulWidget {
|
||||
final PlaylistConfig playlist;
|
||||
@@ -26,7 +27,11 @@ class MobileLiveTVTab extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
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 TextEditingController _searchController = TextEditingController();
|
||||
final FocusNode _searchFocusNode = FocusNode();
|
||||
@@ -35,12 +40,16 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
||||
bool _isSearchEditing = false;
|
||||
bool _justReturnedFromPlayer =
|
||||
false; // Flag to prevent PopScope interception after player
|
||||
bool _isRefreshing = false;
|
||||
|
||||
Timer? _searchTimer;
|
||||
|
||||
@override
|
||||
void 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
|
||||
_searchTimer = null;
|
||||
_searchController.addListener(() {
|
||||
@@ -61,6 +70,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
@@ -71,6 +81,39 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
||||
@override
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
@@ -130,14 +173,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
||||
final shouldExit = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
backgroundColor: AppColors.surfaceContainerLowest,
|
||||
title: const Text(
|
||||
"Quitter l'application",
|
||||
style: TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: AppColors.onSurface),
|
||||
),
|
||||
content: const Text(
|
||||
"Voulez-vous vraiment quitter l'application ?",
|
||||
style: TextStyle(color: Colors.white70),
|
||||
style: TextStyle(color: AppColors.onSurface70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -145,14 +188,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
||||
autofocus: true,
|
||||
child: const Text(
|
||||
'Annuler',
|
||||
style: TextStyle(color: Colors.blueAccent),
|
||||
style: TextStyle(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text(
|
||||
'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),
|
||||
child: Text(
|
||||
'Erreur: $e',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
style: const TextStyle(color: AppColors.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -209,197 +252,228 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
// Floating Header (Search + Navigation)
|
||||
SliverAppBar(
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
backgroundColor: const Color(0xFF000000), // Opaque black
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: _searchQuery.isNotEmpty ||
|
||||
_showFavoritesOnly ||
|
||||
!uiState.isCategoryView
|
||||
? 120.0
|
||||
: 110.0,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Search Bar
|
||||
TVFocusable(
|
||||
scale: 1.0,
|
||||
focusColor: Colors.white,
|
||||
onPressed: () {
|
||||
setState(() => _isSearchEditing = true);
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) {
|
||||
_searchFocusNode.requestFocus();
|
||||
SystemChannels.textInput
|
||||
.invokeMethod('TextInput.show');
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: AppDecorations.searchBar(context),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
color: AppDecorations.textSecondary(
|
||||
context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ExcludeFocus(
|
||||
excluding: !_isSearchEditing,
|
||||
child: TextField(
|
||||
cursorColor: Colors.white,
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
readOnly: !_isSearchEditing,
|
||||
style: TextStyle(
|
||||
color: AppDecorations.textPrimary(
|
||||
context),
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText:
|
||||
'Rechercher une chaîne...',
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: (_) => setState(
|
||||
() => _isSearchEditing = false,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _refreshChannels,
|
||||
color: AppColors.primary,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
// Floating Header (Search + Navigation)
|
||||
SliverAppBar(
|
||||
floating: false,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
backgroundColor:
|
||||
const Color(0xFF000000), // Opaque black
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: _searchQuery.isNotEmpty ||
|
||||
_showFavoritesOnly ||
|
||||
!uiState.isCategoryView
|
||||
? 120.0
|
||||
: 110.0,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Search Bar
|
||||
TVFocusable(
|
||||
scale: 1.0,
|
||||
focusColor: AppColors.onSurface,
|
||||
onPressed: () {
|
||||
setState(() => _isSearchEditing = true);
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) {
|
||||
_searchFocusNode.requestFocus();
|
||||
SystemChannels.textInput
|
||||
.invokeMethod('TextInput.show');
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration:
|
||||
AppDecorations.searchBar(context),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
color: AppDecorations.textSecondary(
|
||||
context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ExcludeFocus(
|
||||
excluding: !_isSearchEditing,
|
||||
child: TextField(
|
||||
cursorColor: AppColors.onSurface,
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
readOnly: !_isSearchEditing,
|
||||
style: TextStyle(
|
||||
color:
|
||||
AppDecorations.textPrimary(
|
||||
context),
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
hintText:
|
||||
'Rechercher une chaîne...',
|
||||
border: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: (_) => setState(
|
||||
() => _isSearchEditing = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_searchQuery.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
_searchController.clear(),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: AppDecorations.textSecondary(
|
||||
context),
|
||||
if (_searchQuery.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
_searchController.clear(),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color:
|
||||
AppDecorations.textSecondary(
|
||||
context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Title / Navigation Row
|
||||
Row(
|
||||
children: [
|
||||
if (!showGrid ||
|
||||
_showFavoritesOnly ||
|
||||
_searchQuery.isNotEmpty) ...[
|
||||
// Title / Navigation Row
|
||||
Row(
|
||||
children: [
|
||||
if (!showGrid ||
|
||||
_showFavoritesOnly ||
|
||||
_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(
|
||||
icon: Icon(
|
||||
Icons.arrow_back,
|
||||
color:
|
||||
AppDecorations.textPrimary(context),
|
||||
_showFavoritesOnly
|
||||
? Icons.favorite
|
||||
: Icons.favorite_border,
|
||||
color: _showFavoritesOnly
|
||||
? AppColors.live
|
||||
: AppDecorations.textSecondary(
|
||||
context),
|
||||
),
|
||||
onPressed: () {
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
_searchController.clear();
|
||||
} else if (_showFavoritesOnly) {
|
||||
setState(
|
||||
() => _showFavoritesOnly = false,
|
||||
);
|
||||
} else {
|
||||
onPressed: () => setState(() {
|
||||
_showFavoritesOnly =
|
||||
!_showFavoritesOnly;
|
||||
if (_showFavoritesOnly) {
|
||||
uiNotifier.state = uiState.copyWith(
|
||||
isCategoryView: true,
|
||||
isCategoryView: false,
|
||||
);
|
||||
}
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
if (!_showFavoritesOnly) {
|
||||
uiNotifier.state = uiState.copyWith(
|
||||
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
|
||||
if (showGrid)
|
||||
_buildCategorySliver(categories, uiNotifier, uiState)
|
||||
else
|
||||
_ChannelListView(
|
||||
playlist: widget.playlist,
|
||||
uiState: uiState,
|
||||
searchQuery: _searchQuery,
|
||||
showFavoritesOnly: _showFavoritesOnly,
|
||||
onPlay: (channel, channels, index) => _playChannel(
|
||||
context,
|
||||
channel,
|
||||
channels,
|
||||
widget.playlist,
|
||||
index,
|
||||
// Main Content Sliver
|
||||
if (showGrid)
|
||||
_buildCategorySliver(categories, uiNotifier, uiState)
|
||||
else
|
||||
_ChannelListView(
|
||||
playlist: widget.playlist,
|
||||
uiState: uiState,
|
||||
searchQuery: _searchQuery,
|
||||
showFavoritesOnly: _showFavoritesOnly,
|
||||
onPlay: (channel, channels, index) => _playChannel(
|
||||
context,
|
||||
channel,
|
||||
channels,
|
||||
widget.playlist,
|
||||
index,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -535,7 +609,7 @@ class _ChannelListView extends ConsumerWidget {
|
||||
child: Center(
|
||||
child: Text(
|
||||
'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
|
||||
? 'Sélectionnez une catégorie pour voir vos favoris'
|
||||
: 'Sélectionnez une catégorie pour rechercher',
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
style: const TextStyle(color: AppColors.onSurface70),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -575,7 +649,7 @@ class _ChannelListView extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Erreur: $e',
|
||||
style: const TextStyle(color: Colors.red),
|
||||
style: const TextStyle(color: AppColors.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -600,7 +674,7 @@ class _ChannelListView extends ConsumerWidget {
|
||||
child: Center(
|
||||
child: Text(
|
||||
'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(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.favorite,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 10,
|
||||
),
|
||||
),
|
||||
@@ -779,7 +853,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
|
||||
Text(
|
||||
widget.channel.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10,
|
||||
),
|
||||
@@ -792,7 +866,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
|
||||
Text(
|
||||
_epgTitle!,
|
||||
style: const TextStyle(
|
||||
color: Colors.yellow,
|
||||
color: AppColors.ratingGold,
|
||||
fontSize: 8,
|
||||
),
|
||||
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/core/models/iptv_models.dart' as model;
|
||||
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
|
||||
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||
|
||||
class MobileMoviesTab extends ConsumerStatefulWidget {
|
||||
final PlaylistConfig playlist;
|
||||
@@ -224,6 +225,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
|
||||
final settings = ref.watch(mobileSettingsProvider);
|
||||
final watchHistory = ref.watch(mobileWatchHistoryProvider);
|
||||
|
||||
@@ -285,14 +287,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
||||
final shouldExit = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
backgroundColor: AppColors.surfaceContainerLowest,
|
||||
title: const Text(
|
||||
"Quitter l'application",
|
||||
style: TextStyle(color: Colors.white),
|
||||
style: TextStyle(color: AppColors.onSurface),
|
||||
),
|
||||
content: const Text(
|
||||
"Voulez-vous vraiment quitter l'application ?",
|
||||
style: TextStyle(color: Colors.white70),
|
||||
style: TextStyle(color: AppColors.onSurface70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@@ -300,14 +302,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
||||
autofocus: true,
|
||||
child: const Text(
|
||||
'Annuler',
|
||||
style: TextStyle(color: Colors.blueAccent),
|
||||
style: TextStyle(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text(
|
||||
'Quitter',
|
||||
style: TextStyle(color: Colors.redAccent),
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -346,7 +348,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
||||
// Search Bar
|
||||
TVFocusable(
|
||||
scale: 1.0,
|
||||
focusColor: Colors.white,
|
||||
focusColor: AppColors.onSurface,
|
||||
onPressed: () {
|
||||
setState(() => _isSearchEditing = true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -367,7 +369,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
||||
child: ExcludeFocus(
|
||||
excluding: !_isSearchEditing,
|
||||
child: TextField(
|
||||
cursorColor: Colors.white,
|
||||
cursorColor: AppColors.onSurface,
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
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/core/models/iptv_models.dart' as model;
|
||||
import 'package:xtremobile/features/iptv/screens/mobile_series_detail_screen.dart';
|
||||
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||
|
||||
class MobileSeriesTab extends ConsumerStatefulWidget {
|
||||
final PlaylistConfig playlist;
|
||||
@@ -215,6 +216,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
|
||||
final settings = ref.watch(mobileSettingsProvider);
|
||||
|
||||
if (_categories.isEmpty && !_isLoading) {
|
||||
@@ -269,18 +271,18 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
|
||||
final shouldExit = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: Colors.grey[900],
|
||||
title: const Text("Quitter l'application", style: TextStyle(color: Colors.white)),
|
||||
content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: Colors.white70)),
|
||||
backgroundColor: AppColors.surfaceContainerLowest,
|
||||
title: const Text("Quitter l'application", style: TextStyle(color: AppColors.onSurface)),
|
||||
content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: AppColors.onSurface70)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
autofocus: true,
|
||||
child: const Text('Annuler', style: TextStyle(color: Colors.blueAccent)),
|
||||
child: const Text('Annuler', style: TextStyle(color: AppColors.primary)),
|
||||
),
|
||||
TextButton(
|
||||
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
|
||||
TVFocusable(
|
||||
scale: 1.0,
|
||||
focusColor: Colors.white,
|
||||
focusColor: AppColors.onSurface,
|
||||
onPressed: () {
|
||||
setState(() => _isSearchEditing = true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -337,7 +339,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
|
||||
child: ExcludeFocus(
|
||||
excluding: !_isSearchEditing,
|
||||
child: TextField(
|
||||
cursorColor: Colors.white,
|
||||
cursorColor: AppColors.onSurface,
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
readOnly: !_isSearchEditing,
|
||||
|
||||
@@ -3,11 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.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_xtream_providers.dart';
|
||||
import 'package:xtremobile/mobile/widgets/tv_focusable.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/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
|
||||
class MobileSettingsTab extends ConsumerStatefulWidget {
|
||||
@@ -105,7 +107,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Version 1.5.1',
|
||||
'Version 1.7.2',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppDecorations.textSecondary(context),
|
||||
@@ -178,8 +180,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
_buildSettingItem(
|
||||
icon: Icons.public,
|
||||
title: 'EPG communautaire',
|
||||
subtitle:
|
||||
'Repli quand le fournisseur ne renvoie aucun programme',
|
||||
subtitle: 'Repli quand le fournisseur ne renvoie aucun programme',
|
||||
value: settings.useCommunityEpg ? 'Activé' : 'Désactivé',
|
||||
onTap: () => ref
|
||||
.read(mobileSettingsProvider.notifier)
|
||||
@@ -196,9 +197,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
onTap: () => _showKeyboardDialog(
|
||||
'URL XMLTV',
|
||||
_xmltvController,
|
||||
(val) => ref
|
||||
.read(mobileSettingsProvider.notifier)
|
||||
.setXmltvUrl(val),
|
||||
(val) =>
|
||||
ref.read(mobileSettingsProvider.notifier).setXmltvUrl(val),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -253,8 +253,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
|
||||
_buildSettingItem(
|
||||
icon: _isRefreshingCache ? Icons.hourglass_empty : Icons.refresh,
|
||||
title: 'Actualiser le cache',
|
||||
subtitle: 'Recharge films, séries et EPG',
|
||||
title: 'Forcer l\'actualisation',
|
||||
subtitle: 'Recharge chaînes, films, séries et EPG',
|
||||
value: _isRefreshingCache ? '...' : 'Appuyer',
|
||||
onTap: _isRefreshingCache ? null : _refreshCache,
|
||||
),
|
||||
@@ -318,16 +318,16 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
setState(() => _isRefreshingCache = true);
|
||||
|
||||
try {
|
||||
// final dir = await getApplicationDocumentsDirectory();
|
||||
// final cacheStore = HiveCacheStore(dir.path);
|
||||
// await cacheStore.clean();
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
refreshFullCatalog(ref);
|
||||
// Brief pause so the tap visibly registers on a TV remote.
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Cache actualisé ! Relancez l\'app pour recharger.'),
|
||||
backgroundColor: Colors.green,
|
||||
content:
|
||||
Text('Chaînes, films, séries et EPG en cours de rechargement'),
|
||||
backgroundColor: AppColors.success,
|
||||
duration: Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
@@ -335,7 +335,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'), backgroundColor: AppColors.error),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -389,54 +390,60 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
builder: (ctx, constraints) {
|
||||
return Row(
|
||||
children: segments.map((seg) {
|
||||
final (mode, icon, label) = seg;
|
||||
final selected = themeState.appThemeMode == mode;
|
||||
final accentColor = Theme.of(context).colorScheme.primary;
|
||||
return Expanded(
|
||||
child: TVFocusable(
|
||||
onPressed: () => notifier.setThemeMode(mode),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? accentColor
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: selected
|
||||
? Colors.white
|
||||
: AppDecorations.textSecondary(context),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
final (mode, icon, label) = seg;
|
||||
final selected = themeState.appThemeMode == mode;
|
||||
final accentColor =
|
||||
Theme.of(context).colorScheme.primary;
|
||||
return Expanded(
|
||||
child: TVFocusable(
|
||||
onPressed: () => notifier.setThemeMode(mode),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin:
|
||||
const EdgeInsets.symmetric(horizontal: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? accentColor
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: selected
|
||||
? Colors.white
|
||||
: AppDecorations.textSecondary(context),
|
||||
? AppColors.onSurface
|
||||
: 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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -482,7 +489,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
),
|
||||
child: Row(
|
||||
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),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -513,7 +521,10 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
vertical: 6,
|
||||
),
|
||||
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),
|
||||
),
|
||||
child: Text(
|
||||
@@ -561,7 +572,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
),
|
||||
child: Row(
|
||||
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),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -591,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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -30,10 +30,44 @@ final mobileXtreamServiceProvider =
|
||||
}
|
||||
});
|
||||
|
||||
// A full catalogue refresh from the settings also drops the cached EPG.
|
||||
ref.listen<int>(
|
||||
fullCatalogRefreshTickProvider, (_, __) => service.clearCache());
|
||||
|
||||
ref.onDispose(service.dispose);
|
||||
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)
|
||||
final mobileLiveCategoriesProvider =
|
||||
FutureProvider.family<List<model.Category>, PlaylistConfig>(
|
||||
@@ -43,9 +77,9 @@ final mobileLiveCategoriesProvider =
|
||||
});
|
||||
|
||||
/// Mobile-specific live channels provider (loads channels for a specific category)
|
||||
final mobileLiveChannelsByCategoryProvider = FutureProvider.family<
|
||||
List<model.Channel>,
|
||||
(PlaylistConfig, String)>((ref, params) async {
|
||||
final mobileLiveChannelsByCategoryProvider =
|
||||
FutureProvider.family<List<model.Channel>, (PlaylistConfig, String)>(
|
||||
(ref, params) async {
|
||||
final (playlist, categoryId) = params;
|
||||
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||
return service.getLiveChannels(categoryId);
|
||||
@@ -79,8 +113,9 @@ final mobileLiveChannelsProvider =
|
||||
|
||||
for (int batchNum = 0; batchNum < totalBatches; batchNum++) {
|
||||
int startIdx = batchNum * batchSize;
|
||||
int endIdx =
|
||||
(startIdx + batchSize < categories.length) ? startIdx + batchSize : categories.length;
|
||||
int endIdx = (startIdx + batchSize < categories.length)
|
||||
? startIdx + batchSize
|
||||
: categories.length;
|
||||
final batch = categories.sublist(startIdx, endIdx);
|
||||
|
||||
print(
|
||||
@@ -120,7 +155,8 @@ final mobileLiveChannelsProvider =
|
||||
|
||||
/// Mobile-specific movies provider
|
||||
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);
|
||||
return service.getMoviesByCategory(""); // Fetch all or default
|
||||
});
|
||||
@@ -134,8 +170,9 @@ final mobileSeriesCategoriesProvider =
|
||||
});
|
||||
|
||||
/// Mobile-specific series pagination provider
|
||||
final mobileSeriesProvider = FutureProvider.family<List<model.Series>,
|
||||
(PlaylistConfig, String?)>((ref, params) async {
|
||||
final mobileSeriesProvider =
|
||||
FutureProvider.family<List<model.Series>, (PlaylistConfig, String?)>(
|
||||
(ref, params) async {
|
||||
final (playlist, categoryId) = params;
|
||||
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||
return service.getSeriesPaginated(categoryId: categoryId);
|
||||
@@ -161,7 +198,8 @@ class SeriesInfoRequest {
|
||||
}
|
||||
|
||||
final mobileSeriesInfoByPlaylistProvider =
|
||||
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>((ref, request) async {
|
||||
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>(
|
||||
(ref, request) async {
|
||||
final service =
|
||||
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
|
||||
return service.getSeriesInfo(request.seriesId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:xtremobile/core/theme/app_decorations.dart';
|
||||
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
||||
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||
|
||||
class MobileCategoryCard extends StatelessWidget {
|
||||
final String title;
|
||||
@@ -13,7 +14,7 @@ class MobileCategoryCard extends StatelessWidget {
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.icon = Icons.folder_rounded,
|
||||
this.iconColor = const Color(0xFF0A84FF),
|
||||
this.iconColor = AppColors.primary,
|
||||
});
|
||||
|
||||
@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/mobile/widgets/tv_focusable.dart';
|
||||
import 'package:xtremobile/core/utils/image_cache_config.dart';
|
||||
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||
|
||||
class MobilePosterCard extends StatelessWidget {
|
||||
final String title;
|
||||
@@ -134,12 +135,12 @@ class MobilePosterCard extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.star, color: Colors.amber, size: 10),
|
||||
const Icon(Icons.star, color: AppColors.ratingGold, size: 10),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
rating!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -157,13 +158,13 @@ class MobilePosterCard extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF30D158),
|
||||
color: AppColors.success,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check,
|
||||
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:intl/intl.dart';
|
||||
import '../../core/theme/app_decorations.dart';
|
||||
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||
|
||||
class MobileScaffold extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
@@ -71,7 +72,7 @@ class _MobileScaffoldState extends ConsumerState<MobileScaffold> {
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => const Icon(
|
||||
Icons.play_circle_filled,
|
||||
color: Colors.white,
|
||||
color: AppColors.onSurface,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
@@ -201,12 +202,12 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const Color selectedColor = Color(0xFF0A84FF);
|
||||
const Color selectedColor = AppColors.primaryFill;
|
||||
final Color unselectedColor = AppDecorations.textSecondary(context);
|
||||
final Color textColor =
|
||||
widget.isSelected ? Colors.white : unselectedColor;
|
||||
widget.isSelected ? AppColors.onSurface : unselectedColor;
|
||||
final Color iconColor =
|
||||
widget.isSelected ? Colors.white : unselectedColor;
|
||||
widget.isSelected ? AppColors.onSurface : unselectedColor;
|
||||
final FontWeight fontWeight =
|
||||
widget.isSelected ? FontWeight.w700 : FontWeight.w500;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:xtremobile/core/theme/app_colors.dart';
|
||||
|
||||
class TVFocusable extends StatefulWidget {
|
||||
final Widget child;
|
||||
@@ -25,7 +26,7 @@ class TVFocusable extends StatefulWidget {
|
||||
this.autofocus = false,
|
||||
this.scale = 1.05, // Apple TV subtle lift
|
||||
this.borderWidth = 2.0,
|
||||
this.focusColor = Colors.white,
|
||||
this.focusColor = AppColors.onSurface,
|
||||
this.borderRadius,
|
||||
this.longPressDuration = const Duration(seconds: 3),
|
||||
});
|
||||
@@ -162,7 +163,7 @@ class _TVFocusableState extends State<TVFocusable> {
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
const BoxShadow(
|
||||
color: Color(0x40FFFFFF), // White glow
|
||||
color: Color(0x590A84FF), // Apple blue glow
|
||||
blurRadius: 12,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
|
||||
+19
-5
@@ -1,7 +1,7 @@
|
||||
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'
|
||||
version: 1.6.0+12
|
||||
version: 1.7.2+16
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
@@ -34,6 +34,13 @@ dependencies:
|
||||
media_kit: ^1.1.11
|
||||
media_kit_video: ^1.2.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
|
||||
google_fonts: ^6.2.1
|
||||
@@ -50,7 +57,11 @@ dependencies:
|
||||
path_provider: ^2.1.5
|
||||
video_player: ^2.10.1
|
||||
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_static: ^1.1.3
|
||||
flutter_cache_manager: ^3.4.1
|
||||
@@ -70,9 +81,12 @@ dev_dependencies:
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
image_path: "assets/images/app-icon.png"
|
||||
ios: true
|
||||
image_path: "assets/images/logo_xtremflow.png"
|
||||
adaptive_icon_background: "#1a1a2e"
|
||||
adaptive_icon_foreground: "assets/images/app-icon.png"
|
||||
adaptive_icon_foreground: "assets/images/logo_xtremflow.png"
|
||||
# iOS refuse une icône comportant un canal alpha.
|
||||
remove_alpha_ios: true
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
Executable
+108
@@ -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})"
|
||||
@@ -25,6 +25,21 @@
|
||||
- [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/`
|
||||
|
||||
## 3. Lecture — arrêt en arrière-plan et tenue dans la durée
|
||||
|
||||
|
||||
Reference in new issue
Block a user