10 Commits
Author SHA1 Message Date
Claude c7cffe91ee chore: bump version to 1.7.3+17
Nouveau logo et icônes (Android, Fire TV, iOS) : publie une release
dédiée plutôt que de remplacer l'APK de v1.7.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyudLt8TgggqbWWkqjB19K
2026-09-30 17:10:16 +00:00
LogiFlow 10a5b8d2bf Merge pull request #3 from R0m1k3/claude/laughing-brown-26r497
Refonte du logo et des icônes (thème graphite/bleu, Fire TV, iOS)
2026-09-30 11:46:46 +02:00
Claude 03126d3b36 Refonte du logo et des icônes aux couleurs du thème (Android, Fire TV, iOS)
- Nouveau logo vectoriel : bouton play dégradé cyan→bleu (#0A84FF) avec
  traînées de vitesse, sur tuile graphite (#2C2C33→#1B1B1F) comme l'app
- scripts/generate_icons.py génère toutes les tailles depuis les SVG
  (assets/branding/)
- Android : icône adaptative dans la zone sûre, icône ronde, écran de
  lancement graphite avec logo
- Android TV / Fire TV : bannière 320x180, LEANBACK_LAUNCHER, écran
  tactile et leanback non requis
- iOS : icône carrée sans alpha dédiée (app_icon_ios.png)
- Remplace l'ancien logo (JPEG avec damier incrusté) et corrige la
  référence à logo_xtremobile.png inexistant dans les réglages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyudLt8TgggqbWWkqjB19K
2026-09-30 07:34:38 +00:00
MichaelandClaude Opus 5.5 462bcc784c chore: bump version to 1.7.2+16
Channel refresh is an incremental change on top of v1.7.1, so ship it as
a patch release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:21:56 +02:00
MichaelandClaude Opus 5.5 60c7f4cece feat(live): refresh the channel list on demand and on resume
Live categories and channels were fetched once per app session and kept
in memory until the process died, so a TV box left running never picked
up provider changes.

- Live tab: pull-to-refresh plus a remote-friendly refresh button, and an
  automatic refetch when the app returns to the foreground after 4 hours.
- Settings: the "refresh cache" entry was a no-op delay; it now forces a
  reload of live channels, movies, series and EPG.
- The Xtream service keeps the last non-empty live lists and serves them
  when a refetch fails, so a refresh on a not-yet-connected network does
  not blank the screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 22:21:56 +02:00
Claude 9ce707a702 ci(ios): n'exécute pas le build macOS pour les commits de documentation
Le workflow iOS tourne sur un runner macOS, facturé 10x en minutes Actions.
Deux ajustements après les premiers runs :

- paths-ignore sur les fichiers Markdown : un commit de documentation
  déclenchait un build complet pour rien.
- generate_release_notes passe à false : les notes sont déjà produites par le
  workflow APK, et softprops/action-gh-release les ajoute au corps de la
  release à chaque publication — le corps de v1.7.1 porte ainsi plusieurs
  copies du lien de changelog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoXgHivbAphDk9ArgKDg8Q
2026-09-09 17:14:11 +00:00
Claude f105becd79 docs(ios): détaille l'installation via SideStore
SideStore est la seule voie gratuite viable pour un appareil qui n'est pas
toujours sur le même réseau qu'un ordinateur : contrairement à AltStore, il
renouvelle la signature depuis l'appareil, sans AltServer ni réseau local.

Remplace la mention d'une ligne par la procédure complète : fichier de pairage
généré une fois depuis un ordinateur, installation de SideStore, tunnel StosVPN
(sans lequel le renouvellement échoue), import de l'IPA. Rappelle les limites
qui subsistent avec un Apple ID gratuit (3 apps, 10 App IDs par semaine).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoXgHivbAphDk9ArgKDg8Q
2026-09-09 17:00:28 +00:00
Claude 93d5741c0c fix(ios): épingle device_info_plus 12.3.0 (SDK Xcode 26.1 requis en 12.4.0)
Deuxième run du workflow Build iOS : volume_controller passe, la compilation
s'arrête maintenant sur device_info_plus 12.4.0 :

  No visible @interface for 'NSProcessInfo' declares the selector
  'isiOSAppOnVision'

Le plugin appelle cette propriété derrière un `@available(iOS 26.1, *)`, garde
d'exécution qui n'évite pas la compilation : le symbole doit exister dans les
en-têtes, donc le SDK d'Xcode 26.1 est requis alors que le runner macOS en
embarque un plus ancien. La 12.3.0 est la dernière version sans cet appel.

Même cause de fond que l'épinglage précédent : faute de pubspec.lock versionné,
la CI résout les plugins à leur dernière version, laquelle vise un outillage
Apple plus récent que le Flutter 3.35.3 et l'Xcode des workflows.

Un balayage des sources natives iOS de l'ensemble des plugins du graphe (API
`@available(iOS >= 18)` et API scene de Flutter 3.38) ne remonte que ces deux
paquets ; les douze autres compilent en l'état.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoXgHivbAphDk9ArgKDg8Q
2026-09-09 16:58:26 +00:00
Claude 2e88a56943 fix(ios): épingle volume_controller 3.4.1 pour compiler sur Flutter 3.35
Premier run du workflow Build iOS : toute la chaîne passe (génération du
projet Xcode, Info.plist, CocoaPods, icônes), mais la compilation Swift
échoue sur volume_controller 3.4.4, tiré transitivement par media_kit_video :

  Cannot find type 'FlutterSceneLifeCycleDelegate' in scope
  Value of type 'any FlutterPluginRegistrar' has no member 'addSceneDelegate'

Ces API sont apparues dans Flutter 3.38. volume_controller les utilise depuis
la 3.4.2 sans relever sa contrainte `flutter:` (toujours >=3.0.0), si bien que
pub la sélectionne sur le Flutter 3.35.3 des workflows. La 3.4.1 est la
dernière version sans ces appels — vérifié en inspectant les sources iOS des
versions 3.4.0 à 3.4.4.

Le côté Android du plugin n'utilise pas ces API, ce qui explique que seul le
build iOS soit touché. Un balayage des autres plugins à code natif iOS du
projet ne remonte que screen_brightness_ios (2.1.3+), absent du graphe depuis
que media_kit_video 1.3.x ne dépend plus que de screen_brightness_android.

Ajoute par ailleurs au guide iOS la marche à suivre pour un appareil qui n'est
pas toujours sur le même réseau que l'ordinateur (SideStore, compte payant).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoXgHivbAphDk9ArgKDg8Q
2026-09-09 16:41:12 +00:00
Claude 26bf240b76 feat(ios): support iPhone/iPad avec build d'un IPA sideloadable
Le projet était Android-only : le dossier ios/ avait été supprimé et aucune
cible Apple n'existait. Le code Dart, lui, est resté portable (aucun appel
spécifique à Android) et media_kit_libs_video embarque déjà libmpv pour iOS.

