From 8d74bbb320d471cd8eab4e31ea159ee37f714f0a Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 11 Aug 2026 19:25:33 +0200 Subject: [PATCH] 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 --- lib/core/theme/app_colors.dart | 368 ++++++---- lib/core/theme/app_decorations.dart | 114 ++-- lib/core/theme/app_theme.dart | 554 +++++++++------ .../iptv/screens/lite_player_screen.dart | 62 +- .../iptv/screens/native_player_screen.dart | 150 +++-- lib/features/iptv/services/xmltv_service.dart | 628 ++++++++++++++++++ .../iptv/services/xtream_service_mobile.dart | 227 ++++++- .../iptv/widgets/mobile_settings_tab.dart | 41 +- .../providers/mobile_settings_providers.dart | 28 + .../providers/mobile_xtream_providers.dart | 18 + lib/mobile/theme/mobile_theme.dart | 436 ++---------- pubspec.yaml | 5 +- task.md | 72 +- test/xmltv_real_dump_test.dart | 71 ++ test/xmltv_service_test.dart | 216 ++++++ 15 files changed, 2138 insertions(+), 852 deletions(-) create mode 100644 lib/features/iptv/services/xmltv_service.dart create mode 100644 test/xmltv_real_dump_test.dart create mode 100644 test/xmltv_service_test.dart diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 2c1b0d2..2234c8a 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -1,170 +1,292 @@ import 'package:flutter/material.dart'; -/// XtremFlow Apple TV Grey Glossy Palette +/// XtremFlow — "Warm Cinema" Design System (ported from the web app) /// -/// 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: charbon chaud + crème + orange brûlé (ember). +/// Inspiration: salle de projection, pellicule, affiches 70s. +/// +/// Règle de perf : AUCUNE couleur de surface n'est translucide. La profondeur +/// vient des ombres et du contraste, pas du flou — pas de `BackdropFilter`, +/// coûteux à chaque frame sur les Android d'entrée de gamme. +/// +/// Les noms hérités de l'ancienne charte Apple TV (`appleBlue`, +/// `glossyCardGradient`, `appleTvGradient`, …) sont conservés en alias pour ne +/// pas casser les sites d'appel existants ; ils pointent désormais vers les +/// teintes chaudes. class AppColors { AppColors._(); + // ============ BASE LEVEL 0 ============ + /// Noir chaud — le "noir de salle", jamais un noir pur bleuté. + static const Color baseLevel0 = Color(0xFF0B0908); + // ============ BACKGROUNDS ============ - /// Pure black — infinite depth, OLED-friendly - static const Color background = Color(0xFF000000); + static const Color background = Color(0xFF100C0A); + static const Color onBackground = Color(0xFFF3E9DF); - /// Apple deep dark grey — primary app background (alias) - static const Color backgroundSecondary = Color(0xFF1C1C1E); + static const Color surface = Color(0xFF100C0A); + static const Color surfaceDim = Color(0xFF0B0908); + static const Color surfaceBright = Color(0xFF3C322C); - /// 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(0xFF070505); + static const Color surfaceContainerLow = Color(0xFF181310); + static const Color surfaceContainer = Color(0xFF1F1815); + static const Color surfaceContainerHigh = Color(0xFF2A211C); + static const Color surfaceContainerHighest = Color(0xFF352A24); + static const Color surfaceVariant = Color(0xFF1F1815); - /// Mid grey — elevated cards, sheets - static const Color surfaceVariant = Color(0xFF2C2C2E); + // ============ SURFACE / ON SURFACE ============ + static const Color onSurface = Color(0xFFF3E9DF); // crème + static const Color onSurfaceVariant = Color(0xFFC5B3A5); // crème atténué - /// Light grey surface — tertiary elevation - static const Color surfaceElevated = Color(0xFF3A3A3C); + // ============ PRIMARY (Ember / orange brûlé) ============ + static const Color primary = Color(0xFFFFB68C); // texte & icônes sur sombre + static const Color onPrimary = Color(0xFF521B00); + static const Color primaryContainer = Color(0xFFD9541F); // remplissage ember + static const Color onPrimaryContainer = Color(0xFF2B0A00); + static const Color inversePrimary = Color(0xFFA63B10); - /// Focus/selected element background - static const Color focusColor = Color(0xFFFFFFFF); + /// Braise profonde — réservée aux **remplissages portant du texte**. + /// `primaryContainer` (#D9541F) est trop lumineux : du blanc dessus plafonne + /// vers 3,9:1, sous le seuil AA de 4,5:1. Cette variante passe le seuil tout + /// en restant la même couleur perçue. + static const Color primaryFill = Color(0xFFA8360C); - // ============ ACCENTS ============ - /// Primary: Pure white — focus rings, active labels - static const Color primary = Color(0xFFFFFFFF); + // ============ SECONDARY (Terre cuite / argile) ============ + static const Color secondary = Color(0xFFE7C3AC); + static const Color onSecondary = Color(0xFF442A1B); + static const Color secondaryContainer = Color(0xFF5D4030); + static const Color onSecondaryContainer = Color(0xFFFFDCC8); - /// Light grey — secondary text / inactive elements - static const Color accent = Color(0xFFEBEBF5); + // ============ TERTIARY (Laiton / doré patiné) ============ + static const Color tertiary = Color(0xFFD9C88C); + static const Color onTertiary = Color(0xFF3A3016); + static const Color tertiaryContainer = Color(0xFF52472A); + static const Color onTertiaryContainer = Color(0xFFF6E4A6); - /// Apple Blue — CTAs, links, active indicators - static const Color appleBlue = Color(0xFF0A84FF); + // ============ ERROR ============ + static const Color error = Color(0xFFFFB4A6); + static const Color onError = Color(0xFF5F1409); + static const Color errorContainer = Color(0xFF8C2416); + static const Color onErrorContainer = Color(0xFFFFDAD4); + + // ============ OUTLINE ============ + static const Color outline = Color(0xFF9C8878); + static const Color outlineVariant = Color(0xFF4A3C33); + + // ============ INVERSE SURFACE ============ + static const Color inverseSurface = Color(0xFFF3E9DF); + static const Color inverseOnSurface = Color(0xFF2A211C); + static const Color surfaceTint = Color(0xFFFFB68C); + + // ============ TEXT / CONTENT ============ + static const Color textPrimary = Color(0xFFF3E9DF); + static const Color textSecondary = Color(0xFFC5B3A5); + static const Color textTertiary = Color(0xFF9C8878); + + // ============ CRÈME À OPACITÉ ============ + // Un blanc pur sur fond chaud tire vers le bleu : on décline la crème. + static const Color onSurface06 = Color(0x0FF3E9DF); + static const Color onSurface12 = Color(0x1FF3E9DF); + static const Color onSurface24 = Color(0x3DF3E9DF); + static const Color onSurface38 = Color(0x61F3E9DF); + static const Color onSurface54 = Color(0x8AF3E9DF); + + // ============ STATUS ============ + static const Color success = Color(0xFF9BC46A); // olive lumineux + static const Color warning = Color(0xFFE9B23C); // ambre + static const Color info = Color(0xFFA3B8C4); // bleu-gris fumé + + // ============ CATÉGORIES ============ + static const Color live = Color(0xFFE5484D); // rouge enseigne + static const Color movies = Color(0xFFD9541F); // ember + static const Color series = Color(0xFFD9C88C); // laiton + + // ============ RATING / PREMIUM ============ + static const Color ratingGold = Color(0xFFE9B23C); + static const Color premiumGold = Color(0xFFC9922A); + + // ============ SHIMMER (placeholders de chargement) ============ + static const Color shimmer = Color(0xFF2A211C); + static const Color shimmerHighlight = Color(0xFF3C302A); + + // ============ BORDERS / FOCUS ============ + static const Color border = outlineVariant; + static const Color focusColor = primaryContainer; + static const Color focusBorder = primaryContainer; + static const Color borderFocused = primaryContainer; + + /// Liseré crème 8 % — définit l'arête d'une surface sans la faire flotter. + static const Color glassBorderColor = Color(0x14F3E9DF); + + // ============ SURFACES "GLASS" — OPAQUES ============ + static const Color glassLevel1Bg = Color(0xFF181310); + static const Color glassLevel1Border = Color(0x14F3E9DF); // crème 8 % + static const Color glassLevel2Bg = Color(0xFF1F1815); + static const Color glassLevel2Border = Color(0x1FF3E9DF); // crème 12 % + static const Color glassLevel2InnerGlow = Color(0x3DD9541F); // ember 24 % + + static const Color 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], + /// Braise : orange brûlé vers rouge profond. + static const LinearGradient primaryGradient = LinearGradient( + colors: [Color(0xFFE9701F), Color(0xFFC13A1C)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ); - /// Legacy alias for backward compatibility - static const RadialGradient appleTvGradient = RadialGradient( + /// Fond de salle : projecteur chaud en haut, obscurité en bas. + static const LinearGradient backgroundGradient = LinearGradient( + colors: [Color(0xFF1A1310), Color(0xFF0B0908)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ); + + /// Halo de projection 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(0xFF1A1310), Color(0xFF0B0908)], 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 sombre en bas. + static const LinearGradient cardGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF352A24), Color(0xFF181310)], + stops: [0.0, 1.0], + ); + + /// Reflet chaud posé sur le haut d'une carte. + 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(0x1FF3E9DF), Color(0x00F3E9DF)], + stops: [0.0, 0.5], + ); + + /// Voile d'affiche : lecture du texte par-dessus une jaquette. + static const LinearGradient posterScrim = LinearGradient( + colors: [Color(0x000B0908), Color(0xCC0B0908), Color(0xFF0B0908)], + 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(0xF20B0908), Color(0x990B0908), Color(0x000B0908)], 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(0xFFD9541F).withValues(alpha: opacity); + static Color glowPrimaryDim(double opacity) => + const Color(0xFFFFB68C).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 « projecteur » : profonde, chaude, jamais grise. + static List lift({double intensity = 1.0}) => [ + BoxShadow( + color: const Color(0xFF000000).withValues(alpha: 0.55 * 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 ember pour l'élément focalisé (navigation TV / télécommande). + static List emberFocus({double intensity = 1.0}) => [ + BoxShadow( + color: const Color(0xFFD9541F).withValues(alpha: 0.45 * intensity), + blurRadius: 32 * intensity, + spreadRadius: -4, + ), + BoxShadow( + color: const Color(0xFF000000).withValues(alpha: 0.6), + 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É (ancienne charte Apple TV) ============ + // Conservés pour ne pas casser les sites d'appel ; remappés sur les teintes + // chaudes. À retirer au fil des réécritures d'écrans. + 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); + /// Ancien accent bleu Apple → braise 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 : « papier kraft » — même famille chaude, inversée. static ColorScheme get lightColorScheme => const ColorScheme.light( - primary: Colors.black, - secondary: Colors.grey, - surface: Color(0xFFF2F2F7), - error: error, + primary: Color(0xFFA63B10), onPrimary: Colors.white, + primaryContainer: Color(0xFFFFDBC8), + onPrimaryContainer: Color(0xFF360F00), + secondary: Color(0xFF765847), onSecondary: Colors.white, - onSurface: Colors.black, + secondaryContainer: Color(0xFFFFDCC8), + onSecondaryContainer: Color(0xFF2B160A), + tertiary: Color(0xFF6A5F3F), + onTertiary: Colors.white, + error: Color(0xFFB3261E), onError: Colors.white, + errorContainer: Color(0xFFFFDAD4), + onErrorContainer: Color(0xFF410E05), + surface: Color(0xFFFBF3EC), + onSurface: Color(0xFF201A16), + onSurfaceVariant: Color(0xFF52443B), + outline: Color(0xFF847369), + outlineVariant: Color(0xFFD6C3B7), brightness: Brightness.light, ); } diff --git a/lib/core/theme/app_decorations.dart b/lib/core/theme/app_decorations.dart index 3c2c84a..23ef75b 100644 --- a/lib/core/theme/app_decorations.dart +++ b/lib/core/theme/app_decorations.dart @@ -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); } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 5b8f119..3a2fc93 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -2,266 +2,394 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'app_colors.dart'; -/// XtremFlow Apple TV Theme +/// XtremFlow — "Warm Cinema" theme (ported from the web app). /// -/// 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), - // Global Text Selection Theme (Fixes black cursor issues) + textTheme: _textTheme( + primary: AppColors.textPrimary, + secondary: AppColors.textSecondary, + ), + + // Curseur crème — un curseur blanc pur jure sur fond chaud. textSelectionTheme: const TextSelectionThemeData( - cursorColor: Colors.white, - selectionColor: Color(0x4DFFFFFF), // 30% White - selectionHandleColor: Colors.white, + cursorColor: AppColors.primary, + selectionColor: Color(0x59D9541F), + 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(0x29D9541F), + ), + + 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 (« papier kraft ») ============ 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(0xFFFBF3EC); + const surfaceColor = Color(0xFFFFFFFF); + const primaryColor = Color(0xFFA63B10); + const textPrimaryColor = Color(0xFF201A16); + const textSecondaryColor = Color(0xFF52443B); + const textTertiaryColor = Color(0xFF847369); + const borderColor = Color(0xFFD6C3B7); 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(0x4DA63B10), 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 +397,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), ); } } diff --git a/lib/features/iptv/screens/lite_player_screen.dart b/lib/features/iptv/screens/lite_player_screen.dart index 4a7b721..6e564c4 100644 --- a/lib/features/iptv/screens/lite_player_screen.dart +++ b/lib/features/iptv/screens/lite_player_screen.dart @@ -72,6 +72,10 @@ class _LitePlayerScreenState extends ConsumerState 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(); @@ -98,17 +102,45 @@ class _LitePlayerScreenState extends ConsumerState @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; + + final controller = _controller; + _controller = null; + controller?.pause(); + controller?.dispose(); + + WakelockPlus.disable(); + } + void _startClock() { _clockTimer?.cancel(); _updateTime(); @@ -132,6 +164,13 @@ class _LitePlayerScreenState extends ConsumerState 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 +199,8 @@ class _LitePlayerScreenState extends ConsumerState Future _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; @@ -359,6 +400,11 @@ class _LitePlayerScreenState extends ConsumerState _controller?.pause(); // Force stop audio immediately _controller?.dispose(); _xtreamService?.dispose(); + + _playPauseFocusNode.dispose(); + _prevFocusNode.dispose(); + _nextFocusNode.dispose(); + super.dispose(); } diff --git a/lib/features/iptv/screens/native_player_screen.dart b/lib/features/iptv/screens/native_player_screen.dart index e0db171..496367a 100644 --- a/lib/features/iptv/screens/native_player_screen.dart +++ b/lib/features/iptv/screens/native_player_screen.dart @@ -96,6 +96,15 @@ class _NativePlayerScreenState extends ConsumerState Timer? _controlsTimer; // Auto-hide timer Timer? _epgTimer; // Periodic EPG refresh for live TV Timer? _liveWatchdog; // Watchdog for live stream reconnection + + /// Every player stream subscription, so none survives this screen. + /// Left dangling they keep firing setState against a dead element — the + /// classic slow leak behind "crashes after a few hours". + final List> _playerSubscriptions = []; + + /// Set once the app is backgrounded, to stop any in-flight reload from + /// resurrecting playback behind the user's back. + bool _isReleased = false; int _reconnectAttempts = 0; static const int _maxReconnectAttempts = 5; String _currentTime = ''; @@ -185,6 +194,13 @@ class _NativePlayerScreenState extends ConsumerState (_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '150000000'); // 150MB RAM max (_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '10'); (_player.platform as dynamic)?.setProperty('demuxer-thread', 'yes'); + // --- Plafond du buffer ARRIÈRE (stabilité longue durée) --- + // mpv garde par défaut 50 Mio de flux déjà lu pour permettre un retour + // arrière. Sur du live ce buffer ne sert à rien : cumulé aux 150 Mo de + // readahead il pousse le tas natif vers ~200 Mo et finit par déclencher + // un kill mémoire Android après plusieurs heures. 8 Mo suffisent aux + // resynchronisations internes du démuxeur. + (_player.platform as dynamic)?.setProperty('demuxer-max-back-bytes', '8000000'); // Si le buffer se vide : courte pause (0.5s) puis reprise — freeze minimal, SANS rattrapage // 0.5s vs 1.5s : moins de gel visible, moins de PTS gap à rattraper (_player.platform as dynamic)?.setProperty('cache-pause', 'yes'); @@ -284,7 +300,7 @@ class _NativePlayerScreenState extends ConsumerState _controller = VideoController(_player); // Listen to player state - _player.stream.playing.listen((playing) { + _playerSubscriptions.add(_player.stream.playing.listen((playing) { if (mounted) { setState(() { _isPlaying = playing; @@ -302,18 +318,25 @@ class _NativePlayerScreenState extends ConsumerState _resetControlsTimer(); // Start auto-hide timer } } - }); + })); - _player.stream.position.listen((position) { + _playerSubscriptions.add(_player.stream.position.listen((position) { if (mounted && !_isSeeking) { - setState(() => _position = position); - // [v23.0] Track last position change time for time-based watchdog if (position != _lastKnownPosition) { _lastKnownPosition = position; _lastPositionChangeTime = DateTime.now(); } + // Only rebuild when the position is actually on screen. mpv emits this + // several times per second; rebuilding the whole player tree for a + // hidden readout burns CPU and GC for hours on a live channel. + if (_showControls) { + setState(() => _position = position); + } else { + _position = position; + } + // Check if 80% of content watched (for VOD/Series only) _checkAndMarkWatched(position); @@ -335,9 +358,9 @@ class _NativePlayerScreenState extends ConsumerState } } } - }); + })); - _player.stream.duration.listen((duration) { + _playerSubscriptions.add(_player.stream.duration.listen((duration) { if (mounted) { setState(() { _duration = duration; @@ -348,16 +371,16 @@ class _NativePlayerScreenState extends ConsumerState } }); } - }); + })); - _player.stream.width.listen((width) { + _playerSubscriptions.add(_player.stream.width.listen((width) { if (mounted && (width ?? 0) > 0 && _isLoading) { debugPrint('MediaKitPlayer: Video width detected ($width), clearing loading overlay'); setState(() => _isLoading = false); } - }); + })); - _player.stream.error.listen((error) async { + _playerSubscriptions.add(_player.stream.error.listen((error) async { if (!mounted) return; if (error.isEmpty) return; @@ -385,10 +408,10 @@ class _NativePlayerScreenState extends ConsumerState _errorMessage = error; _isLoading = false; }); - }); + })); // Listen for stream completion - _player.stream.completed.listen((completed) { + _playerSubscriptions.add(_player.stream.completed.listen((completed) { if (completed && mounted) { // For Live TV: do NOT reconnect immediately — let mpv handle it internally. // Only reconnect after a long pause (handled by watchdog). @@ -409,7 +432,7 @@ class _NativePlayerScreenState extends ConsumerState } } } - }); + })); // Lock to landscape for video playback SystemChrome.setPreferredOrientations([ @@ -435,13 +458,14 @@ class _NativePlayerScreenState extends ConsumerState } void _updateTime() { - if (mounted) { - final now = DateTime.now(); - setState(() { - _currentTime = - "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}"; - }); - } + if (!mounted) return; + final now = DateTime.now(); + final formatted = + "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}"; + // The clock ticks every second but only changes once a minute — rebuild + // only when the displayed value actually differs. + if (formatted == _currentTime) return; + setState(() => _currentTime = formatted); } @override @@ -453,23 +477,52 @@ class _NativePlayerScreenState extends ConsumerState @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.paused || - state == AppLifecycleState.inactive || - state == AppLifecycleState.detached) { - if (widget.streamType == model.StreamType.live) { - // Force stop for Live TV - _player.stop(); - } else { - _player.pause(); - } + // `inactive` is deliberately NOT handled: on Android it fires for transient + // interruptions (notification shade, incoming-call banner, permission + // dialog, split-screen resize). Treating it as "backgrounded" killed + // playback every time the user swiped the shade down. + final isBackgrounded = state == AppLifecycleState.paused || + state == AppLifecycleState.hidden || + state == AppLifecycleState.detached; + if (!isBackgrounded) return; - // Exit player screen so user returns to dashboard on resume - if (mounted) { - Navigator.of(context).pop(); - } + _releasePlayback(); + + // Exit the player screen so the user comes back to the dashboard rather + // than to a frozen frame. + if (mounted && ModalRoute.of(context)?.isCurrent == true) { + Navigator.of(context).pop(); } } + /// Fully stop playback and release the decoder. + /// + /// Android reclaims MediaCodec instances from backgrounded apps: holding one + /// while hidden is what leaves the app with a dead surface (black screen, no + /// audio) on return. Stopping releases it deterministically, and also drops + /// the network reader so no bandwidth is consumed in the background. + void _releasePlayback() { + if (_isReleased) return; + _isReleased = true; + + // Kill the timers first: a watchdog firing mid-release would call + // _loadStream() and restart the stream we are tearing down. + _liveWatchdog?.cancel(); + _liveWatchdog = null; + _epgTimer?.cancel(); + _epgTimer = null; + _controlsTimer?.cancel(); + _controlsTimer = null; + _stopClock(); + + if (widget.streamType != model.StreamType.live) { + _saveResumePositionOnExit(); + } + + _player.stop(); + WakelockPlus.disable(); + } + @override void dispose() { // Remove lifecycle observer @@ -481,8 +534,11 @@ class _NativePlayerScreenState extends ConsumerState // Disable wakelock when leaving player WakelockPlus.disable(); - // Save resume position before cleanup (only for VOD/Series) - _saveResumePositionOnExit(); + // Save resume position before cleanup (only for VOD/Series). + // Skipped when _releasePlayback already did it on backgrounding. + if (!_isReleased) { + _saveResumePositionOnExit(); + } _clockTimer?.cancel(); _controlsTimer?.cancel(); @@ -491,6 +547,13 @@ class _NativePlayerScreenState extends ConsumerState _channelListTimer?.cancel(); _channelListScrollController.dispose(); + // Detach from the player streams BEFORE disposing it, so no late event can + // land on a deactivated element. + for (final subscription in _playerSubscriptions) { + subscription.cancel(); + } + _playerSubscriptions.clear(); + // Stop playback first to prevent audio continuing in background _player.stop(); _player.dispose(); @@ -605,7 +668,7 @@ class _NativePlayerScreenState extends ConsumerState /// Attempt to reconnect a live stream that stopped void _attemptReconnect() { - if (!mounted) return; + if (!mounted || _isReleased) return; if (_reconnectAttempts >= _maxReconnectAttempts) { setState(() { _errorMessage = 'Flux interrompu après plusieurs tentatives'; @@ -693,7 +756,7 @@ class _NativePlayerScreenState extends ConsumerState // Vérifie toutes les 10s, mais seuil de reconnexion à 60s de position gelée _liveWatchdog = Timer.periodic(const Duration(seconds: 10), (_) { - if (!mounted) return; + if (!mounted || _isReleased) return; // Si le player joue et la position avance : tout va bien, reset if (_isPlaying && _lastPositionChangeTime != null) { @@ -727,6 +790,14 @@ class _NativePlayerScreenState extends ConsumerState _xtreamService = XtreamServiceMobile(dir.path); await _xtreamService!.setPlaylistAsync(widget.playlist); + // Resolve DNS and open (then drop) a bare socket to the panel host so + // mpv's first request skips resolution and the TCP/TLS handshake. + // Capped hard: playback must never wait more than a moment on this. + await _xtreamService!.prewarmHost().timeout( + const Duration(milliseconds: 1500), + onTimeout: () {}, + ); + await _loadStream(); } catch (e) { if (mounted) { @@ -745,6 +816,9 @@ class _NativePlayerScreenState extends ConsumerState Future _loadStream({Duration? startAt}) async { try { if (_xtreamService == null) return; + // The app went to background while a (re)load was queued — do not bring + // the stream back up behind the user. + if (_isReleased || !mounted) return; setState(() { if (_isFirstLoad) { diff --git a/lib/features/iptv/services/xmltv_service.dart b/lib/features/iptv/services/xmltv_service.dart new file mode 100644 index 0000000..8fcf057 --- /dev/null +++ b/lib/features/iptv/services/xmltv_service.dart @@ -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? 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 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> _byChannelId = {}; + final Map _normalizedNameToChannelId = {}; + + String? _loadedSourceLabel; + DateTime? _loadedAt; + Future? _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 ensureLoaded(List sources) { + if (isLoaded && !_isStale) return Future.value(true); + return _inFlight ??= _loadFirstAvailable(sources).whenComplete(() { + _inFlight = null; + }); + } + + Future _loadFirstAvailable(List 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? _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 _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 indexFromBytes( + Stream> bytes, { + DateTime? now, + }) async { + final window = _Window.around(now ?? DateTime.now()); + final channelNames = >{}; + final programmes = >{}; + + 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> 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> 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> programmes, + required Map> 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 . + 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> _capped(Stream> 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> _decompressed(Stream> 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> 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 _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 = >{}; + rawChannels.forEach((id, entries) { + if (entries is! List) return; + final parsed = entries + .map(XmltvProgramme.fromCache) + .whereType() + .toList(); + if (parsed.isNotEmpty) programmes[id.toString()] = parsed; + }); + if (programmes.isEmpty) return false; + + final rawNames = decoded['names']; + final channelNames = >{}; + 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 _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); +} diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index d517c78..2ad1c94 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -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 = >[]; + + Future acquire() { + if (_current < maxConcurrent) { + _current++; + return Future.value(); + } + final completer = Completer(); + _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 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 _epgBatchCache = {}; @@ -42,8 +85,54 @@ class XtreamServiceMobile { // In-flight batch requests to deduplicate concurrent calls final Map>> _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 _channelIndex = {}; + + 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 setPlaylistAsync(PlaylistConfig config) async { _baseUrl = config.dns; @@ -59,11 +148,56 @@ 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 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 Future> getLiveChannels(String categoryId) async { @@ -98,6 +232,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 +334,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 +384,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,6 +409,59 @@ 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 _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> _resolveFromXmltv( + List 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() + .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 = {}; + 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 Future> getLiveCategories() async { try { @@ -530,6 +739,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 +780,8 @@ class XtreamServiceMobile { /// Dispose service resources void dispose() { clearCache(); + _channelIndex.clear(); + _xmltv.clear(); + _dio.close(force: true); } } diff --git a/lib/features/iptv/widgets/mobile_settings_tab.dart b/lib/features/iptv/widgets/mobile_settings_tab.dart index 5da25d9..9f1a672 100644 --- a/lib/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/features/iptv/widgets/mobile_settings_tab.dart @@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:xtremobile/mobile/providers/mobile_settings_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'; @@ -20,6 +21,7 @@ class _MobileSettingsTabState extends ConsumerState { late TextEditingController _liveTvController; late TextEditingController _moviesController; late TextEditingController _seriesController; + late TextEditingController _xmltvController; bool _initialized = false; bool _isRefreshingCache = false; @@ -29,6 +31,7 @@ class _MobileSettingsTabState extends ConsumerState { _liveTvController = TextEditingController(); _moviesController = TextEditingController(); _seriesController = TextEditingController(); + _xmltvController = TextEditingController(); } @override @@ -36,6 +39,7 @@ class _MobileSettingsTabState extends ConsumerState { _liveTvController.dispose(); _moviesController.dispose(); _seriesController.dispose(); + _xmltvController.dispose(); super.dispose(); } @@ -44,6 +48,7 @@ class _MobileSettingsTabState extends ConsumerState { _liveTvController.text = settings.liveTvKeywords.join(', '); _moviesController.text = settings.moviesKeywords.join(', '); _seriesController.text = settings.seriesKeywords.join(', '); + _xmltvController.text = settings.xmltvUrl; _initialized = true; } } @@ -167,6 +172,38 @@ class _MobileSettingsTabState extends ConsumerState { 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'), @@ -417,11 +454,9 @@ class _MobileSettingsTabState extends ConsumerState { 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, ), ), ); diff --git a/lib/mobile/providers/mobile_settings_providers.dart b/lib/mobile/providers/mobile_settings_providers.dart index 8a14554..f45d14e 100644 --- a/lib/mobile/providers/mobile_settings_providers.dart +++ b/lib/mobile/providers/mobile_settings_providers.dart @@ -18,6 +18,12 @@ class MobileSettings { final List 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? 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 { final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?; final deinterlacedChannels = _box?.get('deinterlacedChannels', defaultValue: []) as List?; + final xmltvUrl = _box?.get('xmltvUrl', defaultValue: '') as String?; + final useCommunityEpg = + _box?.get('useCommunityEpg', defaultValue: true) as bool?; state = MobileSettings( liveTvKeywords: liveTv?.cast() ?? [], @@ -134,6 +149,8 @@ class MobileSettingsNotifier extends StateNotifier { deinterlace: deinterlace ?? false, bufferDuration: buffer ?? 0, deinterlacedChannels: deinterlacedChannels?.cast() ?? [], + xmltvUrl: xmltvUrl ?? '', + useCommunityEpg: useCommunityEpg ?? true, ); } @@ -187,6 +204,17 @@ class MobileSettingsNotifier extends StateNotifier { _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.from(state.deinterlacedChannels); if (currentList.contains(streamId)) { diff --git a/lib/mobile/providers/mobile_xtream_providers.dart b/lib/mobile/providers/mobile_xtream_providers.dart index b26c25a..8600d84 100644 --- a/lib/mobile/providers/mobile_xtream_providers.dart +++ b/lib/mobile/providers/mobile_xtream_providers.dart @@ -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,6 +14,23 @@ 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(mobileSettingsProvider, (previous, next) { + if (previous?.xmltvUrl != next.xmltvUrl || + previous?.useCommunityEpg != next.useCommunityEpg) { + applyEpgSettings(next); + } + }); + + ref.onDispose(service.dispose); return service; }); diff --git a/lib/mobile/theme/mobile_theme.dart b/lib/mobile/theme/mobile_theme.dart index 508b6e6..ab0c395 100644 --- a/lib/mobile/theme/mobile_theme.dart +++ b/lib/mobile/theme/mobile_theme.dart @@ -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; } diff --git a/pubspec.yaml b/pubspec.yaml index 74ab5a8..549f8de 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: xtremobile description: High-performance IPTV Android Application with Xtream Codes API publish_to: 'none' -version: 1.5.1+11 +version: 1.6.0+12 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 diff --git a/task.md b/task.md index ff1bf1f..566d5c9 100644 --- a/task.md +++ b/task.md @@ -1,25 +1,65 @@ -# 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 -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`. diff --git a/test/xmltv_real_dump_test.dart b/test/xmltv_real_dump_test.dart new file mode 100644 index 0000000..090e3f3 --- /dev/null +++ b/test/xmltv_real_dump_test.dart @@ -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 = >[ + ['', '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, + ); +} diff --git a/test/xmltv_service_test.dart b/test/xmltv_service_test.dart new file mode 100644 index 0000000..9c57080 --- /dev/null +++ b/test/xmltv_service_test.dart @@ -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 ''' + + + TF1 + + + France 3 - Nord Pas-de-Calais + + + Ciné+ Premier + + + Journal de 20h + Le rendez-vous d'information. + + + Film du mardi + + + Ici 19/20 + + + Hors fenêtre + +'''; +} + +Stream> _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(''), + ); + expect(indexed, isFalse); + expect(empty.isLoaded, isFalse); + }); + }); +}