Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7cffe91ee | ||
|
|
10a5b8d2bf | ||
|
|
03126d3b36 | ||
|
|
462bcc784c | ||
|
|
60c7f4cece | ||
|
|
9ce707a702 | ||
|
|
f105becd79 | ||
|
|
93d5741c0c | ||
|
|
2e88a56943 |
No files matched your search
@@ -14,6 +14,11 @@ name: Build iOS
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
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:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -73,4 +78,6 @@ jobs:
|
|||||||
tag_name: v${{ steps.version.outputs.version }}
|
tag_name: v${{ steps.version.outputs.version }}
|
||||||
name: XtremMobile v${{ steps.version.outputs.version }}
|
name: XtremMobile v${{ steps.version.outputs.version }}
|
||||||
files: xtremobile-v${{ steps.version.outputs.version }}-unsigned.ipa
|
files: xtremobile-v${{ steps.version.outputs.version }}-unsigned.ipa
|
||||||
generate_release_notes: true
|
# 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
|
||||||
@@ -94,6 +94,50 @@ Plus simple mais sans renouvellement automatique : installer Sideloadly, branche
|
|||||||
l'appareil, glisser l'IPA, saisir l'Apple ID, cliquer sur *Start*. À refaire tous
|
l'appareil, glisser l'IPA, saisir l'Apple ID, cliquer sur *Start*. À refaire tous
|
||||||
les 7 jours.
|
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)
|
### Avec un compte Apple Developer (99 €/an)
|
||||||
|
|
||||||
La signature tient **un an** et l'app cesse d'expirer toutes les semaines. Deux
|
La signature tient **un an** et l'app cesse d'expirer toutes les semaines. Deux
|
||||||
|
|||||||
@@ -2,11 +2,18 @@
|
|||||||
<!-- Permissions for IPTV streaming -->
|
<!-- Permissions for IPTV streaming -->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
|
|
||||||
|
<!-- Android TV / Fire TV : l'app reste installable sur téléphone
|
||||||
|
et apparaît dans le lanceur TV (pas d'écran tactile requis). -->
|
||||||
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="XtremFlow"
|
android:label="XtremFlow"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:banner="@drawable/banner"
|
||||||
android:usesCleartextTraffic="true">
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@@ -28,6 +35,7 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -1,12 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
<!-- Écran de lancement : fond graphite du thème + logo centré -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item android:drawable="@color/ic_launcher_background" />
|
||||||
|
<item>
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@drawable/ic_launcher_foreground" />
|
||||||
</item> -->
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 22 KiB |
@@ -1,12 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
<!-- Écran de lancement : fond graphite du thème + logo centré -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item android:drawable="@color/ic_launcher_background" />
|
||||||
|
<item>
|
||||||
<!-- You can insert your own image assets here -->
|
|
||||||
<!-- <item>
|
|
||||||
<bitmap
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@drawable/ic_launcher_foreground" />
|
||||||
</item> -->
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Icône adaptative : le calque avant est déjà dessiné dans la zone sûre
|
||||||
|
(66dp sur 108dp), aucun inset supplémentaire n'est nécessaire.
|
||||||
|
Généré par scripts/generate_icons.py -->
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground>
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
<inset
|
|
||||||
android:drawable="@drawable/ic_launcher_foreground"
|
|
||||||
android:inset="16%" />
|
|
||||||
</foreground>
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Icône adaptative : le calque avant est déjà dessiné dans la zone sûre
|
||||||
|
(66dp sur 108dp), aucun inset supplémentaire n'est nécessaire.
|
||||||
|
Généré par scripts/generate_icons.py -->
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -1,4 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#1a1a2e</color>
|
<!-- baseLevel0 de lib/core/theme/app_colors.dart -->
|
||||||
</resources>
|
<color name="ic_launcher_background">#1B1B1F</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2C2C33"/>
|
||||||
|
<stop offset="1" stop-color="#1B1B1F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2"/>
|
||||||
|
<stop offset="0.55" stop-color="#0A84FF"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(215.04 215.04) scale(5.9392)">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2C2C33"/>
|
||||||
|
<stop offset="1" stop-color="#1B1B1F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2"/>
|
||||||
|
<stop offset="0.55" stop-color="#0A84FF"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="512.0" cy="512.0" r="512.0" fill="url(#bg)"/>
|
||||||
|
<circle cx="512.0" cy="512.0" r="512.0" fill="url(#shine)"/>
|
||||||
|
|
||||||
|
<g transform="translate(194.56 194.56) scale(6.3488)">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2C2C33"/>
|
||||||
|
<stop offset="1" stop-color="#1B1B1F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2"/>
|
||||||
|
<stop offset="0.55" stop-color="#0A84FF"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1024" height="1024" rx="230.4" fill="url(#bg)"/>
|
||||||
|
<rect width="1024" height="1024" rx="230.4" fill="url(#shine)"/>
|
||||||
|
<rect x="1.5" y="1.5" width="1021" height="1021" rx="228.9"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
|
||||||
|
|
||||||
|
<g transform="translate(184.32 184.32) scale(6.5536)">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2C2C33"/>
|
||||||
|
<stop offset="1" stop-color="#1B1B1F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2"/>
|
||||||
|
<stop offset="0.55" stop-color="#0A84FF"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1024" height="1024" rx="0" fill="url(#bg)"/>
|
||||||
|
<rect width="1024" height="1024" rx="0" fill="url(#shine)"/>
|
||||||
|
<rect x="1.5" y="1.5" width="1021" height="1021" rx="0"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
|
||||||
|
|
||||||
|
<g transform="translate(184.32 184.32) scale(6.5536)">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2C2C33"/>
|
||||||
|
<stop offset="1" stop-color="#1B1B1F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#0A84FF" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="#0A84FF" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2"/>
|
||||||
|
<stop offset="0.55" stop-color="#0A84FF"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="#7DD3F2" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="#7DD3F2" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="320" height="180" fill="url(#bg)"/>
|
||||||
|
<rect width="320" height="180" fill="url(#shine)"/>
|
||||||
|
|
||||||
|
<g transform="translate(29.60 39.60) scale(1.0080)">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text x="140.8" y="101.0" font-family="Inter" font-weight="bold"
|
||||||
|
font-size="32" fill="#F5F5F7" letter-spacing="-0.6">Xtrem<tspan fill="#8AB9FF">Flow</tspan></text>
|
||||||
|
<text x="141.8" y="124.0" font-family="Inter" font-weight="600"
|
||||||
|
font-size="12" fill="#98989F" letter-spacing="2.4">IPTV PLAYER</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 477 KiB After Width: | Height: | Size: 38 KiB |
@@ -5,6 +5,6 @@
|
|||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
ios: true
|
ios: true
|
||||||
android: false
|
android: false
|
||||||
image_path: "assets/images/logo_xtremflow.png"
|
image_path: "assets/images/app_icon_ios.png"
|
||||||
# L'App Store et iOS refusent une icône avec canal alpha.
|
# L'App Store et iOS refusent une icône avec canal alpha.
|
||||||
remove_alpha_ios: true
|
remove_alpha_ios: true
|
||||||
@@ -98,6 +98,14 @@ class XtreamServiceMobile {
|
|||||||
/// changing any call site.
|
/// changing any call site.
|
||||||
final Map<String, model.Channel> _channelIndex = {};
|
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);
|
late final XmltvService _xmltv = XmltvService(cacheDir);
|
||||||
|
|
||||||
/// User-provided XMLTV URL (empty = not configured).
|
/// User-provided XMLTV URL (empty = not configured).
|
||||||
@@ -200,7 +208,18 @@ class XtreamServiceMobile {
|
|||||||
|
|
||||||
/// Get live channels for a category (with batch EPG support)
|
/// Get live channels for a category (with batch EPG support)
|
||||||
/// If categoryId is empty, fetches ALL channels from all categories
|
/// If categoryId is empty, fetches ALL channels from all categories
|
||||||
|
///
|
||||||
|
/// Falls back to the last good list for this category when the fetch fails.
|
||||||
Future<List<model.Channel>> getLiveChannels(String categoryId) async {
|
Future<List<model.Channel>> getLiveChannels(String categoryId) async {
|
||||||
|
final channels = await _fetchLiveChannels(categoryId);
|
||||||
|
if (channels.isNotEmpty) {
|
||||||
|
_lastLiveChannels[categoryId] = channels;
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
return _lastLiveChannels[categoryId] ?? channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<model.Channel>> _fetchLiveChannels(String categoryId) async {
|
||||||
try {
|
try {
|
||||||
final queryParams = {
|
final queryParams = {
|
||||||
'username': _username,
|
'username': _username,
|
||||||
@@ -463,7 +482,18 @@ class XtreamServiceMobile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get live TV categories
|
/// Get live TV categories
|
||||||
|
///
|
||||||
|
/// Falls back to the last good list when the fetch fails.
|
||||||
Future<List<model.Category>> getLiveCategories() async {
|
Future<List<model.Category>> getLiveCategories() async {
|
||||||
|
final categories = await _fetchLiveCategories();
|
||||||
|
if (categories.isNotEmpty) {
|
||||||
|
_lastLiveCategories = categories;
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
return _lastLiveCategories ?? categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<model.Category>> _fetchLiveCategories() async {
|
||||||
try {
|
try {
|
||||||
if (kDebugMode) print('🔍 Loading live TV categories with 8s timeout...');
|
if (kDebugMode) print('🔍 Loading live TV categories with 8s timeout...');
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ class MobileLiveTVTab extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
||||||
with AutomaticKeepAliveClientMixin {
|
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
|
||||||
|
/// Coming back to the foreground after this long refetches the channel list,
|
||||||
|
/// so an app left running for days on a TV box picks up provider changes.
|
||||||
|
static const _autoRefreshAfter = Duration(hours: 4);
|
||||||
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final FocusNode _searchFocusNode = FocusNode();
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
@@ -36,12 +40,16 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
bool _isSearchEditing = false;
|
bool _isSearchEditing = false;
|
||||||
bool _justReturnedFromPlayer =
|
bool _justReturnedFromPlayer =
|
||||||
false; // Flag to prevent PopScope interception after player
|
false; // Flag to prevent PopScope interception after player
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
|
||||||
Timer? _searchTimer;
|
Timer? _searchTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
// Anchor the auto-refresh clock to this first load.
|
||||||
|
ref.read(liveCatalogRefreshedAtProvider);
|
||||||
// [P1-1 FIX] Debounce search input to prevent per-keystroke rebuilds
|
// [P1-1 FIX] Debounce search input to prevent per-keystroke rebuilds
|
||||||
_searchTimer = null;
|
_searchTimer = null;
|
||||||
_searchController.addListener(() {
|
_searchController.addListener(() {
|
||||||
@@ -62,6 +70,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_searchFocusNode.dispose();
|
_searchFocusNode.dispose();
|
||||||
@@ -72,6 +81,39 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true;
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state != AppLifecycleState.resumed) return;
|
||||||
|
final lastRefresh = ref.read(liveCatalogRefreshedAtProvider);
|
||||||
|
if (DateTime.now().difference(lastRefresh) >= _autoRefreshAfter) {
|
||||||
|
_refreshChannels();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refetch categories and the open category's channels. The current lists
|
||||||
|
/// stay on screen until the new ones arrive.
|
||||||
|
Future<void> _refreshChannels() async {
|
||||||
|
if (_isRefreshing) return;
|
||||||
|
setState(() => _isRefreshing = true);
|
||||||
|
|
||||||
|
refreshLiveCatalog(ref);
|
||||||
|
final categoryId = ref.read(mobileLiveTvUiStateProvider).selectedCategoryId;
|
||||||
|
try {
|
||||||
|
await Future.wait<Object>([
|
||||||
|
ref.read(mobileLiveCategoriesProvider(widget.playlist).future),
|
||||||
|
if (categoryId != null)
|
||||||
|
ref.read(
|
||||||
|
mobileLiveChannelsByCategoryProvider((widget.playlist, categoryId))
|
||||||
|
.future,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
} catch (_) {
|
||||||
|
// Errors surface through the providers' own error states.
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isRefreshing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
@@ -210,197 +252,228 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
|
|||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: CustomScrollView(
|
child: RefreshIndicator(
|
||||||
controller: _scrollController,
|
onRefresh: _refreshChannels,
|
||||||
slivers: [
|
color: AppColors.primary,
|
||||||
// Floating Header (Search + Navigation)
|
child: CustomScrollView(
|
||||||
SliverAppBar(
|
controller: _scrollController,
|
||||||
floating: false,
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
pinned: true,
|
slivers: [
|
||||||
snap: false,
|
// Floating Header (Search + Navigation)
|
||||||
backgroundColor: const Color(0xFF000000), // Opaque black
|
SliverAppBar(
|
||||||
elevation: 0,
|
floating: false,
|
||||||
scrolledUnderElevation: 0,
|
pinned: true,
|
||||||
automaticallyImplyLeading: false,
|
snap: false,
|
||||||
toolbarHeight: _searchQuery.isNotEmpty ||
|
backgroundColor:
|
||||||
_showFavoritesOnly ||
|
const Color(0xFF000000), // Opaque black
|
||||||
!uiState.isCategoryView
|
elevation: 0,
|
||||||
? 120.0
|
scrolledUnderElevation: 0,
|
||||||
: 110.0,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
toolbarHeight: _searchQuery.isNotEmpty ||
|
||||||
background: Container(
|
_showFavoritesOnly ||
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
!uiState.isCategoryView
|
||||||
child: Column(
|
? 120.0
|
||||||
mainAxisSize: MainAxisSize.min,
|
: 110.0,
|
||||||
children: [
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
// Search Bar
|
background: Container(
|
||||||
TVFocusable(
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
scale: 1.0,
|
child: Column(
|
||||||
focusColor: AppColors.onSurface,
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPressed: () {
|
children: [
|
||||||
setState(() => _isSearchEditing = true);
|
// Search Bar
|
||||||
WidgetsBinding.instance
|
TVFocusable(
|
||||||
.addPostFrameCallback((_) {
|
scale: 1.0,
|
||||||
_searchFocusNode.requestFocus();
|
focusColor: AppColors.onSurface,
|
||||||
SystemChannels.textInput
|
onPressed: () {
|
||||||
.invokeMethod('TextInput.show');
|
setState(() => _isSearchEditing = true);
|
||||||
});
|
WidgetsBinding.instance
|
||||||
},
|
.addPostFrameCallback((_) {
|
||||||
borderRadius: BorderRadius.circular(12),
|
_searchFocusNode.requestFocus();
|
||||||
child: Container(
|
SystemChannels.textInput
|
||||||
height: 48,
|
.invokeMethod('TextInput.show');
|
||||||
decoration: AppDecorations.searchBar(context),
|
});
|
||||||
padding: const EdgeInsets.symmetric(
|
},
|
||||||
horizontal: 12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Row(
|
child: Container(
|
||||||
children: [
|
height: 48,
|
||||||
Icon(
|
decoration:
|
||||||
Icons.search,
|
AppDecorations.searchBar(context),
|
||||||
color: AppDecorations.textSecondary(
|
padding: const EdgeInsets.symmetric(
|
||||||
context),
|
horizontal: 12),
|
||||||
),
|
child: Row(
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
Expanded(
|
Icon(
|
||||||
child: ExcludeFocus(
|
Icons.search,
|
||||||
excluding: !_isSearchEditing,
|
color: AppDecorations.textSecondary(
|
||||||
child: TextField(
|
context),
|
||||||
cursorColor: AppColors.onSurface,
|
),
|
||||||
controller: _searchController,
|
const SizedBox(width: 8),
|
||||||
focusNode: _searchFocusNode,
|
Expanded(
|
||||||
readOnly: !_isSearchEditing,
|
child: ExcludeFocus(
|
||||||
style: TextStyle(
|
excluding: !_isSearchEditing,
|
||||||
color: AppDecorations.textPrimary(
|
child: TextField(
|
||||||
context),
|
cursorColor: AppColors.onSurface,
|
||||||
),
|
controller: _searchController,
|
||||||
decoration: const InputDecoration(
|
focusNode: _searchFocusNode,
|
||||||
hintText:
|
readOnly: !_isSearchEditing,
|
||||||
'Rechercher une chaîne...',
|
style: TextStyle(
|
||||||
border: InputBorder.none,
|
color:
|
||||||
focusedBorder: InputBorder.none,
|
AppDecorations.textPrimary(
|
||||||
enabledBorder: InputBorder.none,
|
context),
|
||||||
isDense: true,
|
),
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
onSubmitted: (_) => setState(
|
hintText:
|
||||||
() => _isSearchEditing = false,
|
'Rechercher une chaîne...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => setState(
|
||||||
|
() => _isSearchEditing = false,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (_searchQuery.isNotEmpty)
|
||||||
if (_searchQuery.isNotEmpty)
|
GestureDetector(
|
||||||
GestureDetector(
|
onTap: () =>
|
||||||
onTap: () =>
|
_searchController.clear(),
|
||||||
_searchController.clear(),
|
child: Icon(
|
||||||
child: Icon(
|
Icons.close,
|
||||||
Icons.close,
|
color:
|
||||||
color: AppDecorations.textSecondary(
|
AppDecorations.textSecondary(
|
||||||
context),
|
context),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Title / Navigation Row
|
// Title / Navigation Row
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
if (!showGrid ||
|
if (!showGrid ||
|
||||||
_showFavoritesOnly ||
|
_showFavoritesOnly ||
|
||||||
_searchQuery.isNotEmpty) ...[
|
_searchQuery.isNotEmpty) ...[
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: AppDecorations.textPrimary(
|
||||||
|
context),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (_searchQuery.isNotEmpty) {
|
||||||
|
_searchController.clear();
|
||||||
|
} else if (_showFavoritesOnly) {
|
||||||
|
setState(
|
||||||
|
() => _showFavoritesOnly = false,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
uiNotifier.state = uiState.copyWith(
|
||||||
|
isCategoryView: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_searchQuery.isNotEmpty
|
||||||
|
? 'Résultats de recherche'
|
||||||
|
: _showFavoritesOnly
|
||||||
|
? 'Favoris'
|
||||||
|
: showGrid
|
||||||
|
? 'Catégories'
|
||||||
|
: uiState
|
||||||
|
.selectedCategory ??
|
||||||
|
'Chaînes',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppDecorations.textPrimary(
|
||||||
|
context),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Actualiser les chaînes',
|
||||||
|
icon: _isRefreshing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Icons.refresh,
|
||||||
|
color:
|
||||||
|
AppDecorations.textSecondary(
|
||||||
|
context),
|
||||||
|
),
|
||||||
|
onPressed: _isRefreshing
|
||||||
|
? null
|
||||||
|
: _refreshChannels,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.arrow_back,
|
_showFavoritesOnly
|
||||||
color:
|
? Icons.favorite
|
||||||
AppDecorations.textPrimary(context),
|
: Icons.favorite_border,
|
||||||
|
color: _showFavoritesOnly
|
||||||
|
? AppColors.live
|
||||||
|
: AppDecorations.textSecondary(
|
||||||
|
context),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () => setState(() {
|
||||||
if (_searchQuery.isNotEmpty) {
|
_showFavoritesOnly =
|
||||||
_searchController.clear();
|
!_showFavoritesOnly;
|
||||||
} else if (_showFavoritesOnly) {
|
if (_showFavoritesOnly) {
|
||||||
setState(
|
|
||||||
() => _showFavoritesOnly = false,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
uiNotifier.state = uiState.copyWith(
|
uiNotifier.state = uiState.copyWith(
|
||||||
isCategoryView: true,
|
isCategoryView: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
if (!_showFavoritesOnly) {
|
||||||
padding: EdgeInsets.zero,
|
uiNotifier.state = uiState.copyWith(
|
||||||
constraints: const BoxConstraints(),
|
isCategoryView: true);
|
||||||
|
}
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
],
|
],
|
||||||
Expanded(
|
),
|
||||||
child: Text(
|
],
|
||||||
_searchQuery.isNotEmpty
|
),
|
||||||
? 'Résultats de recherche'
|
|
||||||
: _showFavoritesOnly
|
|
||||||
? 'Favoris'
|
|
||||||
: showGrid
|
|
||||||
? 'Catégories'
|
|
||||||
: uiState.selectedCategory ??
|
|
||||||
'Chaînes',
|
|
||||||
style: TextStyle(
|
|
||||||
color:
|
|
||||||
AppDecorations.textPrimary(context),
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
_showFavoritesOnly
|
|
||||||
? Icons.favorite
|
|
||||||
: Icons.favorite_border,
|
|
||||||
color: _showFavoritesOnly
|
|
||||||
? 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
|
// Main Content Sliver
|
||||||
if (showGrid)
|
if (showGrid)
|
||||||
_buildCategorySliver(categories, uiNotifier, uiState)
|
_buildCategorySliver(categories, uiNotifier, uiState)
|
||||||
else
|
else
|
||||||
_ChannelListView(
|
_ChannelListView(
|
||||||
playlist: widget.playlist,
|
playlist: widget.playlist,
|
||||||
uiState: uiState,
|
uiState: uiState,
|
||||||
searchQuery: _searchQuery,
|
searchQuery: _searchQuery,
|
||||||
showFavoritesOnly: _showFavoritesOnly,
|
showFavoritesOnly: _showFavoritesOnly,
|
||||||
onPlay: (channel, channels, index) => _playChannel(
|
onPlay: (channel, channels, index) => _playChannel(
|
||||||
context,
|
context,
|
||||||
channel,
|
channel,
|
||||||
channels,
|
channels,
|
||||||
widget.playlist,
|
widget.playlist,
|
||||||
index,
|
index,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
|
||||||
final settings = ref.watch(mobileSettingsProvider);
|
final settings = ref.watch(mobileSettingsProvider);
|
||||||
final watchHistory = ref.watch(mobileWatchHistoryProvider);
|
final watchHistory = ref.watch(mobileWatchHistoryProvider);
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
|
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
|
||||||
final settings = ref.watch(mobileSettingsProvider);
|
final settings = ref.watch(mobileSettingsProvider);
|
||||||
|
|
||||||
if (_categories.isEmpty && !_isLoading) {
|
if (_categories.isEmpty && !_isLoading) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
// import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
// import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||||
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
|
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
|
||||||
|
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
|
||||||
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
|
||||||
import 'package:xtremobile/core/theme/app_decorations.dart';
|
import 'package:xtremobile/core/theme/app_decorations.dart';
|
||||||
import 'package:xtremobile/core/theme/app_theme.dart';
|
import 'package:xtremobile/core/theme/app_theme.dart';
|
||||||
@@ -84,7 +85,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
height: 48,
|
height: 48,
|
||||||
// No decoration for free-floating logo
|
// No decoration for free-floating logo
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/logo_xtremobile.png',
|
'assets/images/logo_xtremflow.png',
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
errorBuilder: (_, __, ___) => Icon(
|
||||||
Icons.play_circle_filled,
|
Icons.play_circle_filled,
|
||||||
@@ -106,7 +107,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Version 1.6.1',
|
'Version 1.7.3',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppDecorations.textSecondary(context),
|
color: AppDecorations.textSecondary(context),
|
||||||
@@ -179,8 +180,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
_buildSettingItem(
|
_buildSettingItem(
|
||||||
icon: Icons.public,
|
icon: Icons.public,
|
||||||
title: 'EPG communautaire',
|
title: 'EPG communautaire',
|
||||||
subtitle:
|
subtitle: 'Repli quand le fournisseur ne renvoie aucun programme',
|
||||||
'Repli quand le fournisseur ne renvoie aucun programme',
|
|
||||||
value: settings.useCommunityEpg ? 'Activé' : 'Désactivé',
|
value: settings.useCommunityEpg ? 'Activé' : 'Désactivé',
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(mobileSettingsProvider.notifier)
|
.read(mobileSettingsProvider.notifier)
|
||||||
@@ -197,9 +197,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
onTap: () => _showKeyboardDialog(
|
onTap: () => _showKeyboardDialog(
|
||||||
'URL XMLTV',
|
'URL XMLTV',
|
||||||
_xmltvController,
|
_xmltvController,
|
||||||
(val) => ref
|
(val) =>
|
||||||
.read(mobileSettingsProvider.notifier)
|
ref.read(mobileSettingsProvider.notifier).setXmltvUrl(val),
|
||||||
.setXmltvUrl(val),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -254,8 +253,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
|
|
||||||
_buildSettingItem(
|
_buildSettingItem(
|
||||||
icon: _isRefreshingCache ? Icons.hourglass_empty : Icons.refresh,
|
icon: _isRefreshingCache ? Icons.hourglass_empty : Icons.refresh,
|
||||||
title: 'Actualiser le cache',
|
title: 'Forcer l\'actualisation',
|
||||||
subtitle: 'Recharge films, séries et EPG',
|
subtitle: 'Recharge chaînes, films, séries et EPG',
|
||||||
value: _isRefreshingCache ? '...' : 'Appuyer',
|
value: _isRefreshingCache ? '...' : 'Appuyer',
|
||||||
onTap: _isRefreshingCache ? null : _refreshCache,
|
onTap: _isRefreshingCache ? null : _refreshCache,
|
||||||
),
|
),
|
||||||
@@ -319,15 +318,15 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
setState(() => _isRefreshingCache = true);
|
setState(() => _isRefreshingCache = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// final dir = await getApplicationDocumentsDirectory();
|
refreshFullCatalog(ref);
|
||||||
// final cacheStore = HiveCacheStore(dir.path);
|
// Brief pause so the tap visibly registers on a TV remote.
|
||||||
// await cacheStore.clean();
|
await Future.delayed(const Duration(milliseconds: 600));
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Cache actualisé ! Relancez l\'app pour recharger.'),
|
content:
|
||||||
|
Text('Chaînes, films, séries et EPG en cours de rechargement'),
|
||||||
backgroundColor: AppColors.success,
|
backgroundColor: AppColors.success,
|
||||||
duration: Duration(seconds: 3),
|
duration: Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
@@ -336,7 +335,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: AppColors.error),
|
SnackBar(
|
||||||
|
content: Text('Erreur: $e'), backgroundColor: AppColors.error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -390,54 +390,60 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
builder: (ctx, constraints) {
|
builder: (ctx, constraints) {
|
||||||
return Row(
|
return Row(
|
||||||
children: segments.map((seg) {
|
children: segments.map((seg) {
|
||||||
final (mode, icon, label) = seg;
|
final (mode, icon, label) = seg;
|
||||||
final selected = themeState.appThemeMode == mode;
|
final selected = themeState.appThemeMode == mode;
|
||||||
final accentColor = Theme.of(context).colorScheme.primary;
|
final accentColor =
|
||||||
return Expanded(
|
Theme.of(context).colorScheme.primary;
|
||||||
child: TVFocusable(
|
return Expanded(
|
||||||
onPressed: () => notifier.setThemeMode(mode),
|
child: TVFocusable(
|
||||||
borderRadius: BorderRadius.circular(8),
|
onPressed: () => notifier.setThemeMode(mode),
|
||||||
child: AnimatedContainer(
|
borderRadius: BorderRadius.circular(8),
|
||||||
duration: const Duration(milliseconds: 200),
|
child: AnimatedContainer(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
duration: const Duration(milliseconds: 200),
|
||||||
padding: const EdgeInsets.symmetric(
|
margin:
|
||||||
vertical: 7,
|
const EdgeInsets.symmetric(horizontal: 2),
|
||||||
),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(
|
vertical: 7,
|
||||||
color: selected
|
),
|
||||||
? accentColor
|
decoration: BoxDecoration(
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: selected
|
||||||
borderRadius: BorderRadius.circular(8),
|
? accentColor
|
||||||
),
|
: Theme.of(context)
|
||||||
child: Column(
|
.colorScheme
|
||||||
mainAxisSize: MainAxisSize.min,
|
.surfaceContainerHighest,
|
||||||
children: [
|
borderRadius: BorderRadius.circular(8),
|
||||||
Icon(
|
),
|
||||||
icon,
|
child: Column(
|
||||||
size: 16,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: selected
|
children: [
|
||||||
? AppColors.onSurface
|
Icon(
|
||||||
: AppDecorations.textSecondary(context),
|
icon,
|
||||||
),
|
size: 16,
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: selected
|
|
||||||
? FontWeight.w700
|
|
||||||
: FontWeight.w500,
|
|
||||||
color: selected
|
color: selected
|
||||||
? AppColors.onSurface
|
? 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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: Theme.of(context).colorScheme.primary, size: 24),
|
Icon(icon,
|
||||||
|
color: Theme.of(context).colorScheme.primary, size: 24),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -514,7 +521,10 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
vertical: 6,
|
vertical: 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.15),
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -562,7 +572,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: Theme.of(context).colorScheme.primary, size: 24),
|
Icon(icon,
|
||||||
|
color: Theme.of(context).colorScheme.primary, size: 24),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -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);
|
ref.onDispose(service.dispose);
|
||||||
return service;
|
return service;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// When the live catalogue was last fetched or explicitly refreshed.
|
||||||
|
/// Drives the auto-refresh when the app comes back to the foreground.
|
||||||
|
final liveCatalogRefreshedAtProvider =
|
||||||
|
StateProvider<DateTime>((ref) => DateTime.now());
|
||||||
|
|
||||||
|
/// Bumped by the settings "force refresh". The movies and series tabs, which
|
||||||
|
/// hold their lists in local state, listen to it to reload.
|
||||||
|
final fullCatalogRefreshTickProvider = StateProvider<int>((ref) => 0);
|
||||||
|
|
||||||
|
/// Forces the live categories and channels to be refetched from the panel.
|
||||||
|
///
|
||||||
|
/// The Xtream service is kept: it falls back to its last good lists if the
|
||||||
|
/// refetch fails, so a flaky network never empties the screen.
|
||||||
|
void refreshLiveCatalog(WidgetRef ref) {
|
||||||
|
ref.invalidate(mobileLiveCategoriesProvider);
|
||||||
|
ref.invalidate(mobileLiveChannelsByCategoryProvider);
|
||||||
|
ref.invalidate(mobileLiveChannelsProvider);
|
||||||
|
ref.read(liveCatalogRefreshedAtProvider.notifier).state = DateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forces the whole catalogue (live, movies, series) and the EPG to reload.
|
||||||
|
void refreshFullCatalog(WidgetRef ref) {
|
||||||
|
refreshLiveCatalog(ref);
|
||||||
|
ref.invalidate(mobileMoviesProvider);
|
||||||
|
ref.invalidate(mobileSeriesCategoriesProvider);
|
||||||
|
ref.invalidate(mobileSeriesProvider);
|
||||||
|
ref.invalidate(mobileSeriesInfoByPlaylistProvider);
|
||||||
|
ref.read(fullCatalogRefreshTickProvider.notifier).state++;
|
||||||
|
}
|
||||||
|
|
||||||
/// Mobile-specific live categories provider (for Live TV category grid)
|
/// Mobile-specific live categories provider (for Live TV category grid)
|
||||||
final mobileLiveCategoriesProvider =
|
final mobileLiveCategoriesProvider =
|
||||||
FutureProvider.family<List<model.Category>, PlaylistConfig>(
|
FutureProvider.family<List<model.Category>, PlaylistConfig>(
|
||||||
@@ -43,9 +77,9 @@ final mobileLiveCategoriesProvider =
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Mobile-specific live channels provider (loads channels for a specific category)
|
/// Mobile-specific live channels provider (loads channels for a specific category)
|
||||||
final mobileLiveChannelsByCategoryProvider = FutureProvider.family<
|
final mobileLiveChannelsByCategoryProvider =
|
||||||
List<model.Channel>,
|
FutureProvider.family<List<model.Channel>, (PlaylistConfig, String)>(
|
||||||
(PlaylistConfig, String)>((ref, params) async {
|
(ref, params) async {
|
||||||
final (playlist, categoryId) = params;
|
final (playlist, categoryId) = params;
|
||||||
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||||
return service.getLiveChannels(categoryId);
|
return service.getLiveChannels(categoryId);
|
||||||
@@ -79,8 +113,9 @@ final mobileLiveChannelsProvider =
|
|||||||
|
|
||||||
for (int batchNum = 0; batchNum < totalBatches; batchNum++) {
|
for (int batchNum = 0; batchNum < totalBatches; batchNum++) {
|
||||||
int startIdx = batchNum * batchSize;
|
int startIdx = batchNum * batchSize;
|
||||||
int endIdx =
|
int endIdx = (startIdx + batchSize < categories.length)
|
||||||
(startIdx + batchSize < categories.length) ? startIdx + batchSize : categories.length;
|
? startIdx + batchSize
|
||||||
|
: categories.length;
|
||||||
final batch = categories.sublist(startIdx, endIdx);
|
final batch = categories.sublist(startIdx, endIdx);
|
||||||
|
|
||||||
print(
|
print(
|
||||||
@@ -120,7 +155,8 @@ final mobileLiveChannelsProvider =
|
|||||||
|
|
||||||
/// Mobile-specific movies provider
|
/// Mobile-specific movies provider
|
||||||
final mobileMoviesProvider =
|
final mobileMoviesProvider =
|
||||||
FutureProvider.family<List<model.VodItem>, PlaylistConfig>((ref, playlist) async {
|
FutureProvider.family<List<model.VodItem>, PlaylistConfig>(
|
||||||
|
(ref, playlist) async {
|
||||||
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||||
return service.getMoviesByCategory(""); // Fetch all or default
|
return service.getMoviesByCategory(""); // Fetch all or default
|
||||||
});
|
});
|
||||||
@@ -134,8 +170,9 @@ final mobileSeriesCategoriesProvider =
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// Mobile-specific series pagination provider
|
/// Mobile-specific series pagination provider
|
||||||
final mobileSeriesProvider = FutureProvider.family<List<model.Series>,
|
final mobileSeriesProvider =
|
||||||
(PlaylistConfig, String?)>((ref, params) async {
|
FutureProvider.family<List<model.Series>, (PlaylistConfig, String?)>(
|
||||||
|
(ref, params) async {
|
||||||
final (playlist, categoryId) = params;
|
final (playlist, categoryId) = params;
|
||||||
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
|
||||||
return service.getSeriesPaginated(categoryId: categoryId);
|
return service.getSeriesPaginated(categoryId: categoryId);
|
||||||
@@ -161,7 +198,8 @@ class SeriesInfoRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final mobileSeriesInfoByPlaylistProvider =
|
final mobileSeriesInfoByPlaylistProvider =
|
||||||
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>((ref, request) async {
|
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>(
|
||||||
|
(ref, request) async {
|
||||||
final service =
|
final service =
|
||||||
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
|
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
|
||||||
return service.getSeriesInfo(request.seriesId);
|
return service.getSeriesInfo(request.seriesId);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: xtremobile
|
name: xtremobile
|
||||||
description: High-performance IPTV application (Android, iPhone, iPad) with Xtream Codes API
|
description: High-performance IPTV application (Android, iPhone, iPad) with Xtream Codes API
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.7.1+15
|
version: 1.7.3+17
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -34,6 +34,13 @@ dependencies:
|
|||||||
media_kit: ^1.1.11
|
media_kit: ^1.1.11
|
||||||
media_kit_video: ^1.2.5
|
media_kit_video: ^1.2.5
|
||||||
media_kit_libs_video: ^1.0.5
|
media_kit_libs_video: ^1.0.5
|
||||||
|
# Épinglé : tiré par media_kit_video (^3.0.2). À partir de 3.4.2, le plugin
|
||||||
|
# iOS utilise FlutterSceneLifeCycleDelegate / addSceneDelegate, des API
|
||||||
|
# apparues dans Flutter 3.38, sans relever sa contrainte `flutter:` — pub le
|
||||||
|
# sélectionne donc et la compilation Swift échoue sur le Flutter 3.35.3 des
|
||||||
|
# workflows. 3.4.1 est la dernière version sans ces appels.
|
||||||
|
# À relâcher le jour où les workflows passeront à Flutter >= 3.38.
|
||||||
|
volume_controller: 3.4.1
|
||||||
|
|
||||||
# UI Components
|
# UI Components
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
@@ -50,7 +57,11 @@ dependencies:
|
|||||||
path_provider: ^2.1.5
|
path_provider: ^2.1.5
|
||||||
video_player: ^2.10.1
|
video_player: ^2.10.1
|
||||||
wakelock_plus: ^1.2.8
|
wakelock_plus: ^1.2.8
|
||||||
device_info_plus: ^12.3.0
|
# Épinglé : la 12.4.0 appelle NSProcessInfo.isiOSAppOnVision derrière un
|
||||||
|
# `@available(iOS 26.1, *)`, ce qui exige le SDK d'Xcode 26.1 — plus récent
|
||||||
|
# que celui du runner macOS. 12.3.0 est la dernière version sans cet appel.
|
||||||
|
# À relâcher le jour où le workflow iOS utilisera un Xcode >= 26.1.
|
||||||
|
device_info_plus: 12.3.0
|
||||||
shelf_router: ^1.1.4
|
shelf_router: ^1.1.4
|
||||||
shelf_static: ^1.1.3
|
shelf_static: ^1.1.3
|
||||||
flutter_cache_manager: ^3.4.1
|
flutter_cache_manager: ^3.4.1
|
||||||
@@ -68,12 +79,12 @@ dev_dependencies:
|
|||||||
riverpod_generator: ^2.4.0
|
riverpod_generator: ^2.4.0
|
||||||
hive_generator: ^2.0.1
|
hive_generator: ^2.0.1
|
||||||
|
|
||||||
|
# Les icônes Android (mipmap, adaptative, bannière TV) sont générées par
|
||||||
|
# scripts/generate_icons.py et versionnées : ne pas les écraser ici.
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: false
|
||||||
ios: true
|
ios: true
|
||||||
image_path: "assets/images/logo_xtremflow.png"
|
image_path: "assets/images/app_icon_ios.png"
|
||||||
adaptive_icon_background: "#1a1a2e"
|
|
||||||
adaptive_icon_foreground: "assets/images/logo_xtremflow.png"
|
|
||||||
# iOS refuse une icône comportant un canal alpha.
|
# iOS refuse une icône comportant un canal alpha.
|
||||||
remove_alpha_ios: true
|
remove_alpha_ios: true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Génère toutes les icônes XtremFlow à partir d'un seul dessin vectoriel.
|
||||||
|
|
||||||
|
Palette alignée sur lib/core/theme/app_colors.dart (graphite + bleu Apple).
|
||||||
|
|
||||||
|
Sorties :
|
||||||
|
assets/branding/*.svg sources vectorielles
|
||||||
|
assets/images/logo_xtremflow.png logo in-app (fond transparent)
|
||||||
|
assets/images/app_icon_ios.png icône iOS (carré plein, sans alpha)
|
||||||
|
android/.../mipmap-*/ic_launcher.png icône legacy (tuile arrondie)
|
||||||
|
android/.../mipmap-*/ic_launcher_round.png icône legacy ronde
|
||||||
|
android/.../drawable-*/ic_launcher_foreground.png calque adaptatif
|
||||||
|
android/.../drawable-xhdpi/banner.png bannière Android TV / Fire TV
|
||||||
|
|
||||||
|
Usage : pip install cairosvg pillow && python3 scripts/generate_icons.py
|
||||||
|
La bannière utilise la police Inter (Bold) si elle est installée.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
import cairosvg
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
RES = os.path.join(ROOT, "android", "app", "src", "main", "res")
|
||||||
|
|
||||||
|
# Couleurs du thème (app_colors.dart)
|
||||||
|
BG_TOP = "#2C2C33" # surfaceContainerLow
|
||||||
|
BG_BOTTOM = "#1B1B1F" # baseLevel0
|
||||||
|
BLUE = "#0A84FF" # primaryContainer
|
||||||
|
BLUE_LIGHT = "#8AB9FF" # primary
|
||||||
|
CYAN = "#7DD3F2" # tertiary
|
||||||
|
|
||||||
|
DEFS = f"""
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="{BG_TOP}"/>
|
||||||
|
<stop offset="1" stop-color="{BG_BOTTOM}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
||||||
|
<stop offset="0" stop-color="{BLUE}" stop-opacity="0.35"/>
|
||||||
|
<stop offset="1" stop-color="{BLUE}" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="play" x1="0.2" y1="0" x2="0.9" y2="1">
|
||||||
|
<stop offset="0" stop-color="{CYAN}"/>
|
||||||
|
<stop offset="0.55" stop-color="{BLUE}"/>
|
||||||
|
<stop offset="1" stop-color="#0A5BD6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="streak" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="28" y2="0">
|
||||||
|
<stop offset="0" stop-color="{CYAN}" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="{CYAN}" stop-opacity="1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="shine" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.07"/>
|
||||||
|
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>"""
|
||||||
|
|
||||||
|
|
||||||
|
def mark(cx, cy, size):
|
||||||
|
"""Le symbole : un bouton play fluide précédé de traînées de vitesse.
|
||||||
|
|
||||||
|
Dessiné dans une boîte de 100x100 centrée sur (cx, cy), mise à l'échelle
|
||||||
|
à `size` pixels.
|
||||||
|
"""
|
||||||
|
s = size / 100.0
|
||||||
|
tx, ty = cx - 50 * s, cy - 50 * s
|
||||||
|
return f"""
|
||||||
|
<g transform="translate({tx:.2f} {ty:.2f}) scale({s:.4f})">
|
||||||
|
<circle cx="56" cy="50" r="50" fill="url(#glow)"/>
|
||||||
|
<path d="M40 22 L84 50 L40 78 Z" fill="url(#play)" stroke="url(#play)"
|
||||||
|
stroke-width="12" stroke-linejoin="round"/>
|
||||||
|
<path d="M44 34 L66 48" stroke="#FFFFFF" stroke-opacity="0.28"
|
||||||
|
stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<g stroke="url(#streak)" stroke-linecap="round" fill="none">
|
||||||
|
<path d="M6 38 L28 38" stroke-width="7"/>
|
||||||
|
<path d="M0 50 L28 50" stroke-width="7"/>
|
||||||
|
<path d="M10 62 L28 62" stroke-width="7"/>
|
||||||
|
</g>
|
||||||
|
</g>"""
|
||||||
|
|
||||||
|
|
||||||
|
def svg(w, h, body):
|
||||||
|
return (
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" '
|
||||||
|
f'viewBox="0 0 {w} {h}">{DEFS}{body}\n</svg>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tile(size, radius_ratio, mark_ratio=0.64):
|
||||||
|
"""Tuile graphite (arrondie ou pleine) avec le symbole centré."""
|
||||||
|
r = size * radius_ratio
|
||||||
|
body = f"""
|
||||||
|
<rect width="{size}" height="{size}" rx="{r}" fill="url(#bg)"/>
|
||||||
|
<rect width="{size}" height="{size}" rx="{r}" fill="url(#shine)"/>
|
||||||
|
<rect x="1.5" y="1.5" width="{size - 3}" height="{size - 3}" rx="{max(r - 1.5, 0)}"
|
||||||
|
fill="none" stroke="#FFFFFF" stroke-opacity="0.06" stroke-width="3"/>
|
||||||
|
{mark(size / 2, size / 2, size * mark_ratio)}"""
|
||||||
|
return svg(size, size, body)
|
||||||
|
|
||||||
|
|
||||||
|
def round_tile(size):
|
||||||
|
body = f"""
|
||||||
|
<circle cx="{size / 2}" cy="{size / 2}" r="{size / 2}" fill="url(#bg)"/>
|
||||||
|
<circle cx="{size / 2}" cy="{size / 2}" r="{size / 2}" fill="url(#shine)"/>
|
||||||
|
{mark(size / 2, size / 2, size * 0.62)}"""
|
||||||
|
return svg(size, size, body)
|
||||||
|
|
||||||
|
|
||||||
|
def adaptive_foreground(size):
|
||||||
|
# 108dp de canevas, zone sûre de 66dp : le symbole occupe ~58% du canevas,
|
||||||
|
# soit l'intérieur du cercle sûr quelle que soit la forme du masque.
|
||||||
|
return svg(size, size, mark(size / 2, size / 2, size * 0.58))
|
||||||
|
|
||||||
|
|
||||||
|
def banner(w=320, h=180):
|
||||||
|
"""Bannière TV (Android TV / Fire TV) : 320x180 px en xhdpi."""
|
||||||
|
body = f"""
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#bg)"/>
|
||||||
|
<rect width="{w}" height="{h}" fill="url(#shine)"/>
|
||||||
|
{mark(w * 0.25, h / 2, h * 0.56)}
|
||||||
|
<text x="{w * 0.44}" y="{h / 2 + 11}" font-family="Inter" font-weight="bold"
|
||||||
|
font-size="32" fill="#F5F5F7" letter-spacing="-0.6">Xtrem<tspan fill="{BLUE_LIGHT}">Flow</tspan></text>
|
||||||
|
<text x="{w * 0.44 + 1}" y="{h / 2 + 34}" font-family="Inter" font-weight="600"
|
||||||
|
font-size="12" fill="#98989F" letter-spacing="2.4">IPTV PLAYER</text>"""
|
||||||
|
return svg(w, h, body)
|
||||||
|
|
||||||
|
|
||||||
|
def render(svg_text, out_path, size=None, flatten=None):
|
||||||
|
png = cairosvg.svg2png(
|
||||||
|
bytestring=svg_text.encode(),
|
||||||
|
output_width=size[0] if size else None,
|
||||||
|
output_height=size[1] if size else None,
|
||||||
|
)
|
||||||
|
img = Image.open(io.BytesIO(png)).convert("RGBA")
|
||||||
|
if flatten:
|
||||||
|
bg = Image.new("RGBA", img.size, flatten)
|
||||||
|
bg.alpha_composite(img)
|
||||||
|
img = bg.convert("RGB")
|
||||||
|
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||||
|
img.save(out_path, optimize=True)
|
||||||
|
print(" ", os.path.relpath(out_path, ROOT), img.size)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
brand = os.path.join(ROOT, "assets", "branding")
|
||||||
|
os.makedirs(brand, exist_ok=True)
|
||||||
|
sources = {
|
||||||
|
"icon_rounded.svg": tile(1024, 0.225),
|
||||||
|
"icon_square.svg": tile(1024, 0),
|
||||||
|
"icon_round.svg": round_tile(1024),
|
||||||
|
"icon_foreground.svg": adaptive_foreground(1024),
|
||||||
|
"tv_banner.svg": banner(),
|
||||||
|
}
|
||||||
|
for name, text in sources.items():
|
||||||
|
with open(os.path.join(brand, name), "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
print("Assets Flutter :")
|
||||||
|
render(sources["icon_rounded.svg"], os.path.join(ROOT, "assets/images/logo_xtremflow.png"), (512, 512))
|
||||||
|
# iOS applique son propre masque et refuse l'alpha : carré plein.
|
||||||
|
render(sources["icon_square.svg"], os.path.join(ROOT, "assets/images/app_icon_ios.png"), (1024, 1024), flatten=BG_BOTTOM)
|
||||||
|
|
||||||
|
print("Android :")
|
||||||
|
densities = {"mdpi": 1, "hdpi": 1.5, "xhdpi": 2, "xxhdpi": 3, "xxxhdpi": 4}
|
||||||
|
for d, k in densities.items():
|
||||||
|
legacy = round(48 * k)
|
||||||
|
fg = round(108 * k)
|
||||||
|
render(sources["icon_rounded.svg"], f"{RES}/mipmap-{d}/ic_launcher.png", (legacy, legacy))
|
||||||
|
render(sources["icon_round.svg"], f"{RES}/mipmap-{d}/ic_launcher_round.png", (legacy, legacy))
|
||||||
|
render(sources["icon_foreground.svg"], f"{RES}/drawable-{d}/ic_launcher_foreground.png", (fg, fg))
|
||||||
|
# Bannière TV : 160x90dp -> 320x180 en xhdpi (taille de référence Google/Amazon).
|
||||||
|
render(sources["tv_banner.svg"], f"{RES}/drawable-xhdpi/banner.png", (320, 180), flatten=BG_BOTTOM)
|
||||||
|
render(sources["tv_banner.svg"], f"{RES}/drawable-xxhdpi/banner.png", (480, 270), flatten=BG_BOTTOM)
|
||||||
|
render(sources["tv_banner.svg"], f"{RES}/drawable-xxxhdpi/banner.png", (640, 360), flatten=BG_BOTTOM)
|
||||||
|
# Aperçu grand format pour les stores / README.
|
||||||
|
render(sources["tv_banner.svg"], os.path.join(brand, "tv_banner_1280x720.png"), (1280, 720), flatten=BG_BOTTOM)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||