17 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
MichaelandClaude Fable 5 186f9f89f9 chore: bump version to 1.7.1+15
The channel sidebar is an incremental change on top of v1.7.0, so ship it
as a patch release instead of 1.8.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L8c4kaH8AXVT5UUie3zR4p
2026-09-01 08:09:13 +02:00
MichaelandClaude Fable 5 3c37c6a6e6 feat(player): channel list sidebar accessible from the live OSD
Add a visible playlist button in both players (native + lite) that opens
the TiviMate-style channel sidebar (channels of the current category).
The sidebar existed in the native player but was only reachable via an
edge swipe or the M/Menu key; the lite player had none.

- New OSD button next to channel zapping (live only)
- Lite player: full sidebar port (swipe from right edge, M/Menu key)
- D-pad support: focus lands on the current channel when the list opens,
  arrows navigate, OK switches channel, Back/Menu closes
- Auto-hide timer (6s) restarted on D-pad focus and touch scroll
- Bump version to 1.8.0+15

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EG1frWE7suYF7hp4q2sDiR
2026-09-01 08:05:34 +02:00
MichaelandClaude Fable 5 c096532fec fix(android): sign release APK with a stable keystore in CI
Release builds were signed with the debug keystore. On the GitHub runner
that keystore is generated fresh on every build, so each CI APK carried a
different signature and Android refused to install it over an existing
version ("App not installed").

- build.gradle.kts: read android/key.properties when present and use it
  as the release signing config; fall back to debug otherwise so local
  `flutter run --release` keeps working.
- build-apk.yml: decode the keystore from ANDROID_KEYSTORE_* secrets into
  android/key.properties before building, and fail early if any secret
  is missing instead of silently signing with a throwaway key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3E5JovvDaQQ19D4UR258o
2026-08-30 16:21:51 +02:00
LogiFlow 05ca6f85be Merge pull request #1 from R0m1k3/claude/video-app-theme-latency-remote-xczp28
Thème gris Apple Glass, latence live réduite, correctif audio, mémoire de focus télécommande
2026-08-30 08:28:25 +02:00
Claude 04e9ab5730 ci: build release APK and publish a GitHub Release on main
Adds a Build APK workflow: on every push to main it builds the release
APK with Flutter 3.35.3 and publishes it as a GitHub Release tagged
with the pubspec version (v1.7.0 → xtremobile-v1.7.0.apk). On pull
requests and manual runs the APK is built and uploaded as an artifact
only. Bumps app version to 1.7.0+14 for this release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156exq7fejVrD3HKU3MmS8X
2026-08-28 07:43:21 +00:00
Claude 9b6b7faf4a feat: Apple glass gray theme, lower live latency, audio fix, remote focus memory
Theme — replace the near-black "Warm Cinema" palette with a neutral
"Apple Glass Gray" system: graphite surfaces (#26262C base, lighter
containers), Apple-white text, Apple system blue accent, white glass
borders/highlights. Noticeably less dark overall; light theme moves
from kraft paper to iOS light gray. All AppColors names kept so no
call site changes.

Player latency — live buffering drops from 8s cache / 10s readahead /
150MB to 3s / 5s / 60MB, keeping enough headroom for network jitter
while staying much closer to the live edge.

Audio — the forced audio filter chain
(af=lavrresample,format=channels=stereo:sample_fmts=s16) mixed FFmpeg
and mpv syntax; when it failed to init for a channel's codec, mpv
muted the stream entirely. Replaced by mpv's native
audio-channels=stereo downmix and AO-negotiated output format. Probe
caps raised (2MB/3s) so late-declared audio tracks (AAC-LATM) are
detected without slowing healthy streams.

Remote control — the player OSD now remembers the last focused button
and restores focus to it whenever the OSD reopens (arrows, OK) and
across channel switches, so one press re-zaps instead of five. On
live TV, OK with hidden controls only reopens the OSD instead of
pausing the stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156exq7fejVrD3HKU3MmS8X
2026-08-28 07:41:18 +00:00
MichaelandClaude Opus 5 694b1257b9 fix(theme): remove every hardcoded Apple/Material colour left in the UI
Porting the palette was not enough: the Warm Cinema charter only reached the
widgets that read from AppColors. Nineteen screen and widget files still painted
Apple TV values inline, so the app kept showing blue — most visibly on the
selected navigation pill.

Swept out:
- 0xFF0A84FF (Apple blue) on the selected nav pill, category card icons and the
  "add playlist" button
