15 Commits
Author SHA1 Message Date
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
MichaelandClaude Opus 5 8d74bbb320 feat: XMLTV EPG fallback, Warm Cinema theme, background release
Align the Android app with xtremflow on three fronts.

EPG: add XmltvService, a streaming XMLTV parser (gunzip-aware, indexed to a
-3h/+18h window, JSON disk cache with a 6h TTL). XtreamServiceMobile now falls
back through the provider's xmltv.php, a user-supplied URL, then a community
guide whenever get_short_epg returns nothing. Channels are matched on
epg_channel_id first, then on a normalized name (accents, HD/FHD/4K suffixes,
"FR:" and "|FR|" prefixes). A streamId -> Channel index populated by
getLiveChannels supplies those keys, so no getShortEPG call site changed.
EPG requests are also funnelled through a 3-slot semaphore, as in xtremflow:
a 30-tile grid used to fire 30 parallel player_api.php calls, which rate-limited
panels answered with errors, emptying the guide for the whole screen.

Theme: port the Warm Cinema charter (ember/charcoal/cream, Fraunces + Karla)
into app_colors, app_theme and app_decorations. The 21 Apple TV colour names
already in use are kept as compatibility aliases. mobile_theme now derives from
AppTheme instead of duplicating its 390 lines.

Playback lifecycle: stop reacting to AppLifecycleState.inactive, which fires on
transient Android interruptions such as pulling down the notification shade and
was killing playback. Both engines now release on paused/hidden/detached via
_releasePlayback(): stream stopped, decoder released, timers cancelled, wakelock
dropped. An _isReleased latch keeps the watchdog and reconnect paths from
restarting a stream from the background.

Long-run stability: cancel the six player stream subscriptions on dispose, cap
demuxer-max-back-bytes at 8MB (mpv defaults to 50MiB, which combined with the
150MB readahead pushed the native heap toward an Android memory kill after a few
hours), rebuild on position only while the controls are visible, and dispose the
Lite player's focus nodes.

Startup: prewarmHost() pre-resolves DNS and opens then closes a bare TCP/TLS
socket before playback. No HTTP request is issued, so no provider connection
slot is consumed. Capped at 1.5s.