- scripts/prepare_ios.sh régénère le projet Xcode via `flutter create` puis
  applique les réglages nécessaires à une app IPTV : exception App Transport
  Security (les serveurs Xtream sont en http:// simple), audio en arrière-plan,
  orientations paysage et 4 sens sur iPad, autorisation réseau local, cible
  iOS 13 exigée par media_kit et bundle id aligné sur l'applicationId Android.
  Le script est idempotent et respecte un dossier ios/ déjà versionné.
- Nouveau workflow Build iOS : runner macOS, build --no-codesign, empaquetage
  en IPA non signé publié sur la Release de la version, à côté de l'APK.
  Pas de déclenchement sur les PR, un runner macOS coûtant 10x en minutes.
- IOS_GUIDE.md documente les voies d'installation hors App Store (AltStore /
  SideStore, Sideloadly, compte développeur, TestFlight) et leurs limites.
- flutter_launcher_icons : image_path pointait sur app-icon.png, absent du
  dépôt ; correction vers le logo existant et ajout de la génération iOS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WoXgHivbAphDk9ArgKDg8Q
2026-09-09 16:28:02 +00:00
46 changed files with 1239 additions and 272 deletions

No files matched your search

+83
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+9 -2
View File
@@ -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
+8
View File
@@ -2,11 +2,18 @@
<!-- Permissions for IPTV streaming -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- Android TV / Fire TV : l'app reste installable sur téléphone
et apparaît dans le lanceur TV (pas d'écran tactile requis). -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
<uses-feature android:name="android.software.leanback" android:required="false"/>
<application
android:label="XtremFlow"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:banner="@drawable/banner"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
@@ -28,6 +35,7 @@
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<!-- Écran de lancement : fond graphite du thème + logo centré -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item android:drawable="@color/ic_launcher_background" />
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/ic_launcher_foreground" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 22 KiB

@@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<!-- Écran de lancement : fond graphite du thème + logo centré -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item android:drawable="@color/ic_launcher_background" />
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/ic_launcher_foreground" />
</item>
</layer-list>
@@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Icône adaptative : le calque avant est déjà dessiné dans la zone sûre
(66dp sur 108dp), aucun inset supplémentaire n'est nécessaire.
Généré par scripts/generate_icons.py -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Icône adaptative : le calque avant est déjà dessiné dans la zone sûre
(66dp sur 108dp), aucun inset supplémentaire n'est nécessaire.
Généré par scripts/generate_icons.py -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+3 -2
View File
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1a1a2e</color>
</resources>
<!-- baseLevel0 de lib/core/theme/app_colors.dart -->
<color name="ic_launcher_background">#1B1B1F</color>
</resources>
+37
View File
@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2C2C33"/>
<stop offset="1" stop-color="#1B1B1F"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
</radialGradient>
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#7DD3F2"/>
<stop offset="0.55" stop-color="#0A84FF"/>
<stop offset="1" stop-color="#0A5BD6"/>
</linearGradient>
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
</linearGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
</defs>
<g transform="translate(215.04 215.04) scale(5.9392)">
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
stroke-width="12" stroke-linejoin="round"/>
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
stroke-width="4" stroke-linecap="round"/>
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
<path d="M6 38 L28 38" stroke-width="7"/>
<path d="M0 50 L28 50" stroke-width="7"/>
<path d="M10 62 L28 62" stroke-width="7"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2C2C33"/>
<stop offset="1" stop-color="#1B1B1F"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
</radialGradient>
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#7DD3F2"/>
<stop offset="0.55" stop-color="#0A84FF"/>
<stop offset="1" stop-color="#0A5BD6"/>
</linearGradient>
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
</linearGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
</defs>
<circle cx="512.0" cy="512.0" r="512.0" fill="url(#bg)"/>
<circle cx="512.0" cy="512.0" r="512.0" fill="url(#shine)"/>
<g transform="translate(194.56 194.56) scale(6.3488)">
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
stroke-width="12" stroke-linejoin="round"/>
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
stroke-width="4" stroke-linecap="round"/>
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
<path d="M6 38 L28 38" stroke-width="7"/>
<path d="M0 50 L28 50" stroke-width="7"/>
<path d="M10 62 L28 62" stroke-width="7"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2C2C33"/>
<stop offset="1" stop-color="#1B1B1F"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
</radialGradient>
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#7DD3F2"/>
<stop offset="0.55" stop-color="#0A84FF"/>
<stop offset="1" stop-color="#0A5BD6"/>
</linearGradient>
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
</linearGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="230.4" fill="url(#bg)"/>
<rect width="1024" height="1024" rx="230.4" fill="url(#shine)"/>
<rect x="1.5" y="1.5" width="1021" height="1021" rx="228.9"
fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
<g transform="translate(184.32 184.32) scale(6.5536)">
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
stroke-width="12" stroke-linejoin="round"/>
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
stroke-width="4" stroke-linecap="round"/>
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
<path d="M6 38 L28 38" stroke-width="7"/>
<path d="M0 50 L28 50" stroke-width="7"/>
<path d="M10 62 L28 62" stroke-width="7"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+42
View File
@@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2C2C33"/>
<stop offset="1" stop-color="#1B1B1F"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
</radialGradient>
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#7DD3F2"/>
<stop offset="0.55" stop-color="#0A84FF"/>
<stop offset="1" stop-color="#0A5BD6"/>
</linearGradient>
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
</linearGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="1024" height="1024" rx="0" fill="url(#bg)"/>
<rect width="1024" height="1024" rx="0" fill="url(#shine)"/>
<rect x="1.5" y="1.5" width="1021" height="1021" rx="0"
fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
<g transform="translate(184.32 184.32) scale(6.5536)">
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
stroke-width="12" stroke-linejoin="round"/>
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
stroke-width="4" stroke-linecap="round"/>
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
<path d="M6 38 L28 38" stroke-width="7"/>
<path d="M0 50 L28 50" stroke-width="7"/>
<path d="M10 62 L28 62" stroke-width="7"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+44
View File
@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2C2C33"/>
<stop offset="1" stop-color="#1B1B1F"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
</radialGradient>
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="#7DD3F2"/>
<stop offset="0.55" stop-color="#0A84FF"/>
<stop offset="1" stop-color="#0A5BD6"/>
</linearGradient>
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
</linearGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="320" height="180" fill="url(#bg)"/>
<rect width="320" height="180" fill="url(#shine)"/>
<g transform="translate(29.60 39.60) scale(1.0080)">
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
stroke-width="12" stroke-linejoin="round"/>
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
stroke-width="4" stroke-linecap="round"/>
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
<path d="M6 38 L28 38" stroke-width="7"/>
<path d="M0 50 L28 50" stroke-width="7"/>
<path d="M10 62 L28 62" stroke-width="7"/>
</g>
</g>
<text x="140.8" y="101.0" font-family="Inter" font-weight="bold"
font-size="32" fill="#F5F5F7" letter-spacing="-0.6">Xtrem<tspan fill="#8AB9FF">Flow</tspan></text>
<text x="141.8" y="124.0" font-family="Inter" font-weight="600"
font-size="12" fill="#98989F" letter-spacing="2.4">IPTV PLAYER</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+10
View File
@@ -0,0 +1,10 @@
# Icônes iOS uniquement : le dossier ios/ étant régénéré à chaque build CI,
# l'AppIcon doit être reconstruite après `scripts/prepare_ios.sh`.
# Les icônes Android, elles, sont versionnées dans android/app/src/main/res.
# Usage : dart run flutter_launcher_icons -f flutter_launcher_icons-ios.yaml
flutter_launcher_icons:
ios: true
android: false
image_path: "assets/images/app_icon_ios.png"
# L'App Store et iOS refusent une icône avec canal alpha.
remove_alpha_ios: true
@@ -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...');
+244 -171
View File
@@ -27,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();
@@ -36,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(() {
@@ -62,6 +70,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollController.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
@@ -72,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);
@@ -210,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: 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,
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
? AppColors.live
: 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,
),
),
),
],
],
),
),
);
},
@@ -225,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);
@@ -216,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) {
@@ -3,6 +3,7 @@ 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';
@@ -84,7 +85,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
height: 48,
// No decoration for free-floating logo
child: Image.asset(
'assets/images/logo_xtremobile.png',
'assets/images/logo_xtremflow.png',
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
Icons.play_circle_filled,
@@ -106,7 +107,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
),
),
Text(
'Version 1.6.1',
'Version 1.7.3',
style: GoogleFonts.inter(
fontSize: 13,
color: AppDecorations.textSecondary(context),
@@ -179,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)
@@ -197,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),
),
),
@@ -254,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,
),
@@ -319,15 +318,15 @@ 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.'),
content:
Text('Chaînes, films, séries et EPG en cours de rechargement'),
backgroundColor: AppColors.success,
duration: Duration(seconds: 3),
),
@@ -336,7 +335,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppColors.error),
SnackBar(
content: Text('Erreur: $e'), backgroundColor: AppColors.error),
);
}
} finally {
@@ -390,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
? AppColors.onSurface
: 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
? AppColors.onSurface
: AppDecorations.textSecondary(context),
: AppDecorations.textSecondary(
context),
),
),
],
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
color: selected
? AppColors.onSurface
: AppDecorations.textSecondary(
context),
),
),
],
),
),
),
),
);
}).toList(),
);
}).toList(),
);
},
),
@@ -483,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(
@@ -514,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(
@@ -562,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(
@@ -592,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);
+21 -7
View File
@@ -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.7.1+15
version: 1.7.3+17
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
@@ -68,11 +79,14 @@ dev_dependencies:
riverpod_generator: ^2.4.0
hive_generator: ^2.0.1
# Les icônes Android (mipmap, adaptative, bannière TV) sont générées par
# scripts/generate_icons.py et versionnées : ne pas les écraser ici.
flutter_launcher_icons:
android: true
image_path: "assets/images/app-icon.png"
adaptive_icon_background: "#1a1a2e"
adaptive_icon_foreground: "assets/images/app-icon.png"
android: false
ios: true
image_path: "assets/images/app_icon_ios.png"
# iOS refuse une icône comportant un canal alpha.
remove_alpha_ios: true
flutter:
uses-material-design: true
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Génère toutes les icônes XtremFlow à partir d'un seul dessin vectoriel.
Palette alignée sur lib/core/theme/app_colors.dart (graphite + bleu Apple).
Sorties :
assets/branding/*.svg sources vectorielles
assets/images/logo_xtremflow.png logo in-app (fond transparent)
assets/images/app_icon_ios.png icône iOS (carré plein, sans alpha)
android/.../mipmap-*/ic_launcher.png icône legacy (tuile arrondie)
android/.../mipmap-*/ic_launcher_round.png icône legacy ronde
android/.../drawable-*/ic_launcher_foreground.png calque adaptatif
android/.../drawable-xhdpi/banner.png bannière Android TV / Fire TV
Usage : pip install cairosvg pillow && python3 scripts/generate_icons.py
La bannière utilise la police Inter (Bold) si elle est installée.
"""
import io
import os
import cairosvg
from PIL import Image
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RES = os.path.join(ROOT, "android", "app", "src", "main", "res")
# Couleurs du thème (app_colors.dart)
BG_TOP = "#2C2C33" # surfaceContainerLow
BG_BOTTOM = "#1B1B1F" # baseLevel0
BLUE = "#0A84FF" # primaryContainer
BLUE_LIGHT = "#8AB9FF" # primary
CYAN = "#7DD3F2" # tertiary
DEFS = f"""
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="{BG_TOP}"/>
<stop offset="1" stop-color="{BG_BOTTOM}"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="{BLUE}" stop-opacity="0.35"/>
<stop offset="1" stop-color="{BLUE}" stop-opacity="0"/>
</radialGradient>
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
<stop offset="0" stop-color="{CYAN}"/>
<stop offset="0.55" stop-color="{BLUE}"/>
<stop offset="1" stop-color="#0A5BD6"/>
</linearGradient>
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
<stop offset="0" stop-color="{CYAN}" stop-opacity="0"/>
<stop offset="1" stop-color="{CYAN}" stop-opacity="1"/>
</linearGradient>
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
</defs>"""
def mark(cx, cy, size):
"""Le symbole : un bouton play fluide précédé de traînées de vitesse.
Dessiné dans une boîte de 100x100 centrée sur (cx, cy), mise à l'échelle
à `size` pixels.
"""
s = size / 100.0
tx, ty = cx - 50 * s, cy - 50 * s
return f"""
<g transform="translate({tx:.2f} {ty:.2f}) scale({s:.4f})">
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
stroke-width="12" stroke-linejoin="round"/>
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
stroke-width="4" stroke-linecap="round"/>
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
<path d="M6 38 L28 38" stroke-width="7"/>
<path d="M0 50 L28 50" stroke-width="7"/>
<path d="M10 62 L28 62" stroke-width="7"/>
</g>
</g>"""
def svg(w, h, body):
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" '
f'viewBox="0 0 {w} {h}">{DEFS}{body}\n</svg>\n'
)
def tile(size, radius_ratio, mark_ratio=0.64):
"""Tuile graphite (arrondie ou pleine) avec le symbole centré."""
r = size * radius_ratio
body = f"""
<rect width="{size}" height="{size}" rx="{r}" fill="url(#bg)"/>
<rect width="{size}" height="{size}" rx="{r}" fill="url(#shine)"/>
<rect x="1.5" y="1.5" width="{size - 3}" height="{size - 3}" rx="{max(r - 1.5, 0)}"
fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
{mark(size / 2, size / 2, size * mark_ratio)}"""
return svg(size, size, body)
def round_tile(size):
body = f"""
<circle cx="{size / 2}" cy="{size / 2}" r="{size / 2}" fill="url(#bg)"/>
<circle cx="{size / 2}" cy="{size / 2}" r="{size / 2}" fill="url(#shine)"/>
{mark(size / 2, size / 2, size * 0.62)}"""
return svg(size, size, body)
def adaptive_foreground(size):
# 108dp de canevas, zone sûre de 66dp : le symbole occupe ~58% du canevas,
# soit l'intérieur du cercle sûr quelle que soit la forme du masque.
return svg(size, size, mark(size / 2, size / 2, size * 0.58))
def banner(w=320, h=180):
"""Bannière TV (Android TV / Fire TV) : 320x180 px en xhdpi."""
body = f"""
<rect width="{w}" height="{h}" fill="url(#bg)"/>
<rect width="{w}" height="{h}" fill="url(#shine)"/>
{mark(w * 0.25, h / 2, h * 0.56)}
<text x="{w * 0.44}" y="{h / 2 + 11}" font-family="Inter" font-weight="bold"
font-size="32" fill="#F5F5F7" letter-spacing="-0.6">Xtrem<tspan fill="{BLUE_LIGHT}">Flow</tspan></text>
<text x="{w * 0.44 + 1}" y="{h / 2 + 34}" font-family="Inter" font-weight="600"
font-size="12" fill="#98989F" letter-spacing="2.4">IPTV PLAYER</text>"""
return svg(w, h, body)
def render(svg_text, out_path, size=None, flatten=None):
png = cairosvg.svg2png(
bytestring=svg_text.encode(),
output_width=size[0] if size else None,
output_height=size[1] if size else None,
)
img = Image.open(io.BytesIO(png)).convert("RGBA")
if flatten:
bg = Image.new("RGBA", img.size, flatten)
bg.alpha_composite(img)
img = bg.convert("RGB")
os.makedirs(os.path.dirname(out_path), exist_ok=True)
img.save(out_path, optimize=True)
print(" ", os.path.relpath(out_path, ROOT), img.size)
def main():
brand = os.path.join(ROOT, "assets", "branding")
os.makedirs(brand, exist_ok=True)
sources = {
"icon_rounded.svg": tile(1024, 0.225),
"icon_square.svg": tile(1024, 0),
"icon_round.svg": round_tile(1024),
"icon_foreground.svg": adaptive_foreground(1024),
"tv_banner.svg": banner(),
}
for name, text in sources.items():
with open(os.path.join(brand, name), "w") as f:
f.write(text)
print("Assets Flutter :")
render(sources["icon_rounded.svg"], os.path.join(ROOT, "assets/images/logo_xtremflow.png"), (512, 512))
# iOS applique son propre masque et refuse l'alpha : carré plein.
render(sources["icon_square.svg"], os.path.join(ROOT, "assets/images/app_icon_ios.png"), (1024, 1024), flatten=BG_BOTTOM)
print("Android :")
densities = {"mdpi": 1, "hdpi": 1.5, "xhdpi": 2, "xxhdpi": 3, "xxxhdpi": 4}
for d, k in densities.items():
legacy = round(48 * k)
fg = round(108 * k)
render(sources["icon_rounded.svg"], f"{RES}/mipmap-{d}/ic_launcher.png", (legacy, legacy))
render(sources["icon_round.svg"], f"{RES}/mipmap-{d}/ic_launcher_round.png", (legacy, legacy))
render(sources["icon_foreground.svg"], f"{RES}/drawable-{d}/ic_launcher_foreground.png", (fg, fg))
# Bannière TV : 160x90dp -> 320x180 en xhdpi (taille de référence Google/Amazon).
render(sources["tv_banner.svg"], f"{RES}/drawable-xhdpi/banner.png", (320, 180), flatten=BG_BOTTOM)
render(sources["tv_banner.svg"], f"{RES}/drawable-xxhdpi/banner.png", (480, 270), flatten=BG_BOTTOM)
render(sources["tv_banner.svg"], f"{RES}/drawable-xxxhdpi/banner.png", (640, 360), flatten=BG_BOTTOM)
# Aperçu grand format pour les stores / README.
render(sources["tv_banner.svg"], os.path.join(brand, "tv_banner_1280x720.png"), (1280, 720), flatten=BG_BOTTOM)
if __name__ == "__main__":
main()
+108
View File
@@ -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})"