- 0xFF4FC3F7 and 0xFF1565C0 in the player overlays
- Colors.blueAccent / Colors.blue in dialogs
- Apple greys 1C1C1E / 2C2C2E / 3A3A3C / 8E8E93 -> warm charcoals
- 128 Colors.white* -> the onSurfaceNN cream ladder (a pure white on a warm
  ground reads blue, which is the charter's whole point)
- Colors.red / green / amber / yellow / grey* -> error, success, ratingGold,
  outlineVariant
- pure black ink on light fills -> onPrimaryFixed, a warm near-black
- the white focus glow -> an ember glow

Deliberately left true black: the video surface backgrounds and the scrims over
posters and video, where a warm tint would clash with the picture.

Adds the onPrimaryFixed / primaryFixed / onSurface70 tokens the sweep needed.

Verified: no Material or Apple colour literal remains outside lib/core/theme;
flutter analyze reports no errors; 19/19 tests pass; release APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:39:58 +02:00
65 changed files with 2162 additions and 626 deletions

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
# Construit l'APK release et publie une GitHub Release à chaque mise à jour
# de main (nouvelle version de l'app). Sur une pull request ou un lancement
# manuel (workflow_dispatch), l'APK est seulement construit et déposé en
# artifact — aucune release n'est publiée.
name: Build APK
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.35.3
cache: true
- name: Read app version from pubspec.yaml
id: version
run: echo "version=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d+ -f1)" >> "$GITHUB_OUTPUT"
- run: flutter pub get
# Keystore fixe injecté via secrets : sans lui, Gradle signerait avec une
# clé debug générée sur le runner (différente à chaque build) et l'APK ne
# s'installerait pas par-dessus une version déjà présente sur l'appareil.
- name: Set up release signing
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
for v in KEYSTORE_BASE64 KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; do
if [ -z "${!v}" ]; then
echo "::error::Secret ANDROID_$v manquant — impossible de signer l'APK avec une clé stable."
exit 1
fi
done
echo "$KEYSTORE_BASE64" | base64 -d > android/app/release.jks
{
echo "storeFile=release.jks"
echo "storePassword=$KEYSTORE_PASSWORD"
echo "keyAlias=$KEY_ALIAS"
echo "keyPassword=$KEY_PASSWORD"
} > android/key.properties
- name: Build release APK
run: flutter build apk --release
- name: Rename APK with version
run: cp build/app/outputs/flutter-apk/app-release.apk "xtremobile-v${{ steps.version.outputs.version }}.apk"
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: xtremobile-v${{ steps.version.outputs.version }}-apk
path: xtremobile-v${{ steps.version.outputs.version }}.apk
# La release n'est créée que sur main : un même numéro de version met à
# jour la release existante au lieu d'en créer une seconde.
- name: Publish GitHub Release
if: github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.version }}
name: XtremMobile v${{ steps.version.outputs.version }}
files: xtremobile-v${{ steps.version.outputs.version }}.apk
generate_release_notes: true
+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
+30 -3
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,6 +7,18 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// Signature release stable : android/key.properties (ignoré par git) pointe vers
// un keystore fixe. Sans ce fichier, on retombe sur la clé debug locale pour que
// `flutter run --release` continue de marcher. La CI DOIT fournir key.properties,
// sinon chaque build serait signé avec une clé debug aléatoire et l'APK ne
// s'installerait pas par-dessus une version existante.
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
val hasReleaseKeystore = keystorePropertiesFile.exists()
if (hasReleaseKeystore) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
android {
namespace = "com.example.xtremflow"
compileSdk = flutter.compileSdkVersion
@@ -30,11 +44,24 @@ android {
versionName = flutter.versionName
}
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
+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
+124 -117
View File
@@ -1,115 +1,121 @@
import 'package:flutter/material.dart';
/// XtremFlow — "Warm Cinema" Design System (ported from the web app)
/// XtremFlow — "Apple Glass Gray" Design System
///
/// Palette: charbon chaud + crème + orange brûlé (ember).
/// Inspiration: salle de projection, pellicule, affiches 70s.
/// Palette : graphite neutre + blanc cassé + bleu système Apple.
/// Inspiration : verre dépoli iOS/visionOS — des gris francs, jamais un noir
/// pur, pour une interface nettement moins sombre que l'ancien thème.
///
/// Règle de perf : AUCUNE couleur de surface n'est translucide. La profondeur
/// vient des ombres et du contraste, pas du flou — pas de `BackdropFilter`,
/// coûteux à chaque frame sur les Android d'entrée de gamme.
///
/// Les noms hérités de l'ancienne charte Apple TV (`appleBlue`,
/// `glossyCardGradient`, `appleTvGradient`, …) sont conservés en alias pour ne
/// pas casser les sites d'appel existants ; ils pointent désormais vers les
/// teintes chaudes.
/// coûteux à chaque frame sur les Android d'entrée de gamme. L'effet "glass"
/// est simulé par des gris élevés + liserés blancs à faible opacité.
class AppColors {
AppColors._();
// ============ BASE LEVEL 0 ============
/// Noir chaud — le "noir de salle", jamais un noir pur bleuté.
static const Color baseLevel0 = Color(0xFF0B0908);
/// Graphite profond — le niveau le plus bas, jamais un noir pur.
static const Color baseLevel0 = Color(0xFF1B1B1F);
// ============ BACKGROUNDS ============
static const Color background = Color(0xFF100C0A);
static const Color onBackground = Color(0xFFF3E9DF);
static const Color background = Color(0xFF26262C);
static const Color onBackground = Color(0xFFF5F5F7);
static const Color surface = Color(0xFF100C0A);
static const Color surfaceDim = Color(0xFF0B0908);
static const Color surfaceBright = Color(0xFF3C322C);
static const Color surface = Color(0xFF26262C);
static const Color surfaceDim = Color(0xFF1F1F24);
static const Color surfaceBright = Color(0xFF54545E);
// Surface Containers (hiérarchie Material 3, toutes opaques)
static const Color surfaceContainerLowest = Color(0xFF070505);
static const Color surfaceContainerLow = Color(0xFF181310);
static const Color surfaceContainer = Color(0xFF1F1815);
static const Color surfaceContainerHigh = Color(0xFF2A211C);
static const Color surfaceContainerHighest = Color(0xFF352A24);
static const Color surfaceVariant = Color(0xFF1F1815);
static const Color surfaceContainerLowest = Color(0xFF1B1B1F);
static const Color surfaceContainerLow = Color(0xFF2C2C33);
static const Color surfaceContainer = Color(0xFF32323A);
static const Color surfaceContainerHigh = Color(0xFF3B3B44);
static const Color surfaceContainerHighest = Color(0xFF45454F);
static const Color surfaceVariant = Color(0xFF32323A);
// ============ SURFACE / ON SURFACE ============
static const Color onSurface = Color(0xFFF3E9DF); // crème
static const Color onSurfaceVariant = Color(0xFFC5B3A5); // crème atténué
static const Color onSurface = Color(0xFFF5F5F7); // blanc Apple
static const Color onSurfaceVariant = Color(0xFFC7C7CE); // gris clair
// ============ PRIMARY (Ember / orange brûlé) ============
static const Color primary = Color(0xFFFFB68C); // texte & icônes sur sombre
static const Color onPrimary = Color(0xFF521B00);
static const Color primaryContainer = Color(0xFFD9541F); // remplissage ember
static const Color onPrimaryContainer = Color(0xFF2B0A00);
static const Color inversePrimary = Color(0xFFA63B10);
// ============ PRIMARY (Bleu système Apple) ============
static const Color primary = Color(0xFF8AB9FF); // texte & icônes sur sombre
static const Color onPrimary = Color(0xFF00295C);
static const Color primaryContainer = Color(0xFF0A84FF); // remplissage bleu
static const Color onPrimaryContainer = Color(0xFFEAF2FF);
static const Color inversePrimary = Color(0xFF0066CC);
/// Braise profonde — réservée aux **remplissages portant du texte**.
/// `primaryContainer` (#D9541F) est trop lumineux : du blanc dessus plafonne
/// vers 3,9:1, sous le seuil AA de 4,5:1. Cette variante passe le seuil tout
/// en restant la même couleur perçue.
static const Color primaryFill = Color(0xFFA8360C);
/// Bleu profond — réservé aux **remplissages portant du texte**.
/// `primaryContainer` (#0A84FF) est trop lumineux : du blanc dessus plafonne
/// sous le seuil AA de 4,5:1. Cette variante passe le seuil tout en restant
/// la même couleur perçue.
static const Color primaryFill = Color(0xFF0A6FDC);
// ============ SECONDARY (Terre cuite / argile) ============
static const Color secondary = Color(0xFFE7C3AC);
static const Color onSecondary = Color(0xFF442A1B);
static const Color secondaryContainer = Color(0xFF5D4030);
static const Color onSecondaryContainer = Color(0xFFFFDCC8);
/// Encre froide — remplace le noir pur pour du texte/des icônes posés sur un
/// remplissage clair (blanc cassé, bleu clair). Garde le contraste sans le
/// décalage d'un noir pur.
static const Color onPrimaryFixed = Color(0xFF002451);
static const Color primaryFixed = Color(0xFFD6E5FF);
static const Color primaryFixedDim = Color(0xFF8AB9FF);
static const Color onPrimaryFixedVariant = Color(0xFF0A5BB5);
// ============ TERTIARY (Laiton / doré patiné) ============
static const Color tertiary = Color(0xFFD9C88C);
static const Color onTertiary = Color(0xFF3A3016);
static const Color tertiaryContainer = Color(0xFF52472A);
static const Color onTertiaryContainer = Color(0xFFF6E4A6);
// ============ SECONDARY (Argent / gris clair) ============
static const Color secondary = Color(0xFFD1D1D6);
static const Color onSecondary = Color(0xFF303036);
static const Color secondaryContainer = Color(0xFF44444D);
static const Color onSecondaryContainer = Color(0xFFE8E8ED);
// ============ TERTIARY (Cyan glacé) ============
static const Color tertiary = Color(0xFF7DD3F2);
static const Color onTertiary = Color(0xFF003544);
static const Color tertiaryContainer = Color(0xFF1C4D60);
static const Color onTertiaryContainer = Color(0xFFC8ECFA);
// ============ ERROR ============
static const Color error = Color(0xFFFFB4A6);
static const Color onError = Color(0xFF5F1409);
static const Color errorContainer = Color(0xFF8C2416);
static const Color onErrorContainer = Color(0xFFFFDAD4);
static const Color error = Color(0xFFFFB4AB);
static const Color onError = Color(0xFF690005);
static const Color errorContainer = Color(0xFF93000A);
static const Color onErrorContainer = Color(0xFFFFDAD6);
// ============ OUTLINE ============
static const Color outline = Color(0xFF9C8878);
static const Color outlineVariant = Color(0xFF4A3C33);
static const Color outline = Color(0xFF8E8E93);
static const Color outlineVariant = Color(0xFF48484E);
// ============ INVERSE SURFACE ============
static const Color inverseSurface = Color(0xFFF3E9DF);
static const Color inverseOnSurface = Color(0xFF2A211C);
static const Color surfaceTint = Color(0xFFFFB68C);
static const Color inverseSurface = Color(0xFFF2F2F7);
static const Color inverseOnSurface = Color(0xFF2C2C31);
static const Color surfaceTint = Color(0xFF8AB9FF);
// ============ TEXT / CONTENT ============
static const Color textPrimary = Color(0xFFF3E9DF);
static const Color textSecondary = Color(0xFFC5B3A5);
static const Color textTertiary = Color(0xFF9C8878);
static const Color textPrimary = Color(0xFFF5F5F7);
static const Color textSecondary = Color(0xFFC7C7CE);
static const Color textTertiary = Color(0xFF98989F);
// ============ CRÈME À OPACITÉ ============
// Un blanc pur sur fond chaud tire vers le bleu : on décline la crème.
static const Color onSurface06 = Color(0x0FF3E9DF);
static const Color onSurface12 = Color(0x1FF3E9DF);
static const Color onSurface24 = Color(0x3DF3E9DF);
static const Color onSurface38 = Color(0x61F3E9DF);
static const Color onSurface54 = Color(0x8AF3E9DF);
// ============ BLANC À OPACITÉ ============
// Blanc Apple (#F5F5F7) décliné en opacités pour rester cohérent partout.
static const Color onSurface06 = Color(0x0FF5F5F7);
static const Color onSurface12 = Color(0x1FF5F5F7);
static const Color onSurface24 = Color(0x3DF5F5F7);
static const Color onSurface38 = Color(0x61F5F5F7);
static const Color onSurface54 = Color(0x8AF5F5F7);
static const Color onSurface70 = Color(0xB3F5F5F7);
// ============ STATUS ============
static const Color success = Color(0xFF9BC46A); // olive lumineux
static const Color warning = Color(0xFFE9B23C); // ambre
static const Color info = Color(0xFFA3B8C4); // bleu-gris fumé
static const Color success = Color(0xFF32D74B); // vert système
static const Color warning = Color(0xFFFFD60A); // jaune système
static const Color info = Color(0xFF64D2FF); // cyan système
// ============ CATÉGORIES ============
static const Color live = Color(0xFFE5484D); // rouge enseigne
static const Color movies = Color(0xFFD9541F); // ember
static const Color series = Color(0xFFD9C88C); // laiton
static const Color live = Color(0xFFFF453A); // rouge système
static const Color movies = Color(0xFF0A84FF); // bleu système
static const Color series = Color(0xFFBF5AF2); // violet système
// ============ RATING / PREMIUM ============
static const Color ratingGold = Color(0xFFE9B23C);
static const Color premiumGold = Color(0xFFC9922A);
static const Color ratingGold = Color(0xFFFFD60A);
static const Color premiumGold = Color(0xFFE0B84C);
// ============ SHIMMER (placeholders de chargement) ============
static const Color shimmer = Color(0xFF2A211C);
static const Color shimmerHighlight = Color(0xFF3C302A);
static const Color shimmer = Color(0xFF32323A);
static const Color shimmerHighlight = Color(0xFF45454F);
// ============ BORDERS / FOCUS ============
static const Color border = outlineVariant;
@@ -117,61 +123,62 @@ class AppColors {
static const Color focusBorder = primaryContainer;
static const Color borderFocused = primaryContainer;
/// Liseré crème 8 % — définit l'arête d'une surface sans la faire flotter.
static const Color glassBorderColor = Color(0x14F3E9DF);
/// Liseré blanc 10 % — définit l'arête d'une surface "verre" sans la faire
/// flotter.
static const Color glassBorderColor = Color(0x1AFFFFFF);
// ============ SURFACES "GLASS" — OPAQUES ============
static const Color glassLevel1Bg = Color(0xFF181310);
static const Color glassLevel1Border = Color(0x14F3E9DF); // crème 8 %
static const Color glassLevel2Bg = Color(0xFF1F1815);
static const Color glassLevel2Border = Color(0x1FF3E9DF); // crème 12 %
static const Color glassLevel2InnerGlow = Color(0x3DD9541F); // ember 24 %
static const Color glassLevel1Bg = Color(0xFF2C2C33);
static const Color glassLevel1Border = Color(0x1AFFFFFF); // blanc 10 %
static const Color glassLevel2Bg = Color(0xFF32323A);
static const Color glassLevel2Border = Color(0x26FFFFFF); // blanc 15 %
static const Color glassLevel2InnerGlow = Color(0x3D0A84FF); // bleu 24 %
static const Color glassBackground = glassLevel2Bg;
static const Color glassBorder = glassLevel2Border;
// ============ GRADIENTS ============
/// Braise : orange brûlé vers rouge profond.
/// Bleu système : clair vers profond.
static const LinearGradient primaryGradient = LinearGradient(
colors: [Color(0xFFE9701F), Color(0xFFC13A1C)],
colors: [Color(0xFF4DA2FF), Color(0xFF0A6FDC)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
/// Fond de salle : projecteur chaud en haut, obscurité en bas.
/// Fond "verre dépoli" : gris clair en haut, graphite en bas.
static const LinearGradient backgroundGradient = LinearGradient(
colors: [Color(0xFF1A1310), Color(0xFF0B0908)],
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
/// Halo de projection depuis le centre de l'écran.
/// Halo doux depuis le centre de l'écran.
static const RadialGradient projectorGradient = RadialGradient(
center: Alignment.center,
radius: 1.5,
colors: [Color(0xFF1A1310), Color(0xFF0B0908)],
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
stops: [0.0, 1.0],
);
/// Carte : arête éclairée en haut, matière sombre en bas.
/// Carte : arête éclairée en haut, matière grise en bas.
static const LinearGradient cardGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF352A24), Color(0xFF181310)],
colors: [Color(0xFF45454F), Color(0xFF2C2C33)],
stops: [0.0, 1.0],
);
/// Reflet chaud posé sur le haut d'une carte.
/// Reflet posé sur le haut d'une carte — l'effet "verre".
static const LinearGradient cardHighlight = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x1FF3E9DF), Color(0x00F3E9DF)],
colors: [Color(0x29FFFFFF), Color(0x00FFFFFF)],
stops: [0.0, 0.5],
);
/// Voile d'affiche : lecture du texte par-dessus une jaquette.
static const LinearGradient posterScrim = LinearGradient(
colors: [Color(0x000B0908), Color(0xCC0B0908), Color(0xFF0B0908)],
colors: [Color(0x001F1F24), Color(0xCC1F1F24), Color(0xFF1F1F24)],
stops: [0.0, 0.55, 1.0],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
@@ -179,7 +186,7 @@ class AppColors {
/// Voile latéral pour les héros pleine largeur.
static const LinearGradient heroScrim = LinearGradient(
colors: [Color(0xF20B0908), Color(0x990B0908), Color(0x000B0908)],
colors: [Color(0xF21F1F24), Color(0x991F1F24), Color(0x001F1F24)],
stops: [0.0, 0.45, 1.0],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
@@ -187,43 +194,43 @@ class AppColors {
// ============ GLOW / OMBRES ============
static Color glowPrimary(double opacity) =>
const Color(0xFFD9541F).withValues(alpha: opacity);
const Color(0xFF0A84FF).withValues(alpha: opacity);
static Color glowPrimaryDim(double opacity) =>
const Color(0xFFFFB68C).withValues(alpha: opacity);
const Color(0xFF8AB9FF).withValues(alpha: opacity);
/// Ombre portée « projecteur » : profonde, chaude, jamais grise.
/// Ombre portée douce — moins dense qu'avant : le fond est plus clair.
static List<BoxShadow> lift({double intensity = 1.0}) => [
BoxShadow(
color: const Color(0xFF000000).withValues(alpha: 0.55 * intensity),
color: const Color(0xFF000000).withValues(alpha: 0.40 * intensity),
blurRadius: 28 * intensity,
spreadRadius: -8,
offset: Offset(0, 12 * intensity),
),
];
/// Halo ember pour l'élément focalisé (navigation TV / télécommande).
/// Halo bleu pour l'élément focalisé (navigation TV / télécommande).
/// Le nom historique `emberFocus` est conservé pour les sites d'appel.
static List<BoxShadow> emberFocus({double intensity = 1.0}) => [
BoxShadow(
color: const Color(0xFFD9541F).withValues(alpha: 0.45 * intensity),
color: const Color(0xFF0A84FF).withValues(alpha: 0.45 * intensity),
blurRadius: 32 * intensity,
spreadRadius: -4,
),
BoxShadow(
color: const Color(0xFF000000).withValues(alpha: 0.6),
color: const Color(0xFF000000).withValues(alpha: 0.45),
blurRadius: 24,
spreadRadius: -10,
offset: const Offset(0, 14),
),
];
// ============ ALIAS DE COMPATIBILITÉ (ancienne charte Apple TV) ============
// Conservés pour ne pas casser les sites d'appel ; remappés sur les teintes
// chaudes. À retirer au fil des réécritures d'écrans.
// ============ ALIAS DE COMPATIBILITÉ ============
// Conservés pour ne pas casser les sites d'appel.
static const Color backgroundSecondary = surfaceContainerLow;
static const Color surfaceElevated = surfaceContainerHigh;
static const Color accent = secondary;
/// Ancien accent bleu Apple → braise portant du texte blanc (contraste AA).
/// Accent bleu Apple portant du texte blanc (contraste AA).
static const Color appleBlue = primaryFill;
static const RadialGradient appleTvGradient = projectorGradient;
@@ -266,27 +273,27 @@ class AppColors {
brightness: Brightness.dark,
);
/// Thème clair : « papier kraft » — même famille chaude, inversée.
/// Thème clair : gris système iOS, même famille neutre.
static ColorScheme get lightColorScheme => const ColorScheme.light(
primary: Color(0xFFA63B10),
primary: Color(0xFF0066CC),
onPrimary: Colors.white,
primaryContainer: Color(0xFFFFDBC8),
onPrimaryContainer: Color(0xFF360F00),
secondary: Color(0xFF765847),
primaryContainer: Color(0xFFD6E5FF),
onPrimaryContainer: Color(0xFF002451),
secondary: Color(0xFF54545E),
onSecondary: Colors.white,
secondaryContainer: Color(0xFFFFDCC8),
onSecondaryContainer: Color(0xFF2B160A),
tertiary: Color(0xFF6A5F3F),
secondaryContainer: Color(0xFFE3E3E8),
onSecondaryContainer: Color(0xFF1B1B1F),
tertiary: Color(0xFF00658E),
onTertiary: Colors.white,
error: Color(0xFFB3261E),
onError: Colors.white,
errorContainer: Color(0xFFFFDAD4),
onErrorContainer: Color(0xFF410E05),
surface: Color(0xFFFBF3EC),
onSurface: Color(0xFF201A16),
onSurfaceVariant: Color(0xFF52443B),
outline: Color(0xFF847369),
outlineVariant: Color(0xFFD6C3B7),
surface: Color(0xFFF2F2F7),
onSurface: Color(0xFF1C1C1E),
onSurfaceVariant: Color(0xFF48484E),
outline: Color(0xFF8E8E93),
outlineVariant: Color(0xFFD1D1D6),
brightness: Brightness.light,
);
}
+11 -12
View File
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
/// XtremFlow — "Warm Cinema" theme (ported from the web app).
/// XtremFlow — "Apple Glass Gray" theme.
///
/// Typography: **Fraunces** (serif éditorial) for display/headline, **Karla**
/// (humaniste chaud) for UI and body — very legible at TV distance and on a
@@ -217,10 +217,9 @@ class AppTheme {
secondary: AppColors.textSecondary,
),
// Curseur crème — un curseur blanc pur jure sur fond chaud.
textSelectionTheme: const TextSelectionThemeData(
cursorColor: AppColors.primary,
selectionColor: Color(0x59D9541F),
selectionColor: Color(0x590A84FF),
selectionHandleColor: AppColors.primaryContainer,
),
@@ -334,7 +333,7 @@ class AppTheme {
activeTrackColor: AppColors.primaryContainer,
inactiveTrackColor: AppColors.onSurface24,
thumbColor: AppColors.primary,
overlayColor: Color(0x29D9541F),
overlayColor: Color(0x290A84FF),
),
progressIndicatorTheme: const ProgressIndicatorThemeData(
@@ -362,17 +361,17 @@ class AppTheme {
);
}
// ============ LIGHT THEME (« papier kraft ») ============
// ============ LIGHT THEME (gris système clair) ============
static ThemeData get lightTheme {
final baseTheme = ThemeData.light(useMaterial3: true);
const bgColor = Color(0xFFFBF3EC);
const bgColor = Color(0xFFF2F2F7);
const surfaceColor = Color(0xFFFFFFFF);
const primaryColor = Color(0xFFA63B10);
const textPrimaryColor = Color(0xFF201A16);
const textSecondaryColor = Color(0xFF52443B);
const textTertiaryColor = Color(0xFF847369);
const borderColor = Color(0xFFD6C3B7);
const primaryColor = Color(0xFF0066CC);
const textPrimaryColor = Color(0xFF1C1C1E);
const textSecondaryColor = Color(0xFF48484E);
const textTertiaryColor = Color(0xFF8E8E93);
const borderColor = Color(0xFFD8D8DE);
return baseTheme.copyWith(
colorScheme: AppColors.lightColorScheme,
@@ -386,7 +385,7 @@ class AppTheme {
textSelectionTheme: const TextSelectionThemeData(
cursorColor: primaryColor,
selectionColor: Color(0x4DA63B10),
selectionColor: Color(0x4D0066CC),
selectionHandleColor: primaryColor,
),
+4 -3
View File
@@ -10,6 +10,7 @@ library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
/// Standard cache manager with 200MB size limit
class AppCacheManager {
@@ -103,7 +104,7 @@ class ImageCacheConfig {
width: width,
height: height,
decoration: BoxDecoration(
color: const Color(0xFF2C2C2E),
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
),
@@ -111,10 +112,10 @@ class ImageCacheConfig {
width: width,
height: height,
decoration: BoxDecoration(
color: const Color(0xFF3A3A3C),
color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.image_not_supported, color: Color(0xFF8E8E93)),
child: const Icon(Icons.image_not_supported, color: AppColors.textTertiary),
),
);
}
@@ -150,7 +150,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
style: GoogleFonts.outfit(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
color: AppColors.onSurface,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -161,7 +161,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
item.subtitle!,
style: GoogleFonts.inter(
fontSize: 16,
color: Colors.white.withOpacity(0.8),
color: AppColors.onSurface.withOpacity(0.8),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -227,8 +227,8 @@ class _HeroButtonState extends State<_HeroButton> {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: widget.isPrimary
? (_isHovered ? Colors.white : Colors.white.withOpacity(0.9))
: (_isHovered ? Colors.white.withOpacity(0.2) : Colors.white.withOpacity(0.1)),
? (_isHovered ? AppColors.onSurface : AppColors.onSurface.withOpacity(0.9))
: (_isHovered ? AppColors.onSurface.withOpacity(0.2) : AppColors.onSurface.withOpacity(0.1)),
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
),
child: Row(
@@ -236,14 +236,14 @@ class _HeroButtonState extends State<_HeroButton> {
children: [
Icon(
widget.icon,
color: widget.isPrimary ? Colors.black : Colors.white,
color: widget.isPrimary ? AppColors.onPrimaryFixed : AppColors.onSurface,
size: 20,
),
const SizedBox(width: 8),
Text(
widget.label,
style: GoogleFonts.inter(
color: widget.isPrimary ? Colors.black : Colors.white,
color: widget.isPrimary ? AppColors.onPrimaryFixed : AppColors.onSurface,
fontWeight: FontWeight.w600,
),
),
@@ -134,7 +134,7 @@ class _GradientButtonState extends State<GradientButton>
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.black,
color: AppColors.onPrimaryFixed,
),
)
: Row(
@@ -143,13 +143,13 @@ class _GradientButtonState extends State<GradientButton>
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.icon != null) ...[
Icon(widget.icon, color: Colors.black, size: 20),
Icon(widget.icon, color: AppColors.onPrimaryFixed, size: 20),
const SizedBox(width: 8),
],
Text(
widget.label,
style: const TextStyle(
color: Colors.black,
color: AppColors.onPrimaryFixed,
fontWeight: FontWeight.w600,
fontSize: 14,
),
@@ -247,12 +247,12 @@ class LiveBadge extends StatelessWidget {
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.circle, size: 6, color: Colors.white),
Icon(Icons.circle, size: 6, color: AppColors.onSurface),
SizedBox(width: 4),
Text(
'LIVE',
style: TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 10,
fontWeight: FontWeight.w700,
),
@@ -427,11 +427,11 @@ class _MediaCardState extends State<MediaCard>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, size: 10, color: Color(0xFFFFD700)),
const Icon(Icons.star, size: 10, color: AppColors.ratingGold),
const SizedBox(width: 4),
Text(
widget.rating!,
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
style: const TextStyle(color: AppColors.onSurface, fontSize: 10, fontWeight: FontWeight.bold),
),
],
),
@@ -449,7 +449,7 @@ class _MediaCardState extends State<MediaCard>
color: AppColors.success,
shape: BoxShape.circle,
),
child: const Icon(Icons.check, size: 12, color: Colors.white),
child: const Icon(Icons.check, size: 12, color: AppColors.onSurface),
),
),
],
+4 -3
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class DigitalClock extends StatefulWidget {
const DigitalClock({super.key});
@@ -31,16 +32,16 @@ class _DigitalClockState extends State<DigitalClock> {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
color: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.05)),
border: Border.all(color: AppColors.onSurface.withOpacity(0.05)),
),
child: Text(
timeString,
style: GoogleFonts.inter(
fontWeight: FontWeight.w600,
fontSize: 16,
color: Colors.white,
color: AppColors.onSurface,
),
),
);
+3 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class ThemedLoading extends StatelessWidget {
const ThemedLoading({super.key});
@@ -11,7 +12,7 @@ class ThemedLoading extends StatelessWidget {
center: Alignment(0, -0.2),
radius: 1.5,
colors: [
Color(0xFF2C2C2E),
AppColors.surfaceContainerHigh,
Color(0xFF000000),
],
stops: [0.0, 1.0],
@@ -19,7 +20,7 @@ class ThemedLoading extends StatelessWidget {
),
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
color: AppColors.onSurface,
strokeWidth: 3,
),
),
+322 -14
View File
@@ -80,6 +80,16 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
final FocusNode _playPauseFocusNode = FocusNode();
final FocusNode _prevFocusNode = FocusNode();
final FocusNode _nextFocusNode = FocusNode();
final FocusNode _channelListButtonFocusNode = FocusNode();
/// Focus D-pad de la chaîne active dans la sidebar : permet d'atterrir
/// directement dessus à l'ouverture de la liste.
final FocusNode _channelListItemFocusNode = FocusNode();
// [TiviMate] Channel List Sidebar
bool _showChannelList = false;
Timer? _channelListTimer;
final ScrollController _channelListScrollController = ScrollController();
@override
void initState() {
@@ -132,6 +142,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_controlsTimer = null;
_stabilizationTimer?.cancel();
_stabilizationTimer = null;
_channelListTimer?.cancel();
_channelListTimer = null;
final controller = _controller;
_controller = null;
@@ -339,15 +351,95 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
void _switchChannel(int index) {
if (widget.channels == null) return;
setState(() => _currentIndex = index);
setState(() {
_currentIndex = index;
_showChannelList = false; // close sidebar on switch
});
_channelListTimer?.cancel();
_onUserInteraction(); // Ensure OSD shows up and timer resets on channel change
_loadStream();
}
/// Open/close the channel list sidebar
void _toggleChannelList() {
setState(() => _showChannelList = !_showChannelList);
if (_showChannelList) {
_scrollToCurrentChannel();
_restartChannelListTimer();
// Focus direct sur la chaîne en cours : la télécommande navigue dans la
// liste sans étape intermédiaire. Second essai après l'animation de
// scroll (300 ms), l'item actif pouvant ne pas encore être construit.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _showChannelList) {
_channelListItemFocusNode.requestFocus();
}
});
Future.delayed(const Duration(milliseconds: 400), () {
if (mounted &&
_showChannelList &&
!_channelListItemFocusNode.hasFocus) {
_channelListItemFocusNode.requestFocus();
}
});
} else {
_channelListTimer?.cancel();
}
}
/// (Re)arme l'auto-fermeture de la sidebar. Relancé à chaque interaction
/// (focus D-pad, scroll) pour ne pas fermer la liste sous les doigts.
void _restartChannelListTimer() {
_channelListTimer?.cancel();
_channelListTimer = Timer(const Duration(seconds: 6), () {
if (mounted) setState(() => _showChannelList = false);
});
}
/// Scroll channel list to keep current channel visible
void _scrollToCurrentChannel() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_channelListScrollController.hasClients) return;
const itemHeight = 64.0;
final offset = (_currentIndex * itemHeight) -
(_channelListScrollController.position.viewportDimension / 2) +
(itemHeight / 2);
_channelListScrollController.animateTo(
offset.clamp(0, _channelListScrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
bool _handleKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return false;
final key = event.logicalKey;
// Sidebar ouverte : Back/Menu la ferment, le reste part au système de
// Focus (navigation + OK sur un item). On relance juste l'auto-fermeture.
if (_showChannelList) {
if (key == LogicalKeyboardKey.escape ||
key == LogicalKeyboardKey.goBack ||
key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) {
_channelListTimer?.cancel();
setState(() => _showChannelList = false);
return true;
}
_restartChannelListTimer();
return false;
}
// Menu key or 'M' → toggle channel list sidebar
if (key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) {
if (widget.streamType == model.StreamType.live &&
widget.channels != null) {
_toggleChannelList();
return true;
}
}
if (key == LogicalKeyboardKey.channelUp) {
if (widget.channels != null) {
_switchChannel((_currentIndex + 1) % widget.channels!.length);
@@ -397,6 +489,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_epgTimer?.cancel();
_controlsTimer?.cancel();
_stabilizationTimer?.cancel();
_channelListTimer?.cancel();
_channelListScrollController.dispose();
_controller?.pause(); // Force stop audio immediately
_controller?.dispose();
_xtreamService?.dispose();
@@ -404,6 +498,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_playPauseFocusNode.dispose();
_prevFocusNode.dispose();
_nextFocusNode.dispose();
_channelListButtonFocusNode.dispose();
_channelListItemFocusNode.dispose();
super.dispose();
}
@@ -416,9 +512,26 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
: widget.title;
return Scaffold(
// True black behind the video surface, never the warm ink.
backgroundColor: Colors.black,
body: GestureDetector(
onTap: _onUserInteraction,
onTap: () {
if (_showChannelList) {
_channelListTimer?.cancel();
setState(() => _showChannelList = false);
} else {
_onUserInteraction();
}
},
// Swipe from right edge → open channel list
onHorizontalDragEnd: (details) {
if (widget.streamType == model.StreamType.live &&
widget.channels != null &&
details.primaryVelocity != null &&
details.primaryVelocity! < -300) {
_toggleChannelList();
}
},
child: Stack(
children: [
// Video Surface
@@ -453,7 +566,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
color: AppColors.onSurface,
size: 28,
),
),
@@ -472,7 +585,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
child: Text(
_currentTime,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
@@ -495,7 +608,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
style: const TextStyle(color: AppColors.error),
),
),
),
@@ -508,6 +621,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
// OSD (EPG + Controls)
if (_showControls) _buildOSD(title),
// [TiviMate] Channel list sidebar
if (_showChannelList &&
widget.streamType == model.StreamType.live &&
widget.channels != null &&
widget.channels!.isNotEmpty)
_buildChannelListSidebar(),
],
),
),
@@ -554,7 +674,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10),
border: Border.all(color: AppColors.onSurface12),
),
child: IntrinsicHeight(
child: Row(
@@ -579,7 +699,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: const Icon(
Icons.skip_previous,
color: Colors.white,
color: AppColors.onSurface,
size: 36,
),
),
@@ -591,7 +711,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_isPlaying
? Icons.pause_circle_filled
: Icons.play_circle_filled,
color: Colors.white,
color: AppColors.onSurface,
size: 56,
),
),
@@ -605,11 +725,25 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: const Icon(
Icons.skip_next,
color: Colors.white,
color: AppColors.onSurface,
size: 36,
),
),
const SizedBox(width: 16),
// Channel list sidebar (liste des chaînes de la catégorie)
if (widget.streamType == model.StreamType.live &&
widget.channels != null &&
widget.channels!.isNotEmpty)
TVFocusable(
focusNode: _channelListButtonFocusNode,
onPressed: _toggleChannelList,
child: const Icon(
Icons.playlist_play_rounded,
color: AppColors.onSurface,
size: 36,
),
),
const SizedBox(width: 16),
// Deinterlace Button (Switches to Native Player with forced Deinterlace)
TVFocusable(
@@ -642,7 +776,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
child: const Icon(
Icons
.grid_on, // Icon representing interlaced grid/mesh
color: Colors.white,
color: AppColors.onSurface,
size: 32,
),
),
@@ -684,7 +818,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
child: Text(
title,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
@@ -710,7 +844,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
backgroundColor: Colors.white10,
backgroundColor: AppColors.onSurface12,
color: AppColors.primary,
minHeight: 4,
),
@@ -718,7 +852,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
const SizedBox(height: 6),
Text(
next,
style: const TextStyle(color: Colors.white54, fontSize: 12),
style: const TextStyle(color: AppColors.onSurface54, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@@ -758,7 +892,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
min: 0,
max: _duration.inSeconds.toDouble(),
activeColor: AppColors.primary,
inactiveColor: Colors.white24,
inactiveColor: AppColors.onSurface24,
onChanged: (v) => setState(() {
_position = Duration(seconds: v.toInt());
_isSeeking = true;
@@ -773,4 +907,178 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
);
}
/// [TiviMate] Channel list sidebar — slide-in panel on the right
Widget _buildChannelListSidebar() {
final channels = widget.channels!;
return Positioned(
top: 0,
bottom: 0,
right: 0,
child: Container(
width: 280,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [Color(0xE6000000), Color(0x99000000)],
),
),
child: SafeArea(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.onSurface12),
),
),
child: Row(
children: [
const Icon(Icons.list,
color: AppColors.onSurface70, size: 18),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Chaînes',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
GestureDetector(
onTap: () {
_channelListTimer?.cancel();
setState(() => _showChannelList = false);
},
child: const Icon(Icons.close,
color: AppColors.onSurface54, size: 18),
),
],
),
),
// Channel list
Expanded(
child: NotificationListener<ScrollNotification>(
onNotification: (_) {
_restartChannelListTimer();
return false;
},
child: ListView.builder(
controller: _channelListScrollController,
itemCount: channels.length,
itemExtent: 64.0,
itemBuilder: (context, index) {
final ch = channels[index];
final isActive = index == _currentIndex;
return TVFocusable(
// Le focusNode externe n'est posé que sur la chaîne
// active ; la clé force un remontage quand isActive
// change (TVFocusable ne gère pas didUpdateWidget).
key: ValueKey('sidebar-ch-$index-$isActive'),
focusNode: isActive ? _channelListItemFocusNode : null,
scale: 1.0,
borderRadius: BorderRadius.zero,
onFocus: _restartChannelListTimer,
onPressed: () {
_channelListTimer?.cancel();
_switchChannel(index);
},
child: Container(
decoration: BoxDecoration(
color: isActive
? AppColors.primaryFill.withValues(alpha: 0.6)
: Colors.transparent,
border: isActive
? const Border(
left: BorderSide(
color: AppColors.primary,
width: 3,
),
)
: null,
),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: Row(
children: [
// Logo
Container(
width: 40,
height: 40,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(6),
),
child: ch.streamIcon.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: CachedNetworkImage(
imageUrl: ch.streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv,
color: AppColors.onSurface38,
size: 20),
),
)
: const Icon(Icons.tv,
color: AppColors.onSurface38, size: 20),
),
// Name + num
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
ch.name,
style: TextStyle(
color: isActive
? AppColors.onSurface
: AppColors.onSurface70,
fontSize: 13,
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'CH ${index + 1}',
style: const TextStyle(
color: AppColors.onSurface38,
fontSize: 11,
),
),
],
),
),
// Active indicator
if (isActive)
const Icon(
Icons.play_arrow,
color: AppColors.primary,
size: 18,
),
],
),
),
);
},
),
),
),
],
),
),
),
);
}
}
@@ -236,7 +236,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
? 'Playlist modifiée'
: 'Playlist ajoutée',
),
backgroundColor: Colors.green,
backgroundColor: AppColors.success,
),
);
}
@@ -125,7 +125,7 @@ class _MobilePlaylistSelectionScreenState
const SizedBox(height: 16),
Text(
'Erreur de chargement',
style: GoogleFonts.inter(color: Colors.white),
style: GoogleFonts.inter(color: AppColors.onSurface),
),
TextButton(
onPressed: () => ref.invalidate(playlistsProvider),
@@ -143,14 +143,14 @@ class _MobilePlaylistSelectionScreenState
const Icon(
Icons.playlist_add,
size: 64,
color: Colors.white54,
color: AppColors.onSurface54,
),
const SizedBox(height: 24),
Text(
'Aucune playlist configurée',
style: GoogleFonts.inter(
fontSize: 18,
color: Colors.white70,
color: AppColors.onSurface70,
),
),
const SizedBox(height: 32),
@@ -163,18 +163,18 @@ class _MobilePlaylistSelectionScreenState
vertical: 14,
),
decoration: BoxDecoration(
color: const Color(0xFF0A84FF),
color: AppColors.primaryFill,
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, color: Colors.white, size: 20),
Icon(Icons.add, color: AppColors.onSurface, size: 20),
SizedBox(width: 10),
Text(
'Ajouter une Playlist',
style: TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
fontSize: 15,
),
@@ -268,21 +268,21 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text(label, style: const TextStyle(color: Colors.white)),
title: Text(label, style: const TextStyle(color: AppColors.onSurface)),
content: TextField(
controller: controller,
autofocus: true,
obscureText: obscure,
keyboardType: keyboardType,
textInputAction: TextInputAction.done,
style: const TextStyle(color: Colors.white),
style: const TextStyle(color: AppColors.onSurface),
decoration: const InputDecoration(
labelStyle: TextStyle(color: Colors.white70),
labelStyle: TextStyle(color: AppColors.onSurface70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
borderSide: BorderSide(color: AppColors.onSurface24),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white),
borderSide: BorderSide(color: AppColors.onSurface),
),
),
onSubmitted: (val) => Navigator.pop(ctx, val),
@@ -327,7 +327,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.07),
color: AppColors.onSurface.withOpacity(0.07),
borderRadius: BorderRadius.circular(10),
),
child: Row(
@@ -341,7 +341,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
Text(
label,
style: const TextStyle(
color: Colors.white70,
color: AppColors.onSurface70,
fontSize: 11,
fontWeight: FontWeight.w500,
),
@@ -350,7 +350,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
Text(
displayValue,
style: TextStyle(
color: value.isEmpty ? Colors.white38 : Colors.white,
color: value.isEmpty ? AppColors.onSurface38 : AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500,
),
@@ -360,7 +360,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
],
),
),
const Icon(Icons.edit, color: Colors.white38, size: 18),
const Icon(Icons.edit, color: AppColors.onSurface38, size: 18),
],
),
),
@@ -374,7 +374,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
backgroundColor: AppColors.surface,
title: Text(
widget.isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
style: GoogleFonts.inter(color: Colors.white),
style: GoogleFonts.inter(color: AppColors.onSurface),
),
content: SizedBox(
width: 480,
@@ -435,8 +435,8 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('Confirmer', style: TextStyle(color: Colors.white)),
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: Colors.white70)),
title: const Text('Confirmer', style: TextStyle(color: AppColors.onSurface)),
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: AppColors.onSurface70)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
@@ -444,7 +444,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('Supprimer'),
),
],
@@ -458,7 +458,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('Supprimer', style: TextStyle(color: Colors.red)),
child: Text('Supprimer', style: TextStyle(color: AppColors.error)),
),
),
TVFocusable(
@@ -466,7 +466,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('Annuler', style: TextStyle(color: Colors.white70)),
child: Text('Annuler', style: TextStyle(color: AppColors.onSurface70)),
),
),
TVFocusable(
@@ -482,14 +482,14 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: _name.isEmpty || _dns.isEmpty
? Colors.white12
? AppColors.onSurface12
: AppColors.primary,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_isSaving ? '...' : 'Enregistrer',
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
),
),
@@ -129,7 +129,7 @@ class _MobileSeriesDetailScreenState
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
icon: const Icon(Icons.arrow_back, color: AppColors.onSurface),
onPressed: () => Navigator.pop(context),
tooltip: 'Back',
),
@@ -149,7 +149,7 @@ class _MobileSeriesDetailScreenState
maxHeightDiskCache: 720,
cacheManager: AppCacheManager.instance,
errorWidget: (_, __, ___) =>
Container(color: Colors.grey.shade900),
Container(color: AppColors.surfaceContainerLowest),
),
// Gradient Overlay
Container(
@@ -190,7 +190,7 @@ class _MobileSeriesDetailScreenState
const Icon(
Icons.star,
size: 16,
color: Colors.amber,
color: AppColors.ratingGold,
),
const SizedBox(width: 4),
Text(
@@ -261,15 +261,15 @@ class _MobileSeriesDetailScreenState
}
},
backgroundColor: isDark
? const Color(0xFF1C1C1E)
: Colors.grey.shade300,
selectedColor: isDark ? Colors.white : Colors.blue,
? AppColors.surfaceContainer
: AppColors.surfaceContainerHigh,
selectedColor: isDark ? AppColors.onSurface : AppColors.primaryFill,
labelStyle: TextStyle(
color: isSelected
? (isDark ? Colors.black : Colors.white)
? (isDark ? AppColors.onPrimaryFixed : AppColors.onSurface)
: (isDark
? Colors.grey.shade400
: Colors.grey.shade700),
? AppColors.textSecondary
: AppColors.outlineVariant),
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400,
),
@@ -323,10 +323,10 @@ class _MobileSeriesDetailScreenState
}
// Force dark mode colors since background is hardcoded dark
const textColor = Colors.white;
final secondaryTextColor = Colors.grey.shade400;
const surfaceColor = Color(0xFF1C1C1E);
const primaryColor = Colors.white; // Or accent color if preferred
const textColor = AppColors.onSurface;
final secondaryTextColor = AppColors.textSecondary;
const surfaceColor = AppColors.surfaceContainer;
const primaryColor = AppColors.onSurface; // Or accent color if preferred
return InkWell(
onTap: () {
@@ -356,7 +356,7 @@ class _MobileSeriesDetailScreenState
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.white10)),
border: Border(bottom: BorderSide(color: AppColors.onSurface12)),
),
child: Row(
children: [
@@ -374,7 +374,7 @@ class _MobileSeriesDetailScreenState
child: Icon(
isWatched ? Icons.check : Icons.play_arrow,
color: isWatched
? Colors.black // Checkmark on white bg
? AppColors.onPrimaryFixed // Checkmark on white bg
: primaryColor, // Play icon
size: 20,
),
@@ -413,7 +413,7 @@ class _MobileSeriesDetailScreenState
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.transparent,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.error),
minHeight: 2,
),
],
@@ -73,6 +73,11 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
final FocusNode _sliderFocusNode = FocusNode();
final FocusNode _backFocusNode = FocusNode();
final FocusNode _audioFocusNode = FocusNode();
final FocusNode _channelListButtonFocusNode = FocusNode();
/// Focus D-pad de la chaîne active dans la sidebar : permet d'atterrir
/// directement dessus à l'ouverture de la liste.
final FocusNode _channelListItemFocusNode = FocusNode();
bool _isLoading = true;
bool _isFirstLoad = true; // true = black overlay; false = silent spinner
@@ -135,6 +140,35 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_resetControlsTimer();
}
/// Dernier contrôle de l'OSD qui avait le focus télécommande.
///
/// Quand l'OSD se masque (auto-hide 5s), ses FocusNodes sont démontés et le
/// focus part « on ne sait où » : à la réouverture l'utilisateur devait
/// re-naviguer depuis un point aléatoire jusqu'au bouton de zapping. On
/// mémorise donc le dernier bouton utilisé pour le re-focaliser d'office.
FocusNode? _lastFocusedControl;
void _noteFocus(FocusNode node) {
_lastFocusedControl = node;
_resetControlsTimer();
}
/// Réaffiche l'OSD en redonnant le focus au dernier contrôle utilisé
/// (Play/Pause par défaut) — un seul appui suffit alors pour re-zapper.
void _showControlsRestoringFocus() {
setState(() => _showControls = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls) return;
final target = _lastFocusedControl ?? _playPauseFocusNode;
if (target.canRequestFocus) {
target.requestFocus();
} else {
_playPauseFocusNode.requestFocus();
}
});
_resetControlsTimer();
}
@override
void initState() {
super.initState();
@@ -189,10 +223,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Principe TiviMate : jouer à partir de là où on est, JAMAIS chasser le live edge.
// ExoPlayer n'utilise pas les PTS du stream pour se recaler — on fait pareil.
// --- Buffering stable (TiviMate Medium ~ 8-10s) ---
(_player.platform as dynamic)?.setProperty('cache-secs', '8');
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '150000000'); // 150MB RAM max
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '10');
// --- Buffering basse latence (TiviMate Low ~ 3-5s) ---
// 8s/10s de readahead ajoutaient autant de retard sur le direct : 3s de
// cache + 5s de readahead suffisent à absorber la gigue réseau tout en
// restant nettement plus près du live edge.
(_player.platform as dynamic)?.setProperty('cache-secs', '3');
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '60000000'); // 60MB RAM max
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '5');
(_player.platform as dynamic)?.setProperty('demuxer-thread', 'yes');
// --- Plafond du buffer ARRIÈRE (stabilité longue durée) ---
// mpv garde par défaut 50 Mio de flux déjà lu pour permettre un retour
@@ -234,15 +271,19 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
(_player.platform as dynamic)?.setProperty('video-sync', 'audio');
}
// [V11.0 Perfect Restoration] Recovering settings from working commit a1628a6b
// Forced software re-sampling handles ANY stubborn 5.1/LATM/24-bit audio track.
// [Audio Fix] L'ancienne chaîne `af=lavrresample,format=channels=stereo:
// sample_fmts=s16` mélangeait la syntaxe FFmpeg et mpv : `lavrresample`
// n'existe plus dans les builds récents et `sample_fmts` n'est pas une
// option du filtre `format` de mpv. Quand la chaîne échoue à s'initialiser
// pour un codec donné (AC3/EAC3/LATM selon la chaîne TV), mpv coupe la
// sortie audio ⇒ « certaines chaînes n'ont pas de son ».
// Le downmix natif `audio-channels=stereo` fait la même chose (5.1 → 2.0)
// sans chaîne de filtres fragile, et l'AO négocie lui-même le format.
(_player.platform as dynamic)?.setProperty('ad', 'lavc:*');
(_player.platform as dynamic)?.setProperty('af', 'lavrresample,format=channels=stereo:sample_fmts=s16');
(_player.platform as dynamic)?.setProperty('audio-format', 's16');
(_player.platform as dynamic)?.setProperty('audio-channels', 'stereo');
(_player.platform as dynamic)?.setProperty('ao', 'audiotrack');
(_player.platform as dynamic)?.setProperty('audio-pitch-correction', 'no');
(_player.platform as dynamic)?.setProperty('audio-buffer', '0.2'); // Live: buffer audio court (réduit le désync pendant les cache-pauses)
(_player.platform as dynamic)?.setProperty('audio-samplerate', '48000');
(_player.platform as dynamic)?.setProperty('aid', 'auto');
// --- Seeking & Sync globaux ---
@@ -270,11 +311,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
);
// --- Probing du flux (une seule entrée, consolidée) ---
// Précédent : 2 setProperty('demuxer-lavf-o') en conflit
// fflags=+discardcorrupt supprimé : peut causer des skips sur certains streams IPTV
// Ces valeurs sont des PLAFONDS : FFmpeg s'arrête dès que toutes les pistes
// sont identifiées, donc les relever ne ralentit pas les flux sains.
// 1MB/2s ratait la piste audio (AAC-LATM déclaré tard) sur certaines
// chaînes ⇒ pas de son. 2MB/3s les récupère sans pénaliser le zapping.
(_player.platform as dynamic)?.setProperty(
'demuxer-lavf-o',
'analyzeduration=2000000,probesize=1000000',
'analyzeduration=3000000,probesize=2000000',
);
// --- Décodeur lavc : threads libres, sans fast-mode (qualité prioritaire) ---
@@ -566,6 +610,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_sliderFocusNode.dispose();
_backFocusNode.dispose();
_audioFocusNode.dispose();
_channelListButtonFocusNode.dispose();
_channelListItemFocusNode.dispose();
// Restore normal orientation
SystemChrome.setPreferredOrientations([
@@ -654,7 +700,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
'Reprise à ${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
),
duration: const Duration(seconds: 2),
backgroundColor: Colors.green.shade700,
backgroundColor: AppColors.success,
),
);
} else {
@@ -997,6 +1043,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_showChannelList = false; // close sidebar on switch
_showControls = true; // show regular OSD
});
// Garde le focus sur le bouton de zap qui vient d'être pressé : l'appui
// suivant sur OK rezappe immédiatement, sans re-navigation dans l'OSD.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls) return;
final target = _lastFocusedControl;
if (target != null && target.canRequestFocus && !target.hasFocus) {
target.requestFocus();
}
});
_resetControlsTimer();
_loadStream();
}
@@ -1006,15 +1061,36 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
setState(() => _showChannelList = !_showChannelList);
if (_showChannelList) {
_scrollToCurrentChannel();
_channelListTimer?.cancel();
_channelListTimer = Timer(const Duration(seconds: 6), () {
if (mounted) setState(() => _showChannelList = false);
_restartChannelListTimer();
// Focus direct sur la chaîne en cours : la télécommande navigue dans la
// liste sans étape intermédiaire. Second essai après l'animation de
// scroll (300 ms), l'item actif pouvant ne pas encore être construit.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _showChannelList) {
_channelListItemFocusNode.requestFocus();
}
});
Future.delayed(const Duration(milliseconds: 400), () {
if (mounted &&
_showChannelList &&
!_channelListItemFocusNode.hasFocus) {
_channelListItemFocusNode.requestFocus();
}
});
} else {
_channelListTimer?.cancel();
}
}
/// (Re)arme l'auto-fermeture de la sidebar. Relancé à chaque interaction
/// (focus D-pad, scroll) pour ne pas fermer la liste sous les doigts.
void _restartChannelListTimer() {
_channelListTimer?.cancel();
_channelListTimer = Timer(const Duration(seconds: 6), () {
if (mounted) setState(() => _showChannelList = false);
});
}
/// Scroll channel list to keep current channel visible
void _scrollToCurrentChannel() {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -1049,12 +1125,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
void _toggleControls() {
// Always show controls on tap, then start auto-hide timer
if (!_showControls) {
setState(() => _showControls = true);
// Request focus on Play/Pause button when opening
WidgetsBinding.instance.addPostFrameCallback((_) {
// If controls opened, default to Play/Pause
_playPauseFocusNode.requestFocus();
});
// Reopen on the last focused control (falls back to Play/Pause)
_showControlsRestoringFocus();
return;
}
_resetControlsTimer();
}
@@ -1066,6 +1139,21 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
try {
final key = event.logicalKey;
// Sidebar ouverte : Back/Menu la ferment, le reste part au système de
// Focus (navigation + OK sur un item). On relance juste l'auto-fermeture.
if (_showChannelList) {
if (key == LogicalKeyboardKey.escape ||
key == LogicalKeyboardKey.goBack ||
key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) {
_channelListTimer?.cancel();
setState(() => _showChannelList = false);
return true;
}
_restartChannelListTimer();
return false;
}
// Explicit Play
if (key == LogicalKeyboardKey.mediaPlay) {
if (!_isPlaying) _player.play();
@@ -1087,8 +1175,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
key == LogicalKeyboardKey.numpadEnter ||
key == LogicalKeyboardKey.gameButtonA ||
key == LogicalKeyboardKey.mediaPlayPause) {
// If controls are hidden, toggle them + play/pause
if (!_showControls) {
// Live : OK réaffiche seulement l'OSD (focus restauré sur le dernier
// bouton utilisé) — surtout ne PAS mettre le direct en pause.
if (widget.streamType == model.StreamType.live &&
key != LogicalKeyboardKey.mediaPlayPause) {
_showControlsRestoringFocus();
return true;
}
// VOD : comportement historique — toggle play/pause + OSD
_togglePlayPause();
_toggleControls();
return true;
@@ -1116,8 +1211,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (widget.streamType != model.StreamType.live) {
final newPos = _position - const Duration(seconds: 10);
_player.seek(newPos < Duration.zero ? Duration.zero : newPos);
_onUserInteraction();
} else {
_showControlsRestoringFocus();
}
_onUserInteraction();
return true;
}
return false; // Let Focus handle navigation
@@ -1131,8 +1228,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (newPos < _duration) {
_player.seek(newPos);
}
_onUserInteraction();
} else {
_showControlsRestoringFocus();
}
_onUserInteraction();
return true;
}
return false; // Let Focus handle navigation
@@ -1142,7 +1241,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (key == LogicalKeyboardKey.arrowUp ||
key == LogicalKeyboardKey.arrowDown) {
if (!_showControls) {
_onUserInteraction();
_showControlsRestoringFocus();
return true;
}
return false; // Let Focus handle navigation
@@ -1168,16 +1267,6 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
}
}
// OK / Select when channel list is open → switch to focused channel
if (_showChannelList &&
(key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.select ||
key == LogicalKeyboardKey.numpadEnter)) {
// Channel list handles its own tap, this closes it
setState(() => _showChannelList = false);
return true;
}
// Menu key or 'M' → toggle channel list sidebar
if (key == LogicalKeyboardKey.contextMenu ||
key == LogicalKeyboardKey.keyM) {
@@ -1293,6 +1382,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Loading Overlay — black only on FIRST load
if (_isLoading && _errorMessage == null)
Container(
// True black, not the warm ink: this sits over the video surface.
color: Colors.black,
child: _buildLoadingView(),
),
@@ -1313,7 +1403,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white70,
color: AppColors.onSurface70,
),
),
),
@@ -1349,7 +1439,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: IconButton(
icon: const Icon(
Icons.arrow_back,
color: Colors.white,
color: AppColors.onSurface,
),
onPressed: () => Navigator.pop(context),
),
@@ -1380,7 +1470,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
title,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
@@ -1392,7 +1482,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
_currentTime,
style: const TextStyle(
color: Colors.white70,
color: AppColors.onSurface70,
fontSize: 12,
fontWeight: FontWeight.w500,
),
@@ -1485,7 +1575,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10),
border: Border.all(color: AppColors.onSurface12),
),
child: IntrinsicHeight(
child: Row(
@@ -1503,10 +1593,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable(
focusNode: _prevFocusNode,
onPressed: _playPrevious,
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_prevFocusNode),
child: const Icon(
Icons.skip_previous,
color: Colors.white,
color: AppColors.onSurface,
size: 36,
),
),
@@ -1516,12 +1606,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable(
focusNode: _playPauseFocusNode,
onPressed: _togglePlayPause,
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_playPauseFocusNode),
child: Icon(
_isPlaying
? Icons.pause_circle_filled
: Icons.play_circle_filled,
color: Colors.white,
color: AppColors.onSurface,
size: 56,
),
),
@@ -1531,15 +1621,28 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable(
focusNode: _nextFocusNode,
onPressed: _playNext,
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_nextFocusNode),
child: const Icon(
Icons.skip_next,
color: Colors.white,
color: AppColors.onSurface,
size: 36,
),
),
const SizedBox(width: 16),
// Channel list sidebar (liste des chaînes de la catégorie)
if (widget.channels != null && widget.channels!.isNotEmpty)
TVFocusable(
focusNode: _channelListButtonFocusNode,
onPressed: _toggleChannelList,
onFocus: () => _noteFocus(_channelListButtonFocusNode),
child: const Icon(
Icons.playlist_play_rounded,
color: AppColors.onSurface,
size: 36,
),
),
// Disable Deinterlace Button
if (widget.forceDeinterlace) ...[
const SizedBox(width: 16),
@@ -1570,7 +1673,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
onFocus: _resetControlsTimer,
child: const Icon(
Icons.grid_off,
color: Colors.white,
color: AppColors.onSurface,
size: 32,
),
),
@@ -1618,7 +1721,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
imageUrl: widget.channels![_currentIndex].streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white38),
const Icon(Icons.tv, color: AppColors.onSurface38),
),
),
Expanded(
@@ -1628,7 +1731,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
title,
style: const TextStyle(
color: Colors.white70,
color: AppColors.onSurface70,
fontSize: 14,
fontWeight: FontWeight.w500,
),
@@ -1639,7 +1742,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
nowPlaying,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -1654,7 +1757,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
startTime,
style: const TextStyle(
color: Colors.white54, fontSize: 11),
color: AppColors.onSurface54, fontSize: 11),
),
const SizedBox(width: 6),
Expanded(
@@ -1663,9 +1766,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: LinearProgressIndicator(
value: epgProgress.clamp(0.0, 1.0),
minHeight: 4,
backgroundColor: Colors.white24,
backgroundColor: AppColors.onSurface24,
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF4FC3F7),
AppColors.primary,
),
),
),
@@ -1674,7 +1777,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
endTime,
style: const TextStyle(
color: Colors.white54, fontSize: 11),
color: AppColors.onSurface54, fontSize: 11),
),
],
),
@@ -1714,7 +1817,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.82),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white12),
border: Border.all(color: AppColors.onSurface12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -1728,7 +1831,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
margin: const EdgeInsets.only(right: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.white10,
color: AppColors.onSurface12,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
@@ -1736,7 +1839,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
imageUrl: ch.streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white38, size: 32),
const Icon(Icons.tv, color: AppColors.onSurface38, size: 32),
),
),
)
@@ -1747,9 +1850,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
margin: const EdgeInsets.only(right: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.white10,
color: AppColors.onSurface12,
),
child: const Icon(Icons.tv, color: Colors.white38, size: 32),
child: const Icon(Icons.tv, color: AppColors.onSurface38, size: 32),
),
// Channel info
@@ -1765,13 +1868,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
horizontal: 6, vertical: 2),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: const Color(0xFF1565C0),
color: const AppColors.primaryFill,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'CH $chNum',
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.bold,
),
@@ -1781,7 +1884,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: Text(
ch.name,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
@@ -1797,9 +1900,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: SizedBox(
height: 3,
child: LinearProgressIndicator(
backgroundColor: Colors.white24,
backgroundColor: AppColors.onSurface24,
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF4FC3F7)),
AppColors.primary),
),
),
)
@@ -1808,7 +1911,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
nowPlaying,
style: const TextStyle(
color: Colors.white70,
color: AppColors.onSurface70,
fontSize: 13,
),
maxLines: 1,
@@ -1820,9 +1923,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: LinearProgressIndicator(
value: epgProgress.clamp(0.0, 1.0),
minHeight: 3,
backgroundColor: Colors.white24,
backgroundColor: AppColors.onSurface24,
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF4FC3F7)),
AppColors.primary),
),
),
],
@@ -1861,18 +1964,18 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.white12),
bottom: BorderSide(color: AppColors.onSurface12),
),
),
child: Row(
children: [
const Icon(Icons.list, color: Colors.white70, size: 18),
const Icon(Icons.list, color: AppColors.onSurface70, size: 18),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Chaînes',
style: TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.bold,
),
@@ -1881,34 +1984,47 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
GestureDetector(
onTap: () => setState(() => _showChannelList = false),
child: const Icon(Icons.close,
color: Colors.white54, size: 18),
color: AppColors.onSurface54, size: 18),
),
],
),
),
// Channel list
Expanded(
child: ListView.builder(
child: NotificationListener<ScrollNotification>(
onNotification: (_) {
_restartChannelListTimer();
return false;
},
child: ListView.builder(
controller: _channelListScrollController,
itemCount: channels.length,
itemExtent: 64.0,
itemBuilder: (context, index) {
final ch = channels[index];
final isActive = index == _currentIndex;
return GestureDetector(
onTap: () {
return TVFocusable(
// Le focusNode externe n'est posé que sur la chaîne
// active ; la clé force un remontage quand isActive
// change (TVFocusable ne gère pas didUpdateWidget).
key: ValueKey('sidebar-ch-$index-$isActive'),
focusNode: isActive ? _channelListItemFocusNode : null,
scale: 1.0,
borderRadius: BorderRadius.zero,
onFocus: _restartChannelListTimer,
onPressed: () {
_channelListTimer?.cancel();
_switchChannel(index);
},
child: Container(
decoration: BoxDecoration(
color: isActive
? const Color(0xFF1565C0).withOpacity(0.6)
? AppColors.primaryFill.withValues(alpha: 0.6)
: Colors.transparent,
border: isActive
? const Border(
left: BorderSide(
color: Color(0xFF4FC3F7),
color: AppColors.primary,
width: 3,
),
)
@@ -1924,7 +2040,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
height: 40,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
color: Colors.white10,
color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(6),
),
child: ch.streamIcon.isNotEmpty
@@ -1935,12 +2051,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv,
color: Colors.white38,
color: AppColors.onSurface38,
size: 20),
),
)
: const Icon(Icons.tv,
color: Colors.white38, size: 20),
color: AppColors.onSurface38, size: 20),
),
// Name + num
Expanded(
@@ -1952,8 +2068,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
ch.name,
style: TextStyle(
color: isActive
? Colors.white
: Colors.white70,
? AppColors.onSurface
: AppColors.onSurface70,
fontSize: 13,
fontWeight: isActive
? FontWeight.bold
@@ -1965,7 +2081,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
'CH ${index + 1}',
style: const TextStyle(
color: Colors.white38,
color: AppColors.onSurface38,
fontSize: 11,
),
),
@@ -1976,7 +2092,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (isActive)
const Icon(
Icons.play_arrow,
color: Color(0xFF4FC3F7),
color: AppColors.primary,
size: 18,
),
],
@@ -1984,6 +2100,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
),
);
},
),
),
),
],
@@ -2016,12 +2133,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_player.seek(pos - const Duration(seconds: 10));
_resetControlsTimer();
},
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_prevFocusNode),
borderRadius: BorderRadius.circular(50),
child: IconButton(
icon: const Icon(
Icons.replay_10,
color: Colors.white,
color: AppColors.onSurface,
size: 48,
),
onPressed: () async {
@@ -2037,7 +2154,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable(
focusNode: _playPauseFocusNode,
onPressed: _togglePlayPause,
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_playPauseFocusNode),
scale: 1.1,
borderRadius: BorderRadius.circular(50),
child: IconButton(
@@ -2046,7 +2163,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_isPlaying
? Icons.pause_circle_filled
: Icons.play_circle_filled,
color: Colors.white,
color: AppColors.onSurface,
),
onPressed: _togglePlayPause,
),
@@ -2062,12 +2179,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_player.seek(pos + const Duration(seconds: 10));
_resetControlsTimer();
},
onFocus: _resetControlsTimer,
onFocus: () => _noteFocus(_nextFocusNode),
borderRadius: BorderRadius.circular(50),
child: IconButton(
icon: const Icon(
Icons.forward_10,
color: Colors.white,
color: AppColors.onSurface,
size: 48,
),
onPressed: () async {
@@ -2103,7 +2220,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
_formatDuration(_position),
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 14,
),
),
@@ -2157,14 +2274,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_resetControlsTimer();
},
activeColor: AppColors.primary,
inactiveColor: Colors.white24,
inactiveColor: AppColors.onSurface24,
),
),
),
Text(
_formatDuration(_duration),
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 14,
),
),
@@ -2199,7 +2316,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
height: 72,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white10,
color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
@@ -2208,7 +2325,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
imageUrl: ch.streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white38, size: 40),
const Icon(Icons.tv, color: AppColors.onSurface38, size: 40),
),
),
),
@@ -2217,7 +2334,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text(
ch != null ? ch.name : 'Chargement...',
style: const TextStyle(
color: Colors.white70,
color: AppColors.onSurface70,
fontSize: 15,
fontWeight: FontWeight.w500,
),
@@ -2232,18 +2349,18 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const Icon(Icons.error_outline, size: 64, color: AppColors.error),
const SizedBox(height: 16),
const Text(
'Erreur de lecture',
style: TextStyle(color: Colors.white, fontSize: 18),
style: TextStyle(color: AppColors.onSurface, fontSize: 18),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
_errorMessage ?? 'Une erreur inconnue est survenue',
style: const TextStyle(color: Colors.white70),
style: const TextStyle(color: AppColors.onSurface70),
textAlign: TextAlign.center,
),
),
@@ -2256,8 +2373,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
icon: const Icon(Icons.arrow_back),
label: const Text('Retour'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
backgroundColor: AppColors.onSurface,
foregroundColor: AppColors.onPrimaryFixed,
),
),
const SizedBox(width: 16),
@@ -2267,7 +2384,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.black,
foregroundColor: AppColors.onPrimaryFixed,
),
),
],
@@ -2328,7 +2445,7 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withOpacity(0.5)),
border: Border.all(color: AppColors.success.withOpacity(0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -2337,7 +2454,7 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
const Text(
'STATS FOR NERDS 🤓',
style: TextStyle(
color: Colors.green,
color: AppColors.success,
fontWeight: FontWeight.bold,
fontSize: 12,
),
@@ -2345,11 +2462,11 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
const SizedBox(height: 4),
Text(
'Buffer: ${_buffer.inSeconds}s',
style: const TextStyle(color: Colors.white, fontSize: 12),
style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
),
Text(
'Decoder: $_decoder',
style: const TextStyle(color: Colors.white, fontSize: 12),
style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
),
],
),
@@ -98,6 +98,14 @@ class XtreamServiceMobile {
/// changing any call site.
final Map<String, model.Channel> _channelIndex = {};
/// Last non-empty live lists, keyed by category id ('' = all channels).
///
/// The fetchers swallow errors and return `[]`, so a refresh attempted while
/// the network is down (typically right after a TV wakes from standby) would
/// otherwise replace a good list with an empty screen.
final Map<String, List<model.Channel>> _lastLiveChannels = {};
List<model.Category>? _lastLiveCategories;
late final XmltvService _xmltv = XmltvService(cacheDir);
/// User-provided XMLTV URL (empty = not configured).
@@ -200,7 +208,18 @@ class XtreamServiceMobile {
/// Get live channels for a category (with batch EPG support)
/// If categoryId is empty, fetches ALL channels from all categories
///
/// Falls back to the last good list for this category when the fetch fails.
Future<List<model.Channel>> getLiveChannels(String categoryId) async {
final channels = await _fetchLiveChannels(categoryId);
if (channels.isNotEmpty) {
_lastLiveChannels[categoryId] = channels;
return channels;
}
return _lastLiveChannels[categoryId] ?? channels;
}
Future<List<model.Channel>> _fetchLiveChannels(String categoryId) async {
try {
final queryParams = {
'username': _username,
@@ -463,7 +482,18 @@ class XtreamServiceMobile {
}
/// Get live TV categories
///
/// Falls back to the last good list when the fetch fails.
Future<List<model.Category>> getLiveCategories() async {
final categories = await _fetchLiveCategories();
if (categories.isNotEmpty) {
_lastLiveCategories = categories;
return categories;
}
return _lastLiveCategories ?? categories;
}
Future<List<model.Category>> _fetchLiveCategories() async {
try {
if (kDebugMode) print('🔍 Loading live TV categories with 8s timeout...');
+259 -185
View File
@@ -16,6 +16,7 @@ import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
import 'package:flutter/services.dart';
import 'package:xtremobile/core/services/ip_service.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileLiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -26,7 +27,11 @@ class MobileLiveTVTab extends ConsumerStatefulWidget {
}
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
with AutomaticKeepAliveClientMixin {
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
/// Coming back to the foreground after this long refetches the channel list,
/// so an app left running for days on a TV box picks up provider changes.
static const _autoRefreshAfter = Duration(hours: 4);
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
@@ -35,12 +40,16 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
bool _isSearchEditing = false;
bool _justReturnedFromPlayer =
false; // Flag to prevent PopScope interception after player
bool _isRefreshing = false;
Timer? _searchTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Anchor the auto-refresh clock to this first load.
ref.read(liveCatalogRefreshedAtProvider);
// [P1-1 FIX] Debounce search input to prevent per-keystroke rebuilds
_searchTimer = null;
_searchController.addListener(() {
@@ -61,6 +70,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_scrollController.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
@@ -71,6 +81,39 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
@override
bool get wantKeepAlive => true;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) return;
final lastRefresh = ref.read(liveCatalogRefreshedAtProvider);
if (DateTime.now().difference(lastRefresh) >= _autoRefreshAfter) {
_refreshChannels();
}
}
/// Refetch categories and the open category's channels. The current lists
/// stay on screen until the new ones arrive.
Future<void> _refreshChannels() async {
if (_isRefreshing) return;
setState(() => _isRefreshing = true);
refreshLiveCatalog(ref);
final categoryId = ref.read(mobileLiveTvUiStateProvider).selectedCategoryId;
try {
await Future.wait<Object>([
ref.read(mobileLiveCategoriesProvider(widget.playlist).future),
if (categoryId != null)
ref.read(
mobileLiveChannelsByCategoryProvider((widget.playlist, categoryId))
.future,
),
]);
} catch (_) {
// Errors surface through the providers' own error states.
} finally {
if (mounted) setState(() => _isRefreshing = false);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
@@ -130,14 +173,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.grey[900],
backgroundColor: AppColors.surfaceContainerLowest,
title: const Text(
"Quitter l'application",
style: TextStyle(color: Colors.white),
style: TextStyle(color: AppColors.onSurface),
),
content: const Text(
"Voulez-vous vraiment quitter l'application ?",
style: TextStyle(color: Colors.white70),
style: TextStyle(color: AppColors.onSurface70),
),
actions: [
TextButton(
@@ -145,14 +188,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
autofocus: true,
child: const Text(
'Annuler',
style: TextStyle(color: Colors.blueAccent),
style: TextStyle(color: AppColors.primary),
),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text(
'Quitter',
style: TextStyle(color: Colors.redAccent),
style: TextStyle(color: AppColors.error),
),
),
],
@@ -171,7 +214,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
padding: const EdgeInsets.all(16),
child: Text(
'Erreur: $e',
style: const TextStyle(color: Colors.red),
style: const TextStyle(color: AppColors.error),
textAlign: TextAlign.center,
),
),
@@ -209,197 +252,228 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
return SafeArea(
bottom: false,
child: CustomScrollView(
controller: _scrollController,
slivers: [
// Floating Header (Search + Navigation)
SliverAppBar(
floating: false,
pinned: true,
snap: false,
backgroundColor: const Color(0xFF000000), // Opaque black
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
toolbarHeight: _searchQuery.isNotEmpty ||
_showFavoritesOnly ||
!uiState.isCategoryView
? 120.0
: 110.0,
flexibleSpace: FlexibleSpaceBar(
background: Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance
.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
SystemChannels.textInput
.invokeMethod('TextInput.show');
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(
horizontal: 12),
child: Row(
children: [
Icon(
Icons.search,
color: AppDecorations.textSecondary(
context),
),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(
color: AppDecorations.textPrimary(
context),
),
decoration: const InputDecoration(
hintText:
'Rechercher une chaîne...',
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
),
onSubmitted: (_) => setState(
() => _isSearchEditing = false,
child: RefreshIndicator(
onRefresh: _refreshChannels,
color: AppColors.primary,
child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Floating Header (Search + Navigation)
SliverAppBar(
floating: false,
pinned: true,
snap: false,
backgroundColor:
const Color(0xFF000000), // Opaque black
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
toolbarHeight: _searchQuery.isNotEmpty ||
_showFavoritesOnly ||
!uiState.isCategoryView
? 120.0
: 110.0,
flexibleSpace: FlexibleSpaceBar(
background: Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: AppColors.onSurface,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance
.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
SystemChannels.textInput
.invokeMethod('TextInput.show');
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration:
AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(
horizontal: 12),
child: Row(
children: [
Icon(
Icons.search,
color: AppDecorations.textSecondary(
context),
),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: AppColors.onSurface,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(
color:
AppDecorations.textPrimary(
context),
),
decoration: const InputDecoration(
hintText:
'Rechercher une chaîne...',
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
),
onSubmitted: (_) => setState(
() => _isSearchEditing = false,
),
),
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () =>
_searchController.clear(),
child: Icon(
Icons.close,
color: AppDecorations.textSecondary(
context),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () =>
_searchController.clear(),
child: Icon(
Icons.close,
color:
AppDecorations.textSecondary(
context),
),
),
),
],
],
),
),
),
),
const SizedBox(height: 12),
const SizedBox(height: 12),
// Title / Navigation Row
Row(
children: [
if (!showGrid ||
_showFavoritesOnly ||
_searchQuery.isNotEmpty) ...[
// Title / Navigation Row
Row(
children: [
if (!showGrid ||
_showFavoritesOnly ||
_searchQuery.isNotEmpty) ...[
IconButton(
icon: Icon(
Icons.arrow_back,
color: AppDecorations.textPrimary(
context),
),
onPressed: () {
if (_searchQuery.isNotEmpty) {
_searchController.clear();
} else if (_showFavoritesOnly) {
setState(
() => _showFavoritesOnly = false,
);
} else {
uiNotifier.state = uiState.copyWith(
isCategoryView: true,
);
}
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
_searchQuery.isNotEmpty
? 'Résultats de recherche'
: _showFavoritesOnly
? 'Favoris'
: showGrid
? 'Catégories'
: uiState
.selectedCategory ??
'Chaînes',
style: TextStyle(
color: AppDecorations.textPrimary(
context),
fontWeight: FontWeight.bold,
fontSize: 18,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: 'Actualiser les chaînes',
icon: _isRefreshing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(
Icons.refresh,
color:
AppDecorations.textSecondary(
context),
),
onPressed: _isRefreshing
? null
: _refreshChannels,
),
IconButton(
icon: Icon(
Icons.arrow_back,
color:
AppDecorations.textPrimary(context),
_showFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? AppColors.live
: AppDecorations.textSecondary(
context),
),
onPressed: () {
if (_searchQuery.isNotEmpty) {
_searchController.clear();
} else if (_showFavoritesOnly) {
setState(
() => _showFavoritesOnly = false,
);
} else {
onPressed: () => setState(() {
_showFavoritesOnly =
!_showFavoritesOnly;
if (_showFavoritesOnly) {
uiNotifier.state = uiState.copyWith(
isCategoryView: true,
isCategoryView: false,
);
}
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
if (!_showFavoritesOnly) {
uiNotifier.state = uiState.copyWith(
isCategoryView: true);
}
}),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
_searchQuery.isNotEmpty
? 'Résultats de recherche'
: _showFavoritesOnly
? 'Favoris'
: showGrid
? 'Catégories'
: uiState.selectedCategory ??
'Chaînes',
style: TextStyle(
color:
AppDecorations.textPrimary(context),
fontWeight: FontWeight.bold,
fontSize: 18,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
_showFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? const Color(0xFFFF453A)
: AppDecorations.textSecondary(
context),
),
onPressed: () => setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
if (_showFavoritesOnly) {
uiNotifier.state = uiState.copyWith(
isCategoryView: false,
);
}
if (!_showFavoritesOnly) {
uiNotifier.state = uiState.copyWith(
isCategoryView: true);
}
}),
),
],
),
],
),
],
),
),
),
),
),
// Main Content Sliver
if (showGrid)
_buildCategorySliver(categories, uiNotifier, uiState)
else
_ChannelListView(
playlist: widget.playlist,
uiState: uiState,
searchQuery: _searchQuery,
showFavoritesOnly: _showFavoritesOnly,
onPlay: (channel, channels, index) => _playChannel(
context,
channel,
channels,
widget.playlist,
index,
// Main Content Sliver
if (showGrid)
_buildCategorySliver(categories, uiNotifier, uiState)
else
_ChannelListView(
playlist: widget.playlist,
uiState: uiState,
searchQuery: _searchQuery,
showFavoritesOnly: _showFavoritesOnly,
onPlay: (channel, channels, index) => _playChannel(
context,
channel,
channels,
widget.playlist,
index,
),
),
),
],
],
),
),
);
},
@@ -535,7 +609,7 @@ class _ChannelListView extends ConsumerWidget {
child: Center(
child: Text(
'Aucune catégorie sélectionnée',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: AppColors.onSurface70),
),
),
);
@@ -557,7 +631,7 @@ class _ChannelListView extends ConsumerWidget {
showFavoritesOnly
? 'Sélectionnez une catégorie pour voir vos favoris'
: 'Sélectionnez une catégorie pour rechercher',
style: const TextStyle(color: Colors.white70),
style: const TextStyle(color: AppColors.onSurface70),
textAlign: TextAlign.center,
),
),
@@ -575,7 +649,7 @@ class _ChannelListView extends ConsumerWidget {
padding: const EdgeInsets.all(16),
child: Text(
'Erreur: $e',
style: const TextStyle(color: Colors.red),
style: const TextStyle(color: AppColors.error),
textAlign: TextAlign.center,
),
),
@@ -600,7 +674,7 @@ class _ChannelListView extends ConsumerWidget {
child: Center(
child: Text(
'Aucune chaîne trouvée',
style: TextStyle(color: Colors.white70),
style: TextStyle(color: AppColors.onSurface70),
),
),
);
@@ -754,12 +828,12 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.red,
color: AppColors.error,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.favorite,
color: Colors.white,
color: AppColors.onSurface,
size: 10,
),
),
@@ -779,7 +853,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
Text(
widget.channel.name,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontWeight: FontWeight.bold,
fontSize: 10,
),
@@ -792,7 +866,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
Text(
_epgTitle!,
style: const TextStyle(
color: Colors.yellow,
color: AppColors.ratingGold,
fontSize: 8,
),
textAlign: TextAlign.center,
@@ -11,6 +11,7 @@ import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileMoviesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -224,6 +225,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
final settings = ref.watch(mobileSettingsProvider);
final watchHistory = ref.watch(mobileWatchHistoryProvider);
@@ -285,14 +287,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.grey[900],
backgroundColor: AppColors.surfaceContainerLowest,
title: const Text(
"Quitter l'application",
style: TextStyle(color: Colors.white),
style: TextStyle(color: AppColors.onSurface),
),
content: const Text(
"Voulez-vous vraiment quitter l'application ?",
style: TextStyle(color: Colors.white70),
style: TextStyle(color: AppColors.onSurface70),
),
actions: [
TextButton(
@@ -300,14 +302,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
autofocus: true,
child: const Text(
'Annuler',
style: TextStyle(color: Colors.blueAccent),
style: TextStyle(color: AppColors.primary),
),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text(
'Quitter',
style: TextStyle(color: Colors.redAccent),
style: TextStyle(color: AppColors.error),
),
),
],
@@ -346,7 +348,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
focusColor: AppColors.onSurface,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -367,7 +369,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
cursorColor: AppColors.onSurface,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
@@ -11,6 +11,7 @@ import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/features/iptv/screens/mobile_series_detail_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileSeriesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
@@ -215,6 +216,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
@override
Widget build(BuildContext context) {
super.build(context);
ref.listen<int>(fullCatalogRefreshTickProvider, (_, __) => _refresh());
final settings = ref.watch(mobileSettingsProvider);
if (_categories.isEmpty && !_isLoading) {
@@ -269,18 +271,18 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.grey[900],
title: const Text("Quitter l'application", style: TextStyle(color: Colors.white)),
content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: Colors.white70)),
backgroundColor: AppColors.surfaceContainerLowest,
title: const Text("Quitter l'application", style: TextStyle(color: AppColors.onSurface)),
content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: AppColors.onSurface70)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
autofocus: true,
child: const Text('Annuler', style: TextStyle(color: Colors.blueAccent)),
child: const Text('Annuler', style: TextStyle(color: AppColors.primary)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Quitter', style: TextStyle(color: Colors.redAccent)),
child: const Text('Quitter', style: TextStyle(color: AppColors.error)),
),
],
),
@@ -316,7 +318,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
focusColor: AppColors.onSurface,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -337,7 +339,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
cursorColor: AppColors.onSurface,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
@@ -3,11 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
// import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/core/theme/app_theme.dart';
import 'package:xtremobile/core/providers/theme_provider.dart';
import 'package:xtremobile/features/iptv/screens/mobile_playlist_selection_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
/// Simplified mobile settings tab - optimized for TV remote control
class MobileSettingsTab extends ConsumerStatefulWidget {
@@ -83,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,
@@ -105,7 +107,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
),
),
Text(
'Version 1.5.1',
'Version 1.7.3',
style: GoogleFonts.inter(
fontSize: 13,
color: AppDecorations.textSecondary(context),
@@ -178,8 +180,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_buildSettingItem(
icon: Icons.public,
title: 'EPG communautaire',
subtitle:
'Repli quand le fournisseur ne renvoie aucun programme',
subtitle: 'Repli quand le fournisseur ne renvoie aucun programme',
value: settings.useCommunityEpg ? 'Activé' : 'Désactivé',
onTap: () => ref
.read(mobileSettingsProvider.notifier)
@@ -196,9 +197,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
onTap: () => _showKeyboardDialog(
'URL XMLTV',
_xmltvController,
(val) => ref
.read(mobileSettingsProvider.notifier)
.setXmltvUrl(val),
(val) =>
ref.read(mobileSettingsProvider.notifier).setXmltvUrl(val),
),
),
@@ -253,8 +253,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_buildSettingItem(
icon: _isRefreshingCache ? Icons.hourglass_empty : Icons.refresh,
title: 'Actualiser le cache',
subtitle: 'Recharge films, séries et EPG',
title: 'Forcer l\'actualisation',
subtitle: 'Recharge chaînes, films, séries et EPG',
value: _isRefreshingCache ? '...' : 'Appuyer',
onTap: _isRefreshingCache ? null : _refreshCache,
),
@@ -318,16 +318,16 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
setState(() => _isRefreshingCache = true);
try {
// final dir = await getApplicationDocumentsDirectory();
// final cacheStore = HiveCacheStore(dir.path);
// await cacheStore.clean();
await Future.delayed(const Duration(seconds: 1));
refreshFullCatalog(ref);
// Brief pause so the tap visibly registers on a TV remote.
await Future.delayed(const Duration(milliseconds: 600));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cache actualisé ! Relancez l\'app pour recharger.'),
backgroundColor: Colors.green,
content:
Text('Chaînes, films, séries et EPG en cours de rechargement'),
backgroundColor: AppColors.success,
duration: Duration(seconds: 3),
),
);
@@ -335,7 +335,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
SnackBar(
content: Text('Erreur: $e'), backgroundColor: AppColors.error),
);
}
} finally {
@@ -389,54 +390,60 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
builder: (ctx, constraints) {
return Row(
children: segments.map((seg) {
final (mode, icon, label) = seg;
final selected = themeState.appThemeMode == mode;
final accentColor = Theme.of(context).colorScheme.primary;
return Expanded(
child: TVFocusable(
onPressed: () => notifier.setThemeMode(mode),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(
vertical: 7,
),
decoration: BoxDecoration(
color: selected
? accentColor
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: selected
? Colors.white
: AppDecorations.textSecondary(context),
),
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
final (mode, icon, label) = seg;
final selected = themeState.appThemeMode == mode;
final accentColor =
Theme.of(context).colorScheme.primary;
return Expanded(
child: TVFocusable(
onPressed: () => notifier.setThemeMode(mode),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin:
const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(
vertical: 7,
),
decoration: BoxDecoration(
color: selected
? accentColor
: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: selected
? Colors.white
: AppDecorations.textSecondary(context),
? AppColors.onSurface
: AppDecorations.textSecondary(
context),
),
),
],
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
color: selected
? AppColors.onSurface
: AppDecorations.textSecondary(
context),
),
),
],
),
),
),
),
);
}).toList(),
);
}).toList(),
);
},
),
@@ -482,7 +489,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
),
child: Row(
children: [
Icon(icon, color: Theme.of(context).colorScheme.primary, size: 24),
Icon(icon,
color: Theme.of(context).colorScheme.primary, size: 24),
const SizedBox(width: 16),
Expanded(
child: Column(
@@ -513,7 +521,10 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.15),
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
@@ -561,7 +572,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
),
child: Row(
children: [
Icon(icon, color: Theme.of(context).colorScheme.primary, size: 24),
Icon(icon,
color: Theme.of(context).colorScheme.primary, size: 24),
const SizedBox(width: 16),
Expanded(
child: Column(
@@ -591,7 +603,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
],
),
),
Icon(Icons.edit, color: AppDecorations.textSecondary(context), size: 20),
Icon(Icons.edit,
color: AppDecorations.textSecondary(context), size: 20),
],
),
),
@@ -30,10 +30,44 @@ final mobileXtreamServiceProvider =
}
});
// A full catalogue refresh from the settings also drops the cached EPG.
ref.listen<int>(
fullCatalogRefreshTickProvider, (_, __) => service.clearCache());
ref.onDispose(service.dispose);
return service;
});
/// When the live catalogue was last fetched or explicitly refreshed.
/// Drives the auto-refresh when the app comes back to the foreground.
final liveCatalogRefreshedAtProvider =
StateProvider<DateTime>((ref) => DateTime.now());
/// Bumped by the settings "force refresh". The movies and series tabs, which
/// hold their lists in local state, listen to it to reload.
final fullCatalogRefreshTickProvider = StateProvider<int>((ref) => 0);
/// Forces the live categories and channels to be refetched from the panel.
///
/// The Xtream service is kept: it falls back to its last good lists if the
/// refetch fails, so a flaky network never empties the screen.
void refreshLiveCatalog(WidgetRef ref) {
ref.invalidate(mobileLiveCategoriesProvider);
ref.invalidate(mobileLiveChannelsByCategoryProvider);
ref.invalidate(mobileLiveChannelsProvider);
ref.read(liveCatalogRefreshedAtProvider.notifier).state = DateTime.now();
}
/// Forces the whole catalogue (live, movies, series) and the EPG to reload.
void refreshFullCatalog(WidgetRef ref) {
refreshLiveCatalog(ref);
ref.invalidate(mobileMoviesProvider);
ref.invalidate(mobileSeriesCategoriesProvider);
ref.invalidate(mobileSeriesProvider);
ref.invalidate(mobileSeriesInfoByPlaylistProvider);
ref.read(fullCatalogRefreshTickProvider.notifier).state++;
}
/// Mobile-specific live categories provider (for Live TV category grid)
final mobileLiveCategoriesProvider =
FutureProvider.family<List<model.Category>, PlaylistConfig>(
@@ -43,9 +77,9 @@ final mobileLiveCategoriesProvider =
});
/// Mobile-specific live channels provider (loads channels for a specific category)
final mobileLiveChannelsByCategoryProvider = FutureProvider.family<
List<model.Channel>,
(PlaylistConfig, String)>((ref, params) async {
final mobileLiveChannelsByCategoryProvider =
FutureProvider.family<List<model.Channel>, (PlaylistConfig, String)>(
(ref, params) async {
final (playlist, categoryId) = params;
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getLiveChannels(categoryId);
@@ -79,8 +113,9 @@ final mobileLiveChannelsProvider =
for (int batchNum = 0; batchNum < totalBatches; batchNum++) {
int startIdx = batchNum * batchSize;
int endIdx =
(startIdx + batchSize < categories.length) ? startIdx + batchSize : categories.length;
int endIdx = (startIdx + batchSize < categories.length)
? startIdx + batchSize
: categories.length;
final batch = categories.sublist(startIdx, endIdx);
print(
@@ -120,7 +155,8 @@ final mobileLiveChannelsProvider =
/// Mobile-specific movies provider
final mobileMoviesProvider =
FutureProvider.family<List<model.VodItem>, PlaylistConfig>((ref, playlist) async {
FutureProvider.family<List<model.VodItem>, PlaylistConfig>(
(ref, playlist) async {
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getMoviesByCategory(""); // Fetch all or default
});
@@ -134,8 +170,9 @@ final mobileSeriesCategoriesProvider =
});
/// Mobile-specific series pagination provider
final mobileSeriesProvider = FutureProvider.family<List<model.Series>,
(PlaylistConfig, String?)>((ref, params) async {
final mobileSeriesProvider =
FutureProvider.family<List<model.Series>, (PlaylistConfig, String?)>(
(ref, params) async {
final (playlist, categoryId) = params;
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getSeriesPaginated(categoryId: categoryId);
@@ -161,7 +198,8 @@ class SeriesInfoRequest {
}
final mobileSeriesInfoByPlaylistProvider =
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>((ref, request) async {
FutureProvider.family<model.SeriesInfo?, SeriesInfoRequest>(
(ref, request) async {
final service =
await ref.watch(mobileXtreamServiceProvider(request.playlist).future);
return service.getSeriesInfo(request.seriesId);
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileCategoryCard extends StatelessWidget {
final String title;
@@ -13,7 +14,7 @@ class MobileCategoryCard extends StatelessWidget {
required this.title,
required this.onTap,
this.icon = Icons.folder_rounded,
this.iconColor = const Color(0xFF0A84FF),
this.iconColor = AppColors.primary,
});
@override
+5 -4
View File
@@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/utils/image_cache_config.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobilePosterCard extends StatelessWidget {
final String title;
@@ -134,12 +135,12 @@ class MobilePosterCard extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star, color: Colors.amber, size: 10),
const Icon(Icons.star, color: AppColors.ratingGold, size: 10),
const SizedBox(width: 2),
Text(
rating!,
style: const TextStyle(
color: Colors.white,
color: AppColors.onSurface,
fontSize: 10,
fontWeight: FontWeight.bold,
),
@@ -157,13 +158,13 @@ class MobilePosterCard extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Color(0xFF30D158),
color: AppColors.success,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
size: 10,
color: Colors.white,
color: AppColors.onSurface,
),
),
);
+5 -4
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/theme/app_decorations.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileScaffold extends ConsumerStatefulWidget {
final Widget child;
@@ -71,7 +72,7 @@ class _MobileScaffoldState extends ConsumerState<MobileScaffold> {
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(
Icons.play_circle_filled,
color: Colors.white,
color: AppColors.onSurface,
size: 40,
),
),
@@ -201,12 +202,12 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> {
@override
Widget build(BuildContext context) {
const Color selectedColor = Color(0xFF0A84FF);
const Color selectedColor = AppColors.primaryFill;
final Color unselectedColor = AppDecorations.textSecondary(context);
final Color textColor =
widget.isSelected ? Colors.white : unselectedColor;
widget.isSelected ? AppColors.onSurface : unselectedColor;
final Color iconColor =
widget.isSelected ? Colors.white : unselectedColor;
widget.isSelected ? AppColors.onSurface : unselectedColor;
final FontWeight fontWeight =
widget.isSelected ? FontWeight.w700 : FontWeight.w500;
+3 -2
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class TVFocusable extends StatefulWidget {
final Widget child;
@@ -25,7 +26,7 @@ class TVFocusable extends StatefulWidget {
this.autofocus = false,
this.scale = 1.05, // Apple TV subtle lift
this.borderWidth = 2.0,
this.focusColor = Colors.white,
this.focusColor = AppColors.onSurface,
this.borderRadius,
this.longPressDuration = const Duration(seconds: 3),
});
@@ -162,7 +163,7 @@ class _TVFocusableState extends State<TVFocusable> {
offset: const Offset(0, 6),
),
const BoxShadow(
color: Color(0x40FFFFFF), // White glow
color: Color(0x590A84FF), // Apple blue glow
blurRadius: 12,
spreadRadius: 0,
),
+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.6.0+12
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})"
+15
View File
@@ -25,6 +25,21 @@
- [x] `mobile_theme.dart` : dérive désormais de `AppTheme` (échelle typo mobile)
au lieu de dupliquer 390 lignes
- [x] `app_decorations.dart` : hexs Apple codés en dur remplacés par la palette
- [x] **Passe 2 (v1.6.1)** — le thème ne suffisait pas : du bleu restait codé en
dur dans 19 fichiers d'écran/widget, hors palette. Balayage complet :
- `0xFF0A84FF` (bleu Apple) dans la **pilule de navigation sélectionnée**
(`mobile_scaffold`), les icônes de catégorie et le bouton « ajouter »
- `0xFF4FC3F7` / `0xFF1565C0` (bleus) dans les overlays du lecteur
- `Colors.blueAccent` / `Colors.blue` dans les boîtes de dialogue
- gris Apple `1C1C1E` / `2C2C2E` / `3A3A3C` / `8E8E93` → charbons chauds
- `Colors.white*` (128 occurrences) → paliers crème `onSurfaceNN`
- `Colors.red/green/amber/yellow/grey*` → `error` / `success` /
`ratingGold` / `outlineVariant`
- noir pur sur remplissage clair → encre chaude `onPrimaryFixed`
- halo de focus blanc → halo ember
- Conservés en noir vrai : fonds de surface vidéo et voiles (`scrims`)
- Vérification : plus **aucune** couleur Material/Apple codée en dur hors
de `lib/core/theme/`
## 3. Lecture — arrêt en arrière-plan et tenue dans la durée