Verified: flutter analyze clean on all touched files; 19/19 tests, including a
run over a real 46MB/69802-programme dump (517 channels indexed in 1.1s, 5/5
name probes matched); flutter build apk --release succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:25:33 +02:00
38 changed files with 3742 additions and 1319 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")
}
}
}
}
+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/logo_xtremflow.png"
# L'App Store et iOS refusent une icône avec canal alpha.
remove_alpha_ios: true
+252 -123
View File
@@ -1,170 +1,299 @@
import 'package:flutter/material.dart';
/// XtremFlow Apple TV Grey Glossy Palette
/// XtremFlow — "Apple Glass Gray" Design System
///
/// Premium Apple TV design language:
/// - Background: Deep dark grey (#1C1C1E) to black (#000000) gradient
/// - Surface cards: Glossy dark grey (#2C2C2E to #3A3A3C) with glass reflections
/// - Primary accent: Bright white (#FFFFFF) and light grey (#EBEBF5)
/// - Secondary accent: Apple Blue (#0A84FF)
/// - Borders: Subtle white 8% opacity for glass effect
/// - Glossy highlight: White 10-15% at top fading to transparent
/// 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. L'effet "glass"
/// est simulé par des gris élevés + liserés blancs à faible opacité.
class AppColors {
AppColors._();
// ============ BASE LEVEL 0 ============
/// Graphite profond — le niveau le plus bas, jamais un noir pur.
static const Color baseLevel0 = Color(0xFF1B1B1F);
// ============ BACKGROUNDS ============
/// Pure black — infinite depth, OLED-friendly
static const Color background = Color(0xFF000000);
static const Color background = Color(0xFF26262C);
static const Color onBackground = Color(0xFFF5F5F7);
/// Apple deep dark grey — primary app background (alias)
static const Color backgroundSecondary = Color(0xFF1C1C1E);
static const Color surface = Color(0xFF26262C);
static const Color surfaceDim = Color(0xFF1F1F24);
static const Color surfaceBright = Color(0xFF54545E);
/// Apple deep dark grey — primary surface
static const Color surface = Color(0xFF1C1C1E);
// Surface Containers (hiérarchie Material 3, toutes opaques)
static const Color surfaceContainerLowest = Color(0xFF1B1B1F);
static const Color surfaceContainerLow = Color(0xFF2C2C33);
static const Color surfaceContainer = Color(0xFF32323A);
static const Color surfaceContainerHigh = Color(0xFF3B3B44);
static const Color surfaceContainerHighest = Color(0xFF45454F);
static const Color surfaceVariant = Color(0xFF32323A);
/// Mid grey — elevated cards, sheets
static const Color surfaceVariant = Color(0xFF2C2C2E);
// ============ SURFACE / ON SURFACE ============
static const Color onSurface = Color(0xFFF5F5F7); // blanc Apple
static const Color onSurfaceVariant = Color(0xFFC7C7CE); // gris clair
/// Light grey surface — tertiary elevation
static const Color surfaceElevated = Color(0xFF3A3A3C);
// ============ 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);
/// Focus/selected element background
static const Color focusColor = Color(0xFFFFFFFF);
/// 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);
// ============ ACCENTS ============
/// Primary: Pure white — focus rings, active labels
static const Color primary = Color(0xFFFFFFFF);
/// 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);
/// Light grey — secondary text / inactive elements
static const Color accent = Color(0xFFEBEBF5);
// ============ 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);
/// Apple Blue — CTAs, links, active indicators
static const Color appleBlue = Color(0xFF0A84FF);
// ============ 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(0xFFFFB4AB);
static const Color onError = Color(0xFF690005);
static const Color errorContainer = Color(0xFF93000A);
static const Color onErrorContainer = Color(0xFFFFDAD6);
// ============ OUTLINE ============
static const Color outline = Color(0xFF8E8E93);
static const Color outlineVariant = Color(0xFF48484E);
// ============ INVERSE SURFACE ============
static const Color inverseSurface = Color(0xFFF2F2F7);
static const Color inverseOnSurface = Color(0xFF2C2C31);
static const Color surfaceTint = Color(0xFF8AB9FF);
// ============ TEXT / CONTENT ============
static const Color textPrimary = Color(0xFFF5F5F7);
static const Color textSecondary = Color(0xFFC7C7CE);
static const Color textTertiary = Color(0xFF98989F);
// ============ BLANC À OPACITÉ ============
// Blanc Apple (#F5F5F7) décliné en opacités pour rester cohérent partout.
static const Color onSurface06 = Color(0x0FF5F5F7);
static const Color onSurface12 = Color(0x1FF5F5F7);
static const Color onSurface24 = Color(0x3DF5F5F7);
static const Color onSurface38 = Color(0x61F5F5F7);
static const Color onSurface54 = Color(0x8AF5F5F7);
static const Color onSurface70 = Color(0xB3F5F5F7);
// ============ STATUS ============
static const Color success = Color(0xFF32D74B); // vert système
static const Color warning = Color(0xFFFFD60A); // jaune système
static const Color info = Color(0xFF64D2FF); // cyan système
// ============ CATÉGORIES ============
static const Color live = Color(0xFFFF453A); // rouge système
static const Color movies = Color(0xFF0A84FF); // bleu système
static const Color series = Color(0xFFBF5AF2); // violet système
// ============ RATING / PREMIUM ============
static const Color ratingGold = Color(0xFFFFD60A);
static const Color premiumGold = Color(0xFFE0B84C);
// ============ SHIMMER (placeholders de chargement) ============
static const Color shimmer = Color(0xFF32323A);
static const Color shimmerHighlight = Color(0xFF45454F);
// ============ BORDERS / FOCUS ============
static const Color border = outlineVariant;
static const Color focusColor = primaryContainer;
static const Color focusBorder = primaryContainer;
static const Color borderFocused = primaryContainer;
/// Liseré blanc 10 % — définit l'arête d'une surface "verre" sans la faire
/// flotter.
static const Color glassBorderColor = Color(0x1AFFFFFF);
// ============ SURFACES "GLASS" — OPAQUES ============
static const Color glassLevel1Bg = Color(0xFF2C2C33);
static const Color glassLevel1Border = Color(0x1AFFFFFF); // blanc 10 %
static const Color glassLevel2Bg = Color(0xFF32323A);
static const Color glassLevel2Border = Color(0x26FFFFFF); // blanc 15 %
static const Color glassLevel2InnerGlow = Color(0x3D0A84FF); // bleu 24 %
static const Color glassBackground = glassLevel2Bg;
static const Color glassBorder = glassLevel2Border;
// ============ GRADIENTS ============
/// Apple TV background: deep dark top → pure black bottom
static const LinearGradient backgroundGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF1C1C1E), // Deep dark grey
Color(0xFF000000), // Pure black
],
stops: [0.0, 1.0],
/// Bleu système : clair vers profond.
static const LinearGradient primaryGradient = LinearGradient(
colors: [Color(0xFF4DA2FF), Color(0xFF0A6FDC)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
/// Legacy alias for backward compatibility
static const RadialGradient appleTvGradient = RadialGradient(
/// Fond "verre dépoli" : gris clair en haut, graphite en bas.
static const LinearGradient backgroundGradient = LinearGradient(
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
/// Halo doux depuis le centre de l'écran.
static const RadialGradient projectorGradient = RadialGradient(
center: Alignment.center,
radius: 1.5,
colors: [
Color(0xFF1A1A1C), // Slightly lighter than pure black
Color(0xFF000000), // Pure black edge
],
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
stops: [0.0, 1.0],
);
/// Glossy card gradient: bright highlight at top fading to dark base
static const LinearGradient glossyCardGradient = LinearGradient(
/// 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(0xFF45454F), Color(0xFF2C2C33)],
stops: [0.0, 1.0],
);
/// Reflet posé sur le haut d'une carte — l'effet "verre".
static const LinearGradient cardHighlight = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF484848), // Lighter top for gloss highlight
Color(0xFF2C2C2E), // Dark mid
Color(0xFF1C1C1E), // Deeper bottom
],
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(0x001F1F24), Color(0xCC1F1F24), Color(0xFF1F1F24)],
stops: [0.0, 0.55, 1.0],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
/// Voile latéral pour les héros pleine largeur.
static const LinearGradient heroScrim = LinearGradient(
colors: [Color(0xF21F1F24), Color(0x991F1F24), Color(0x001F1F24)],
stops: [0.0, 0.45, 1.0],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
);
/// Glossy inner highlight (white shimmer layer, overlaid on top of card)
static const LinearGradient glossyHighlight = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x26FFFFFF), // White ~15% at top
Color(0x00FFFFFF), // Transparent at bottom
],
stops: [0.0, 0.5],
);
// ============ GLOW / OMBRES ============
static Color glowPrimary(double opacity) =>
const Color(0xFF0A84FF).withValues(alpha: opacity);
static Color glowPrimaryDim(double opacity) =>
const Color(0xFF8AB9FF).withValues(alpha: opacity);
/// Card glossy gradient (Apple TV spec alias — dark base)
static const LinearGradient cardGlossyGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF48484A), Color(0xFF2C2C2E)],
);
/// 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.40 * intensity),
blurRadius: 28 * intensity,
spreadRadius: -8,
offset: Offset(0, 12 * intensity),
),
];
/// Card glossy highlight (Apple TV spec alias — top-left shimmer)
static const LinearGradient cardGlossyHighlight = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0x26FFFFFF), Color(0x00FFFFFF)],
stops: [0.0, 0.5],
);
/// 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(0xFF0A84FF).withValues(alpha: 0.45 * intensity),
blurRadius: 32 * intensity,
spreadRadius: -4,
),
BoxShadow(
color: const Color(0xFF000000).withValues(alpha: 0.45),
blurRadius: 24,
spreadRadius: -10,
offset: const Offset(0, 14),
),
];
/// Primary silver gradient (used for buttons/badges)
static const LinearGradient primaryGradient = LinearGradient(
colors: [Color(0xFFFFFFFF), Color(0xFFCCCCCC)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
// ============ 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;
// ============ TEXT ============
/// Pure white — headings, active labels
static const Color textPrimary = Color(0xFFFFFFFF);
/// Accent bleu Apple portant du texte blanc (contraste AA).
static const Color appleBlue = primaryFill;
/// Light grey 60% — secondary info, captions
static const Color textSecondary = Color(0x99EBEBF5); // #EBEBF5 @ 60%
static const RadialGradient appleTvGradient = projectorGradient;
static const LinearGradient glossyCardGradient = cardGradient;
static const LinearGradient glossyHighlight = cardHighlight;
static const LinearGradient cardGlossyGradient = cardGradient;
static const LinearGradient cardGlossyHighlight = cardHighlight;
/// Darker grey — hints, placeholders
static const Color textTertiary = Color(0xFF636366);
// ============ FUNCTIONAL COLORS ============
static const Color success = Color(0xFF30D158); // Apple Green
static const Color warning = Color(0xFFFF9F0A); // Apple Orange
static const Color error = Color(0xFFFF453A); // Apple Red
static const Color live = Color(0xFFFF453A); // Apple Red (live badge)
// ============ BORDERS ============
/// Invisible border — unfocused state
static const Color border = Color(0xFF2C2C2E);
/// Glass border: white at 8% opacity for subtle edge definition
static const Color glassBorderColor = Color(0x14FFFFFF); // 8% white
/// Focus border: pure white ring
static const Color focusBorder = Color(0xFFFFFFFF);
/// Alias: focused border — pure white (Apple TV spec)
static const Color borderFocused = Color(0xFFFFFFFF);
// ============ GLASSMORPHISM ============
static final Color glassBackground = const Color(0xFF1E1E1E).withValues(alpha: 0.75);
static final Color glassBorder = const Color(0xFFFFFFFF).withValues(alpha: 0.08);
// ============ THEME SCHEMES ============
// ============ COLOR SCHEMES ============
static ColorScheme get darkColorScheme => const ColorScheme.dark(
primary: appleBlue,
secondary: accent,
surface: surface,
primary: primary,
onPrimary: onPrimary,
primaryContainer: primaryContainer,
onPrimaryContainer: onPrimaryContainer,
secondary: secondary,
onSecondary: onSecondary,
secondaryContainer: secondaryContainer,
onSecondaryContainer: onSecondaryContainer,
tertiary: tertiary,
onTertiary: onTertiary,
tertiaryContainer: tertiaryContainer,
onTertiaryContainer: onTertiaryContainer,
error: error,
onPrimary: Colors.white,
onSecondary: Colors.black,
onSurface: textPrimary,
onError: Colors.white,
onError: onError,
errorContainer: errorContainer,
onErrorContainer: onErrorContainer,
surface: surface,
onSurface: onSurface,
onSurfaceVariant: onSurfaceVariant,
surfaceContainerLowest: surfaceContainerLowest,
surfaceContainerLow: surfaceContainerLow,
surfaceContainer: surfaceContainer,
surfaceContainerHigh: surfaceContainerHigh,
surfaceContainerHighest: surfaceContainerHighest,
surfaceTint: surfaceTint,
outline: outline,
outlineVariant: outlineVariant,
inverseSurface: inverseSurface,
inversePrimary: inversePrimary,
brightness: Brightness.dark,
);
/// Thème clair : gris système iOS, même famille neutre.
static ColorScheme get lightColorScheme => const ColorScheme.light(
primary: Colors.black,
secondary: Colors.grey,
surface: Color(0xFFF2F2F7),
error: error,
primary: Color(0xFF0066CC),
onPrimary: Colors.white,
primaryContainer: Color(0xFFD6E5FF),
onPrimaryContainer: Color(0xFF002451),
secondary: Color(0xFF54545E),
onSecondary: Colors.white,
onSurface: Colors.black,
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(0xFFF2F2F7),
onSurface: Color(0xFF1C1C1E),
onSurfaceVariant: Color(0xFF48484E),
outline: Color(0xFF8E8E93),
outlineVariant: Color(0xFFD1D1D6),
brightness: Brightness.light,
);
}
+42 -72
View File
@@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
/// Theme-aware decoration helpers.
/// Returns dark (Apple TV glossy) or light (iOS clean white) styles
/// based on current [BuildContext] brightness.
import 'app_colors.dart';
/// Theme-aware decoration helpers — "Warm Cinema" charter.
///
/// Depth comes from warm shadows and contrast, never from `BackdropFilter`:
/// a blur layer repaints every frame and is the first thing to drop frames on
/// low-end Android.
class AppDecorations {
AppDecorations._();
// Force Dark/Glossy mode for IPTV experience regardless of system theme
// Force Dark mode for the IPTV experience regardless of system theme.
static bool _isDark(BuildContext context) => true;
// ─── Backgrounds ────────────────────────────────────────────────────────────
@@ -14,20 +18,13 @@ class AppDecorations {
/// Full-screen gradient background for tab content areas.
static BoxDecoration background(BuildContext context) {
if (_isDark(context)) {
return const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF1C1C1E), Color(0xFF000000)],
stops: [0.0, 1.0],
),
);
return const BoxDecoration(gradient: AppColors.backgroundGradient);
}
return const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFEAEAF0), Color(0xFFD8D8E0)],
colors: [Color(0xFFFBF3EC), Color(0xFFEFE2D6)],
stops: [0.0, 1.0],
),
);
@@ -39,28 +36,20 @@ class AppDecorations {
static BoxDecoration navPill(BuildContext context) {
if (_isDark(context)) {
return BoxDecoration(
color: const Color(0xFF1C1C1E),
color: AppColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(40),
border: Border.all(color: const Color(0x20FFFFFF), width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 20,
spreadRadius: 0,
offset: Offset(0, 6),
),
],
border: Border.all(color: AppColors.glassLevel2Border, width: 1),
boxShadow: AppColors.lift(),
);
}
return BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(40),
border: Border.all(color: const Color(0x1A000000), width: 1),
border: Border.all(color: const Color(0x1A201A16), width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x1A000000),
color: Color(0x1A201A16),
blurRadius: 16,
spreadRadius: 0,
offset: Offset(0, 4),
),
],
@@ -69,59 +58,41 @@ class AppDecorations {
// ─── Cards ───────────────────────────────────────────────────────────────────
/// Glossy category / channel card — primary card style.
/// Category / channel card — primary card style.
static BoxDecoration glossyCard(
BuildContext context, {
double radius = 16,
}) {
if (_isDark(context)) {
return BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF4A4A4E), Color(0xFF1C1C1E)],
stops: [0.0, 1.0],
),
gradient: AppColors.cardGradient,
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: const Color(0x28FFFFFF), width: 1),
boxShadow: const [
BoxShadow(
color: Color(0xCC000000),
blurRadius: 16,
spreadRadius: 2,
offset: Offset(0, 4),
),
],
border: Border.all(color: AppColors.glassLevel2Border, width: 1),
boxShadow: AppColors.lift(intensity: 0.8),
);
}
return BoxDecoration(
color: const Color(0xFFF2F2F7),
color: const Color(0xFFFBF3EC),
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: const Color(0x1A000000), width: 1),
border: Border.all(color: const Color(0x1A201A16), width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
color: Color(0x14201A16),
blurRadius: 8,
spreadRadius: 0,
offset: Offset(0, 2),
),
],
);
}
/// Gloss shimmer overlay — placed on top-left of a card Stack.
/// Warm highlight overlay — placed on top of a card Stack.
static BoxDecoration glossShimmer(
BuildContext context, {
double radius = 16,
}) {
if (_isDark(context)) {
return BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x45FFFFFF), Color(0x00FFFFFF)],
stops: [0.0, 0.5],
),
gradient: AppColors.cardHighlight,
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
);
}
@@ -129,7 +100,7 @@ class AppDecorations {
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x0A000000), Color(0x00000000)],
colors: [Color(0x0A201A16), Color(0x00201A16)],
stops: [0.0, 0.55],
),
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
@@ -139,20 +110,23 @@ class AppDecorations {
/// Smaller channel card base (top area behind the logo).
static BoxDecoration channelCardBase(BuildContext context) {
if (_isDark(context)) {
return BoxDecoration(
gradient: const LinearGradient(
return const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2C2C2E), Color(0xFF1C1C1E)],
colors: [
AppColors.surfaceContainerHigh,
AppColors.surfaceContainerLow,
],
),
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
border: Border(
top: BorderSide(color: Colors.white.withOpacity(0.12), width: 0.5),
top: BorderSide(color: AppColors.glassLevel2Border, width: 0.5),
),
);
}
return const BoxDecoration(
color: Color(0xFFE4E4EC),
color: Color(0xFFEFE2D6),
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
);
}
@@ -163,33 +137,29 @@ class AppDecorations {
static BoxDecoration searchBar(BuildContext context) {
if (_isDark(context)) {
return BoxDecoration(
color: const Color(0xFF2C2C2E),
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x18FFFFFF), width: 1),
border: Border.all(color: AppColors.glassLevel2Border, width: 1),
);
}
return BoxDecoration(
color: const Color(0xFFEEEEF4),
color: const Color(0xFFF3E7DB),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x18000000), width: 1),
border: Border.all(color: const Color(0x18201A16), width: 1),
);
}
// ─── Colours ─────────────────────────────────────────────────────────────────
static Color textPrimary(BuildContext context) =>
_isDark(context) ? Colors.white : const Color(0xFF000000);
_isDark(context) ? AppColors.textPrimary : const Color(0xFF201A16);
static Color textSecondary(BuildContext context) =>
_isDark(context)
? const Color(0x99EBEBF5)
: const Color(0x993C3C43);
_isDark(context) ? AppColors.textSecondary : const Color(0xFF52443B);
static Color iconMuted(BuildContext context) =>
_isDark(context) ? Colors.white38 : Colors.black26;
_isDark(context) ? AppColors.onSurface38 : Colors.black26;
static Color divider(BuildContext context) =>
_isDark(context)
? const Color(0x1FFFFFFF)
: const Color(0x1A000000);
_isDark(context) ? AppColors.onSurface12 : const Color(0x1A201A16);
}
+339 -214
View File
@@ -2,266 +2,393 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
/// XtremFlow Apple TV Theme
/// XtremFlow — "Apple Glass Gray" theme.
///
/// Focus-driven, immersive, minimal.
/// Typography: **Fraunces** (serif éditorial) for display/headline, **Karla**
/// (humaniste chaud) for UI and body — very legible at TV distance and on a
/// phone held at arm's length.
class AppTheme {
AppTheme._();
// ============ SPACING ============
// ============ SPACING (8pt base) ============
static const double spacing2 = 2.0;
static const double spacing4 = 4.0;
static const double spacing8 = 8.0;
static const double spacing12 = 12.0;
static const double spacing16 = 16.0;
static const double spacing20 = 20.0;
static const double spacing24 = 24.0;
static const double spacing32 = 32.0;
static const double spacing48 = 48.0; // Larger spacing for TV feel
static const double spacing48 = 48.0;
// Semantic aliases
static const double spacingXs = spacing4;
static const double spacingSm = spacing8;
static const double spacingMd = spacing16;
static const double spacingLg = spacing24;
static const double spacingXl = spacing32;
// ============ RADIUS ============
// ============ RADIUS ============
static const double radiusSm = 8.0;
static const double radiusMd = 12.0;
static const double radiusLg = 20.0;
static const double radiusXl = 32.0;
static const double radiusNone = 0.0;
static const double radiusXs = 4.0;
static const double radiusSm = 6.0;
static const double radiusMd = 10.0;
static const double radiusLg = 14.0;
static const double radiusXl = 20.0;
static const double radiusFull = 9999.0;
// ============ ANIMATION ============
static const Duration durationFast = Duration(milliseconds: 200);
static const Duration durationNormal = Duration(milliseconds: 300);
static const Curve curveDefault =
Curves.fastOutSlowIn; // Apple-like snappy curve
static const Duration durationXs = Duration(milliseconds: 120);
static const Duration durationFast = Duration(milliseconds: 180);
static const Duration durationNormal = Duration(milliseconds: 260);
static const Duration durationMd = Duration(milliseconds: 260);
static const Duration durationLg = Duration(milliseconds: 380);
static const Duration durationXl = Duration(milliseconds: 520);
// ============ DARK THEME (TV Main) ============
static const Curve curveDefault = Curves.easeOutCubic;
static const Curve curveSmooth = Curves.easeOutCubic;
/// Ouverture de rideau — décélération très longue, "cinéma".
static const Curve curveCinema = Cubic(0.16, 1.0, 0.3, 1.0);
// ============ TYPOGRAPHY HELPERS ============
/// Display serif éditorial — titres, héros, chiffres marquants.
static TextStyle display({
required double fontSize,
required FontWeight fontWeight,
double? letterSpacing,
double? height,
Color color = AppColors.onSurface,
FontStyle? fontStyle,
}) {
return GoogleFonts.fraunces(
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: letterSpacing,
height: height,
color: color,
fontStyle: fontStyle,
);
}
/// Corps / UI — humaniste chaud, très lisible à distance.
static TextStyle body({
required double fontSize,
required FontWeight fontWeight,
double? letterSpacing,
double? height,
Color color = AppColors.onSurface,
}) {
return GoogleFonts.karla(
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: letterSpacing,
height: height,
color: color,
);
}
/// Micro-label capitales espacées — « GÉNÉRIQUE DE FILM ».
static TextStyle eyebrow({
double fontSize = 11,
Color color = AppColors.textTertiary,
FontWeight fontWeight = FontWeight.w700,
}) {
return GoogleFonts.karla(
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: 1.6,
height: 1.1,
color: color,
);
}
static TextTheme _textTheme({
required Color primary,
required Color secondary,
}) {
return TextTheme(
displayLarge: display(
fontSize: 44,
fontWeight: FontWeight.w700,
letterSpacing: -1.2,
height: 1.03,
color: primary,
),
displayMedium: display(
fontSize: 34,
fontWeight: FontWeight.w600,
letterSpacing: -0.8,
height: 1.08,
color: primary,
),
displaySmall: display(
fontSize: 26,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
height: 1.15,
color: primary,
),
headlineLarge: display(
fontSize: 28,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
height: 1.12,
color: primary,
),
headlineMedium: display(
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
height: 1.2,
color: primary,
),
headlineSmall: display(
fontSize: 19,
fontWeight: FontWeight.w600,
height: 1.25,
color: primary,
),
titleLarge: body(
fontSize: 18,
fontWeight: FontWeight.w700,
height: 1.3,
color: primary,
),
titleMedium: body(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.35,
color: primary,
),
titleSmall: body(
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.35,
color: primary,
),
bodyLarge: body(
fontSize: 16,
fontWeight: FontWeight.w400,
height: 1.5,
color: secondary,
),
bodyMedium: body(
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.5,
color: secondary,
),
bodySmall: body(
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.45,
color: secondary,
),
labelLarge: body(
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: primary,
),
labelMedium: body(
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
color: primary,
),
);
}
// ============ DARK THEME (principal) ============
static ThemeData get darkTheme {
final baseTheme = ThemeData.dark(useMaterial3: true);
return baseTheme.copyWith(
colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background,
canvasColor: AppColors.background,
splashColor: AppColors.primaryContainer.withValues(alpha: 0.12),
highlightColor: AppColors.primaryContainer.withValues(alpha: 0.08),
textTheme: _textTheme(
primary: AppColors.textPrimary,
secondary: AppColors.textSecondary,
),
// Global Text Selection Theme (Fixes black cursor issues)
textSelectionTheme: const TextSelectionThemeData(
cursorColor: Colors.white,
selectionColor: Color(0x4DFFFFFF), // 30% White
selectionHandleColor: Colors.white,
cursorColor: AppColors.primary,
selectionColor: Color(0x590A84FF),
selectionHandleColor: AppColors.primaryContainer,
),
// Typography: San Francisco style (using Inter as proxy)
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(
fontSize: 56,
fontWeight: FontWeight.w700,
letterSpacing: -1.0,
color: AppColors.textPrimary,
),
displayMedium: GoogleFonts.inter(
fontSize: 48,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: AppColors.textPrimary,
),
displaySmall: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
headlineMedium: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleLarge: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
bodyLarge: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
bodyMedium: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
labelLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
// AppBar: Transparent / Glass
// AppBar: transparente — le dégradé de fond passe au travers.
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
foregroundColor: AppColors.textPrimary,
elevation: 0,
centerTitle: true,
scrolledUnderElevation: 0,
titleTextStyle: GoogleFonts.inter(
fontSize: 17,
titleTextStyle: display(
fontSize: 19,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
// Inputs: Dark, minimal, no borders unless focused
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: AppColors.surfaceContainerLow,
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textTertiary,
selectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w700),
unselectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w500),
type: BottomNavigationBarType.fixed,
elevation: 0,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
fillColor: AppColors.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing24,
horizontal: spacing16,
vertical: spacing16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none,
borderSide: const BorderSide(color: AppColors.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none,
borderSide: const BorderSide(color: AppColors.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.focusColor, width: 2),
borderSide: const BorderSide(
color: AppColors.primaryContainer,
width: 2,
),
),
hintStyle: body(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textTertiary,
),
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
),
// Buttons: White pill or minimal text
// Boutons : remplissage braise, texte crème (contraste AA).
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.focusColor, // White buttons
foregroundColor: Colors.black, // Black text
backgroundColor: AppColors.primaryFill,
foregroundColor: AppColors.onSurface,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
padding: const EdgeInsets.symmetric(
horizontal: spacing24,
vertical: spacing16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
textStyle:
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
).copyWith(
overlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.hovered)) {
return Colors.black.withOpacity(0.1);
}
if (states.contains(WidgetState.pressed)) {
return Colors.black.withOpacity(0.2);
}
return null;
}),
textStyle: body(fontSize: 15, fontWeight: FontWeight.w700),
),
),
// Cards: Transparent by default (content defines look)
// cardTheme: const CardTheme(
// color: AppColors.surface,
// elevation: 0,
// margin: EdgeInsets.zero,
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(radiusMd))),
// ),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.primary,
textStyle: body(fontSize: 14, fontWeight: FontWeight.w700),
),
),
// Dialogs: Glass/Dark
// dialogTheme: DialogTheme(
// backgroundColor: const Color(0xFF1C1C1E), // Apple Dark Grey
// elevation: 24,
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusLg)),
// titleTextStyle: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary),
// contentTextStyle: GoogleFonts.inter(fontSize: 16, color: AppColors.textSecondary),
// ),
cardTheme: CardThemeData(
color: AppColors.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
side: const BorderSide(color: AppColors.glassLevel2Border),
),
),
dialogTheme: DialogThemeData(
backgroundColor: AppColors.surfaceContainerHigh,
elevation: 24,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusXl),
side: const BorderSide(color: AppColors.glassLevel2Border),
),
titleTextStyle: display(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
contentTextStyle: body(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
),
sliderTheme: const SliderThemeData(
activeTrackColor: AppColors.primaryContainer,
inactiveTrackColor: AppColors.onSurface24,
thumbColor: AppColors.primary,
overlayColor: Color(0x290A84FF),
),
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.primaryContainer,
linearTrackColor: AppColors.onSurface12,
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? AppColors.primary
: AppColors.textTertiary,
),
trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected)
? AppColors.primaryFill
: AppColors.surfaceContainerHigh,
),
),
dividerTheme: const DividerThemeData(
color: AppColors.onSurface12,
thickness: 1,
),
);
}
static const double radiusFull = 999.0;
// ============ LIGHT THEME (Apple Clean White) ============
// ============ LIGHT THEME (gris système clair) ============
static ThemeData get lightTheme {
final baseTheme = ThemeData.light(useMaterial3: true);
// Apple light palette
const bgColor = Color(0xFFF2F2F7); // Apple system grey6
const surfaceColor = Color(0xFFFFFFFF); // Pure white surfaces
const primaryColor = Color(0xFF007AFF); // Apple Blue (light)
const textPrimaryColor = Color(0xFF000000);
const textSecondaryColor = Color(0x993C3C43); // #3C3C43 @ 60%
const textTertiaryColor = Color(0xFFC7C7CC);
const borderColor = Color(0x1A000000); // #000000 @ 10%
const bgColor = Color(0xFFF2F2F7);
const surfaceColor = Color(0xFFFFFFFF);
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.copyWith(
primary: primaryColor,
secondary: const Color(0xFF34C759), // Apple green
surface: surfaceColor,
onPrimary: Colors.white,
onSurface: textPrimaryColor,
brightness: Brightness.light,
),
colorScheme: AppColors.lightColorScheme,
scaffoldBackgroundColor: bgColor,
canvasColor: bgColor,
textTheme: _textTheme(
primary: textPrimaryColor,
secondary: textSecondaryColor,
),
// Text selection — blue cursor on light
textSelectionTheme: const TextSelectionThemeData(
cursorColor: primaryColor,
selectionColor: Color(0x4D007AFF),
selectionColor: Color(0x4D0066CC),
selectionHandleColor: primaryColor,
),
// Typography
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(
fontSize: 56,
fontWeight: FontWeight.w700,
letterSpacing: -1.0,
color: textPrimaryColor,
),
displayMedium: GoogleFonts.inter(
fontSize: 48,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: textPrimaryColor,
),
displaySmall: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
headlineMedium: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
titleLarge: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
bodyLarge: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w400,
color: textSecondaryColor,
),
bodyMedium: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: textSecondaryColor,
),
labelLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
),
// AppBar: White with subtle bottom shadow
appBarTheme: AppBarTheme(
backgroundColor: surfaceColor,
foregroundColor: textPrimaryColor,
@@ -269,99 +396,97 @@ class AppTheme {
scrolledUnderElevation: 1,
shadowColor: borderColor,
centerTitle: true,
titleTextStyle: GoogleFonts.inter(
fontSize: 17,
titleTextStyle: display(
fontSize: 19,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
),
// Bottom Navigation Bar: white, Apple Blue selected
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: surfaceColor,
selectedItemColor: primaryColor,
unselectedItemColor: textSecondaryColor,
selectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w500,
),
selectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w700),
unselectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w500),
type: BottomNavigationBarType.fixed,
elevation: 8,
),
// Inputs: white fill, subtle border
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceColor,
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing24,
horizontal: spacing16,
vertical: spacing16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: borderColor, width: 1),
borderSide: const BorderSide(color: borderColor),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: borderColor, width: 1),
borderSide: const BorderSide(color: borderColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
hintStyle: GoogleFonts.inter(color: textTertiaryColor),
hintStyle: body(
fontSize: 14,
fontWeight: FontWeight.w400,
color: textTertiaryColor,
),
),
// Buttons: Apple Blue pill
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
padding: const EdgeInsets.symmetric(
horizontal: spacing24,
vertical: spacing16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
textStyle:
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
textStyle: body(fontSize: 15, fontWeight: FontWeight.w700),
),
),
// Cards: white with subtle shadow
cardTheme: const CardThemeData(
cardTheme: CardThemeData(
color: surfaceColor,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(radiusMd)),
side: BorderSide(color: borderColor, width: 1),
borderRadius: BorderRadius.circular(radiusLg),
side: const BorderSide(color: borderColor),
),
shadowColor: borderColor,
),
// Dialogs: white, clean
dialogTheme: DialogThemeData(
backgroundColor: surfaceColor,
elevation: 8,
shadowColor: borderColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
side: const BorderSide(color: borderColor, width: 1),
borderRadius: BorderRadius.circular(radiusXl),
side: const BorderSide(color: borderColor),
),
titleTextStyle: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.bold,
titleTextStyle: display(
fontSize: 20,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
contentTextStyle: GoogleFonts.inter(
contentTextStyle: body(
fontSize: 14,
fontWeight: FontWeight.w400,
color: textSecondaryColor,
),
),
dividerTheme: const DividerThemeData(color: borderColor, thickness: 1),
);
}
}
+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,
),
),
+376 -22
View File
@@ -72,10 +72,24 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
Timer? _controlsTimer;
int _loadId = 0;
/// Set once the app is backgrounded, so a queued reload cannot restart the
/// stream behind the user.
bool _isReleased = false;
// Focus
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() {
@@ -98,17 +112,47 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (_controller == null) return;
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
_controller?.pause();
if (mounted) {
Navigator.of(context).pop();
}
// `inactive` is deliberately NOT handled: on Android it fires for transient
// interruptions (notification shade, call banner, permission dialog).
final isBackgrounded = state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden ||
state == AppLifecycleState.detached;
if (!isBackgrounded) return;
_releasePlayback();
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
Navigator.of(context).pop();
}
}
/// Stop playback and release the decoder when the app leaves the foreground.
///
/// `pause()` alone is not enough: ExoPlayer keeps filling its buffer, so the
/// stream would still be pulled (and metered) in the background.
void _releasePlayback() {
if (_isReleased) return;
_isReleased = true;
_clockTimer?.cancel();
_clockTimer = null;
_epgTimer?.cancel();
_epgTimer = null;
_controlsTimer?.cancel();
_controlsTimer = null;
_stabilizationTimer?.cancel();
_stabilizationTimer = null;
_channelListTimer?.cancel();
_channelListTimer = null;
final controller = _controller;
_controller = null;
controller?.pause();
controller?.dispose();
WakelockPlus.disable();
}
void _startClock() {
_clockTimer?.cancel();
_updateTime();
@@ -132,6 +176,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
final dir = await getApplicationDocumentsDirectory();
_xtreamService = XtreamServiceMobile(dir.path);
await _xtreamService!.setPlaylistAsync(widget.playlist);
// Warm DNS + TCP/TLS to the panel host before the first request.
await _xtreamService!.prewarmHost().timeout(
const Duration(milliseconds: 1500),
onTimeout: () {},
);
_loadStream();
if (widget.streamType == model.StreamType.live) {
@@ -160,6 +211,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
Future<void> _loadStream() async {
if (_xtreamService == null) return;
// Backgrounded while a (re)load was queued — do not resurrect the stream.
if (_isReleased || !mounted) return;
await _controller?.dispose();
_controller = null;
@@ -298,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);
@@ -356,9 +489,18 @@ 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();
_playPauseFocusNode.dispose();
_prevFocusNode.dispose();
_nextFocusNode.dispose();
_channelListButtonFocusNode.dispose();
_channelListItemFocusNode.dispose();
super.dispose();
}
@@ -370,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
@@ -407,7 +566,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: const Icon(
Icons.arrow_back,
color: Colors.white,
color: AppColors.onSurface,
size: 28,
),
),
@@ -426,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,
),
@@ -449,7 +608,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
style: const TextStyle(color: AppColors.error),
),
),
),
@@ -462,6 +621,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
// OSD (EPG + Controls)
if (_showControls) _buildOSD(title),
// [TiviMate] Channel list sidebar
if (_showChannelList &&
widget.streamType == model.StreamType.live &&
widget.channels != null &&
widget.channels!.isNotEmpty)
_buildChannelListSidebar(),
],
),
),
@@ -508,7 +674,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
decoration: BoxDecoration(
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(
@@ -533,7 +699,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
child: const Icon(
Icons.skip_previous,
color: Colors.white,
color: AppColors.onSurface,
size: 36,
),
),
@@ -545,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,
),
),
@@ -559,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(
@@ -596,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,
),
),
@@ -638,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,
),
@@ -664,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,
),
@@ -672,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,
),
@@ -712,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;
@@ -727,4 +907,178 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
),
);
}
/// [TiviMate] Channel list sidebar — slide-in panel on the right
Widget _buildChannelListSidebar() {
final channels = widget.channels!;
return Positioned(
top: 0,
bottom: 0,
right: 0,
child: Container(
width: 280,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerRight,
end: Alignment.centerLeft,
colors: [Color(0xE6000000), Color(0x99000000)],
),
),
child: SafeArea(
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.onSurface12),
),
),
child: Row(
children: [
const Icon(Icons.list,
color: AppColors.onSurface70, size: 18),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Chaînes',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
GestureDetector(
onTap: () {
_channelListTimer?.cancel();
setState(() => _showChannelList = false);
},
child: const Icon(Icons.close,
color: AppColors.onSurface54, size: 18),
),
],
),
),
// Channel list
Expanded(
child: NotificationListener<ScrollNotification>(
onNotification: (_) {
_restartChannelListTimer();
return false;
},
child: ListView.builder(
controller: _channelListScrollController,
itemCount: channels.length,
itemExtent: 64.0,
itemBuilder: (context, index) {
final ch = channels[index];
final isActive = index == _currentIndex;
return TVFocusable(
// Le focusNode externe n'est posé que sur la chaîne
// active ; la clé force un remontage quand isActive
// change (TVFocusable ne gère pas didUpdateWidget).
key: ValueKey('sidebar-ch-$index-$isActive'),
focusNode: isActive ? _channelListItemFocusNode : null,
scale: 1.0,
borderRadius: BorderRadius.zero,
onFocus: _restartChannelListTimer,
onPressed: () {
_channelListTimer?.cancel();
_switchChannel(index);
},
child: Container(
decoration: BoxDecoration(
color: isActive
? AppColors.primaryFill.withValues(alpha: 0.6)
: Colors.transparent,
border: isActive
? const Border(
left: BorderSide(
color: AppColors.primary,
width: 3,
),
)
: null,
),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
child: Row(
children: [
// Logo
Container(
width: 40,
height: 40,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(6),
),
child: ch.streamIcon.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: CachedNetworkImage(
imageUrl: ch.streamIcon,
fit: BoxFit.contain,
errorWidget: (_, __, ___) =>
const Icon(Icons.tv,
color: AppColors.onSurface38,
size: 20),
),
)
: const Icon(Icons.tv,
color: AppColors.onSurface38, size: 20),
),
// Name + num
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
ch.name,
style: TextStyle(
color: isActive
? AppColors.onSurface
: AppColors.onSurface70,
fontSize: 13,
fontWeight: isActive
? FontWeight.bold
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'CH ${index + 1}',
style: const TextStyle(
color: AppColors.onSurface38,
fontSize: 11,
),
),
],
),
),
// Active indicator
if (isActive)
const Icon(
Icons.play_arrow,
color: AppColors.primary,
size: 18,
),
],
),
),
);
},
),
),
),
],
),
),
),
);
}
}
@@ -236,7 +236,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
? 'Playlist modifiée'
: 'Playlist 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,
),
],
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,628 @@
/// XMLTV EPG fallback service
///
/// Used when the IPTV provider's `get_short_epg` returns nothing for a channel.
/// Resolution order is decided by the caller (see [XtremServiceMobile]); this
/// service only knows how to fetch, parse, index and cache one XMLTV source.
///
/// Design constraints:
/// - XMLTV dumps are routinely 50-300 MB uncompressed. Parsing is done as a
/// **streaming** pass (`toXmlEvents`) so the whole document is never held in
/// memory, and only programmes inside a short time window are kept.
/// - The reduced index is persisted to disk, so a cold start costs one small
/// JSON read instead of a multi-megabyte download.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:xml/xml.dart';
import 'package:xml/xml_events.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
/// One XMLTV source to try, in priority order.
class XmltvSource {
/// Human-readable origin, surfaced in settings/diagnostics.
final String label;
final String url;
final Map<String, String>? headers;
const XmltvSource({
required this.label,
required this.url,
this.headers,
});
}
/// A single programme kept in the in-memory index.
class XmltvProgramme {
final DateTime start;
final DateTime end;
final String title;
final String description;
const XmltvProgramme({
required this.start,
required this.end,
required this.title,
this.description = '',
});
bool covers(DateTime at) => !at.isBefore(start) && at.isBefore(end);
List<dynamic> toCache() => [
start.toIso8601String(),
end.toIso8601String(),
title,
description,
];
static XmltvProgramme? fromCache(dynamic raw) {
if (raw is! List || raw.length < 4) return null;
final start = DateTime.tryParse(raw[0].toString());
final end = DateTime.tryParse(raw[1].toString());
if (start == null || end == null) return null;
return XmltvProgramme(
start: start,
end: end,
title: raw[2].toString(),
description: raw[3].toString(),
);
}
}
class XmltvService {
XmltvService(this.cacheDir);
final String cacheDir;
/// How long a downloaded+indexed guide stays usable before a refetch.
static const Duration cacheTtl = Duration(hours: 6);
/// Only programmes overlapping [now - back, now + forward] are indexed.
/// Keeps a national guide down to a few hundred KB instead of hundreds of MB.
static const Duration windowBack = Duration(hours: 3);
static const Duration windowForward = Duration(hours: 18);
/// Hard ceiling on the download, so a hostile or broken URL can't fill
/// the device storage or spin forever.
static const int maxDownloadBytes = 220 * 1024 * 1024;
final Map<String, List<XmltvProgramme>> _byChannelId = {};
final Map<String, String> _normalizedNameToChannelId = {};
String? _loadedSourceLabel;
DateTime? _loadedAt;
Future<bool>? _inFlight;
/// True once at least one source has been indexed successfully.
bool get isLoaded => _byChannelId.isNotEmpty;
/// Label of the source currently backing the index (`null` if none).
String? get loadedSourceLabel => _loadedSourceLabel;
int get channelCount => _byChannelId.length;
bool get _isStale {
final at = _loadedAt;
if (at == null) return true;
return DateTime.now().difference(at) > cacheTtl;
}
/// Load the first source that yields a usable guide.
///
/// Concurrent callers share a single in-flight attempt. Returns `false` when
/// every source failed — callers should then simply render "no EPG".
Future<bool> ensureLoaded(List<XmltvSource> sources) {
if (isLoaded && !_isStale) return Future.value(true);
return _inFlight ??= _loadFirstAvailable(sources).whenComplete(() {
_inFlight = null;
});
}
Future<bool> _loadFirstAvailable(List<XmltvSource> sources) async {
for (final source in sources) {
if (source.url.trim().isEmpty) continue;
try {
if (await _loadFromDiskCache(source)) {
_loadedSourceLabel = source.label;
if (kDebugMode) {
print(
'✅ [XMLTV] Disk cache hit "${source.label}" '
'($channelCount channels)',
);
}
return true;
}
if (await _download(source)) {
_loadedSourceLabel = source.label;
_loadedAt = DateTime.now();
unawaited(_writeDiskCache(source));
if (kDebugMode) {
print(
'✅ [XMLTV] Indexed "${source.label}" '
'($channelCount channels)',
);
}
return true;
}
if (kDebugMode) {
print('⚠️ [XMLTV] "${source.label}" returned no usable programme');
}
} catch (e) {
if (kDebugMode) print('⚠️ [XMLTV] "${source.label}" failed: $e');
}
}
return false;
}
// ─── Lookup ────────────────────────────────────────────────────────────────
/// Find the programme airing at [at] for a channel.
///
/// Matching is tried on the Xtream `epg_channel_id` first (the reliable key),
/// then on a normalized channel name, which is what rescues providers that
/// ship empty or non-standard `epg_channel_id` values.
XmltvProgramme? programmeFor({
String? epgChannelId,
String? channelName,
DateTime? at,
}) {
final moment = at ?? DateTime.now();
final programmes = _programmesFor(
epgChannelId: epgChannelId,
channelName: channelName,
);
if (programmes == null || programmes.isEmpty) return null;
for (final programme in programmes) {
if (programme.covers(moment)) return programme;
}
// Nothing airing right now (guide gap): fall back to the next entry so the
// UI shows something meaningful rather than an empty slot.
for (final programme in programmes) {
if (programme.start.isAfter(moment)) return programme;
}
return null;
}
/// Same lookup, shaped as the app's [model.ShortEPG].
model.ShortEPG? shortEpgFor({
required String streamId,
String? epgChannelId,
String? channelName,
DateTime? at,
}) {
final programme = programmeFor(
epgChannelId: epgChannelId,
channelName: channelName,
at: at,
);
if (programme == null || programme.title.isEmpty) return null;
return model.ShortEPG(
id: streamId,
title: programme.title,
start: _formatLocal(programme.start),
end: _formatLocal(programme.end),
description: programme.description,
);
}
List<XmltvProgramme>? _programmesFor({
String? epgChannelId,
String? channelName,
}) {
final id = epgChannelId?.trim().toLowerCase();
if (id != null && id.isNotEmpty) {
final direct = _byChannelId[id];
if (direct != null && direct.isNotEmpty) return direct;
}
final normalized = normalizeName(channelName ?? '');
if (normalized.isEmpty) return null;
final mapped = _normalizedNameToChannelId[normalized];
if (mapped == null) return null;
return _byChannelId[mapped];
}
void clear() {
_byChannelId.clear();
_normalizedNameToChannelId.clear();
_loadedSourceLabel = null;
_loadedAt = null;
}
// ─── Download & streaming parse ────────────────────────────────────────────
Future<bool> _download(XmltvSource source) async {
final uri = Uri.tryParse(source.url.trim());
if (uri == null || !uri.hasScheme) return false;
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 20)
// `.xml.gz` bodies arrive as a gzip *payload*, not a gzip
// Content-Encoding, so decompression is handled explicitly below.
..autoUncompress = false;
try {
final request = await client.getUrl(uri);
request.headers.set(HttpHeaders.acceptEncodingHeader, 'gzip');
request.headers.set(HttpHeaders.userAgentHeader, 'XtremFlow/1.0');
source.headers?.forEach(request.headers.set);
final response = await request.close().timeout(
const Duration(seconds: 30),
);
if (response.statusCode != 200) {
if (kDebugMode) {
print('⚠️ [XMLTV] HTTP ${response.statusCode} on ${source.label}');
}
return false;
}
return indexFromBytes(_capped(response));
} on TimeoutException {
if (kDebugMode) print('⏱️ [XMLTV] Timeout on ${source.label}');
return false;
} finally {
client.close(force: true);
}
}
/// Parse and index an XMLTV payload from a byte stream.
///
/// Split out of [_download] so the parsing path is testable without HTTP.
/// Gzip is detected from the payload itself, and only programmes overlapping
/// the current window are retained.
@visibleForTesting
Future<bool> indexFromBytes(
Stream<List<int>> bytes, {
DateTime? now,
}) async {
final window = _Window.around(now ?? DateTime.now());
final channelNames = <String, List<String>>{};
final programmes = <String, List<XmltvProgramme>>{};
final events = _decompressed(bytes)
.transform(const Utf8Decoder(allowMalformed: true))
.toXmlEvents()
.normalizeEvents()
.selectSubtreeEvents(
(event) => event.name == 'channel' || event.name == 'programme',
)
.toXmlNodes()
.flatten();
await for (final node in events) {
if (node is! XmlElement) continue;
if (node.name.local == 'channel') {
_indexChannelElement(node, channelNames);
} else {
_indexProgrammeElement(node, window, programmes);
}
}
if (programmes.isEmpty) return false;
_commit(programmes: programmes, channelNames: channelNames);
_loadedAt = DateTime.now();
return true;
}
void _indexChannelElement(
XmlElement node,
Map<String, List<String>> channelNames,
) {
final id = node.getAttribute('id')?.trim().toLowerCase();
if (id == null || id.isEmpty) return;
final names = node
.findElements('display-name')
.map((e) => e.innerText.trim())
.where((e) => e.isNotEmpty)
.toList();
if (names.isEmpty) return;
channelNames.putIfAbsent(id, () => []).addAll(names);
}
void _indexProgrammeElement(
XmlElement node,
_Window window,
Map<String, List<XmltvProgramme>> programmes,
) {
final channel = node.getAttribute('channel')?.trim().toLowerCase();
if (channel == null || channel.isEmpty) return;
final start = parseXmltvTime(node.getAttribute('start'));
final end = parseXmltvTime(node.getAttribute('stop'));
if (start == null || end == null) return;
if (!window.overlaps(start, end)) return;
final title = node
.findElements('title')
.map((e) => e.innerText.trim())
.firstWhere((e) => e.isNotEmpty, orElse: () => '')
.trim();
if (title.isEmpty) return;
final description = node
.findElements('desc')
.map((e) => e.innerText.trim())
.firstWhere((e) => e.isNotEmpty, orElse: () => '');
programmes.putIfAbsent(channel, () => []).add(
XmltvProgramme(
start: start,
end: end,
title: title,
description: description,
),
);
}
void _commit({
required Map<String, List<XmltvProgramme>> programmes,
required Map<String, List<String>> channelNames,
}) {
_byChannelId
..clear()
..addAll(programmes);
for (final list in _byChannelId.values) {
list.sort((a, b) => a.start.compareTo(b.start));
}
_normalizedNameToChannelId.clear();
// Channel ids are themselves readable labels in most community dumps
// (`France.2..fr`, `Trace.Toca.fr`), so they are indexed as name
// candidates too — this rescues channels that carry no <display-name>.
for (final id in _byChannelId.keys) {
final fromId = normalizeName(_labelFromChannelId(id));
if (fromId.isNotEmpty) {
_normalizedNameToChannelId.putIfAbsent(fromId, () => id);
}
}
channelNames.forEach((id, names) {
if (!_byChannelId.containsKey(id)) return;
for (final name in names) {
final normalized = normalizeName(name);
if (normalized.isEmpty) continue;
_normalizedNameToChannelId[normalized] = id;
}
});
}
/// Enforce [maxDownloadBytes] mid-stream.
Stream<List<int>> _capped(Stream<List<int>> input) async* {
var total = 0;
await for (final chunk in input) {
total += chunk.length;
if (total > maxDownloadBytes) {
throw StateError('XMLTV payload exceeds ${maxDownloadBytes ~/ 1048576}MB');
}
yield chunk;
}
}
/// Transparently gunzip when the payload starts with the gzip magic number.
Stream<List<int>> _decompressed(Stream<List<int>> input) async* {
final iterator = StreamIterator(input);
try {
if (!await iterator.moveNext()) return;
var head = iterator.current;
while (head.length < 2 && await iterator.moveNext()) {
head = [...head, ...iterator.current];
}
Stream<List<int>> replayed() async* {
yield head;
while (await iterator.moveNext()) {
yield iterator.current;
}
}
final isGzip = head.length >= 2 && head[0] == 0x1f && head[1] == 0x8b;
yield* isGzip ? gzip.decoder.bind(replayed()) : replayed();
} finally {
await iterator.cancel();
}
}
// ─── Disk cache ────────────────────────────────────────────────────────────
File _cacheFile(XmltvSource source) {
final key = source.url.hashCode.toUnsigned(32).toRadixString(16);
return File('$cacheDir${Platform.pathSeparator}xmltv_$key.json');
}
Future<bool> _loadFromDiskCache(XmltvSource source) async {
final file = _cacheFile(source);
if (!await file.exists()) return false;
final fetchedAt = await file.lastModified();
if (DateTime.now().difference(fetchedAt) > cacheTtl) return false;
try {
final decoded = json.decode(await file.readAsString());
if (decoded is! Map) return false;
final rawChannels = decoded['channels'];
if (rawChannels is! Map) return false;
final programmes = <String, List<XmltvProgramme>>{};
rawChannels.forEach((id, entries) {
if (entries is! List) return;
final parsed = entries
.map(XmltvProgramme.fromCache)
.whereType<XmltvProgramme>()
.toList();
if (parsed.isNotEmpty) programmes[id.toString()] = parsed;
});
if (programmes.isEmpty) return false;
final rawNames = decoded['names'];
final channelNames = <String, List<String>>{};
if (rawNames is Map) {
rawNames.forEach((normalized, id) {
channelNames
.putIfAbsent(id.toString(), () => [])
.add(normalized.toString());
});
}
_commit(programmes: programmes, channelNames: channelNames);
_loadedAt = fetchedAt;
return true;
} catch (e) {
if (kDebugMode) print('⚠️ [XMLTV] Corrupt disk cache, ignoring: $e');
return false;
}
}
Future<void> _writeDiskCache(XmltvSource source) async {
try {
final payload = {
'source': source.label,
'url': source.url,
'fetchedAt': DateTime.now().toIso8601String(),
'channels': _byChannelId.map(
(id, list) => MapEntry(id, list.map((p) => p.toCache()).toList()),
),
'names': _normalizedNameToChannelId,
};
await _cacheFile(source).writeAsString(json.encode(payload), flush: true);
} catch (e) {
if (kDebugMode) print('⚠️ [XMLTV] Could not persist cache: $e');
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────
/// Parse an XMLTV timestamp: `20260811183000 +0200` (offset optional).
static DateTime? parseXmltvTime(String? raw) {
final value = raw?.trim();
if (value == null || value.length < 14) return null;
int? part(int start, int end) => int.tryParse(value.substring(start, end));
final year = part(0, 4);
final month = part(4, 6);
final day = part(6, 8);
final hour = part(8, 10);
final minute = part(10, 12);
final second = part(12, 14);
if (year == null ||
month == null ||
day == null ||
hour == null ||
minute == null ||
second == null) {
return null;
}
final utc = DateTime.utc(year, month, day, hour, minute, second);
final offset = value.substring(14).trim();
if (offset.isEmpty) return utc.toLocal();
final match = RegExp(r'^([+-])(\d{2})(\d{2})$').firstMatch(offset);
if (match == null) return utc.toLocal();
final sign = match.group(1) == '-' ? -1 : 1;
final offsetDuration = Duration(
hours: int.parse(match.group(2)!),
minutes: int.parse(match.group(3)!),
);
return utc.subtract(offsetDuration * sign).toLocal();
}
/// Turn an XMLTV channel id into the label it encodes.
///
/// `France.2..fr` → `France 2`, `Trace.Toca.fr` → `Trace Toca`.
static String _labelFromChannelId(String id) {
var value = id.replaceAll(
RegExp(
r'\.(fr|uk|us|de|es|it|be|ch|ca|nl|pt|pl|ar|br|tr|ma|dz|tn)$',
caseSensitive: false,
),
'',
);
return value.replaceAll('.', ' ').trim();
}
/// Reduce a channel label to a comparable key.
///
/// Strips accents, quality suffixes (`HD`, `FHD`, `4K`, …) and any
/// punctuation, so `TF1 HD`, `tf1-fhd` and `TF1` all collapse to `tf1`.
static String normalizeName(String raw) {
var value = raw.toLowerCase().trim();
if (value.isEmpty) return '';
const accents = 'àáâãäåçèéêëìíîïñòóôõöùúûüýÿ';
const plain = 'aaaaaaceeeeiiiinooooouuuuyy';
final buffer = StringBuffer();
for (final rune in value.runes) {
final char = String.fromCharCode(rune);
final index = accents.indexOf(char);
buffer.write(index >= 0 ? plain[index] : char);
}
value = buffer.toString();
// Drop the country prefix providers prepend.
// Bracketed form — "|FR| TF1", "[FR] TF1", "(FR) TF1".
value = value.replaceFirst(
RegExp(r'^\s*[\|\[\(][a-z]{2,4}[\|\]\)]\s*'),
'',
);
// Bare form — "FR: TF1". A colon is required: accepting a dash too would
// eat legitimate names like "Ciné - Premier".
value = value.replaceFirst(RegExp(r'^\s*[a-z]{2,3}\s*:\s*'), '');
value = value.replaceAll(RegExp(r'[^a-z0-9+]'), '');
for (final suffix in const [
'fullhd',
'ultrahd',
'uhd',
'fhd',
'hdr',
'4k',
'1080p',
'720p',
'hd',
'sd',
]) {
if (value.length > suffix.length && value.endsWith(suffix)) {
value = value.substring(0, value.length - suffix.length);
break;
}
}
return value;
}
static String _formatLocal(DateTime value) {
String two(int n) => n.toString().padLeft(2, '0');
return '${value.year}-${two(value.month)}-${two(value.day)} '
'${two(value.hour)}:${two(value.minute)}:${two(value.second)}';
}
}
class _Window {
final DateTime from;
final DateTime to;
const _Window(this.from, this.to);
factory _Window.around(DateTime now) => _Window(
now.subtract(XmltvService.windowBack),
now.add(XmltvService.windowForward),
);
bool overlaps(DateTime start, DateTime end) =>
end.isAfter(from) && start.isBefore(to);
}
@@ -7,8 +7,38 @@ library;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'dart:async';
import 'dart:io';
import '../models/xtream_models.dart' as model;
import 'package:xtremobile/core/api/dns_resolver.dart';
import 'package:xtremobile/core/models/playlist_config.dart';
import 'xmltv_service.dart';
/// Counting semaphore used to cap concurrent EPG requests.
class _Semaphore {
_Semaphore(this.maxConcurrent);
final int maxConcurrent;
int _current = 0;
final _waiting = <Completer<void>>[];
Future<void> acquire() {
if (_current < maxConcurrent) {
_current++;
return Future.value();
}
final completer = Completer<void>();
_waiting.add(completer);
return completer.future;
}
void release() {
if (_waiting.isNotEmpty) {
_waiting.removeAt(0).complete();
return;
}
if (_current > 0) _current--;
}
}
/// EPG cache entry with TTL
class _EpgCacheEntry {
@@ -22,19 +52,32 @@ class _EpgCacheEntry {
DateTime.now().difference(timestamp).inSeconds > ttlSeconds;
}
/// Default community XMLTV guides, used only when the provider has no EPG of
/// its own. Ordered by preference; the first one that yields programmes wins.
const List<XmltvSource> kDefaultPublicXmltvSources = [
XmltvSource(
label: 'EPGShare FR1',
url: 'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz',
),
];
/// Xtream API Service for Mobile
///
/// Optimized for Android with:
/// - Batch EPG loading (N+1 prevention)
/// - Provider-level caching with TTL
/// - XMLTV fallback when the provider serves no EPG
/// - Configurable timeouts and retries
/// - Memory-efficient streaming
class XtreamServiceMobile {
final String cacheDir;
late Dio _dio;
late String _baseUrl;
late String _username;
late String _password;
/// Replaced with the configured instance in [setPlaylistAsync]; initialised
/// eagerly so [dispose] is safe even if the service was never configured.
Dio _dio = Dio();
String _baseUrl = '';
String _username = '';
String _password = '';
// Batch EPG cache to prevent N+1 queries
final Map<String, _EpgCacheEntry> _epgBatchCache = {};
@@ -42,8 +85,62 @@ class XtreamServiceMobile {
// In-flight batch requests to deduplicate concurrent calls
final Map<String, Future<Map<String, model.ShortEPG>>> _inFlightBatches = {};
/// A channel grid asks for EPG one tile at a time, so a screenful fires ~30
/// simultaneous `player_api.php` calls. Many Xtream panels rate-limit past a
/// handful of parallel connections and answer with errors, which empties the
/// EPG of the whole grid. Requests are therefore funnelled in small packets.
static final _Semaphore _epgGate = _Semaphore(3);
/// streamId → channel, populated by [getLiveChannels].
///
/// The XMLTV fallback matches on `epg_channel_id` / channel name, neither of
/// which the per-stream EPG calls carry — this index supplies them without
/// changing any call site.
final Map<String, model.Channel> _channelIndex = {};
/// Last non-empty live lists, keyed by category id ('' = all channels).
///
/// The fetchers swallow errors and return `[]`, so a refresh attempted while
/// the network is down (typically right after a TV wakes from standby) would
/// otherwise replace a good list with an empty screen.
final Map<String, List<model.Channel>> _lastLiveChannels = {};
List<model.Category>? _lastLiveCategories;
late final XmltvService _xmltv = XmltvService(cacheDir);
/// User-provided XMLTV URL (empty = not configured).
String _customXmltvUrl = '';
/// Whether the community guides may be used as the last resort.
bool _allowPublicXmltv = true;
/// Set once every XMLTV source has failed, so a channel grid does not retry
/// a multi-megabyte download for each of its tiles.
bool _xmltvExhausted = false;
XtreamServiceMobile(this.cacheDir);
/// Label of the XMLTV source currently backing the fallback, if any.
String? get xmltvSourceLabel => _xmltv.loadedSourceLabel;
/// Configure the XMLTV fallback. Safe to call at any time.
void setXmltvConfig({String? customUrl, bool? allowPublicFallback}) {
final normalizedUrl = customUrl?.trim() ?? _customXmltvUrl;
final changed = normalizedUrl != _customXmltvUrl ||
(allowPublicFallback ?? _allowPublicXmltv) != _allowPublicXmltv;
_customXmltvUrl = normalizedUrl;
_allowPublicXmltv = allowPublicFallback ?? _allowPublicXmltv;
if (changed) {
// Sources changed — drop the "nothing works" latch and any indexed guide
// so the new configuration is actually tried.
_xmltvExhausted = false;
_xmltv.clear();
_epgBatchCache.clear();
}
}
/// Initialize with playlist configuration
Future<void> setPlaylistAsync(PlaylistConfig config) async {
_baseUrl = config.dns;
@@ -59,14 +156,70 @@ class XtreamServiceMobile {
),
);
// Warm the DNS entry for the panel host now, so the first zap does not pay
// for resolution. Fire-and-forget: playback must never wait on this.
unawaited(prewarmHost());
if (kDebugMode) {
print('✅ [XtreamServiceMobile] Initialized with URL: $_baseUrl');
}
}
/// Resolve and pre-connect to the panel host.
///
/// Opens a bare TCP/TLS socket and closes it immediately: no HTTP request is
/// issued, so this never consumes one of the provider's connection slots,
/// while still leaving the DNS entry cached and the route warm.
Future<void> prewarmHost() async {
final uri = Uri.tryParse(_baseUrl);
if (uri == null || uri.host.isEmpty) return;
try {
await DnsResolver.resolve(uri.host).timeout(
const Duration(seconds: 3),
onTimeout: () => null,
);
} catch (_) {
// DNS pre-resolution is best effort.
}
final isSecure = uri.scheme == 'https';
final port = uri.hasPort ? uri.port : (isSecure ? 443 : 80);
try {
final socket = isSecure
? await SecureSocket.connect(
uri.host,
port,
timeout: const Duration(milliseconds: 1200),
onBadCertificate: (_) => true,
)
: await Socket.connect(
uri.host,
port,
timeout: const Duration(milliseconds: 1200),
);
socket.destroy();
if (kDebugMode) print('🔥 [Prewarm] ${uri.host}:$port ready');
} catch (e) {
if (kDebugMode) print('⚠️ [Prewarm] ${uri.host}:$port failed: $e');
}
}
/// Get live channels for a category (with batch EPG support)
/// 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,
@@ -98,6 +251,11 @@ class XtreamServiceMobile {
.map((e) => model.Channel.fromJson(e))
.toList();
// Keep the EPG-matching keys reachable from a bare streamId.
for (final channel in channels) {
_channelIndex[channel.streamId] = channel;
}
if (kDebugMode) {
final categories = channels.map((c) => c.categoryName).toSet();
print(
@@ -195,6 +353,7 @@ class XtreamServiceMobile {
// Load EPG for each stream_id individually (API doesn't support true batch)
for (final streamId in streamIds) {
await _epgGate.acquire();
try {
final response = await _dio
.get(
@@ -244,6 +403,22 @@ class XtreamServiceMobile {
}
} catch (e) {
if (kDebugMode) print('⚠️ EPG failed for $streamId: $e');
} finally {
_epgGate.release();
}
}
// ── Fallback: whatever the provider could not answer for ──────────────
final missing =
streamIds.where((id) => !result.containsKey(id)).toList(growable: false);
if (missing.isNotEmpty) {
final recovered = await _resolveFromXmltv(missing);
result.addAll(recovered);
if (kDebugMode && recovered.isNotEmpty) {
print(
'📺 XMLTV fallback recovered ${recovered.length}/${missing.length} '
'channels via "${_xmltv.loadedSourceLabel}"',
);
}
}
@@ -253,8 +428,72 @@ class XtreamServiceMobile {
return result;
}
/// XMLTV sources to try, in the order requested: the provider's own dump
/// first (it is often populated even when `get_short_epg` is not), then the
/// user's URL, then the community guides.
List<XmltvSource> _xmltvSources() {
return [
XmltvSource(
label: 'Fournisseur (xmltv.php)',
url: '$_baseUrl/xmltv.php?username=$_username&password=$_password',
),
if (_customXmltvUrl.isNotEmpty)
XmltvSource(label: 'URL personnalisée', url: _customXmltvUrl),
if (_allowPublicXmltv) ...kDefaultPublicXmltvSources,
];
}
/// Resolve missing EPG entries from the XMLTV index, loading it on demand.
Future<Map<String, model.ShortEPG>> _resolveFromXmltv(
List<String> streamIds,
) async {
if (_xmltvExhausted) return {};
// Nothing to match on: the channel list has not been loaded in this
// session, so neither epg_channel_id nor the name is known.
final known = streamIds
.map((id) => _channelIndex[id])
.whereType<model.Channel>()
.toList();
if (known.isEmpty) return {};
try {
final loaded = await _xmltv.ensureLoaded(_xmltvSources());
if (!loaded) {
_xmltvExhausted = true;
return {};
}
} catch (e) {
if (kDebugMode) print('⚠️ XMLTV load failed: $e');
_xmltvExhausted = true;
return {};
}
final resolved = <String, model.ShortEPG>{};
for (final channel in known) {
final epg = _xmltv.shortEpgFor(
streamId: channel.streamId,
epgChannelId: channel.epgChannelId,
channelName: channel.name,
);
if (epg != null) resolved[channel.streamId] = epg;
}
return resolved;
}
/// Get live TV categories
///
/// 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...');
@@ -530,6 +769,13 @@ class XtreamServiceMobile {
_inFlightBatches.clear();
}
/// Drop the XMLTV index (keeps its disk cache) and allow a fresh attempt.
void resetXmltv() {
_xmltv.clear();
_xmltvExhausted = false;
_epgBatchCache.clear();
}
/// Get Live Stream URL
String getLiveStreamUrl(String streamId) {
return '$_baseUrl/live/$_username/$_password/$streamId.ts';
@@ -564,5 +810,8 @@ class XtreamServiceMobile {
/// Dispose service resources
void dispose() {
clearCache();
_channelIndex.clear();
_xmltv.clear();
_dio.close(force: true);
}
}
+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,10 +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 {
@@ -20,6 +23,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
late TextEditingController _liveTvController;
late TextEditingController _moviesController;
late TextEditingController _seriesController;
late TextEditingController _xmltvController;
bool _initialized = false;
bool _isRefreshingCache = false;
@@ -29,6 +33,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_liveTvController = TextEditingController();
_moviesController = TextEditingController();
_seriesController = TextEditingController();
_xmltvController = TextEditingController();
}
@override
@@ -36,6 +41,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_liveTvController.dispose();
_moviesController.dispose();
_seriesController.dispose();
_xmltvController.dispose();
super.dispose();
}
@@ -44,6 +50,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_liveTvController.text = settings.liveTvKeywords.join(', ');
_moviesController.text = settings.moviesKeywords.join(', ');
_seriesController.text = settings.seriesKeywords.join(', ');
_xmltvController.text = settings.xmltvUrl;
_initialized = true;
}
}
@@ -100,7 +107,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
),
),
Text(
'Version 1.5.1',
'Version 1.7.2',
style: GoogleFonts.inter(
fontSize: 13,
color: AppDecorations.textSecondary(context),
@@ -167,6 +174,36 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
const SizedBox(height: 24),
// === GUIDE TV (EPG) ===
_buildSectionHeader('Guide TV (EPG)'),
_buildSettingItem(
icon: Icons.public,
title: 'EPG communautaire',
subtitle: 'Repli quand le fournisseur ne renvoie aucun programme',
value: settings.useCommunityEpg ? 'Activé' : 'Désactivé',
onTap: () => ref
.read(mobileSettingsProvider.notifier)
.toggleCommunityEpg(!settings.useCommunityEpg),
),
_buildSettingItem(
icon: Icons.link,
title: 'URL XMLTV personnalisée',
subtitle: settings.xmltvUrl.isEmpty
? 'Aucune — xmltv.php du fournisseur, puis source communautaire'
: settings.xmltvUrl,
value: settings.xmltvUrl.isEmpty ? 'Ajouter' : 'Modifier',
onTap: () => _showKeyboardDialog(
'URL XMLTV',
_xmltvController,
(val) =>
ref.read(mobileSettingsProvider.notifier).setXmltvUrl(val),
),
),
const SizedBox(height: 24),
// === FILTRES ===
_buildSectionHeader('Filtres de Contenu'),
@@ -216,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,
),
@@ -281,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),
),
);
@@ -298,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 {
@@ -352,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(),
);
},
),
@@ -417,11 +461,9 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8),
child: Text(
title.toUpperCase(),
style: GoogleFonts.inter(
style: AppTheme.eyebrow(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
letterSpacing: 1.2,
),
),
);
@@ -447,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(
@@ -478,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(
@@ -526,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(
@@ -556,7 +603,8 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
],
),
),
Icon(Icons.edit, color: AppDecorations.textSecondary(context), size: 20),
Icon(Icons.edit,
color: AppDecorations.textSecondary(context), size: 20),
],
),
),
@@ -18,6 +18,12 @@ class MobileSettings {
final List<String>
deinterlacedChannels; // IDs of channels with forced deinterlacing
/// Custom XMLTV guide URL, tried after the provider's own `xmltv.php`.
final String xmltvUrl;
/// Allow the built-in community XMLTV guides as the last EPG resort.
final bool useCommunityEpg;
const MobileSettings({
this.liveTvKeywords = const [],
this.moviesKeywords = const [],
@@ -30,6 +36,8 @@ class MobileSettings {
this.deinterlace = false,
this.bufferDuration = 0,
this.deinterlacedChannels = const [],
this.xmltvUrl = '',
this.useCommunityEpg = true,
});
MobileSettings copyWith({
@@ -44,6 +52,8 @@ class MobileSettings {
bool? deinterlace,
int? bufferDuration,
List<String>? deinterlacedChannels,
String? xmltvUrl,
bool? useCommunityEpg,
}) {
return MobileSettings(
liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords,
@@ -57,6 +67,8 @@ class MobileSettings {
deinterlace: deinterlace ?? this.deinterlace,
bufferDuration: bufferDuration ?? this.bufferDuration,
deinterlacedChannels: deinterlacedChannels ?? this.deinterlacedChannels,
xmltvUrl: xmltvUrl ?? this.xmltvUrl,
useCommunityEpg: useCommunityEpg ?? this.useCommunityEpg,
);
}
@@ -121,6 +133,9 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?;
final deinterlacedChannels =
_box?.get('deinterlacedChannels', defaultValue: <String>[]) as List?;
final xmltvUrl = _box?.get('xmltvUrl', defaultValue: '') as String?;
final useCommunityEpg =
_box?.get('useCommunityEpg', defaultValue: true) as bool?;
state = MobileSettings(
liveTvKeywords: liveTv?.cast<String>() ?? [],
@@ -134,6 +149,8 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
deinterlace: deinterlace ?? false,
bufferDuration: buffer ?? 0,
deinterlacedChannels: deinterlacedChannels?.cast<String>() ?? [],
xmltvUrl: xmltvUrl ?? '',
useCommunityEpg: useCommunityEpg ?? true,
);
}
@@ -187,6 +204,17 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
_box?.put('bufferDuration', seconds);
}
void setXmltvUrl(String url) {
final trimmed = url.trim();
state = state.copyWith(xmltvUrl: trimmed);
_box?.put('xmltvUrl', trimmed);
}
void toggleCommunityEpg(bool value) {
state = state.copyWith(useCommunityEpg: value);
_box?.put('useCommunityEpg', value);
}
void toggleChannelDeinterlace(String streamId) {
final currentList = List<String>.from(state.deinterlacedChannels);
if (currentList.contains(streamId)) {
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/playlist_config.dart';
import '../../core/models/iptv_models.dart' as model;
import '../../features/iptv/services/xtream_service_mobile.dart';
import 'mobile_settings_providers.dart';
/// Mobile-specific Xtream service provider
import 'package:path_provider/path_provider.dart';
@@ -13,9 +14,60 @@ final mobileXtreamServiceProvider =
final service = XtreamServiceMobile(dir.path);
await service
.setPlaylistAsync(playlist); // Async to resolve DNS before API calls
// EPG fallback configuration follows the settings live, without tearing the
// service (and its caches) down on every unrelated settings change.
void applyEpgSettings(MobileSettings settings) => service.setXmltvConfig(
customUrl: settings.xmltvUrl,
allowPublicFallback: settings.useCommunityEpg,
);
applyEpgSettings(ref.read(mobileSettingsProvider));
ref.listen<MobileSettings>(mobileSettingsProvider, (previous, next) {
if (previous?.xmltvUrl != next.xmltvUrl ||
previous?.useCommunityEpg != next.useCommunityEpg) {
applyEpgSettings(next);
}
});
// A full catalogue refresh from the settings also drops the cached EPG.
ref.listen<int>(
fullCatalogRefreshTickProvider, (_, __) => service.clearCache());
ref.onDispose(service.dispose);
return service;
});
/// 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>(
@@ -25,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);
@@ -61,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(
@@ -102,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
});
@@ -116,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);
@@ -143,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);
+63 -373
View File
@@ -1,390 +1,80 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/theme/app_colors.dart';
/// XtremFlow Mobile Theme — Apple TV Grey Glossy Style (dark)
/// + Apple Clean White (light)
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
/// XtremFlow Mobile Theme — "Warm Cinema", phone-scaled.
///
/// Derived from [AppTheme] rather than redefined: the palette, typography and
/// component styling stay in one place, and this layer only shrinks the type
/// scale and tightens paddings for a handset.
class MobileTheme {
MobileTheme._();
// ============ SPACING ============
static const double spacing4 = 4.0;
static const double spacing8 = 8.0;
static const double spacing12 = 12.0;
static const double spacing16 = 16.0;
static const double spacing24 = 24.0;
static const double spacing4 = AppTheme.spacing4;
static const double spacing8 = AppTheme.spacing8;
static const double spacing12 = AppTheme.spacing12;
static const double spacing16 = AppTheme.spacing16;
static const double spacing24 = AppTheme.spacing24;
// ============ RADIUS ============
static const double radiusSm = 8.0;
static const double radiusMd = 12.0;
static const double radiusLg = 16.0;
static const double radiusSm = AppTheme.radiusSm;
static const double radiusMd = AppTheme.radiusMd;
static const double radiusLg = AppTheme.radiusLg;
/// Phone type scale: display sizes come down ~20%, body stays legible.
static TextTheme _mobileTextTheme(TextTheme base) {
TextStyle? shrink(TextStyle? style, double factor) {
if (style?.fontSize == null) return style;
return style!.copyWith(fontSize: style.fontSize! * factor);
}
return base.copyWith(
displayLarge: shrink(base.displayLarge, 0.70),
displayMedium: shrink(base.displayMedium, 0.76),
displaySmall: shrink(base.displaySmall, 0.84),
headlineLarge: shrink(base.headlineLarge, 0.78),
headlineMedium: shrink(base.headlineMedium, 0.86),
headlineSmall: shrink(base.headlineSmall, 0.92),
titleLarge: shrink(base.titleLarge, 0.94),
bodyLarge: shrink(base.bodyLarge, 0.90),
bodyMedium: shrink(base.bodyMedium, 0.92),
);
}
static ThemeData _scaled(ThemeData base) {
return base.copyWith(
textTheme: _mobileTextTheme(base.textTheme),
inputDecorationTheme: base.inputDecorationTheme.copyWith(
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing16,
vertical: spacing12,
),
),
filledButtonTheme: FilledButtonThemeData(
style: base.filledButtonTheme.style?.copyWith(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: spacing16, vertical: spacing12),
),
),
),
);
}
// ============ DARK THEME (Mobile Main) ============
static ThemeData get darkTheme {
final baseTheme = ThemeData.dark(useMaterial3: true);
static ThemeData get darkTheme => _scaled(AppTheme.darkTheme);
return baseTheme.copyWith(
colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background,
// ============ LIGHT THEME ============
static ThemeData get lightTheme => _scaled(AppTheme.lightTheme);
// Global text selection — white cursor on dark
textSelectionTheme: const TextSelectionThemeData(
cursorColor: Colors.white,
selectionColor: Color(0x4DFFFFFF),
selectionHandleColor: Colors.white,
),
// Typography: San Francisco proxy via Inter — clean and modern
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -1.0,
color: AppColors.textPrimary,
),
displayMedium: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: AppColors.textPrimary,
),
displaySmall: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
headlineMedium: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleLarge: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
bodyLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
bodyMedium: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
labelLarge: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
// AppBar: Transparent — background gradient shows through
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
foregroundColor: AppColors.textPrimary,
elevation: 0,
centerTitle: true,
scrolledUnderElevation: 0,
titleTextStyle: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
// Bottom Navigation Bar: glossy dark surface, Apple Blue selected
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: AppColors.surface,
selectedItemColor: AppColors.appleBlue,
unselectedItemColor: AppColors.textSecondary,
selectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w500,
),
type: BottomNavigationBarType.fixed,
elevation: 0,
),
// Inputs: dark surface fill, white focus border
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surfaceVariant,
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing16,
vertical: spacing12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(
color: AppColors.glassBorderColor,
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(
color: AppColors.glassBorderColor,
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: Colors.white, width: 1.5),
),
hintStyle: GoogleFonts.inter(
color: AppColors.textTertiary,
fontSize: 14,
),
),
// Buttons: Apple Blue fill — modern CTA style
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.appleBlue,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
textStyle: GoogleFonts.inter(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
// Cards: glossy dark surface with subtle glass border
cardTheme: const CardThemeData(
color: AppColors.surfaceVariant,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(radiusMd)),
side: BorderSide(color: AppColors.glassBorderColor, width: 1),
),
),
// Dialogs: glass/dark
dialogTheme: DialogThemeData(
backgroundColor: AppColors.surface,
elevation: 24,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
side: const BorderSide(color: AppColors.glassBorderColor, width: 1),
),
titleTextStyle: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
contentTextStyle: GoogleFonts.inter(
fontSize: 14,
color: AppColors.textSecondary,
),
),
);
}
// ============ LIGHT THEME (Apple Clean White) ============
static ThemeData get lightTheme {
final baseTheme = ThemeData.light(useMaterial3: true);
const bgColor = Color(0xFFEAEAF0);
const surfaceColor = Color(0xFFF2F2F7);
const primaryColor = Color(0xFF007AFF);
const textPrimaryColor = Color(0xFF000000);
const textSecondaryColor = Color(0x993C3C43);
const textTertiaryColor = Color(0xFFC7C7CC);
const borderColor = Color(0x1A000000);
return baseTheme.copyWith(
colorScheme: const ColorScheme.light(
primary: primaryColor,
secondary: Color(0xFF34C759),
surface: surfaceColor,
error: AppColors.error,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: textPrimaryColor,
onError: Colors.white,
brightness: Brightness.light,
),
scaffoldBackgroundColor: bgColor,
textSelectionTheme: const TextSelectionThemeData(
cursorColor: primaryColor,
selectionColor: Color(0x4D007AFF),
selectionHandleColor: primaryColor,
),
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -1.0,
color: textPrimaryColor,
),
displayMedium: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: textPrimaryColor,
),
displaySmall: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
headlineMedium: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
titleLarge: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
bodyLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: textSecondaryColor,
),
bodyMedium: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: textSecondaryColor,
),
labelLarge: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
),
appBarTheme: AppBarTheme(
backgroundColor: surfaceColor,
foregroundColor: textPrimaryColor,
elevation: 0,
scrolledUnderElevation: 1,
shadowColor: borderColor,
centerTitle: true,
titleTextStyle: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: surfaceColor,
selectedItemColor: primaryColor,
unselectedItemColor: textSecondaryColor,
selectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w500,
),
type: BottomNavigationBarType.fixed,
elevation: 8,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceColor,
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing16,
vertical: spacing12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: borderColor, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: borderColor, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
hintStyle: GoogleFonts.inter(
color: textTertiaryColor,
fontSize: 14,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
textStyle: GoogleFonts.inter(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
cardTheme: const CardThemeData(
color: surfaceColor,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(radiusMd)),
side: BorderSide(color: borderColor, width: 1),
),
shadowColor: borderColor,
),
dialogTheme: DialogThemeData(
backgroundColor: surfaceColor,
elevation: 8,
shadowColor: borderColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
side: const BorderSide(color: borderColor, width: 1),
),
titleTextStyle: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.bold,
color: textPrimaryColor,
),
contentTextStyle: GoogleFonts.inter(
fontSize: 14,
color: textSecondaryColor,
),
),
);
}
/// Returns the appropriate MobileTheme based on the current [BuildContext] brightness.
/// Returns the appropriate MobileTheme based on the current [BuildContext]
/// brightness.
static ThemeData themeOf(BuildContext context) {
final brightness = Theme.of(context).brightness;
return brightness == Brightness.dark ? darkTheme : lightTheme;
}
/// Accent used for active/selected affordances on mobile.
static const Color accent = AppColors.primaryContainer;
}
+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,
),
+22 -5
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.5.1+11
version: 1.7.2+16
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -23,6 +23,9 @@ dependencies:
dio_cache_interceptor: ^3.5.1
dio_cache_interceptor_hive_store: ^3.2.2
http: ^1.2.2
# EPG (XMLTV fallback parsing)
xml: ^6.5.0
# Routing
go_router: ^13.2.5
@@ -31,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
@@ -47,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
@@ -67,9 +81,12 @@ dev_dependencies:
flutter_launcher_icons:
android: true
image_path: "assets/images/app-icon.png"
ios: true
image_path: "assets/images/logo_xtremflow.png"
adaptive_icon_background: "#1a1a2e"
adaptive_icon_foreground: "assets/images/app-icon.png"
adaptive_icon_foreground: "assets/images/logo_xtremflow.png"
# iOS refuse une icône comportant un canal alpha.
remove_alpha_ios: true
flutter:
uses-material-design: true
+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})"
+71 -16
View File
@@ -1,25 +1,80 @@
# Task: Corrections Fire Stick & UI - TERMINÉ
# Task: Alignement sur xtremflow — EPG XMLTV, thème, stabilité des flux
## Bugs corrigés
## 1. EPG — repli XMLTV quand le fournisseur ne répond pas
### Session 1 (07h45)
- [x] Nouveau `lib/features/iptv/services/xmltv_service.dart` : téléchargement,
gunzip transparent, parse **en flux** (`toXmlEvents`), index réduit à une
fenêtre `-3 h / +18 h`, cache disque JSON (TTL 6 h)
- [x] Chaîne de repli dans `XtreamServiceMobile` :
`get_short_epg` → `{dns}/xmltv.php` du fournisseur → URL personnalisée →
source communautaire (`epgshare01` FR1)
- [x] Correspondance par `epg_channel_id`, puis par nom normalisé (accents,
suffixes `HD/FHD/4K`, préfixes `FR:` / `|FR|`)
- [x] Index `streamId → Channel` alimenté par `getLiveChannels` : aucun site
d'appel de `getShortEPG` n'a changé
- [x] Sémaphore EPG (3 requêtes max), comme xtremflow — une grille de 30 tuiles
ne noie plus le panel et ne vide plus l'EPG de tout l'écran
- [x] Réglages : « EPG communautaire » (on/off) + « URL XMLTV personnalisée »
1. ✅ **Mise en veille pendant lecture** - Ajout de `wakelock_plus` dans les deux players
2. ✅ **Settings surbrillance invisible** - Toutes les box utilisent TVFocusable avec bordure blanche
3. ✅ **Player Lite bouton pause** - Descendu et rendu semi-transparent
## 2. Thème — port de la charte « Warm Cinema » de xtremflow
### Session 2 (18h15)
- [x] `app_colors.dart` : palette ember/charbon/crème, alias de compatibilité
conservés pour les 21 symboles Apple TV déjà utilisés
- [x] `app_theme.dart` : typo **Fraunces** (display) + **Karla** (UI), tokens
spacing/radius/durations/curves alignés sur xtremflow
- [x] `mobile_theme.dart` : dérive désormais de `AppTheme` (échelle typo mobile)
au lieu de dupliquer 390 lignes
- [x] `app_decorations.dart` : hexs Apple codés en dur remplacés par la palette
- [x] **Passe 2 (v1.6.1)** — le thème ne suffisait pas : du bleu restait codé en
dur dans 19 fichiers d'écran/widget, hors palette. Balayage complet :
- `0xFF0A84FF` (bleu Apple) dans la **pilule de navigation sélectionnée**
(`mobile_scaffold`), les icônes de catégorie et le bouton « ajouter »
- `0xFF4FC3F7` / `0xFF1565C0` (bleus) dans les overlays du lecteur
- `Colors.blueAccent` / `Colors.blue` dans les boîtes de dialogue
- gris Apple `1C1C1E` / `2C2C2E` / `3A3A3C` / `8E8E93` → charbons chauds
- `Colors.white*` (128 occurrences) → paliers crème `onSurfaceNN`
- `Colors.red/green/amber/yellow/grey*` → `error` / `success` /
`ratingGold` / `outlineVariant`
- noir pur sur remplissage clair → encre chaude `onPrimaryFixed`
- halo de focus blanc → halo ember
- Conservés en noir vrai : fonds de surface vidéo et voiles (`scrims`)
- Vérification : plus **aucune** couleur Material/Apple codée en dur hors
de `lib/core/theme/`
4. ✅ **Retour depuis player TV** - Le bouton retour ramène maintenant à la liste des chaînes (pas aux catégories)
## 3. Lecture — arrêt en arrière-plan et tenue dans la durée
## Fichiers modifiés
- [x] `AppLifecycleState.inactive` retiré du déclencheur d'arrêt : il se
déclenchait au moindre volet de notifications
- [x] `_releasePlayback()` sur `paused` / `hidden` / `detached` — arrêt du flux,
libération du décodeur, annulation des timers, wakelock coupé
(les deux moteurs : `native_player_screen` et `lite_player_screen`)
- [x] Verrou `_isReleased` : watchdog et reconnexions ne peuvent plus relancer
un flux depuis l'arrière-plan
- [x] Abonnements aux `Stream` du player stockés et annulés au `dispose`
- [x] `demuxer-max-back-bytes` plafonné à 8 Mo (défaut mpv : 50 Mio) — évite la
dérive mémoire qui finissait en kill Android après plusieurs heures
- [x] `setState` de position limité aux moments où les contrôles sont visibles ;
horloge ne reconstruit qu'au changement de minute
- [x] FocusNodes du lecteur Lite libérés au `dispose`
- `pubspec.yaml` - Ajout de `wakelock_plus`
- `native_player_screen.dart` - WakeLock activé/désactivé
- `lite_player_screen.dart` - WakeLock + nouveau design boutons + seek réduit
- `mobile_settings_tab.dart` - TVFocusable sur toutes les options
- `mobile_live_tv_tab.dart` - Correction navigation retour depuis player
## 4. Vitesse de chargement
## APK généré
- [x] `prewarmHost()` : pré-résolution DNS + socket TCP/TLS ouvert puis fermé
avant l'ouverture du flux (aucune requête HTTP, donc aucun slot de
connexion consommé chez le fournisseur), plafonné à 1,5 s
`build\app\outputs\flutter-apk\app-release.apk` (92.3MB)
> Non retenu par l'utilisateur : décodage matériel VOD, réduction du probe,
> audio multicanal natif. Le profil de décodage reste inchangé.
## Vérifications
- [x] `flutter analyze` : 0 erreur / 0 warning sur les fichiers touchés
(3 warnings de code mort restants, déjà présents dans HEAD)
- [x] `flutter test` : 19/19 (18 unitaires + 1 sur un vrai dump)
- [x] Vrai dump `epg_ripper_FR1.xml.gz` (5,4 Mo gz / 46 Mo XML / 69 802
programmes) : 517 chaînes indexées en 1,1 s, 5/5 correspondances
- [x] `flutter build apk --release` → `app-release.apk` (94,3 Mo)
Non corrigé (pré-existant, hors périmètre) : erreurs d'analyse dans
`lib/core/utils/memory_profiler.dart` et `hive_encryption_benchmark.dart`
(orphelins, hors graphe d'import) et `test/widget_test.dart`.
+71
View File
@@ -0,0 +1,71 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xtremobile/features/iptv/services/xmltv_service.dart';
/// Parses a real community XMLTV dump end to end.
///
/// Skipped unless `XMLTV_FIXTURE` points at a `.xml`/`.xml.gz` file, so CI and
/// day-to-day runs stay offline and fast:
///
/// flutter test test/xmltv_real_dump_test.dart \
/// --dart-define=XMLTV_FIXTURE=C:\path\epg_ripper_FR1.xml.gz
const String _fixturePath = String.fromEnvironment('XMLTV_FIXTURE');
void main() {
test(
'indexes a real XMLTV dump within a mobile memory budget',
() async {
final file = File(_fixturePath);
expect(file.existsSync(), isTrue, reason: 'fixture not found');
final service = XmltvService(Directory.systemTemp.path);
final started = DateTime.now();
final indexed = await service.indexFromBytes(file.openRead());
final elapsed = DateTime.now().difference(started);
final peakMb = ProcessInfo.maxRss / 1048576;
// ignore: avoid_print
print(
'channels=${service.channelCount} '
'time=${elapsed.inMilliseconds}ms '
'peakRss=${peakMb.toStringAsFixed(1)}MB',
);
expect(indexed, isTrue);
expect(service.channelCount, greaterThan(100));
// The whole point of the streaming parse: a ~46 MB document must never
// be resident. Generous ceiling to stay stable across VM versions.
expect(peakMb, lessThan(400));
// Spot-check the matching paths with labels shaped the way an Xtream
// provider reports them.
const probes = <List<String>>[
['', 'TF1 HD'],
['', 'FR: France 2'],
['', 'M6 FHD'],
['', '|FR| France 5'],
['', 'Arte'],
];
var hits = 0;
for (final probe in probes) {
final epg = service.shortEpgFor(
streamId: 'x',
epgChannelId: probe[0],
channelName: probe[1],
);
// ignore: avoid_print
print(' ${probe[1].padRight(16)} -> ${epg?.title ?? "NO MATCH"}');
if (epg != null) hits++;
}
expect(hits, greaterThanOrEqualTo(4),
reason: 'name matching should resolve mainstream French channels');
},
skip: _fixturePath.isEmpty ? 'set --dart-define=XMLTV_FIXTURE' : false,
);
}
+216
View File
@@ -0,0 +1,216 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xtremobile/features/iptv/services/xmltv_service.dart';
/// Fixture shaped exactly like the community dumps the fallback consumes
/// (channel ids with dots, `+0200` offsets, base-less titles).
String _fixture(DateTime now) {
String stamp(DateTime value) {
String two(int n) => n.toString().padLeft(2, '0');
final utc = value.toUtc();
return '${utc.year}${two(utc.month)}${two(utc.day)}'
'${two(utc.hour)}${two(utc.minute)}${two(utc.second)} +0000';
}
final airingStart = now.subtract(const Duration(minutes: 20));
final airingEnd = now.add(const Duration(minutes: 40));
final nextEnd = airingEnd.add(const Duration(minutes: 50));
final farFuture = now.add(const Duration(days: 4));
return '''<?xml version="1.0" encoding="UTF-8" ?>
<tv generator-info-name="none">
<channel id="TF1.fr">
<display-name lang="fr">TF1</display-name>
</channel>
<channel id="France.3.-.Nord.Pas-de-Calais.fr">
<display-name lang="fr">France 3 - Nord Pas-de-Calais</display-name>
</channel>
<channel id="Cine.Premier.fr">
<display-name lang="fr">Ciné+ Premier</display-name>
</channel>
<programme channel="TF1.fr" start="${stamp(airingStart)}" stop="${stamp(airingEnd)}">
<title lang="fr">Journal de 20h</title>
<desc lang="fr">Le rendez-vous d'information.</desc>
</programme>
<programme channel="TF1.fr" start="${stamp(airingEnd)}" stop="${stamp(nextEnd)}">
<title lang="fr">Film du mardi</title>
</programme>
<programme channel="France.3.-.Nord.Pas-de-Calais.fr" start="${stamp(airingStart)}" stop="${stamp(airingEnd)}">
<title lang="fr">Ici 19/20</title>
</programme>
<programme channel="Cine.Premier.fr" start="${stamp(farFuture)}" stop="${stamp(farFuture.add(const Duration(hours: 1)))}">
<title lang="fr">Hors fenêtre</title>
</programme>
</tv>''';
}
Stream<List<int>> _bytes(String xml) => Stream.value(utf8.encode(xml));
void main() {
group('parseXmltvTime', () {
test('applies the trailing UTC offset', () {
final parsed = XmltvService.parseXmltvTime('20260811183000 +0200');
expect(parsed, isNotNull);
expect(
parsed!.toUtc(),
DateTime.utc(2026, 8, 11, 16, 30),
reason: '18:30 at +0200 is 16:30 UTC',
);
});
test('handles a negative offset', () {
final parsed = XmltvService.parseXmltvTime('20260811183000 -0430');
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 23, 0));
});
test('treats a missing offset as UTC', () {
final parsed = XmltvService.parseXmltvTime('20260811183000');
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 18, 30));
});
test('rejects malformed input', () {
expect(XmltvService.parseXmltvTime(null), isNull);
expect(XmltvService.parseXmltvTime(''), isNull);
expect(XmltvService.parseXmltvTime('2026081118'), isNull);
expect(XmltvService.parseXmltvTime('not-a-timestamp!'), isNull);
});
});
group('normalizeName', () {
test('collapses quality suffixes', () {
expect(XmltvService.normalizeName('TF1 HD'), 'tf1');
expect(XmltvService.normalizeName('tf1-fhd'), 'tf1');
expect(XmltvService.normalizeName('TF1 4K'), 'tf1');
expect(XmltvService.normalizeName('TF1'), 'tf1');
});
test('strips country prefixes in both forms', () {
expect(XmltvService.normalizeName('FR: TF1'), 'tf1');
expect(XmltvService.normalizeName('|FR| TF1 HD'), 'tf1');
expect(XmltvService.normalizeName('[FR] TF1'), 'tf1');
});
test('keeps a dash-separated name intact', () {
// "Ciné - Premier" must not lose its first word to prefix stripping.
expect(XmltvService.normalizeName('Ciné - Premier'), 'cinepremier');
});
test('folds accents and keeps the plus sign', () {
expect(XmltvService.normalizeName('Ciné+ Émotion'), 'cine+emotion');
});
test('is empty for a blank label', () {
expect(XmltvService.normalizeName(' '), '');
});
});
group('indexFromBytes + lookup', () {
late XmltvService service;
late DateTime now;
setUp(() async {
service = XmltvService(Directory.systemTemp.path);
now = DateTime.now();
final indexed = await service.indexFromBytes(
_bytes(_fixture(now)),
now: now,
);
expect(indexed, isTrue);
});
test('matches on epg_channel_id', () {
final epg = service.shortEpgFor(
streamId: '123',
epgChannelId: 'TF1.fr',
channelName: 'anything',
);
expect(epg, isNotNull);
expect(epg!.title, 'Journal de 20h');
expect(epg.id, '123');
expect(epg.description, "Le rendez-vous d'information.");
});
test('falls back to the channel name when the id is unknown', () {
final epg = service.shortEpgFor(
streamId: '456',
epgChannelId: '',
channelName: 'FR: TF1 HD',
);
expect(epg, isNotNull);
expect(epg!.title, 'Journal de 20h');
});
test('matches a name reconstructed from a dotted channel id', () {
final epg = service.shortEpgFor(
streamId: '789',
epgChannelId: 'not-in-guide',
channelName: 'France 3 - Nord Pas-de-Calais',
);
expect(epg, isNotNull);
expect(epg!.title, 'Ici 19/20');
});
test('returns the currently airing programme, not the next one', () {
final programme = service.programmeFor(
epgChannelId: 'TF1.fr',
at: now,
);
expect(programme!.title, 'Journal de 20h');
expect(programme.covers(now), isTrue);
});
test('falls forward to the next entry when nothing is airing', () {
// 10 minutes into the gap-free schedule's second slot.
final during = now.add(const Duration(minutes: 50));
final programme = service.programmeFor(
epgChannelId: 'TF1.fr',
at: during,
);
expect(programme!.title, 'Film du mardi');
});
test('drops programmes outside the retention window', () {
// The Ciné+ entry is 4 days out — beyond windowForward, so the channel
// holds no programme at all.
final epg = service.shortEpgFor(
streamId: '999',
epgChannelId: 'Cine.Premier.fr',
);
expect(epg, isNull);
});
test('returns null for a channel absent from the guide', () {
final epg = service.shortEpgFor(
streamId: '000',
epgChannelId: 'unknown.channel',
channelName: 'Chaîne Inconnue',
);
expect(epg, isNull);
});
test('parses a gzipped payload transparently', () async {
final gzipped = XmltvService(Directory.systemTemp.path);
final compressed = gzip.encode(utf8.encode(_fixture(now)));
final indexed = await gzipped.indexFromBytes(
Stream.value(compressed),
now: now,
);
expect(indexed, isTrue);
expect(
gzipped.shortEpgFor(streamId: '1', epgChannelId: 'TF1.fr')?.title,
'Journal de 20h',
);
});
test('reports failure on a payload with no programme', () async {
final empty = XmltvService(Directory.systemTemp.path);
final indexed = await empty.indexFromBytes(
_bytes('<?xml version="1.0"?><tv></tv>'),
);
expect(indexed, isFalse);
expect(empty.isLoaded, isFalse);
});
});
}