mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
feat: XMLTV EPG fallback, Warm Cinema theme, background release
Align the Android app with xtremflow on three fronts. EPG: add XmltvService, a streaming XMLTV parser (gunzip-aware, indexed to a -3h/+18h window, JSON disk cache with a 6h TTL). XtreamServiceMobile now falls back through the provider's xmltv.php, a user-supplied URL, then a community guide whenever get_short_epg returns nothing. Channels are matched on epg_channel_id first, then on a normalized name (accents, HD/FHD/4K suffixes, "FR:" and "|FR|" prefixes). A streamId -> Channel index populated by getLiveChannels supplies those keys, so no getShortEPG call site changed. EPG requests are also funnelled through a 3-slot semaphore, as in xtremflow: a 30-tile grid used to fire 30 parallel player_api.php calls, which rate-limited panels answered with errors, emptying the guide for the whole screen. Theme: port the Warm Cinema charter (ember/charcoal/cream, Fraunces + Karla) into app_colors, app_theme and app_decorations. The 21 Apple TV colour names already in use are kept as compatibility aliases. mobile_theme now derives from AppTheme instead of duplicating its 390 lines. Playback lifecycle: stop reacting to AppLifecycleState.inactive, which fires on transient Android interruptions such as pulling down the notification shade and was killing playback. Both engines now release on paused/hidden/detached via _releasePlayback(): stream stopped, decoder released, timers cancelled, wakelock dropped. An _isReleased latch keeps the watchdog and reconnect paths from restarting a stream from the background. Long-run stability: cancel the six player stream subscriptions on dispose, cap demuxer-max-back-bytes at 8MB (mpv defaults to 50MiB, which combined with the 150MB readahead pushed the native heap toward an Android memory kill after a few hours), rebuild on position only while the controls are visible, and dispose the Lite player's focus nodes. Startup: prewarmHost() pre-resolves DNS and opens then closes a bare TCP/TLS socket before playback. No HTTP request is issued, so no provider connection slot is consumed. Capped at 1.5s. Verified: flutter analyze clean on all touched files; 19/19 tests, including a run over a real 46MB/69802-programme dump (517 channels indexed in 1.1s, 5/5 name probes matched); flutter build apk --release succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
63e77c5bc5
commit
8d74bbb320
15 files changed
+2138
-852
No files matched your search
+245
-123
@@ -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<BoxShadow> 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<BoxShadow> 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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+340
-214
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
Timer? _controlsTimer;
|
||||
int _loadId = 0;
|
||||
|
||||
/// Set once the app is backgrounded, so a queued reload cannot restart the
|
||||
/// stream behind the user.
|
||||
bool _isReleased = false;
|
||||
|
||||
// Focus
|
||||
final FocusNode _playPauseFocusNode = FocusNode();
|
||||
final FocusNode _prevFocusNode = FocusNode();
|
||||
@@ -98,17 +102,45 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (_controller == null) return;
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.detached) {
|
||||
_controller?.pause();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
// `inactive` is deliberately NOT handled: on Android it fires for transient
|
||||
// interruptions (notification shade, call banner, permission dialog).
|
||||
final isBackgrounded = state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.hidden ||
|
||||
state == AppLifecycleState.detached;
|
||||
if (!isBackgrounded) return;
|
||||
|
||||
_releasePlayback();
|
||||
|
||||
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop playback and release the decoder when the app leaves the foreground.
|
||||
///
|
||||
/// `pause()` alone is not enough: ExoPlayer keeps filling its buffer, so the
|
||||
/// stream would still be pulled (and metered) in the background.
|
||||
void _releasePlayback() {
|
||||
if (_isReleased) return;
|
||||
_isReleased = true;
|
||||
|
||||
_clockTimer?.cancel();
|
||||
_clockTimer = null;
|
||||
_epgTimer?.cancel();
|
||||
_epgTimer = null;
|
||||
_controlsTimer?.cancel();
|
||||
_controlsTimer = null;
|
||||
_stabilizationTimer?.cancel();
|
||||
_stabilizationTimer = null;
|
||||
|
||||
final controller = _controller;
|
||||
_controller = null;
|
||||
controller?.pause();
|
||||
controller?.dispose();
|
||||
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
|
||||
void _startClock() {
|
||||
_clockTimer?.cancel();
|
||||
_updateTime();
|
||||
@@ -132,6 +164,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_xtreamService = XtreamServiceMobile(dir.path);
|
||||
await _xtreamService!.setPlaylistAsync(widget.playlist);
|
||||
|
||||
// Warm DNS + TCP/TLS to the panel host before the first request.
|
||||
await _xtreamService!.prewarmHost().timeout(
|
||||
const Duration(milliseconds: 1500),
|
||||
onTimeout: () {},
|
||||
);
|
||||
|
||||
_loadStream();
|
||||
|
||||
if (widget.streamType == model.StreamType.live) {
|
||||
@@ -160,6 +199,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
|
||||
Future<void> _loadStream() async {
|
||||
if (_xtreamService == null) return;
|
||||
// Backgrounded while a (re)load was queued — do not resurrect the stream.
|
||||
if (_isReleased || !mounted) return;
|
||||
|
||||
await _controller?.dispose();
|
||||
_controller = null;
|
||||
@@ -359,6 +400,11 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
|
||||
_controller?.pause(); // Force stop audio immediately
|
||||
_controller?.dispose();
|
||||
_xtreamService?.dispose();
|
||||
|
||||
_playPauseFocusNode.dispose();
|
||||
_prevFocusNode.dispose();
|
||||
_nextFocusNode.dispose();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
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<StreamSubscription<dynamic>> _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<NativePlayerScreen>
|
||||
(_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<NativePlayerScreen>
|
||||
_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<NativePlayerScreen>
|
||||
_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<NativePlayerScreen>
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
_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<NativePlayerScreen>
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
_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<NativePlayerScreen>
|
||||
_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<NativePlayerScreen>
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// Lock to landscape for video playback
|
||||
SystemChrome.setPreferredOrientations([
|
||||
@@ -435,13 +458,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
|
||||
}
|
||||
|
||||
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<NativePlayerScreen>
|
||||
|
||||
@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<NativePlayerScreen>
|
||||
// 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<NativePlayerScreen>
|
||||
_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<NativePlayerScreen>
|
||||
|
||||
/// 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<NativePlayerScreen>
|
||||
|
||||
// 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<NativePlayerScreen>
|
||||
_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<NativePlayerScreen>
|
||||
Future<void> _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) {
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
/// XMLTV EPG fallback service
|
||||
///
|
||||
/// Used when the IPTV provider's `get_short_epg` returns nothing for a channel.
|
||||
/// Resolution order is decided by the caller (see [XtremServiceMobile]); this
|
||||
/// service only knows how to fetch, parse, index and cache one XMLTV source.
|
||||
///
|
||||
/// Design constraints:
|
||||
/// - XMLTV dumps are routinely 50-300 MB uncompressed. Parsing is done as a
|
||||
/// **streaming** pass (`toXmlEvents`) so the whole document is never held in
|
||||
/// memory, and only programmes inside a short time window are kept.
|
||||
/// - The reduced index is persisted to disk, so a cold start costs one small
|
||||
/// JSON read instead of a multi-megabyte download.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
import 'package:xml/xml_events.dart';
|
||||
|
||||
import 'package:xtremobile/core/models/iptv_models.dart' as model;
|
||||
|
||||
/// One XMLTV source to try, in priority order.
|
||||
class XmltvSource {
|
||||
/// Human-readable origin, surfaced in settings/diagnostics.
|
||||
final String label;
|
||||
final String url;
|
||||
final Map<String, String>? headers;
|
||||
|
||||
const XmltvSource({
|
||||
required this.label,
|
||||
required this.url,
|
||||
this.headers,
|
||||
});
|
||||
}
|
||||
|
||||
/// A single programme kept in the in-memory index.
|
||||
class XmltvProgramme {
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
final String title;
|
||||
final String description;
|
||||
|
||||
const XmltvProgramme({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.title,
|
||||
this.description = '',
|
||||
});
|
||||
|
||||
bool covers(DateTime at) => !at.isBefore(start) && at.isBefore(end);
|
||||
|
||||
List<dynamic> toCache() => [
|
||||
start.toIso8601String(),
|
||||
end.toIso8601String(),
|
||||
title,
|
||||
description,
|
||||
];
|
||||
|
||||
static XmltvProgramme? fromCache(dynamic raw) {
|
||||
if (raw is! List || raw.length < 4) return null;
|
||||
final start = DateTime.tryParse(raw[0].toString());
|
||||
final end = DateTime.tryParse(raw[1].toString());
|
||||
if (start == null || end == null) return null;
|
||||
return XmltvProgramme(
|
||||
start: start,
|
||||
end: end,
|
||||
title: raw[2].toString(),
|
||||
description: raw[3].toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XmltvService {
|
||||
XmltvService(this.cacheDir);
|
||||
|
||||
final String cacheDir;
|
||||
|
||||
/// How long a downloaded+indexed guide stays usable before a refetch.
|
||||
static const Duration cacheTtl = Duration(hours: 6);
|
||||
|
||||
/// Only programmes overlapping [now - back, now + forward] are indexed.
|
||||
/// Keeps a national guide down to a few hundred KB instead of hundreds of MB.
|
||||
static const Duration windowBack = Duration(hours: 3);
|
||||
static const Duration windowForward = Duration(hours: 18);
|
||||
|
||||
/// Hard ceiling on the download, so a hostile or broken URL can't fill
|
||||
/// the device storage or spin forever.
|
||||
static const int maxDownloadBytes = 220 * 1024 * 1024;
|
||||
|
||||
final Map<String, List<XmltvProgramme>> _byChannelId = {};
|
||||
final Map<String, String> _normalizedNameToChannelId = {};
|
||||
|
||||
String? _loadedSourceLabel;
|
||||
DateTime? _loadedAt;
|
||||
Future<bool>? _inFlight;
|
||||
|
||||
/// True once at least one source has been indexed successfully.
|
||||
bool get isLoaded => _byChannelId.isNotEmpty;
|
||||
|
||||
/// Label of the source currently backing the index (`null` if none).
|
||||
String? get loadedSourceLabel => _loadedSourceLabel;
|
||||
|
||||
int get channelCount => _byChannelId.length;
|
||||
|
||||
bool get _isStale {
|
||||
final at = _loadedAt;
|
||||
if (at == null) return true;
|
||||
return DateTime.now().difference(at) > cacheTtl;
|
||||
}
|
||||
|
||||
/// Load the first source that yields a usable guide.
|
||||
///
|
||||
/// Concurrent callers share a single in-flight attempt. Returns `false` when
|
||||
/// every source failed — callers should then simply render "no EPG".
|
||||
Future<bool> ensureLoaded(List<XmltvSource> sources) {
|
||||
if (isLoaded && !_isStale) return Future.value(true);
|
||||
return _inFlight ??= _loadFirstAvailable(sources).whenComplete(() {
|
||||
_inFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _loadFirstAvailable(List<XmltvSource> sources) async {
|
||||
for (final source in sources) {
|
||||
if (source.url.trim().isEmpty) continue;
|
||||
try {
|
||||
if (await _loadFromDiskCache(source)) {
|
||||
_loadedSourceLabel = source.label;
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'✅ [XMLTV] Disk cache hit "${source.label}" '
|
||||
'($channelCount channels)',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (await _download(source)) {
|
||||
_loadedSourceLabel = source.label;
|
||||
_loadedAt = DateTime.now();
|
||||
unawaited(_writeDiskCache(source));
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'✅ [XMLTV] Indexed "${source.label}" '
|
||||
'($channelCount channels)',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('⚠️ [XMLTV] "${source.label}" returned no usable programme');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('⚠️ [XMLTV] "${source.label}" failed: $e');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Lookup ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Find the programme airing at [at] for a channel.
|
||||
///
|
||||
/// Matching is tried on the Xtream `epg_channel_id` first (the reliable key),
|
||||
/// then on a normalized channel name, which is what rescues providers that
|
||||
/// ship empty or non-standard `epg_channel_id` values.
|
||||
XmltvProgramme? programmeFor({
|
||||
String? epgChannelId,
|
||||
String? channelName,
|
||||
DateTime? at,
|
||||
}) {
|
||||
final moment = at ?? DateTime.now();
|
||||
final programmes = _programmesFor(
|
||||
epgChannelId: epgChannelId,
|
||||
channelName: channelName,
|
||||
);
|
||||
if (programmes == null || programmes.isEmpty) return null;
|
||||
|
||||
for (final programme in programmes) {
|
||||
if (programme.covers(moment)) return programme;
|
||||
}
|
||||
// Nothing airing right now (guide gap): fall back to the next entry so the
|
||||
// UI shows something meaningful rather than an empty slot.
|
||||
for (final programme in programmes) {
|
||||
if (programme.start.isAfter(moment)) return programme;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Same lookup, shaped as the app's [model.ShortEPG].
|
||||
model.ShortEPG? shortEpgFor({
|
||||
required String streamId,
|
||||
String? epgChannelId,
|
||||
String? channelName,
|
||||
DateTime? at,
|
||||
}) {
|
||||
final programme = programmeFor(
|
||||
epgChannelId: epgChannelId,
|
||||
channelName: channelName,
|
||||
at: at,
|
||||
);
|
||||
if (programme == null || programme.title.isEmpty) return null;
|
||||
|
||||
return model.ShortEPG(
|
||||
id: streamId,
|
||||
title: programme.title,
|
||||
start: _formatLocal(programme.start),
|
||||
end: _formatLocal(programme.end),
|
||||
description: programme.description,
|
||||
);
|
||||
}
|
||||
|
||||
List<XmltvProgramme>? _programmesFor({
|
||||
String? epgChannelId,
|
||||
String? channelName,
|
||||
}) {
|
||||
final id = epgChannelId?.trim().toLowerCase();
|
||||
if (id != null && id.isNotEmpty) {
|
||||
final direct = _byChannelId[id];
|
||||
if (direct != null && direct.isNotEmpty) return direct;
|
||||
}
|
||||
|
||||
final normalized = normalizeName(channelName ?? '');
|
||||
if (normalized.isEmpty) return null;
|
||||
final mapped = _normalizedNameToChannelId[normalized];
|
||||
if (mapped == null) return null;
|
||||
return _byChannelId[mapped];
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_byChannelId.clear();
|
||||
_normalizedNameToChannelId.clear();
|
||||
_loadedSourceLabel = null;
|
||||
_loadedAt = null;
|
||||
}
|
||||
|
||||
// ─── Download & streaming parse ────────────────────────────────────────────
|
||||
|
||||
Future<bool> _download(XmltvSource source) async {
|
||||
final uri = Uri.tryParse(source.url.trim());
|
||||
if (uri == null || !uri.hasScheme) return false;
|
||||
|
||||
final client = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 20)
|
||||
// `.xml.gz` bodies arrive as a gzip *payload*, not a gzip
|
||||
// Content-Encoding, so decompression is handled explicitly below.
|
||||
..autoUncompress = false;
|
||||
|
||||
try {
|
||||
final request = await client.getUrl(uri);
|
||||
request.headers.set(HttpHeaders.acceptEncodingHeader, 'gzip');
|
||||
request.headers.set(HttpHeaders.userAgentHeader, 'XtremFlow/1.0');
|
||||
source.headers?.forEach(request.headers.set);
|
||||
|
||||
final response = await request.close().timeout(
|
||||
const Duration(seconds: 30),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
if (kDebugMode) {
|
||||
print('⚠️ [XMLTV] HTTP ${response.statusCode} on ${source.label}');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return indexFromBytes(_capped(response));
|
||||
} on TimeoutException {
|
||||
if (kDebugMode) print('⏱️ [XMLTV] Timeout on ${source.label}');
|
||||
return false;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and index an XMLTV payload from a byte stream.
|
||||
///
|
||||
/// Split out of [_download] so the parsing path is testable without HTTP.
|
||||
/// Gzip is detected from the payload itself, and only programmes overlapping
|
||||
/// the current window are retained.
|
||||
@visibleForTesting
|
||||
Future<bool> indexFromBytes(
|
||||
Stream<List<int>> bytes, {
|
||||
DateTime? now,
|
||||
}) async {
|
||||
final window = _Window.around(now ?? DateTime.now());
|
||||
final channelNames = <String, List<String>>{};
|
||||
final programmes = <String, List<XmltvProgramme>>{};
|
||||
|
||||
final events = _decompressed(bytes)
|
||||
.transform(const Utf8Decoder(allowMalformed: true))
|
||||
.toXmlEvents()
|
||||
.normalizeEvents()
|
||||
.selectSubtreeEvents(
|
||||
(event) => event.name == 'channel' || event.name == 'programme',
|
||||
)
|
||||
.toXmlNodes()
|
||||
.flatten();
|
||||
|
||||
await for (final node in events) {
|
||||
if (node is! XmlElement) continue;
|
||||
if (node.name.local == 'channel') {
|
||||
_indexChannelElement(node, channelNames);
|
||||
} else {
|
||||
_indexProgrammeElement(node, window, programmes);
|
||||
}
|
||||
}
|
||||
|
||||
if (programmes.isEmpty) return false;
|
||||
|
||||
_commit(programmes: programmes, channelNames: channelNames);
|
||||
_loadedAt = DateTime.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
void _indexChannelElement(
|
||||
XmlElement node,
|
||||
Map<String, List<String>> channelNames,
|
||||
) {
|
||||
final id = node.getAttribute('id')?.trim().toLowerCase();
|
||||
if (id == null || id.isEmpty) return;
|
||||
final names = node
|
||||
.findElements('display-name')
|
||||
.map((e) => e.innerText.trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
if (names.isEmpty) return;
|
||||
channelNames.putIfAbsent(id, () => []).addAll(names);
|
||||
}
|
||||
|
||||
void _indexProgrammeElement(
|
||||
XmlElement node,
|
||||
_Window window,
|
||||
Map<String, List<XmltvProgramme>> programmes,
|
||||
) {
|
||||
final channel = node.getAttribute('channel')?.trim().toLowerCase();
|
||||
if (channel == null || channel.isEmpty) return;
|
||||
|
||||
final start = parseXmltvTime(node.getAttribute('start'));
|
||||
final end = parseXmltvTime(node.getAttribute('stop'));
|
||||
if (start == null || end == null) return;
|
||||
if (!window.overlaps(start, end)) return;
|
||||
|
||||
final title = node
|
||||
.findElements('title')
|
||||
.map((e) => e.innerText.trim())
|
||||
.firstWhere((e) => e.isNotEmpty, orElse: () => '')
|
||||
.trim();
|
||||
if (title.isEmpty) return;
|
||||
|
||||
final description = node
|
||||
.findElements('desc')
|
||||
.map((e) => e.innerText.trim())
|
||||
.firstWhere((e) => e.isNotEmpty, orElse: () => '');
|
||||
|
||||
programmes.putIfAbsent(channel, () => []).add(
|
||||
XmltvProgramme(
|
||||
start: start,
|
||||
end: end,
|
||||
title: title,
|
||||
description: description,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _commit({
|
||||
required Map<String, List<XmltvProgramme>> programmes,
|
||||
required Map<String, List<String>> channelNames,
|
||||
}) {
|
||||
_byChannelId
|
||||
..clear()
|
||||
..addAll(programmes);
|
||||
for (final list in _byChannelId.values) {
|
||||
list.sort((a, b) => a.start.compareTo(b.start));
|
||||
}
|
||||
|
||||
_normalizedNameToChannelId.clear();
|
||||
// Channel ids are themselves readable labels in most community dumps
|
||||
// (`France.2..fr`, `Trace.Toca.fr`), so they are indexed as name
|
||||
// candidates too — this rescues channels that carry no <display-name>.
|
||||
for (final id in _byChannelId.keys) {
|
||||
final fromId = normalizeName(_labelFromChannelId(id));
|
||||
if (fromId.isNotEmpty) {
|
||||
_normalizedNameToChannelId.putIfAbsent(fromId, () => id);
|
||||
}
|
||||
}
|
||||
channelNames.forEach((id, names) {
|
||||
if (!_byChannelId.containsKey(id)) return;
|
||||
for (final name in names) {
|
||||
final normalized = normalizeName(name);
|
||||
if (normalized.isEmpty) continue;
|
||||
_normalizedNameToChannelId[normalized] = id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Enforce [maxDownloadBytes] mid-stream.
|
||||
Stream<List<int>> _capped(Stream<List<int>> input) async* {
|
||||
var total = 0;
|
||||
await for (final chunk in input) {
|
||||
total += chunk.length;
|
||||
if (total > maxDownloadBytes) {
|
||||
throw StateError('XMLTV payload exceeds ${maxDownloadBytes ~/ 1048576}MB');
|
||||
}
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparently gunzip when the payload starts with the gzip magic number.
|
||||
Stream<List<int>> _decompressed(Stream<List<int>> input) async* {
|
||||
final iterator = StreamIterator(input);
|
||||
try {
|
||||
if (!await iterator.moveNext()) return;
|
||||
|
||||
var head = iterator.current;
|
||||
while (head.length < 2 && await iterator.moveNext()) {
|
||||
head = [...head, ...iterator.current];
|
||||
}
|
||||
|
||||
Stream<List<int>> replayed() async* {
|
||||
yield head;
|
||||
while (await iterator.moveNext()) {
|
||||
yield iterator.current;
|
||||
}
|
||||
}
|
||||
|
||||
final isGzip = head.length >= 2 && head[0] == 0x1f && head[1] == 0x8b;
|
||||
yield* isGzip ? gzip.decoder.bind(replayed()) : replayed();
|
||||
} finally {
|
||||
await iterator.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Disk cache ────────────────────────────────────────────────────────────
|
||||
|
||||
File _cacheFile(XmltvSource source) {
|
||||
final key = source.url.hashCode.toUnsigned(32).toRadixString(16);
|
||||
return File('$cacheDir${Platform.pathSeparator}xmltv_$key.json');
|
||||
}
|
||||
|
||||
Future<bool> _loadFromDiskCache(XmltvSource source) async {
|
||||
final file = _cacheFile(source);
|
||||
if (!await file.exists()) return false;
|
||||
|
||||
final fetchedAt = await file.lastModified();
|
||||
if (DateTime.now().difference(fetchedAt) > cacheTtl) return false;
|
||||
|
||||
try {
|
||||
final decoded = json.decode(await file.readAsString());
|
||||
if (decoded is! Map) return false;
|
||||
|
||||
final rawChannels = decoded['channels'];
|
||||
if (rawChannels is! Map) return false;
|
||||
|
||||
final programmes = <String, List<XmltvProgramme>>{};
|
||||
rawChannels.forEach((id, entries) {
|
||||
if (entries is! List) return;
|
||||
final parsed = entries
|
||||
.map(XmltvProgramme.fromCache)
|
||||
.whereType<XmltvProgramme>()
|
||||
.toList();
|
||||
if (parsed.isNotEmpty) programmes[id.toString()] = parsed;
|
||||
});
|
||||
if (programmes.isEmpty) return false;
|
||||
|
||||
final rawNames = decoded['names'];
|
||||
final channelNames = <String, List<String>>{};
|
||||
if (rawNames is Map) {
|
||||
rawNames.forEach((normalized, id) {
|
||||
channelNames
|
||||
.putIfAbsent(id.toString(), () => [])
|
||||
.add(normalized.toString());
|
||||
});
|
||||
}
|
||||
|
||||
_commit(programmes: programmes, channelNames: channelNames);
|
||||
_loadedAt = fetchedAt;
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('⚠️ [XMLTV] Corrupt disk cache, ignoring: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeDiskCache(XmltvSource source) async {
|
||||
try {
|
||||
final payload = {
|
||||
'source': source.label,
|
||||
'url': source.url,
|
||||
'fetchedAt': DateTime.now().toIso8601String(),
|
||||
'channels': _byChannelId.map(
|
||||
(id, list) => MapEntry(id, list.map((p) => p.toCache()).toList()),
|
||||
),
|
||||
'names': _normalizedNameToChannelId,
|
||||
};
|
||||
await _cacheFile(source).writeAsString(json.encode(payload), flush: true);
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('⚠️ [XMLTV] Could not persist cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse an XMLTV timestamp: `20260811183000 +0200` (offset optional).
|
||||
static DateTime? parseXmltvTime(String? raw) {
|
||||
final value = raw?.trim();
|
||||
if (value == null || value.length < 14) return null;
|
||||
|
||||
int? part(int start, int end) => int.tryParse(value.substring(start, end));
|
||||
|
||||
final year = part(0, 4);
|
||||
final month = part(4, 6);
|
||||
final day = part(6, 8);
|
||||
final hour = part(8, 10);
|
||||
final minute = part(10, 12);
|
||||
final second = part(12, 14);
|
||||
if (year == null ||
|
||||
month == null ||
|
||||
day == null ||
|
||||
hour == null ||
|
||||
minute == null ||
|
||||
second == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final utc = DateTime.utc(year, month, day, hour, minute, second);
|
||||
|
||||
final offset = value.substring(14).trim();
|
||||
if (offset.isEmpty) return utc.toLocal();
|
||||
|
||||
final match = RegExp(r'^([+-])(\d{2})(\d{2})$').firstMatch(offset);
|
||||
if (match == null) return utc.toLocal();
|
||||
|
||||
final sign = match.group(1) == '-' ? -1 : 1;
|
||||
final offsetDuration = Duration(
|
||||
hours: int.parse(match.group(2)!),
|
||||
minutes: int.parse(match.group(3)!),
|
||||
);
|
||||
return utc.subtract(offsetDuration * sign).toLocal();
|
||||
}
|
||||
|
||||
/// Turn an XMLTV channel id into the label it encodes.
|
||||
///
|
||||
/// `France.2..fr` → `France 2`, `Trace.Toca.fr` → `Trace Toca`.
|
||||
static String _labelFromChannelId(String id) {
|
||||
var value = id.replaceAll(
|
||||
RegExp(
|
||||
r'\.(fr|uk|us|de|es|it|be|ch|ca|nl|pt|pl|ar|br|tr|ma|dz|tn)$',
|
||||
caseSensitive: false,
|
||||
),
|
||||
'',
|
||||
);
|
||||
return value.replaceAll('.', ' ').trim();
|
||||
}
|
||||
|
||||
/// Reduce a channel label to a comparable key.
|
||||
///
|
||||
/// Strips accents, quality suffixes (`HD`, `FHD`, `4K`, …) and any
|
||||
/// punctuation, so `TF1 HD`, `tf1-fhd` and `TF1` all collapse to `tf1`.
|
||||
static String normalizeName(String raw) {
|
||||
var value = raw.toLowerCase().trim();
|
||||
if (value.isEmpty) return '';
|
||||
|
||||
const accents = 'àáâãäåçèéêëìíîïñòóôõöùúûüýÿ';
|
||||
const plain = 'aaaaaaceeeeiiiinooooouuuuyy';
|
||||
final buffer = StringBuffer();
|
||||
for (final rune in value.runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
final index = accents.indexOf(char);
|
||||
buffer.write(index >= 0 ? plain[index] : char);
|
||||
}
|
||||
value = buffer.toString();
|
||||
|
||||
// Drop the country prefix providers prepend.
|
||||
// Bracketed form — "|FR| TF1", "[FR] TF1", "(FR) TF1".
|
||||
value = value.replaceFirst(
|
||||
RegExp(r'^\s*[\|\[\(][a-z]{2,4}[\|\]\)]\s*'),
|
||||
'',
|
||||
);
|
||||
// Bare form — "FR: TF1". A colon is required: accepting a dash too would
|
||||
// eat legitimate names like "Ciné - Premier".
|
||||
value = value.replaceFirst(RegExp(r'^\s*[a-z]{2,3}\s*:\s*'), '');
|
||||
|
||||
value = value.replaceAll(RegExp(r'[^a-z0-9+]'), '');
|
||||
|
||||
for (final suffix in const [
|
||||
'fullhd',
|
||||
'ultrahd',
|
||||
'uhd',
|
||||
'fhd',
|
||||
'hdr',
|
||||
'4k',
|
||||
'1080p',
|
||||
'720p',
|
||||
'hd',
|
||||
'sd',
|
||||
]) {
|
||||
if (value.length > suffix.length && value.endsWith(suffix)) {
|
||||
value = value.substring(0, value.length - suffix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String _formatLocal(DateTime value) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${value.year}-${two(value.month)}-${two(value.day)} '
|
||||
'${two(value.hour)}:${two(value.minute)}:${two(value.second)}';
|
||||
}
|
||||
}
|
||||
|
||||
class _Window {
|
||||
final DateTime from;
|
||||
final DateTime to;
|
||||
|
||||
const _Window(this.from, this.to);
|
||||
|
||||
factory _Window.around(DateTime now) => _Window(
|
||||
now.subtract(XmltvService.windowBack),
|
||||
now.add(XmltvService.windowForward),
|
||||
);
|
||||
|
||||
bool overlaps(DateTime start, DateTime end) =>
|
||||
end.isAfter(from) && start.isBefore(to);
|
||||
}
|
||||
@@ -7,8 +7,38 @@ library;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import '../models/xtream_models.dart' as model;
|
||||
import 'package:xtremobile/core/api/dns_resolver.dart';
|
||||
import 'package:xtremobile/core/models/playlist_config.dart';
|
||||
import 'xmltv_service.dart';
|
||||
|
||||
/// Counting semaphore used to cap concurrent EPG requests.
|
||||
class _Semaphore {
|
||||
_Semaphore(this.maxConcurrent);
|
||||
|
||||
final int maxConcurrent;
|
||||
int _current = 0;
|
||||
final _waiting = <Completer<void>>[];
|
||||
|
||||
Future<void> acquire() {
|
||||
if (_current < maxConcurrent) {
|
||||
_current++;
|
||||
return Future.value();
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
_waiting.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_waiting.isNotEmpty) {
|
||||
_waiting.removeAt(0).complete();
|
||||
return;
|
||||
}
|
||||
if (_current > 0) _current--;
|
||||
}
|
||||
}
|
||||
|
||||
/// EPG cache entry with TTL
|
||||
class _EpgCacheEntry {
|
||||
@@ -22,19 +52,32 @@ class _EpgCacheEntry {
|
||||
DateTime.now().difference(timestamp).inSeconds > ttlSeconds;
|
||||
}
|
||||
|
||||
/// Default community XMLTV guides, used only when the provider has no EPG of
|
||||
/// its own. Ordered by preference; the first one that yields programmes wins.
|
||||
const List<XmltvSource> kDefaultPublicXmltvSources = [
|
||||
XmltvSource(
|
||||
label: 'EPGShare FR1',
|
||||
url: 'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz',
|
||||
),
|
||||
];
|
||||
|
||||
/// Xtream API Service for Mobile
|
||||
///
|
||||
/// Optimized for Android with:
|
||||
/// - Batch EPG loading (N+1 prevention)
|
||||
/// - Provider-level caching with TTL
|
||||
/// - XMLTV fallback when the provider serves no EPG
|
||||
/// - Configurable timeouts and retries
|
||||
/// - Memory-efficient streaming
|
||||
class XtreamServiceMobile {
|
||||
final String cacheDir;
|
||||
late Dio _dio;
|
||||
late String _baseUrl;
|
||||
late String _username;
|
||||
late String _password;
|
||||
|
||||
/// Replaced with the configured instance in [setPlaylistAsync]; initialised
|
||||
/// eagerly so [dispose] is safe even if the service was never configured.
|
||||
Dio _dio = Dio();
|
||||
String _baseUrl = '';
|
||||
String _username = '';
|
||||
String _password = '';
|
||||
|
||||
// Batch EPG cache to prevent N+1 queries
|
||||
final Map<String, _EpgCacheEntry> _epgBatchCache = {};
|
||||
@@ -42,8 +85,54 @@ class XtreamServiceMobile {
|
||||
// In-flight batch requests to deduplicate concurrent calls
|
||||
final Map<String, Future<Map<String, model.ShortEPG>>> _inFlightBatches = {};
|
||||
|
||||
/// A channel grid asks for EPG one tile at a time, so a screenful fires ~30
|
||||
/// simultaneous `player_api.php` calls. Many Xtream panels rate-limit past a
|
||||
/// handful of parallel connections and answer with errors, which empties the
|
||||
/// EPG of the whole grid. Requests are therefore funnelled in small packets.
|
||||
static final _Semaphore _epgGate = _Semaphore(3);
|
||||
|
||||
/// streamId → channel, populated by [getLiveChannels].
|
||||
///
|
||||
/// The XMLTV fallback matches on `epg_channel_id` / channel name, neither of
|
||||
/// which the per-stream EPG calls carry — this index supplies them without
|
||||
/// changing any call site.
|
||||
final Map<String, model.Channel> _channelIndex = {};
|
||||
|
||||
late final XmltvService _xmltv = XmltvService(cacheDir);
|
||||
|
||||
/// User-provided XMLTV URL (empty = not configured).
|
||||
String _customXmltvUrl = '';
|
||||
|
||||
/// Whether the community guides may be used as the last resort.
|
||||
bool _allowPublicXmltv = true;
|
||||
|
||||
/// Set once every XMLTV source has failed, so a channel grid does not retry
|
||||
/// a multi-megabyte download for each of its tiles.
|
||||
bool _xmltvExhausted = false;
|
||||
|
||||
XtreamServiceMobile(this.cacheDir);
|
||||
|
||||
/// Label of the XMLTV source currently backing the fallback, if any.
|
||||
String? get xmltvSourceLabel => _xmltv.loadedSourceLabel;
|
||||
|
||||
/// Configure the XMLTV fallback. Safe to call at any time.
|
||||
void setXmltvConfig({String? customUrl, bool? allowPublicFallback}) {
|
||||
final normalizedUrl = customUrl?.trim() ?? _customXmltvUrl;
|
||||
final changed = normalizedUrl != _customXmltvUrl ||
|
||||
(allowPublicFallback ?? _allowPublicXmltv) != _allowPublicXmltv;
|
||||
|
||||
_customXmltvUrl = normalizedUrl;
|
||||
_allowPublicXmltv = allowPublicFallback ?? _allowPublicXmltv;
|
||||
|
||||
if (changed) {
|
||||
// Sources changed — drop the "nothing works" latch and any indexed guide
|
||||
// so the new configuration is actually tried.
|
||||
_xmltvExhausted = false;
|
||||
_xmltv.clear();
|
||||
_epgBatchCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize with playlist configuration
|
||||
Future<void> setPlaylistAsync(PlaylistConfig config) async {
|
||||
_baseUrl = config.dns;
|
||||
@@ -59,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<void> prewarmHost() async {
|
||||
final uri = Uri.tryParse(_baseUrl);
|
||||
if (uri == null || uri.host.isEmpty) return;
|
||||
|
||||
try {
|
||||
await DnsResolver.resolve(uri.host).timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
} catch (_) {
|
||||
// DNS pre-resolution is best effort.
|
||||
}
|
||||
|
||||
final isSecure = uri.scheme == 'https';
|
||||
final port = uri.hasPort ? uri.port : (isSecure ? 443 : 80);
|
||||
|
||||
try {
|
||||
final socket = isSecure
|
||||
? await SecureSocket.connect(
|
||||
uri.host,
|
||||
port,
|
||||
timeout: const Duration(milliseconds: 1200),
|
||||
onBadCertificate: (_) => true,
|
||||
)
|
||||
: await Socket.connect(
|
||||
uri.host,
|
||||
port,
|
||||
timeout: const Duration(milliseconds: 1200),
|
||||
);
|
||||
socket.destroy();
|
||||
if (kDebugMode) print('🔥 [Prewarm] ${uri.host}:$port ready');
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('⚠️ [Prewarm] ${uri.host}:$port failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get live channels for a category (with batch EPG support)
|
||||
/// If categoryId is empty, fetches ALL channels from all categories
|
||||
Future<List<model.Channel>> 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<XmltvSource> _xmltvSources() {
|
||||
return [
|
||||
XmltvSource(
|
||||
label: 'Fournisseur (xmltv.php)',
|
||||
url: '$_baseUrl/xmltv.php?username=$_username&password=$_password',
|
||||
),
|
||||
if (_customXmltvUrl.isNotEmpty)
|
||||
XmltvSource(label: 'URL personnalisée', url: _customXmltvUrl),
|
||||
if (_allowPublicXmltv) ...kDefaultPublicXmltvSources,
|
||||
];
|
||||
}
|
||||
|
||||
/// Resolve missing EPG entries from the XMLTV index, loading it on demand.
|
||||
Future<Map<String, model.ShortEPG>> _resolveFromXmltv(
|
||||
List<String> streamIds,
|
||||
) async {
|
||||
if (_xmltvExhausted) return {};
|
||||
|
||||
// Nothing to match on: the channel list has not been loaded in this
|
||||
// session, so neither epg_channel_id nor the name is known.
|
||||
final known = streamIds
|
||||
.map((id) => _channelIndex[id])
|
||||
.whereType<model.Channel>()
|
||||
.toList();
|
||||
if (known.isEmpty) return {};
|
||||
|
||||
try {
|
||||
final loaded = await _xmltv.ensureLoaded(_xmltvSources());
|
||||
if (!loaded) {
|
||||
_xmltvExhausted = true;
|
||||
return {};
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) print('⚠️ XMLTV load failed: $e');
|
||||
_xmltvExhausted = true;
|
||||
return {};
|
||||
}
|
||||
|
||||
final resolved = <String, model.ShortEPG>{};
|
||||
for (final channel in known) {
|
||||
final epg = _xmltv.shortEpgFor(
|
||||
streamId: channel.streamId,
|
||||
epgChannelId: channel.epgChannelId,
|
||||
channelName: channel.name,
|
||||
);
|
||||
if (epg != null) resolved[channel.streamId] = epg;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// Get live TV categories
|
||||
Future<List<model.Category>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<MobileSettingsTab> {
|
||||
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<MobileSettingsTab> {
|
||||
_liveTvController = TextEditingController();
|
||||
_moviesController = TextEditingController();
|
||||
_seriesController = TextEditingController();
|
||||
_xmltvController = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -36,6 +39,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
_liveTvController.dispose();
|
||||
_moviesController.dispose();
|
||||
_seriesController.dispose();
|
||||
_xmltvController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -44,6 +48,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
_liveTvController.text = settings.liveTvKeywords.join(', ');
|
||||
_moviesController.text = settings.moviesKeywords.join(', ');
|
||||
_seriesController.text = settings.seriesKeywords.join(', ');
|
||||
_xmltvController.text = settings.xmltvUrl;
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
@@ -167,6 +172,38 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// === GUIDE TV (EPG) ===
|
||||
_buildSectionHeader('Guide TV (EPG)'),
|
||||
|
||||
_buildSettingItem(
|
||||
icon: Icons.public,
|
||||
title: 'EPG communautaire',
|
||||
subtitle:
|
||||
'Repli quand le fournisseur ne renvoie aucun programme',
|
||||
value: settings.useCommunityEpg ? 'Activé' : 'Désactivé',
|
||||
onTap: () => ref
|
||||
.read(mobileSettingsProvider.notifier)
|
||||
.toggleCommunityEpg(!settings.useCommunityEpg),
|
||||
),
|
||||
|
||||
_buildSettingItem(
|
||||
icon: Icons.link,
|
||||
title: 'URL XMLTV personnalisée',
|
||||
subtitle: settings.xmltvUrl.isEmpty
|
||||
? 'Aucune — xmltv.php du fournisseur, puis source communautaire'
|
||||
: settings.xmltvUrl,
|
||||
value: settings.xmltvUrl.isEmpty ? 'Ajouter' : 'Modifier',
|
||||
onTap: () => _showKeyboardDialog(
|
||||
'URL XMLTV',
|
||||
_xmltvController,
|
||||
(val) => ref
|
||||
.read(mobileSettingsProvider.notifier)
|
||||
.setXmltvUrl(val),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// === FILTRES ===
|
||||
_buildSectionHeader('Filtres de Contenu'),
|
||||
|
||||
@@ -417,11 +454,9 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: GoogleFonts.inter(
|
||||
style: AppTheme.eyebrow(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -18,6 +18,12 @@ class MobileSettings {
|
||||
final List<String>
|
||||
deinterlacedChannels; // IDs of channels with forced deinterlacing
|
||||
|
||||
/// Custom XMLTV guide URL, tried after the provider's own `xmltv.php`.
|
||||
final String xmltvUrl;
|
||||
|
||||
/// Allow the built-in community XMLTV guides as the last EPG resort.
|
||||
final bool useCommunityEpg;
|
||||
|
||||
const MobileSettings({
|
||||
this.liveTvKeywords = const [],
|
||||
this.moviesKeywords = const [],
|
||||
@@ -30,6 +36,8 @@ class MobileSettings {
|
||||
this.deinterlace = false,
|
||||
this.bufferDuration = 0,
|
||||
this.deinterlacedChannels = const [],
|
||||
this.xmltvUrl = '',
|
||||
this.useCommunityEpg = true,
|
||||
});
|
||||
|
||||
MobileSettings copyWith({
|
||||
@@ -44,6 +52,8 @@ class MobileSettings {
|
||||
bool? deinterlace,
|
||||
int? bufferDuration,
|
||||
List<String>? deinterlacedChannels,
|
||||
String? xmltvUrl,
|
||||
bool? useCommunityEpg,
|
||||
}) {
|
||||
return MobileSettings(
|
||||
liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords,
|
||||
@@ -57,6 +67,8 @@ class MobileSettings {
|
||||
deinterlace: deinterlace ?? this.deinterlace,
|
||||
bufferDuration: bufferDuration ?? this.bufferDuration,
|
||||
deinterlacedChannels: deinterlacedChannels ?? this.deinterlacedChannels,
|
||||
xmltvUrl: xmltvUrl ?? this.xmltvUrl,
|
||||
useCommunityEpg: useCommunityEpg ?? this.useCommunityEpg,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +133,9 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
|
||||
final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?;
|
||||
final deinterlacedChannels =
|
||||
_box?.get('deinterlacedChannels', defaultValue: <String>[]) as List?;
|
||||
final xmltvUrl = _box?.get('xmltvUrl', defaultValue: '') as String?;
|
||||
final useCommunityEpg =
|
||||
_box?.get('useCommunityEpg', defaultValue: true) as bool?;
|
||||
|
||||
state = MobileSettings(
|
||||
liveTvKeywords: liveTv?.cast<String>() ?? [],
|
||||
@@ -134,6 +149,8 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
|
||||
deinterlace: deinterlace ?? false,
|
||||
bufferDuration: buffer ?? 0,
|
||||
deinterlacedChannels: deinterlacedChannels?.cast<String>() ?? [],
|
||||
xmltvUrl: xmltvUrl ?? '',
|
||||
useCommunityEpg: useCommunityEpg ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,6 +204,17 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
|
||||
_box?.put('bufferDuration', seconds);
|
||||
}
|
||||
|
||||
void setXmltvUrl(String url) {
|
||||
final trimmed = url.trim();
|
||||
state = state.copyWith(xmltvUrl: trimmed);
|
||||
_box?.put('xmltvUrl', trimmed);
|
||||
}
|
||||
|
||||
void toggleCommunityEpg(bool value) {
|
||||
state = state.copyWith(useCommunityEpg: value);
|
||||
_box?.put('useCommunityEpg', value);
|
||||
}
|
||||
|
||||
void toggleChannelDeinterlace(String streamId) {
|
||||
final currentList = List<String>.from(state.deinterlacedChannels);
|
||||
if (currentList.contains(streamId)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/models/playlist_config.dart';
|
||||
import '../../core/models/iptv_models.dart' as model;
|
||||
import '../../features/iptv/services/xtream_service_mobile.dart';
|
||||
import 'mobile_settings_providers.dart';
|
||||
|
||||
/// Mobile-specific Xtream service provider
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -13,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<MobileSettings>(mobileSettingsProvider, (previous, next) {
|
||||
if (previous?.xmltvUrl != next.xmltvUrl ||
|
||||
previous?.useCommunityEpg != next.useCommunityEpg) {
|
||||
applyEpgSettings(next);
|
||||
}
|
||||
});
|
||||
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xtremobile/features/iptv/services/xmltv_service.dart';
|
||||
|
||||
/// Parses a real community XMLTV dump end to end.
|
||||
///
|
||||
/// Skipped unless `XMLTV_FIXTURE` points at a `.xml`/`.xml.gz` file, so CI and
|
||||
/// day-to-day runs stay offline and fast:
|
||||
///
|
||||
/// flutter test test/xmltv_real_dump_test.dart \
|
||||
/// --dart-define=XMLTV_FIXTURE=C:\path\epg_ripper_FR1.xml.gz
|
||||
const String _fixturePath = String.fromEnvironment('XMLTV_FIXTURE');
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'indexes a real XMLTV dump within a mobile memory budget',
|
||||
() async {
|
||||
final file = File(_fixturePath);
|
||||
expect(file.existsSync(), isTrue, reason: 'fixture not found');
|
||||
|
||||
final service = XmltvService(Directory.systemTemp.path);
|
||||
final started = DateTime.now();
|
||||
|
||||
final indexed = await service.indexFromBytes(file.openRead());
|
||||
|
||||
final elapsed = DateTime.now().difference(started);
|
||||
final peakMb = ProcessInfo.maxRss / 1048576;
|
||||
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'channels=${service.channelCount} '
|
||||
'time=${elapsed.inMilliseconds}ms '
|
||||
'peakRss=${peakMb.toStringAsFixed(1)}MB',
|
||||
);
|
||||
|
||||
expect(indexed, isTrue);
|
||||
expect(service.channelCount, greaterThan(100));
|
||||
|
||||
// The whole point of the streaming parse: a ~46 MB document must never
|
||||
// be resident. Generous ceiling to stay stable across VM versions.
|
||||
expect(peakMb, lessThan(400));
|
||||
|
||||
// Spot-check the matching paths with labels shaped the way an Xtream
|
||||
// provider reports them.
|
||||
const probes = <List<String>>[
|
||||
['', 'TF1 HD'],
|
||||
['', 'FR: France 2'],
|
||||
['', 'M6 FHD'],
|
||||
['', '|FR| France 5'],
|
||||
['', 'Arte'],
|
||||
];
|
||||
|
||||
var hits = 0;
|
||||
for (final probe in probes) {
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: 'x',
|
||||
epgChannelId: probe[0],
|
||||
channelName: probe[1],
|
||||
);
|
||||
// ignore: avoid_print
|
||||
print(' ${probe[1].padRight(16)} -> ${epg?.title ?? "NO MATCH"}');
|
||||
if (epg != null) hits++;
|
||||
}
|
||||
|
||||
expect(hits, greaterThanOrEqualTo(4),
|
||||
reason: 'name matching should resolve mainstream French channels');
|
||||
},
|
||||
skip: _fixturePath.isEmpty ? 'set --dart-define=XMLTV_FIXTURE' : false,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xtremobile/features/iptv/services/xmltv_service.dart';
|
||||
|
||||
/// Fixture shaped exactly like the community dumps the fallback consumes
|
||||
/// (channel ids with dots, `+0200` offsets, base-less titles).
|
||||
String _fixture(DateTime now) {
|
||||
String stamp(DateTime value) {
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
final utc = value.toUtc();
|
||||
return '${utc.year}${two(utc.month)}${two(utc.day)}'
|
||||
'${two(utc.hour)}${two(utc.minute)}${two(utc.second)} +0000';
|
||||
}
|
||||
|
||||
final airingStart = now.subtract(const Duration(minutes: 20));
|
||||
final airingEnd = now.add(const Duration(minutes: 40));
|
||||
final nextEnd = airingEnd.add(const Duration(minutes: 50));
|
||||
final farFuture = now.add(const Duration(days: 4));
|
||||
|
||||
return '''<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<tv generator-info-name="none">
|
||||
<channel id="TF1.fr">
|
||||
<display-name lang="fr">TF1</display-name>
|
||||
</channel>
|
||||
<channel id="France.3.-.Nord.Pas-de-Calais.fr">
|
||||
<display-name lang="fr">France 3 - Nord Pas-de-Calais</display-name>
|
||||
</channel>
|
||||
<channel id="Cine.Premier.fr">
|
||||
<display-name lang="fr">Ciné+ Premier</display-name>
|
||||
</channel>
|
||||
<programme channel="TF1.fr" start="${stamp(airingStart)}" stop="${stamp(airingEnd)}">
|
||||
<title lang="fr">Journal de 20h</title>
|
||||
<desc lang="fr">Le rendez-vous d'information.</desc>
|
||||
</programme>
|
||||
<programme channel="TF1.fr" start="${stamp(airingEnd)}" stop="${stamp(nextEnd)}">
|
||||
<title lang="fr">Film du mardi</title>
|
||||
</programme>
|
||||
<programme channel="France.3.-.Nord.Pas-de-Calais.fr" start="${stamp(airingStart)}" stop="${stamp(airingEnd)}">
|
||||
<title lang="fr">Ici 19/20</title>
|
||||
</programme>
|
||||
<programme channel="Cine.Premier.fr" start="${stamp(farFuture)}" stop="${stamp(farFuture.add(const Duration(hours: 1)))}">
|
||||
<title lang="fr">Hors fenêtre</title>
|
||||
</programme>
|
||||
</tv>''';
|
||||
}
|
||||
|
||||
Stream<List<int>> _bytes(String xml) => Stream.value(utf8.encode(xml));
|
||||
|
||||
void main() {
|
||||
group('parseXmltvTime', () {
|
||||
test('applies the trailing UTC offset', () {
|
||||
final parsed = XmltvService.parseXmltvTime('20260811183000 +0200');
|
||||
expect(parsed, isNotNull);
|
||||
expect(
|
||||
parsed!.toUtc(),
|
||||
DateTime.utc(2026, 8, 11, 16, 30),
|
||||
reason: '18:30 at +0200 is 16:30 UTC',
|
||||
);
|
||||
});
|
||||
|
||||
test('handles a negative offset', () {
|
||||
final parsed = XmltvService.parseXmltvTime('20260811183000 -0430');
|
||||
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 23, 0));
|
||||
});
|
||||
|
||||
test('treats a missing offset as UTC', () {
|
||||
final parsed = XmltvService.parseXmltvTime('20260811183000');
|
||||
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 18, 30));
|
||||
});
|
||||
|
||||
test('rejects malformed input', () {
|
||||
expect(XmltvService.parseXmltvTime(null), isNull);
|
||||
expect(XmltvService.parseXmltvTime(''), isNull);
|
||||
expect(XmltvService.parseXmltvTime('2026081118'), isNull);
|
||||
expect(XmltvService.parseXmltvTime('not-a-timestamp!'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('normalizeName', () {
|
||||
test('collapses quality suffixes', () {
|
||||
expect(XmltvService.normalizeName('TF1 HD'), 'tf1');
|
||||
expect(XmltvService.normalizeName('tf1-fhd'), 'tf1');
|
||||
expect(XmltvService.normalizeName('TF1 4K'), 'tf1');
|
||||
expect(XmltvService.normalizeName('TF1'), 'tf1');
|
||||
});
|
||||
|
||||
test('strips country prefixes in both forms', () {
|
||||
expect(XmltvService.normalizeName('FR: TF1'), 'tf1');
|
||||
expect(XmltvService.normalizeName('|FR| TF1 HD'), 'tf1');
|
||||
expect(XmltvService.normalizeName('[FR] TF1'), 'tf1');
|
||||
});
|
||||
|
||||
test('keeps a dash-separated name intact', () {
|
||||
// "Ciné - Premier" must not lose its first word to prefix stripping.
|
||||
expect(XmltvService.normalizeName('Ciné - Premier'), 'cinepremier');
|
||||
});
|
||||
|
||||
test('folds accents and keeps the plus sign', () {
|
||||
expect(XmltvService.normalizeName('Ciné+ Émotion'), 'cine+emotion');
|
||||
});
|
||||
|
||||
test('is empty for a blank label', () {
|
||||
expect(XmltvService.normalizeName(' '), '');
|
||||
});
|
||||
});
|
||||
|
||||
group('indexFromBytes + lookup', () {
|
||||
late XmltvService service;
|
||||
late DateTime now;
|
||||
|
||||
setUp(() async {
|
||||
service = XmltvService(Directory.systemTemp.path);
|
||||
now = DateTime.now();
|
||||
final indexed = await service.indexFromBytes(
|
||||
_bytes(_fixture(now)),
|
||||
now: now,
|
||||
);
|
||||
expect(indexed, isTrue);
|
||||
});
|
||||
|
||||
test('matches on epg_channel_id', () {
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: '123',
|
||||
epgChannelId: 'TF1.fr',
|
||||
channelName: 'anything',
|
||||
);
|
||||
expect(epg, isNotNull);
|
||||
expect(epg!.title, 'Journal de 20h');
|
||||
expect(epg.id, '123');
|
||||
expect(epg.description, "Le rendez-vous d'information.");
|
||||
});
|
||||
|
||||
test('falls back to the channel name when the id is unknown', () {
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: '456',
|
||||
epgChannelId: '',
|
||||
channelName: 'FR: TF1 HD',
|
||||
);
|
||||
expect(epg, isNotNull);
|
||||
expect(epg!.title, 'Journal de 20h');
|
||||
});
|
||||
|
||||
test('matches a name reconstructed from a dotted channel id', () {
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: '789',
|
||||
epgChannelId: 'not-in-guide',
|
||||
channelName: 'France 3 - Nord Pas-de-Calais',
|
||||
);
|
||||
expect(epg, isNotNull);
|
||||
expect(epg!.title, 'Ici 19/20');
|
||||
});
|
||||
|
||||
test('returns the currently airing programme, not the next one', () {
|
||||
final programme = service.programmeFor(
|
||||
epgChannelId: 'TF1.fr',
|
||||
at: now,
|
||||
);
|
||||
expect(programme!.title, 'Journal de 20h');
|
||||
expect(programme.covers(now), isTrue);
|
||||
});
|
||||
|
||||
test('falls forward to the next entry when nothing is airing', () {
|
||||
// 10 minutes into the gap-free schedule's second slot.
|
||||
final during = now.add(const Duration(minutes: 50));
|
||||
final programme = service.programmeFor(
|
||||
epgChannelId: 'TF1.fr',
|
||||
at: during,
|
||||
);
|
||||
expect(programme!.title, 'Film du mardi');
|
||||
});
|
||||
|
||||
test('drops programmes outside the retention window', () {
|
||||
// The Ciné+ entry is 4 days out — beyond windowForward, so the channel
|
||||
// holds no programme at all.
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: '999',
|
||||
epgChannelId: 'Cine.Premier.fr',
|
||||
);
|
||||
expect(epg, isNull);
|
||||
});
|
||||
|
||||
test('returns null for a channel absent from the guide', () {
|
||||
final epg = service.shortEpgFor(
|
||||
streamId: '000',
|
||||
epgChannelId: 'unknown.channel',
|
||||
channelName: 'Chaîne Inconnue',
|
||||
);
|
||||
expect(epg, isNull);
|
||||
});
|
||||
|
||||
test('parses a gzipped payload transparently', () async {
|
||||
final gzipped = XmltvService(Directory.systemTemp.path);
|
||||
final compressed = gzip.encode(utf8.encode(_fixture(now)));
|
||||
final indexed = await gzipped.indexFromBytes(
|
||||
Stream.value(compressed),
|
||||
now: now,
|
||||
);
|
||||
expect(indexed, isTrue);
|
||||
expect(
|
||||
gzipped.shortEpgFor(streamId: '1', epgChannelId: 'TF1.fr')?.title,
|
||||
'Journal de 20h',
|
||||
);
|
||||
});
|
||||
|
||||
test('reports failure on a payload with no programme', () async {
|
||||
final empty = XmltvService(Directory.systemTemp.path);
|
||||
final indexed = await empty.indexFromBytes(
|
||||
_bytes('<?xml version="1.0"?><tv></tv>'),
|
||||
);
|
||||
expect(indexed, isFalse);
|
||||
expect(empty.isLoaded, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user