Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5 6f0f51384a feat(design): Warm Cinema redesign + fast-start players
Full visual overhaul from Cyber-Cinematic Glass to "Warm Cinema":
warm charcoal (#0B0908), cream (#F3E9DF), ember (#D9541F).
Fraunces (editorial serif) for display, Karla for body/UI.

Design
- app_colors: new palette, all existing token names preserved,
  plus posterScrim/heroScrim gradients and lift()/emberFocus() shadows
- app_theme: new type scale, tighter radii, 20+ component themes
- mobile_theme aligned on the same tokens
- glass_container: opaque layered surfaces replace glassmorphism;
  adds GlassCard (hover + remote-focus states) and CinemaBackdrop
- players and boot splash restyled (film-leader motif, no web fonts)

Performance
- remove every BackdropFilter (33 call sites): backdrop blur forced a
  full framebuffer read per frame per card, the main scroll jank source
- bundle Fraunces/Karla as assets: google_fonts no longer fetches from
  fonts.gstatic.com at startup
- drop the blocking hls.js CDN script from index.html; no Dart code
  ever used it, only the player iframes need it
- self-host hls.js/mpegts.js and load them on demand, so ~213 KB of
  mpegts is never downloaded for HLS streams
- bound image decode size on 10 sites: a 40-poster grid drops from
  ~240 MB to a few tens of MB of GPU memory

Players (new shared engine: web/xf-player-core.js)
- event-driven start instead of a 200 ms buffer poll plus a 1.5 s wait
- initialLiveManifestSize 3 -> 1, liveSyncDurationCount 10 -> 2,
  startFragPrefetch on, testBandwidth off
- MPEG-TS initial stash 512 KB -> 64 KB
- adaptive escalation fast -> balanced -> safe on repeated stalls
  (60 s rolling window), applied live for HLS so there is no visible cut

Pre-existing build breakage fixed along the way
- undefined AppColors.shimmer, AppTheme.durationXs and spacing8/12/16/32
- broken relative imports in channel_card and quality_selector_widget
- missing GlassCard widget and hasBorder/showShadow parameters

Also resolves a leftover unmerged .claude/settings.local.json by
keeping the remote version.

Still broken (pre-existing, dead code, not reachable from main.dart):
recommendations_provider.dart and continue_watching_widget.dart import
a models/playlist.dart that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:26:25 +02:00
54 changed files with 2425 additions and 1775 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(dart analyze:*)",
"Bash(git checkout:*)",
"Bash(git commit -m 'fix: restore RecordingsTab to working version without tab complexity:*)",
"Bash(git push:*)",
"Bash(git add:*)",
"Bash(git commit -m 'fix: simplify RecordingsTab to fix null check operator error:*)",
"Bash(rtk git *)"
]
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+171 -98
View File
@@ -1,133 +1,202 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Cyber-Cinematic Glass Design System — Material 3 Token Palette /// XtremFlow — "Warm Cinema" Design System
/// ///
/// Extracted from the Stitch design system for XtremFlow. /// Palette: charbon chaud + crème + orange brûlé (ember).
/// Optimized for OLED displays and low-light environments. /// Inspiration: salle de projection, pellicule, affiches 70s.
/// Primary accent: Neon blue (#4b8eff / #adc6ff) ///
/// Règle de perf : AUCUNE couleur de surface n'est translucide.
/// Les anciennes "glass" surfaces sont désormais opaques et pré-teintées,
/// ce qui supprime tout besoin de `BackdropFilter` (killer de perf sur
/// Flutter Web). La profondeur vient des ombres et du contraste, pas du flou.
class AppColors { class AppColors {
AppColors._(); AppColors._();
// ============ COMPATIBILITY ALIASES (transition) ============ // ============ COMPATIBILITY ALIASES ============
static Color get focusColor => primaryContainer; static Color get focusColor => primaryContainer;
static Color get border => outlineVariant; static Color get border => outlineVariant;
// ============ SURFACE / ON SURFACE ============ // ============ SURFACE / ON SURFACE ============
static const Color onSurface = Color(0xFFe3e2e7); static const Color onSurface = Color(0xFFF3E9DF); // crème
static const Color onSurfaceVariant = Color(0xFFc1c6d7); static const Color onSurfaceVariant = Color(0xFFC5B3A5); // crème atténué
// ============ BASE LEVEL 0 ============ // ============ BASE LEVEL 0 ============
static const Color baseLevel0 = Color(0xFF0F1014); /// Noir chaud — le "noir de salle", jamais un noir pur bleuté.
static const Color baseLevel0 = Color(0xFF0B0908);
// ============ BACKGROUNDS ============ // ============ BACKGROUNDS ============
static const Color background = Color(0xFF121317); static const Color background = Color(0xFF100C0A);
static const Color onBackground = Color(0xFFe3e2e7); static const Color onBackground = Color(0xFFF3E9DF);
static const Color surface = Color(0xFF121317); static const Color surface = Color(0xFF100C0A);
static const Color surfaceDim = Color(0xFF121317); static const Color surfaceDim = Color(0xFF0B0908);
static const Color surfaceBright = Color(0xFF38393d); static const Color surfaceBright = Color(0xFF3C322C);
// Surface Containers (Material 3 hierarchy) // Surface Containers (hiérarchie Material 3, toutes opaques)
static const Color surfaceContainerLowest = Color(0xFF0d0e12); static const Color surfaceContainerLowest = Color(0xFF070505);
static const Color surfaceContainerLow = Color(0xFF1a1b20); static const Color surfaceContainerLow = Color(0xFF181310);
static const Color surfaceContainer = Color(0xFF1f1f24); static const Color surfaceContainer = Color(0xFF1F1815);
static const Color surfaceContainerHigh = Color(0xFF292a2e); static const Color surfaceContainerHigh = Color(0xFF2A211C);
static const Color surfaceContainerHighest = Color(0xFF343439); static const Color surfaceContainerHighest = Color(0xFF352A24);
static const Color surfaceVariant = Color(0xFF343439); static const Color surfaceVariant = Color(0xFF352A24);
// ============ PRIMARY (Neon Blue) ============ // ============ PRIMARY (Ember / orange brûlé) ============
static const Color primary = Color(0xFFadc6ff); static const Color primary = Color(0xFFFFB68C); // texte & icônes sur sombre
static const Color onPrimary = Color(0xFF002e69); static const Color onPrimary = Color(0xFF521B00);
static const Color primaryContainer = Color(0xFF4b8eff); static const Color primaryContainer = Color(0xFFD9541F); // remplissage ember
static const Color onPrimaryContainer = Color(0xFF00285c); static const Color onPrimaryContainer = Color(0xFF2B0A00);
static const Color inversePrimary = Color(0xFF005bc1); static const Color inversePrimary = Color(0xFFA63B10);
static const Color primaryFixed = Color(0xFFd8e2ff);
static const Color primaryFixedDim = Color(0xFFadc6ff);
static const Color onPrimaryFixed = Color(0xFF001a41);
static const Color onPrimaryFixedVariant = Color(0xFF004493);
// ============ SECONDARY ============ /// Braise profonde — réservée aux **remplissages portant du texte**.
static const Color secondary = Color(0xFFc6c5cf); /// `primaryContainer` (#D9541F) est trop lumineux : blanc dessus plafonne
static const Color onSecondary = Color(0xFF2f3037); /// vers 3,9:1, sous le seuil AA de 4,5:1 pour du corps de texte. Cette
static const Color secondaryContainer = Color(0xFF4a4b53); /// variante passe le seuil tout en restant la même couleur perçue.
static const Color onSecondaryContainer = Color(0xFFbcbbc4); static const Color primaryFill = Color(0xFFA8360C);
static const Color secondaryFixed = Color(0xFFe3e1eb); static const Color primaryFixed = Color(0xFFFFDBC8);
static const Color secondaryFixedDim = Color(0xFFc6c5cf); static const Color primaryFixedDim = Color(0xFFFFB68C);
static const Color onSecondaryFixed = Color(0xFF1a1b22); static const Color onPrimaryFixed = Color(0xFF360F00);
static const Color onSecondaryFixedVariant = Color(0xFF46464e); static const Color onPrimaryFixedVariant = Color(0xFF7A2C08);
// ============ TERTIARY ============ // ============ SECONDARY (Terre cuite / argile) ============
static const Color tertiary = Color(0xFFc6c6c7); static const Color secondary = Color(0xFFE7C3AC);
static const Color onTertiary = Color(0xFF2f3131); static const Color onSecondary = Color(0xFF442A1B);
static const Color tertiaryContainer = Color(0xFF909191); static const Color secondaryContainer = Color(0xFF5D4030);
static const Color onTertiaryContainer = Color(0xFF282a2a); static const Color onSecondaryContainer = Color(0xFFFFDCC8);
static const Color tertiaryFixed = Color(0xFFe2e2e2); static const Color secondaryFixed = Color(0xFFFFDCC8);
static const Color tertiaryFixedDim = Color(0xFFc6c6c7); static const Color secondaryFixedDim = Color(0xFFE7C3AC);
static const Color onTertiaryFixed = Color(0xFF1a1c1c); static const Color onSecondaryFixed = Color(0xFF2B160A);
static const Color onTertiaryFixedVariant = Color(0xFF454747); static const Color onSecondaryFixedVariant = Color(0xFF5D4030);
// ============ 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);
static const Color tertiaryFixed = Color(0xFFF6E4A6);
static const Color tertiaryFixedDim = Color(0xFFD9C88C);
static const Color onTertiaryFixed = Color(0xFF231B04);
static const Color onTertiaryFixedVariant = Color(0xFF52472A);
// ============ ERROR ============ // ============ ERROR ============
static const Color error = Color(0xFFffb4ab); static const Color error = Color(0xFFFFB4A6);
static const Color onError = Color(0xFF690005); static const Color onError = Color(0xFF5F1409);
static const Color errorContainer = Color(0xFF93000a); static const Color errorContainer = Color(0xFF8C2416);
static const Color onErrorContainer = Color(0xFFffdad6); static const Color onErrorContainer = Color(0xFFFFDAD4);
// ============ OUTLINE ============ // ============ OUTLINE ============
static const Color outline = Color(0xFF8b90a0); static const Color outline = Color(0xFF9C8878);
static const Color outlineVariant = Color(0xFF414755); static const Color outlineVariant = Color(0xFF4A3C33);
// ============ INVERSE SURFACE ============ // ============ INVERSE SURFACE ============
static const Color inverseSurface = Color(0xFFe3e2e7); static const Color inverseSurface = Color(0xFFF3E9DF);
static const Color inverseOnSurface = Color(0xFF2f3035); static const Color inverseOnSurface = Color(0xFF2A211C);
// ============ SURFACE TINT ============ // ============ SURFACE TINT ============
static const Color surfaceTint = Color(0xFFadc6ff); static const Color surfaceTint = Color(0xFFFFB68C);
// ============ TEXT / CONTENT COLORS ============ // ============ TEXT / CONTENT ============
static const Color textPrimary = Color(0xFFe3e2e7); static const Color textPrimary = Color(0xFFF3E9DF);
static const Color textSecondary = Color(0xFFc1c6d7); static const Color textSecondary = Color(0xFFC5B3A5);
static const Color textTertiary = Color(0xFF8b90a0); static const Color textTertiary = Color(0xFF9C8878);
// ============ STATUS COLORS ============ // ============ CRÈME À OPACITÉ (remplace les Colors.whiteNN) ============
static const Color success = Color(0xFF00E676); // Déclarés `const` pour rester utilisables dans les widgets constants.
static const Color warning = Color(0xFFFFAB00); // Un blanc pur sur fond chaud tire vers le bleu : on décline la crème.
static const Color info = Color(0xFF2979FF); 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);
// ============ CATEGORY COLORS (kept for semantic meaning) ============ // ============ STATUS ============
static const Color live = Color(0xFFFF5252); static const Color success = Color(0xFF9BC46A); // olive lumineux
static const Color movies = Color(0xFF448AFF); static const Color warning = Color(0xFFE9B23C); // ambre
static const Color series = Color(0xFF69F0AE); 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 ============ // ============ RATING / PREMIUM ============
static const Color ratingGold = Color(0xFFFFD700); static const Color ratingGold = Color(0xFFE9B23C);
static const Color premiumGold = Color(0xFFFFA000); static const Color premiumGold = Color(0xFFC9922A);
// ============ GLASSMORPHISM LEVELS ============ // ============ SHIMMER (placeholders de chargement) ============
static const Color glassLevel1Bg = Color(0x66121317); // 40% opacity static const Color shimmer = Color(0xFF2A211C);
static const Color glassLevel1Border = Color(0x1AFFFFFF); // 10% white static const Color shimmerHighlight = Color(0xFF3C302A);
static const Color glassLevel2Bg = Color(0x99121317); // 60% opacity // ============ SURFACES "GLASS" — désormais OPAQUES ============
static const Color glassLevel2Border = Color(0x1AFFFFFF); // 10% white // Noms conservés pour compatibilité avec les 33 sites d'appel existants.
static const Color glassLevel2InnerGlow = Color(0x33adc6ff); // 20% primary top-left // Plus aucune transparence : zéro BackdropFilter, zéro repaint coûteux.
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 %
// ============ GRADIENTS ============ // ============ GRADIENTS ============
/// Braise : orange brûlé vers rouge profond.
static const LinearGradient primaryGradient = LinearGradient( static const LinearGradient primaryGradient = LinearGradient(
colors: [Color(0xFF007AFF), Color(0xFF00C6FF)], colors: [Color(0xFFE9701F), Color(0xFFC13A1C)],
begin: Alignment.centerLeft, begin: Alignment.topLeft,
end: Alignment.centerRight, end: Alignment.bottomRight,
); );
/// Fond de salle : projecteur chaud en haut, obscurité en bas.
static const LinearGradient backgroundGradient = LinearGradient( static const LinearGradient backgroundGradient = LinearGradient(
colors: [Color(0xFF0F1014), Color(0xFF121317)], colors: [Color(0xFF1A1310), Color(0xFF0B0908)],
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
); );
// ============ GLOW / SHADOWS ============ /// 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,
);
// ============ GLOW / OMBRES ============
static Color glowPrimary(double opacity) => static Color glowPrimary(double opacity) =>
const Color(0xFF007AFF).withOpacity(opacity); const Color(0xFFD9541F).withValues(alpha: opacity);
static Color glowPrimaryDim(double opacity) => static Color glowPrimaryDim(double opacity) =>
const Color(0xFFadc6ff).withOpacity(opacity); const Color(0xFFFFB68C).withValues(alpha: opacity);
/// 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),
),
];
/// 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),
),
];
// ============ COLOR SCHEMES ============ // ============ COLOR SCHEMES ============
static ColorScheme get darkColorScheme => const ColorScheme.dark( static ColorScheme get darkColorScheme => const ColorScheme.dark(
@@ -149,6 +218,7 @@ class AppColors {
onErrorContainer: onErrorContainer, onErrorContainer: onErrorContainer,
surface: surface, surface: surface,
onSurface: onSurface, onSurface: onSurface,
onSurfaceVariant: onSurfaceVariant,
surfaceContainerLowest: surfaceContainerLowest, surfaceContainerLowest: surfaceContainerLowest,
surfaceContainerLow: surfaceContainerLow, surfaceContainerLow: surfaceContainerLow,
surfaceContainer: surfaceContainer, surfaceContainer: surfaceContainer,
@@ -162,24 +232,27 @@ class AppColors {
brightness: Brightness.dark, brightness: Brightness.dark,
); );
// Light scheme is minimal — Stitch is dark-only /// Thème clair : « papier kraft » — même famille chaude, inversée.
static ColorScheme get lightColorScheme => const ColorScheme.light( static ColorScheme get lightColorScheme => const ColorScheme.light(
primary: Color(0xFF005bc1), primary: Color(0xFFA63B10),
onPrimary: Colors.white, onPrimary: Colors.white,
primaryContainer: Color(0xFFd8e2ff), primaryContainer: Color(0xFFFFDBC8),
onPrimaryContainer: Color(0xFF001a41), onPrimaryContainer: Color(0xFF360F00),
secondary: Color(0xFF5e5c68), secondary: Color(0xFF765847),
onSecondary: Colors.white, onSecondary: Colors.white,
secondaryContainer: Color(0xFFe3e1eb), secondaryContainer: Color(0xFFFFDCC8),
onSecondaryContainer: Color(0xFF1a1b22), onSecondaryContainer: Color(0xFF2B160A),
error: Color(0xFFba1a1a), tertiary: Color(0xFF6A5F3F),
onTertiary: Colors.white,
error: Color(0xFFB3261E),
onError: Colors.white, onError: Colors.white,
errorContainer: Color(0xFFFFdad6), errorContainer: Color(0xFFFFDAD4),
onErrorContainer: Color(0xFF410002), onErrorContainer: Color(0xFF410E05),
surface: Color(0xFFF9F9FF), surface: Color(0xFFFBF3EC),
onSurface: Color(0xFF1a1b22), onSurface: Color(0xFF201A16),
outline: Color(0xFF757680), onSurfaceVariant: Color(0xFF52443B),
outlineVariant: Color(0xFFc5c6d0), outline: Color(0xFF847369),
outlineVariant: Color(0xFFD6C3B7),
brightness: Brightness.light, brightness: Brightness.light,
); );
} }
+404 -171
View File
@@ -2,15 +2,15 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart'; import 'app_colors.dart';
/// XtremFlow Cyber-Cinematic Glass Theme /// XtremFlow — Thème « Warm Cinema »
/// ///
/// Design system extracted from Google Stitch. /// Typo : Fraunces (display, serif éditorial) + Karla (corps / UI).
/// Fonts: Space Grotesk (headlines) + Inter (body/labels) /// Style : charbon chaud, crème, orange brûlé. Profondeur par l'ombre,
/// Style: Dark-only glassmorphism with neon blue accents. /// jamais par le flou — aucun `BackdropFilter` dans tout le thème.
class AppTheme { class AppTheme {
AppTheme._(); AppTheme._();
// ============ SPACING (8px rhythmic system) ============ // ============ ESPACEMENT (rythme 4 / 8) ============
static const double spacingXs = 4.0; static const double spacingXs = 4.0;
static const double spacingSm = 8.0; static const double spacingSm = 8.0;
static const double spacingBase = 8.0; static const double spacingBase = 8.0;
@@ -20,33 +20,69 @@ class AppTheme {
static const double spacing2xl = 48.0; static const double spacing2xl = 48.0;
static const double spacing3xl = 80.0; static const double spacing3xl = 80.0;
// ============ RADIUS ============ // Alias numériques (utilisés dans plusieurs écrans)
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 spacing32 = 32.0;
static const double spacing48 = 48.0;
// ============ RAYONS ============
// Cinéma : angles doux mais assumés, jamais des bulles.
static const double radiusNone = 0.0; static const double radiusNone = 0.0;
static const double radiusSm = 8.0; static const double radiusSm = 6.0;
static const double radiusMd = 12.0; static const double radiusMd = 10.0;
static const double radiusLg = 16.0; static const double radiusLg = 14.0;
static const double radiusXl = 24.0; static const double radiusXl = 20.0;
static const double radiusFull = 9999.0; static const double radiusFull = 9999.0;
// ============ ANIMATION ============ // ============ ANIMATION ============
static const Duration durationFast = Duration(milliseconds: 200); // Rythme lent, « fondu de projection ».
static const Duration durationNormal = Duration(milliseconds: 300); static const Duration durationXs = Duration(milliseconds: 120);
static const Duration durationSm = Duration(milliseconds: 200); static const Duration durationFast = Duration(milliseconds: 180);
static const Duration durationMd = Duration(milliseconds: 300); static const Duration durationNormal = Duration(milliseconds: 260);
static const Duration durationLg = Duration(milliseconds: 400); static const Duration durationSm = Duration(milliseconds: 180);
static const Duration durationXl = Duration(milliseconds: 500); static const Duration durationMd = Duration(milliseconds: 260);
static const Curve curveDefault = Curves.fastOutSlowIn; static const Duration durationLg = Duration(milliseconds: 380);
static const Curve curveSmooth = Curves.fastOutSlowIn; static const Duration durationXl = Duration(milliseconds: 520);
// ============ TYPOGRAPHY HELPERS ============ static const Curve curveDefault = Curves.easeOutCubic;
static TextStyle _spaceGrotesk({ static const Curve curveSmooth = Curves.easeOutCubic;
/// Courbe « entrée cinéma » : démarre vite, s'installe lentement.
static const Curve curveCinema = Cubic(0.16, 1.0, 0.3, 1.0);
// ============ TYPOGRAPHIE ============
/// 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 TV.
static TextStyle body({
required double fontSize, required double fontSize,
required FontWeight fontWeight, required FontWeight fontWeight,
double? letterSpacing, double? letterSpacing,
double? height, double? height,
Color color = AppColors.onSurface, Color color = AppColors.onSurface,
}) { }) {
return GoogleFonts.spaceGrotesk( return GoogleFonts.karla(
fontSize: fontSize, fontSize: fontSize,
fontWeight: fontWeight, fontWeight: fontWeight,
letterSpacing: letterSpacing, letterSpacing: letterSpacing,
@@ -55,137 +91,178 @@ class AppTheme {
); );
} }
static TextStyle _inter({ /// 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,
);
}
// Alias internes historiques
static TextStyle _display({
required double fontSize, required double fontSize,
required FontWeight fontWeight, required FontWeight fontWeight,
double? letterSpacing, double? letterSpacing,
double? height, double? height,
Color color = AppColors.onSurface, Color color = AppColors.onSurface,
}) { }) =>
return GoogleFonts.inter( display(
fontSize: fontSize, fontSize: fontSize,
fontWeight: fontWeight, fontWeight: fontWeight,
letterSpacing: letterSpacing, letterSpacing: letterSpacing,
height: height, height: height,
color: color, color: color,
); );
}
// ============ DARK THEME (Main) ============ static TextStyle _body({
required double fontSize,
required FontWeight fontWeight,
double? letterSpacing,
double? height,
Color color = AppColors.onSurface,
}) =>
body(
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: letterSpacing,
height: height,
color: color,
);
// ============ THÈME SOMBRE (principal) ============
static ThemeData get darkTheme { static ThemeData get darkTheme {
final baseTheme = ThemeData.dark(useMaterial3: true); final baseTheme = ThemeData.dark(useMaterial3: true);
return baseTheme.copyWith( return baseTheme.copyWith(
colorScheme: AppColors.darkColorScheme, colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background, scaffoldBackgroundColor: AppColors.background,
canvasColor: AppColors.background,
splashColor: AppColors.primaryContainer.withValues(alpha: 0.12),
highlightColor: AppColors.primaryContainer.withValues(alpha: 0.08),
// Typography
textTheme: TextTheme( textTheme: TextTheme(
// Display / Headline (Space Grotesk) // Display — Fraunces, serrée, autoritaire
displayLarge: _spaceGrotesk( displayLarge: _display(
fontSize: 48, fontSize: 56,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.02, letterSpacing: -1.4,
height: 1.1, height: 1.02,
), ),
displayMedium: _spaceGrotesk( displayMedium: _display(
fontSize: 40,
fontWeight: FontWeight.w600,
letterSpacing: -0.9,
height: 1.08,
),
displaySmall: _display(
fontSize: 30,
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
height: 1.15,
),
headlineLarge: _display(
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.01, letterSpacing: -0.6,
height: 1.2, height: 1.12,
), ),
displaySmall: _spaceGrotesk( headlineMedium: _display(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w500,
height: 1.3,
),
headlineLarge: _spaceGrotesk(
fontSize: 32,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.01, letterSpacing: -0.3,
height: 1.2, height: 1.2,
), ),
headlineMedium: _spaceGrotesk( headlineSmall: _display(
fontSize: 24,
fontWeight: FontWeight.w500,
height: 1.3,
),
headlineSmall: _spaceGrotesk(
fontSize: 20,
fontWeight: FontWeight.w500,
height: 1.3,
),
titleLarge: _spaceGrotesk(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.25,
), ),
titleMedium: _inter( titleLarge: _display(
fontSize: 19,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
),
titleMedium: _body(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
letterSpacing: 0.1,
), ),
titleSmall: _inter( titleSmall: _body(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
letterSpacing: 0.2,
), ),
// Body (Inter) // Corps — Karla
bodyLarge: _inter( bodyLarge: _body(
fontSize: 18, fontSize: 17,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.6, height: 1.62,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
), ),
bodyMedium: _inter( bodyMedium: _body(
fontSize: 16, fontSize: 15,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.5, height: 1.55,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
), ),
bodySmall: _inter( bodySmall: _body(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.4, height: 1.45,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
), ),
// Labels (Inter with tracking) // Labels — capitales espacées, esprit générique de film
labelLarge: _inter( labelLarge: _body(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
letterSpacing: 0.05, letterSpacing: 1.1,
height: 1.2, height: 1.2,
), ),
labelMedium: _inter( labelMedium: _body(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
letterSpacing: 0.02, letterSpacing: 0.9,
height: 1.2, height: 1.2,
), ),
labelSmall: _inter( labelSmall: _body(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
letterSpacing: 0.02, letterSpacing: 1.4,
height: 1.2, height: 1.2,
color: AppColors.textTertiary,
), ),
), ),
// AppBar: Transparent glass // AppBar : plate, sans élévation, le fond fait le travail
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
foregroundColor: AppColors.onSurface, foregroundColor: AppColors.onSurface,
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: false,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
titleTextStyle: _spaceGrotesk( titleTextStyle: _display(
fontSize: 20, fontSize: 22,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.3,
), ),
), ),
// Inputs: Dark filled with blue glow on focus // Champs : creusés dans la surface, liseré ember au focus
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: AppColors.surfaceContainerLow, fillColor: AppColors.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: spacingLg, horizontal: spacingMd,
vertical: spacingMd, vertical: spacingMd,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -198,84 +275,116 @@ class AppTheme {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.primaryContainer, width: 2), borderSide:
const BorderSide(color: AppColors.primaryContainer, width: 2),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.error, width: 1), borderSide: const BorderSide(color: AppColors.error, width: 1),
), ),
hintStyle: _inter( focusedErrorBorder: OutlineInputBorder(
fontSize: 16, borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.error, width: 2),
),
hintStyle: _body(
fontSize: 15,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: AppColors.outline, color: AppColors.textTertiary,
),
labelStyle: _inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.onSurfaceVariant,
), ),
labelStyle: eyebrow(color: AppColors.onSurfaceVariant),
floatingLabelStyle: eyebrow(color: AppColors.primary),
), ),
// Buttons: Gradient primary, ghost secondary // Boutons pleins : ember franc, coins nets
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.primaryContainer, backgroundColor: AppColors.primaryFill,
foregroundColor: AppColors.onPrimaryContainer, foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.surfaceContainerHigh,
disabledForegroundColor: AppColors.textTertiary,
elevation: 0, elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 16),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
), ),
textStyle: _inter( textStyle: _body(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
letterSpacing: 0.05, letterSpacing: 1.1,
), ),
).copyWith( ).copyWith(
overlayColor: WidgetStateProperty.resolveWith((states) { overlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.hovered)) {
return Colors.white.withOpacity(0.1);
}
if (states.contains(WidgetState.pressed)) { if (states.contains(WidgetState.pressed)) {
return Colors.white.withOpacity(0.2); return Colors.black.withValues(alpha: 0.18);
}
if (states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) {
return AppColors.onSurface.withValues(alpha: 0.12);
} }
return null; return null;
}), }),
), ),
), ),
// Outlined buttons: ghost style elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.surfaceContainerHigh,
foregroundColor: AppColors.onSurface,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
textStyle: _body(
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 1.1,
),
),
),
// Boutons contour : liseré crème discret
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: AppColors.onSurface, foregroundColor: AppColors.onSurface,
side: const BorderSide(color: AppColors.outlineVariant, width: 1), side: const BorderSide(color: AppColors.outlineVariant, width: 1),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
), ),
textStyle: _inter( textStyle: _body(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
letterSpacing: 0.05, letterSpacing: 1.1,
), ),
), ),
), ),
// Text buttons
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: AppColors.primary, foregroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
textStyle: _inter( textStyle: _body(
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
letterSpacing: 0.8,
), ),
), ),
), ),
// Cards: Glass style iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: AppColors.onSurfaceVariant,
hoverColor: AppColors.primaryContainer.withValues(alpha: 0.14),
highlightColor: AppColors.primaryContainer.withValues(alpha: 0.2),
),
),
// Cartes : surface opaque + ombre profonde (zéro blur)
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: AppColors.glassLevel1Bg, color: AppColors.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.black,
elevation: 0, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -284,115 +393,239 @@ class AppTheme {
), ),
), ),
// Dialogs: Floating glass // Dialogues : bloc opaque flottant
dialogTheme: DialogThemeData( dialogTheme: DialogThemeData(
backgroundColor: AppColors.glassLevel2Bg, backgroundColor: AppColors.surfaceContainer,
elevation: 24, surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusXl), borderRadius: BorderRadius.circular(radiusXl),
side: const BorderSide(color: AppColors.glassLevel2Border, width: 1), side: const BorderSide(color: AppColors.glassLevel2Border, width: 1),
), ),
titleTextStyle: _spaceGrotesk( titleTextStyle: _display(
fontSize: 20, fontSize: 22,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.3,
), ),
contentTextStyle: _inter( contentTextStyle: _body(
fontSize: 16, fontSize: 15,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.55,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
), ),
), ),
// Bottom sheets
bottomSheetTheme: const BottomSheetThemeData( bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: AppColors.surfaceContainerLow, backgroundColor: AppColors.surfaceContainer,
surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(radiusXl)), borderRadius: BorderRadius.vertical(top: Radius.circular(radiusXl)),
), ),
), ),
// Snackbars
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.surfaceContainerHigh, backgroundColor: AppColors.surfaceContainerHighest,
contentTextStyle: _inter( contentTextStyle: _body(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
actionTextColor: AppColors.primary,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
), ),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
elevation: 0,
), ),
// Chips: Pill shaped // Chips : étiquettes de bobine
chipTheme: ChipThemeData( chipTheme: ChipThemeData(
backgroundColor: AppColors.primary.withOpacity(0.1), backgroundColor: AppColors.surfaceContainerHigh,
deleteIconColor: AppColors.onSurface, deleteIconColor: AppColors.onSurfaceVariant,
disabledColor: AppColors.surfaceContainerHighest, disabledColor: AppColors.surfaceContainer,
selectedColor: AppColors.primaryContainer, selectedColor: AppColors.primaryFill,
secondarySelectedColor: AppColors.primaryContainer, secondarySelectedColor: AppColors.primaryFill,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), checkmarkColor: Colors.white,
shape: StadiumBorder( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
side: BorderSide(color: AppColors.primary.withOpacity(0.3)), side: const BorderSide(color: AppColors.outlineVariant, width: 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusSm),
), ),
labelStyle: _inter( labelStyle: _body(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.onSurface,
),
secondaryLabelStyle: _inter(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onPrimaryContainer, letterSpacing: 0.6,
color: AppColors.onSurfaceVariant,
),
secondaryLabelStyle: _body(
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: Colors.white,
), ),
), ),
// Tabs // Onglets : soulignement ember épais
tabBarTheme: TabBarThemeData( tabBarTheme: TabBarThemeData(
labelColor: AppColors.onSurface, labelColor: AppColors.onSurface,
unselectedLabelColor: AppColors.outline, unselectedLabelColor: AppColors.textTertiary,
indicatorColor: AppColors.primaryContainer, indicatorColor: AppColors.primaryContainer,
labelStyle: _inter(fontSize: 14, fontWeight: FontWeight.w600), indicatorSize: TabBarIndicatorSize.label,
unselectedLabelStyle: _inter(fontSize: 14, fontWeight: FontWeight.w500), dividerColor: Colors.transparent,
labelStyle: _body(
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
),
unselectedLabelStyle: _body(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.8,
),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: AppColors.surfaceContainerLowest,
indicatorColor: AppColors.primaryContainer.withValues(alpha: 0.22),
selectedIconTheme: const IconThemeData(color: AppColors.primary),
unselectedIconTheme: const IconThemeData(color: AppColors.textTertiary),
selectedLabelTextStyle: eyebrow(color: AppColors.primary),
unselectedLabelTextStyle: eyebrow(color: AppColors.textTertiary),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.surfaceContainerLowest,
surfaceTintColor: Colors.transparent,
indicatorColor: AppColors.primaryContainer.withValues(alpha: 0.22),
elevation: 0,
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => eyebrow(
color: states.contains(WidgetState.selected)
? AppColors.primary
: AppColors.textTertiary,
),
),
iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData(
color: states.contains(WidgetState.selected)
? AppColors.primary
: AppColors.textTertiary,
size: 24,
),
),
), ),
// Divider
dividerTheme: const DividerThemeData( dividerTheme: const DividerThemeData(
color: AppColors.outlineVariant, color: AppColors.outlineVariant,
thickness: 1, thickness: 1,
space: 1,
), ),
// Scrollbar
scrollbarTheme: ScrollbarThemeData( scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStateProperty.all(AppColors.outlineVariant), thumbColor: WidgetStateProperty.all(AppColors.outlineVariant),
trackColor: WidgetStateProperty.all(Colors.transparent), trackColor: WidgetStateProperty.all(Colors.transparent),
radius: const Radius.circular(4), radius: const Radius.circular(2),
thickness: WidgetStateProperty.all(6), thickness: WidgetStateProperty.all(5),
),
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.primaryContainer,
linearTrackColor: AppColors.surfaceContainerHigh,
circularTrackColor: Colors.transparent,
),
sliderTheme: SliderThemeData(
activeTrackColor: AppColors.primaryContainer,
inactiveTrackColor: AppColors.surfaceContainerHigh,
thumbColor: AppColors.primary,
overlayColor: AppColors.primaryContainer.withValues(alpha: 0.18),
trackHeight: 4,
), ),
// Switch
switchTheme: SwitchThemeData( switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) { thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) { if (states.contains(WidgetState.selected)) return AppColors.onSurface;
return AppColors.onPrimary;
}
return AppColors.outline; return AppColors.outline;
}), }),
trackColor: WidgetStateProperty.resolveWith((states) { trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) { if (states.contains(WidgetState.selected)) {
return AppColors.primaryContainer; return AppColors.primaryContainer;
} }
return AppColors.surfaceContainerHighest; return AppColors.surfaceContainerHigh;
}), }),
trackOutlineColor:
WidgetStateProperty.all(AppColors.outlineVariant),
),
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.primaryContainer;
}
return Colors.transparent;
}),
checkColor: WidgetStateProperty.all(AppColors.onSurface),
side: const BorderSide(color: AppColors.outline, width: 1.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: AppColors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(radiusSm),
border: Border.all(color: AppColors.outlineVariant),
),
textStyle: _body(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.onSurface,
),
),
listTileTheme: ListTileThemeData(
iconColor: AppColors.onSurfaceVariant,
textColor: AppColors.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
),
popupMenuTheme: PopupMenuThemeData(
color: AppColors.surfaceContainerHigh,
surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
side: const BorderSide(color: AppColors.outlineVariant),
),
textStyle: _body(fontSize: 14, fontWeight: FontWeight.w500),
), ),
); );
} }
// Light theme is minimal — Stitch is dark-only // ============ THÈME CLAIR (« papier kraft ») ============
static ThemeData get lightTheme => ThemeData.light(useMaterial3: true).copyWith( static ThemeData get lightTheme {
colorScheme: AppColors.lightColorScheme, final scheme = AppColors.lightColorScheme;
scaffoldBackgroundColor: AppColors.lightColorScheme.surface, return ThemeData.light(useMaterial3: true).copyWith(
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme), colorScheme: scheme,
); scaffoldBackgroundColor: scheme.surface,
textTheme: GoogleFonts.karlaTextTheme(ThemeData.light().textTheme),
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
foregroundColor: scheme.onSurface,
elevation: 0,
centerTitle: false,
titleTextStyle: GoogleFonts.fraunces(
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
color: scheme.onSurface,
),
),
);
}
} }
@@ -140,7 +140,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
children: [ children: [
Text( Text(
item.title, item.title,
style: GoogleFonts.spaceGrotesk( style: GoogleFonts.fraunces(
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -152,7 +152,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
item.subtitle!, item.subtitle!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
@@ -245,7 +245,7 @@ class _HeroButtonState extends State<_HeroButton> {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
widget.label, widget.label,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -149,7 +149,7 @@ class _GradientButtonState extends State<GradientButton>
], ],
Text( Text(
widget.label, widget.label,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 14,
@@ -253,7 +253,7 @@ class LiveBadge extends StatelessWidget {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'LIVE', 'LIVE',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -302,7 +302,7 @@ class CategoryChip extends StatelessWidget {
), ),
child: Text( child: Text(
label, label,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 13, fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? AppColors.primary : AppColors.onSurfaceVariant, color: isSelected ? AppColors.primary : AppColors.onSurfaceVariant,
@@ -458,7 +458,7 @@ class _MediaCardState extends State<MediaCard>
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
widget.rating!, widget.rating!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -502,7 +502,7 @@ class _MediaCardState extends State<MediaCard>
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: _isHovered color: _isHovered
? AppColors.primary ? AppColors.primary
: AppColors.onSurface, : AppColors.onSurface,
@@ -518,7 +518,7 @@ class _MediaCardState extends State<MediaCard>
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
fontSize: 11, fontSize: 11,
), ),
+3 -3
View File
@@ -32,13 +32,13 @@ class _DigitalClockState extends State<DigitalClock> {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1), color: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.05)), border: Border.all(color: AppColors.onSurface.withOpacity(0.05)),
), ),
child: Text( child: Text(
timeString, timeString,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 16, fontSize: 16,
color: AppColors.onSurface, color: AppColors.onSurface,
+274 -85
View File
@@ -1,12 +1,19 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
/// Glassmorphism container with 3 depth levels (Stitch Design System) /// Conteneur de surface « Warm Cinema » — 3 niveaux de profondeur.
/// ///
/// Level 0: Pure base background (#0F1014), no blur. /// Level 0 : socle brut (#0B0908), aucune ombre.
/// Level 1: Translucent layer with backdrop blur 20px, 1px border 10% white. /// Level 1 : plaque opaque légèrement éclaircie, liseré crème 8 %, ombre douce.
/// Level 2: High-blur floating container with subtle inner glow in primary accent. /// Level 2 : bloc flottant, liseré ember sur l'arête haute, ombre profonde.
///
/// PERF : cette version n'utilise **aucun** `BackdropFilter`. Le flou de fond
/// forçait Flutter Web à relire le framebuffer à chaque frame pour chaque
/// carte à l'écran (33 sites d'appel ici) — cause principale du jank au
/// scroll. La profondeur est désormais rendue par des surfaces opaques
/// pré-teintées et des ombres, ce qui coûte quasiment rien au GPU.
///
/// L'API publique est inchangée : aucun site d'appel n'a besoin d'être modifié.
enum GlassLevel { level0, level1, level2 } enum GlassLevel { level0, level1, level2 }
class GlassContainer extends StatelessWidget { class GlassContainer extends StatelessWidget {
@@ -21,6 +28,14 @@ class GlassContainer extends StatelessWidget {
final bool border; final bool border;
final List<BoxShadow>? boxShadow; final List<BoxShadow>? boxShadow;
/// Alias de [border] (nom utilisé par les widgets de navigation TV).
/// Prioritaire sur [border] lorsqu'il est fourni.
final bool? hasBorder;
/// Passer `false` supprime toute ombre portée — utile pour les barres
/// collées à un bord, où l'ombre créerait une couture visible.
final bool showShadow;
const GlassContainer({ const GlassContainer({
super.key, super.key,
required this.child, required this.child,
@@ -28,14 +43,18 @@ class GlassContainer extends StatelessWidget {
this.height, this.height,
this.padding = const EdgeInsets.all(16), this.padding = const EdgeInsets.all(16),
this.margin, this.margin,
this.borderRadius = 16.0, this.borderRadius = 14.0,
this.level = GlassLevel.level1, this.level = GlassLevel.level1,
this.borderColor, this.borderColor,
this.border = true, this.border = true,
this.boxShadow, this.boxShadow,
this.hasBorder,
this.showShadow = true,
}); });
/// Convenience constructor for Level 0 (pure base) bool get _showBorder => hasBorder ?? border;
/// Niveau 0 — socle brut.
const GlassContainer.base({ const GlassContainer.base({
super.key, super.key,
required this.child, required this.child,
@@ -43,13 +62,15 @@ class GlassContainer extends StatelessWidget {
this.height, this.height,
this.padding = const EdgeInsets.all(16), this.padding = const EdgeInsets.all(16),
this.margin, this.margin,
this.borderRadius = 16.0, this.borderRadius = 14.0,
this.borderColor, this.borderColor,
this.border = false, this.border = false,
this.boxShadow, this.boxShadow,
this.hasBorder,
this.showShadow = true,
}) : level = GlassLevel.level0; }) : level = GlassLevel.level0;
/// Convenience constructor for Level 1 (standard glass) /// Niveau 1 — plaque standard.
const GlassContainer.glass({ const GlassContainer.glass({
super.key, super.key,
required this.child, required this.child,
@@ -57,13 +78,15 @@ class GlassContainer extends StatelessWidget {
this.height, this.height,
this.padding = const EdgeInsets.all(16), this.padding = const EdgeInsets.all(16),
this.margin, this.margin,
this.borderRadius = 16.0, this.borderRadius = 14.0,
this.borderColor, this.borderColor,
this.border = true, this.border = true,
this.boxShadow, this.boxShadow,
this.hasBorder,
this.showShadow = true,
}) : level = GlassLevel.level1; }) : level = GlassLevel.level1;
/// Convenience constructor for Level 2 (floating with inner glow) /// Niveau 2 — bloc flottant (modales, panneaux, overlays).
const GlassContainer.floating({ const GlassContainer.floating({
super.key, super.key,
required this.child, required this.child,
@@ -71,10 +94,12 @@ class GlassContainer extends StatelessWidget {
this.height, this.height,
this.padding = const EdgeInsets.all(16), this.padding = const EdgeInsets.all(16),
this.margin, this.margin,
this.borderRadius = 24.0, this.borderRadius = 20.0,
this.borderColor, this.borderColor,
this.border = true, this.border = true,
this.boxShadow, this.boxShadow,
this.hasBorder,
this.showShadow = true,
}) : level = GlassLevel.level2; }) : level = GlassLevel.level2;
@override @override
@@ -98,12 +123,13 @@ class GlassContainer extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.baseLevel0, color: AppColors.baseLevel0,
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
border: border border: _showBorder
? Border.all( ? Border.all(
color: borderColor ?? Colors.transparent, color: borderColor ?? Colors.transparent,
width: 1, width: 1,
) )
: null, : null,
boxShadow: showShadow ? boxShadow : null,
), ),
child: child, child: child,
); );
@@ -114,34 +140,29 @@ class GlassContainer extends StatelessWidget {
width: width, width: width,
height: height, height: height,
margin: margin, margin: margin,
child: ClipRRect( padding: padding,
decoration: BoxDecoration(
color: AppColors.glassLevel1Bg,
borderRadius: BorderRadius.circular(borderRadius), borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter( border: _showBorder
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), ? Border.all(
child: Container( color: borderColor ?? AppColors.glassLevel1Border,
padding: padding, width: 1,
decoration: BoxDecoration( )
borderRadius: BorderRadius.circular(borderRadius), : null,
color: AppColors.glassLevel1Bg, boxShadow: !showShadow
border: border ? null
? Border.all( : boxShadow ??
color: borderColor ?? AppColors.glassLevel1Border, const [
width: 1, BoxShadow(
) color: Color(0x59000000), // noir 35 %
: null, blurRadius: 18,
boxShadow: boxShadow ?? spreadRadius: -6,
[ offset: Offset(0, 8),
BoxShadow( ),
color: Colors.black.withOpacity(0.2), ],
blurRadius: 20,
spreadRadius: -5,
),
],
),
child: child,
),
),
), ),
child: child,
); );
} }
@@ -150,54 +171,222 @@ class GlassContainer extends StatelessWidget {
width: width, width: width,
height: height, height: height,
margin: margin, margin: margin,
child: ClipRRect( padding: padding,
borderRadius: BorderRadius.circular(borderRadius), decoration: BoxDecoration(
child: BackdropFilter( // Dégradé très court : arête haute captant la « lumière du projecteur ».
filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40), gradient: const LinearGradient(
child: Container( colors: [Color(0xFF262019), Color(0xFF1F1815)],
padding: padding, stops: [0.0, 0.45],
decoration: BoxDecoration( begin: Alignment.topCenter,
borderRadius: BorderRadius.circular(borderRadius), end: Alignment.bottomCenter,
color: AppColors.glassLevel2Bg,
border: border
? Border(
top: BorderSide(
color: borderColor ?? AppColors.glassLevel2InnerGlow,
width: 1,
),
left: BorderSide(
color: borderColor ?? AppColors.glassLevel2InnerGlow,
width: 1,
),
right: BorderSide(
color: borderColor ?? AppColors.glassLevel2Border,
width: 1,
),
bottom: BorderSide(
color: borderColor ?? AppColors.glassLevel2Border,
width: 1,
),
)
: null,
boxShadow: boxShadow ??
[
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 30,
spreadRadius: -10,
offset: const Offset(0, 10),
),
BoxShadow(
color: AppColors.primary.withOpacity(0.1),
blurRadius: 40,
spreadRadius: -10,
),
],
),
child: child,
),
), ),
borderRadius: BorderRadius.circular(borderRadius),
border: _showBorder
? Border(
top: BorderSide(
color: borderColor ?? AppColors.glassLevel2InnerGlow,
width: 1,
),
left: BorderSide(
color: borderColor ?? AppColors.glassLevel2Border,
width: 1,
),
right: BorderSide(
color: borderColor ?? AppColors.glassLevel2Border,
width: 1,
),
bottom: BorderSide(
color: borderColor ?? AppColors.glassLevel2Border,
width: 1,
),
)
: null,
boxShadow: !showShadow
? null
: boxShadow ??
const [
BoxShadow(
color: Color(0xA6000000), // noir 65 %
blurRadius: 40,
spreadRadius: -12,
offset: Offset(0, 20),
),
BoxShadow(
color: Color(0x1FD9541F), // halo ember 12 %
blurRadius: 48,
spreadRadius: -18,
),
],
),
child: child,
);
}
}
/// Carte de surface interactive — variante cliquable de [GlassContainer].
///
/// Survol : la carte se soulève et son liseré passe à la braise.
/// Focus clavier / télécommande : halo ember net, indispensable en usage TV.
class GlassCard extends StatefulWidget {
final Widget child;
final double? width;
final double? height;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final double borderRadius;
final GlassLevel level;
/// Active les réactions au survol et au focus.
final bool interactive;
final VoidCallback? onTap;
const GlassCard({
super.key,
required this.child,
this.width,
this.height,
this.padding = const EdgeInsets.all(16),
this.margin,
this.borderRadius = 14.0,
this.level = GlassLevel.level1,
this.interactive = false,
this.onTap,
});
@override
State<GlassCard> createState() => _GlassCardState();
}
class _GlassCardState extends State<GlassCard> {
bool _hovered = false;
bool _focused = false;
bool get _active => widget.interactive && (_hovered || _focused);
@override
Widget build(BuildContext context) {
final surface = AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: widget.width,
height: widget.height,
margin: widget.margin,
padding: widget.padding,
decoration: BoxDecoration(
color: widget.level == GlassLevel.level0
? AppColors.baseLevel0
: (_active
? AppColors.surfaceContainerHigh
: AppColors.glassLevel1Bg),
borderRadius: BorderRadius.circular(widget.borderRadius),
border: Border.all(
color: _active
? AppColors.primaryContainer
: AppColors.glassLevel1Border,
width: _active ? 2 : 1,
),
boxShadow: _active
? AppColors.emberFocus()
: const [
BoxShadow(
color: Color(0x59000000),
blurRadius: 18,
spreadRadius: -6,
offset: Offset(0, 8),
),
],
),
// Le contenu est rogné pour que les affiches épousent le rayon.
child: ClipRRect(
borderRadius: BorderRadius.circular(
(widget.borderRadius - 1).clamp(0.0, widget.borderRadius),
),
child: widget.child,
),
);
if (!widget.interactive && widget.onTap == null) return surface;
return FocusableActionDetector(
onShowFocusHighlight: (v) => setState(() => _focused = v),
onShowHoverHighlight: (v) => setState(() => _hovered = v),
mouseCursor: SystemMouseCursors.click,
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (_) {
widget.onTap?.call();
return null;
},
),
},
child: GestureDetector(
onTap: widget.onTap,
behavior: HitTestBehavior.opaque,
child: surface,
), ),
); );
} }
} }
/// Fond de scène « Warm Cinema ».
///
/// Dégradé chaud + vignettage, sans image ni flou. Sert de toile de fond aux
/// écrans principaux pour éviter les aplats plats et sans atmosphère.
class CinemaBackdrop extends StatelessWidget {
final Widget child;
/// Halo ember diffus en haut à gauche (lueur de projection).
final bool projectorGlow;
const CinemaBackdrop({
super.key,
required this.child,
this.projectorGlow = true,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: AppColors.backgroundGradient,
),
child: projectorGlow
? Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: const Alignment(-0.75, -0.95),
radius: 1.25,
colors: [
AppColors.primaryContainer.withValues(alpha: 0.10),
Colors.transparent,
],
stops: const [0.0, 1.0],
),
),
),
),
// Vignettage bas : ancre le contenu, style projection.
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.center,
colors: [
Colors.black.withValues(alpha: 0.35),
Colors.transparent,
],
),
),
),
),
child,
],
)
: child,
);
}
}
+8 -5
View File
@@ -179,6 +179,9 @@ class _HeroCarouselState extends State<HeroCarousel>
Image.network( Image.network(
item.imageUrl!, item.imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
// Le heros est plein ecran : plafond a 1280 px, au-dela le gain
// visuel est nul et le cout de decodage explose.
cacheWidth: 1280,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return Container( return Container(
color: AppColors.surface, color: AppColors.surface,
@@ -220,7 +223,7 @@ class _HeroCarouselState extends State<HeroCarousel>
), ),
child: Text( child: Text(
item.badge!, item.badge!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.primary, color: AppColors.primary,
@@ -233,7 +236,7 @@ class _HeroCarouselState extends State<HeroCarousel>
item.title, item.title,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
fontSize: 48, fontSize: 48,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: AppColors.textPrimary, color: AppColors.textPrimary,
@@ -247,7 +250,7 @@ class _HeroCarouselState extends State<HeroCarousel>
item.subtitle!, item.subtitle!,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -292,7 +295,7 @@ class _HeroCarouselState extends State<HeroCarousel>
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Play', 'Play',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.primary, color: AppColors.primary,
@@ -330,7 +333,7 @@ class _HeroCarouselState extends State<HeroCarousel>
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'More Info', 'More Info',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.textPrimary, color: AppColors.textPrimary,
+28 -23
View File
@@ -2,43 +2,48 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
/// Stitch-themed loading screen with dark gradient and blue accent. /// Écran de chargement « Warm Cinema » — salle qui s'éteint avant projection.
class ThemedLoading extends StatelessWidget { class ThemedLoading extends StatelessWidget {
const ThemedLoading({super.key}); const ThemedLoading({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return const DecoratedBox(
decoration: const BoxDecoration( decoration: BoxDecoration(gradient: AppColors.backgroundGradient),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF0F1014), // Deep Space Dark
Color(0xFF121317), // Stitch background
],
),
),
child: Center( child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const CircularProgressIndicator( SizedBox(
color: Color(0xFF4b8eff), // AppColors.primaryContainer width: 34,
strokeWidth: 3, height: 34,
), child: CircularProgressIndicator(
const SizedBox(height: 16), color: AppColors.primaryContainer,
Text( strokeWidth: 2,
'Loading...',
style: GoogleFonts.inter(
color: AppColors.onSurfaceVariant,
fontSize: 14,
fontWeight: FontWeight.w500,
), ),
), ),
SizedBox(height: 20),
_LoadingLabel(),
], ],
), ),
), ),
); );
} }
} }
class _LoadingLabel extends StatelessWidget {
const _LoadingLabel();
@override
Widget build(BuildContext context) {
return Text(
'PROJECTION',
style: GoogleFonts.karla(
color: AppColors.textTertiary,
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 3.2,
),
);
}
}
+10 -5
View File
@@ -131,7 +131,7 @@ class _TvModernCardState extends State<TvModernCard>
), ),
child: Text( child: Text(
widget.badge!, widget.badge!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.black, color: Colors.black,
@@ -208,7 +208,7 @@ class _TvModernCardState extends State<TvModernCard>
widget.title, widget.title,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary, color: AppColors.textPrimary,
@@ -229,7 +229,7 @@ class _TvModernCardState extends State<TvModernCard>
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
widget.rating!, widget.rating!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -241,7 +241,7 @@ class _TvModernCardState extends State<TvModernCard>
if (widget.year != null) if (widget.year != null)
Text( Text(
widget.year!, widget.year!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: AppColors.textTertiary, color: AppColors.textTertiary,
@@ -273,6 +273,11 @@ class _TvModernCardState extends State<TvModernCard>
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: widget.imageUrl!, imageUrl: widget.imageUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
// Decodage borne a la taille d'affichage : une grille de 40 affiches
// passe de ~240 Mo a quelques dizaines de Mo en memoire GPU.
memCacheWidth: 400,
fadeInDuration: const Duration(milliseconds: 180),
fadeOutDuration: Duration.zero,
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
color: AppColors.shimmer, color: AppColors.shimmer,
child: const Center( child: const Center(
@@ -326,7 +331,7 @@ class _TvModernCardState extends State<TvModernCard>
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
label, label,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.black, color: Colors.black,
+5 -5
View File
@@ -62,7 +62,7 @@ class _TvTopNavBarState extends State<TvTopNavBar> {
const SizedBox(width: AppTheme.spacing16), const SizedBox(width: AppTheme.spacing16),
Text( Text(
widget.title, widget.title,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.textPrimary, color: AppColors.textPrimary,
@@ -152,10 +152,10 @@ class _TvTopNavBarState extends State<TvTopNavBar> {
), ),
child: Text( child: Text(
badge, badge,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: AppColors.onSurface,
), ),
), ),
), ),
@@ -256,7 +256,7 @@ class _TvSideNavState extends State<TvSideNav> {
Expanded( Expanded(
child: Text( child: Text(
item.label, item.label,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isSelected ? Colors.black : AppColors.textPrimary, color: isSelected ? Colors.black : AppColors.textPrimary,
@@ -364,7 +364,7 @@ class _TvFloatingMenuState extends State<TvFloatingMenu>
const SizedBox(width: AppTheme.spacing12), const SizedBox(width: AppTheme.spacing12),
Text( Text(
widget.actions[index].label, widget.actions[index].label,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.textPrimary, color: AppColors.textPrimary,
+7 -7
View File
@@ -152,7 +152,7 @@ class _AdminContentState extends ConsumerState<AdminContent>
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
label, label,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 14,
color: isSelected ? AppColors.textPrimary : AppColors.textSecondary, color: isSelected ? AppColors.textPrimary : AppColors.textSecondary,
@@ -265,7 +265,7 @@ class _PlaylistsTab extends ConsumerWidget {
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium .titleMedium
?.copyWith(color: Colors.white), ?.copyWith(color: AppColors.onSurface),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
@@ -273,7 +273,7 @@ class _PlaylistsTab extends ConsumerWidget {
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.bodyMedium .bodyMedium
?.copyWith(color: Colors.white60), ?.copyWith(color: AppColors.onSurface54),
), ),
], ],
), ),
@@ -282,7 +282,7 @@ class _PlaylistsTab extends ConsumerWidget {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.edit_outlined, size: 20), icon: const Icon(Icons.edit_outlined, size: 20),
color: Colors.white70, color: AppColors.onSurfaceVariant,
onPressed: () => onPressed: () =>
_showPlaylistDialog(context, ref, playlist), _showPlaylistDialog(context, ref, playlist),
tooltip: 'Edit', tooltip: 'Edit',
@@ -544,7 +544,7 @@ class _UsersTab extends ConsumerWidget {
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium .titleMedium
?.copyWith(color: Colors.white), ?.copyWith(color: AppColors.onSurface),
), ),
if (user.isAdmin) ...[ if (user.isAdmin) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -576,7 +576,7 @@ class _UsersTab extends ConsumerWidget {
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.bodyMedium .bodyMedium
?.copyWith(color: Colors.white60), ?.copyWith(color: AppColors.onSurface54),
), ),
], ],
), ),
@@ -584,7 +584,7 @@ class _UsersTab extends ConsumerWidget {
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon( icon: const Icon(
Icons.more_vert, Icons.more_vert,
color: Colors.white70, color: AppColors.onSurfaceVariant,
), ),
color: AppColors.surface, color: AppColors.surface,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
+6 -6
View File
@@ -144,7 +144,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
// Title // Title
Text( Text(
'XtremFlow', 'XtremFlow',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 42, fontSize: 42,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.textPrimary, color: AppColors.textPrimary,
@@ -154,7 +154,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Sign in to sync your playlists', 'Sign in to sync your playlists',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 18, fontSize: 18,
color: AppColors.textSecondary, color: AppColors.textSecondary,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@@ -266,7 +266,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
) )
: Text( : Text(
'Sign In', 'Sign In',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.primary, color: AppColors.primary,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -282,7 +282,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
Text( Text(
'Default: admin / admin', 'Default: admin / admin',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
color: AppColors.textTertiary, color: AppColors.textTertiary,
), ),
@@ -310,13 +310,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
return TextFormField( return TextFormField(
controller: controller, controller: controller,
obscureText: obscureText, obscureText: obscureText,
style: GoogleFonts.inter(fontSize: 18, color: AppColors.textPrimary), style: GoogleFonts.karla(fontSize: 18, color: AppColors.textPrimary),
autofocus: autofocus, autofocus: autofocus,
textInputAction: nextFocus ? TextInputAction.next : TextInputAction.done, textInputAction: nextFocus ? TextInputAction.next : TextInputAction.done,
onFieldSubmitted: isLast ? (_) => onSubmitted?.call() : null, onFieldSubmitted: isLast ? (_) => onSubmitted?.call() : null,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
labelStyle: GoogleFonts.inter(color: AppColors.textSecondary), labelStyle: GoogleFonts.karla(color: AppColors.textSecondary),
prefixIcon: Icon(icon, color: AppColors.textSecondary), prefixIcon: Icon(icon, color: AppColors.textSecondary),
suffixIcon: suffix, suffixIcon: suffix,
filled: true, filled: true,
+31 -31
View File
@@ -446,7 +446,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
'Unknown') 'Unknown')
.toString() .toString()
: widget.title, : widget.title,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -455,7 +455,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
), ),
Text( Text(
'Live TV', 'Live TV',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.primary, color: AppColors.primary,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -512,7 +512,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
_buildInlineEpg(), _buildInlineEpg(),
const SizedBox(height: 12), const SizedBox(height: 12),
const Divider( const Divider(
color: Colors.white10, color: AppColors.onSurface06,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Control Buttons // Control Buttons
@@ -612,10 +612,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
Expanded( Expanded(
child: Text( child: Text(
widget.title, widget.title,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: AppColors.onSurface,
), ),
), ),
), ),
@@ -639,7 +639,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
right: 40, right: 40,
child: GlassContainer.glass( child: GlassContainer.glass(
borderRadius: 24, borderRadius: 24,
borderColor: Colors.white.withOpacity(0.1), borderColor: AppColors.onSurface.withOpacity(0.1),
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -653,8 +653,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
seconds: _currentPosition.toInt(), seconds: _currentPosition.toInt(),
), ),
), ),
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.white70, color: AppColors.onSurfaceVariant,
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -664,8 +664,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
trackHeight: 4, trackHeight: 4,
activeTrackColor: AppColors.primary, activeTrackColor: AppColors.primary,
inactiveTrackColor: inactiveTrackColor:
Colors.white.withOpacity(0.2), AppColors.onSurface.withOpacity(0.2),
thumbColor: Colors.white, thumbColor: AppColors.onSurface,
thumbShape: const RoundSliderThumbShape( thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 8, enabledThumbRadius: 8,
), ),
@@ -705,8 +705,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
seconds: _totalDuration.toInt(), seconds: _totalDuration.toInt(),
), ),
), ),
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.white70, color: AppColors.onSurfaceVariant,
), ),
), ),
], ],
@@ -784,7 +784,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
}) { }) {
// Button with direct Material/InkWell - PointerInterceptor is on outer container // Button with direct Material/InkWell - PointerInterceptor is on outer container
return Material( return Material(
color: transparent ? Colors.transparent : Colors.white.withOpacity(0.1), color: transparent ? Colors.transparent : AppColors.onSurface.withOpacity(0.1),
shape: const CircleBorder(), shape: const CircleBorder(),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
@@ -800,9 +800,9 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
shape: BoxShape.circle, shape: BoxShape.circle,
border: transparent border: transparent
? null ? null
: Border.all(color: Colors.white.withOpacity(0.1)), : Border.all(color: AppColors.onSurface.withOpacity(0.1)),
), ),
child: Icon(icon, color: Colors.white, size: iconSize), child: Icon(icon, color: AppColors.onSurface, size: iconSize),
), ),
), ),
); );
@@ -867,7 +867,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
return Material( return Material(
color: highlighted color: highlighted
? AppColors.primary.withOpacity(0.2) ? AppColors.primary.withOpacity(0.2)
: Colors.white.withOpacity(0.1), : AppColors.onSurface.withOpacity(0.1),
shape: const CircleBorder(), shape: const CircleBorder(),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
@@ -886,13 +886,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
border: Border.all( border: Border.all(
color: highlighted color: highlighted
? AppColors.primary.withOpacity(0.5) ? AppColors.primary.withOpacity(0.5)
: Colors.white.withOpacity(0.2), : AppColors.onSurface.withOpacity(0.2),
width: highlighted ? 2 : 1, width: highlighted ? 2 : 1,
), ),
), ),
child: Icon( child: Icon(
icon, icon,
color: highlighted ? AppColors.primary : Colors.white, color: highlighted ? AppColors.primary : AppColors.onSurface,
size: iconSize, size: iconSize,
), ),
), ),
@@ -930,7 +930,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: const Text( child: const Text(
'LIVE', 'LIVE',
style: TextStyle( style: TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -942,8 +942,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
widget.channels != null widget.channels != null
? widget.channels![_currentIndex].name ? widget.channels![_currentIndex].name
: widget.title, : widget.title,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -978,7 +978,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: const Text( child: const Text(
'LIVE', 'LIVE',
style: TextStyle( style: TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -992,8 +992,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
children: [ children: [
Text( Text(
currentProgram.title, currentProgram.title,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -1003,8 +1003,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
if (currentProgram.description.isNotEmpty) if (currentProgram.description.isNotEmpty)
Text( Text(
currentProgram.description, currentProgram.description,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.white60, color: AppColors.onSurface54,
fontSize: 12, fontSize: 12,
), ),
maxLines: 1, maxLines: 1,
@@ -1027,7 +1027,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
child: const Text( child: const Text(
'LIVE', 'LIVE',
style: TextStyle( style: TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -1038,8 +1038,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
widget.channels != null widget.channels != null
? widget.channels![_currentIndex].name ? widget.channels![_currentIndex].name
: widget.title, : widget.title,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -1050,8 +1050,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
widget.channels != null widget.channels != null
? widget.channels![_currentIndex].name ? widget.channels![_currentIndex].name
: widget.title, : widget.title,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -27,7 +27,7 @@ class PlaylistSelectionScreen extends ConsumerWidget {
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
'Select Playlist', 'Select Playlist',
style: GoogleFonts.roboto( style: GoogleFonts.karla(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
@@ -97,7 +97,7 @@ class PlaylistSelectionScreen extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Error loading playlists', 'Error loading playlists',
style: GoogleFonts.outfit( style: GoogleFonts.karla(
fontSize: 18, fontSize: 18,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
@@ -109,7 +109,7 @@ class PlaylistSelectionScreen extends ConsumerWidget {
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
foregroundColor: AppColors.textPrimary, foregroundColor: AppColors.textPrimary,
), ),
child: Text('Retry', style: GoogleFonts.inter()), child: Text('Retry', style: GoogleFonts.karla()),
), ),
], ],
), ),
@@ -123,14 +123,14 @@ class PlaylistSelectionScreen extends ConsumerWidget {
const Icon( const Icon(
Icons.playlist_remove, Icons.playlist_remove,
size: 64, size: 64,
color: Colors.white24, color: AppColors.onSurface24,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No playlists available', 'No playlists available',
style: GoogleFonts.outfit( style: GoogleFonts.karla(
fontSize: 18, fontSize: 18,
color: Colors.white60, color: AppColors.onSurface54,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -138,9 +138,9 @@ class PlaylistSelectionScreen extends ConsumerWidget {
currentUser?.isAdmin ?? false currentUser?.isAdmin ?? false
? 'Add playlists in Admin Panel' ? 'Add playlists in Admin Panel'
: 'Contact administrator', : 'Contact administrator',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
color: Colors.white38, color: AppColors.onSurface38,
), ),
), ),
], ],
@@ -209,13 +209,13 @@ class _PlaylistCardState extends State<_PlaylistCard> {
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isHovered color: _isHovered
? Colors.white.withOpacity(0.1) ? AppColors.onSurface.withOpacity(0.1)
: Colors.white.withOpacity(0.03), : AppColors.onSurface.withOpacity(0.03),
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all( border: Border.all(
color: _isHovered color: _isHovered
? AppColors.primary.withOpacity(0.5) ? AppColors.primary.withOpacity(0.5)
: Colors.white.withOpacity(0.08), : AppColors.onSurface.withOpacity(0.08),
width: 1.5, width: 1.5,
), ),
boxShadow: _isHovered boxShadow: _isHovered
@@ -245,8 +245,8 @@ class _PlaylistCardState extends State<_PlaylistCard> {
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
Colors.white.withOpacity(0.1), AppColors.onSurface.withOpacity(0.1),
Colors.white.withOpacity(0.05), AppColors.onSurface.withOpacity(0.05),
], ],
), ),
shape: BoxShape.circle, shape: BoxShape.circle,
@@ -263,16 +263,16 @@ class _PlaylistCardState extends State<_PlaylistCard> {
child: Icon( child: Icon(
Icons.playlist_play_rounded, Icons.playlist_play_rounded,
size: 32, size: 32,
color: _isHovered ? Colors.white : Colors.white70, color: _isHovered ? AppColors.onSurface : AppColors.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
widget.playlist.name, widget.playlist.name,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: AppColors.onSurface,
height: 1.2, height: 1.2,
), ),
maxLines: 2, maxLines: 2,
@@ -281,9 +281,9 @@ class _PlaylistCardState extends State<_PlaylistCard> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
Uri.tryParse(widget.playlist.dns)?.host ?? widget.playlist.dns, Uri.tryParse(widget.playlist.dns)?.host ?? widget.playlist.dns,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 13, fontSize: 13,
color: Colors.white38, color: AppColors.onSurface38,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
maxLines: 1, maxLines: 1,
@@ -130,6 +130,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
width: 248, width: 248,
height: 350, height: 350,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 500,
errorWidget: (_, __, ___) => Container( errorWidget: (_, __, ___) => Container(
width: 248, width: 248,
height: 350, height: 350,
@@ -143,7 +144,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
// Title // Title
Text( Text(
_seriesInfo!.name, _seriesInfo!.name,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -159,7 +160,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(_seriesInfo!.rating!)!, _formatRating(_seriesInfo!.rating!)!,
style: GoogleFonts.roboto(fontSize: 13, color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(fontSize: 13, color: AppColors.onSurfaceVariant),
), ),
], ],
), ),
@@ -169,7 +170,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
if (_seriesInfo!.plot != null && _seriesInfo!.plot!.isNotEmpty) if (_seriesInfo!.plot != null && _seriesInfo!.plot!.isNotEmpty)
Text( Text(
_seriesInfo!.plot!, _seriesInfo!.plot!,
style: GoogleFonts.roboto(fontSize: 12, color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(fontSize: 12, color: AppColors.onSurfaceVariant),
maxLines: 5, maxLines: 5,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -178,7 +179,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
// Season selector // Season selector
Text( Text(
'Seasons', 'Seasons',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -200,7 +201,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
}, },
selectedColor: AppColors.primaryContainer, selectedColor: AppColors.primaryContainer,
backgroundColor: AppColors.surfaceContainer, backgroundColor: AppColors.surfaceContainer,
labelStyle: GoogleFonts.inter( labelStyle: GoogleFonts.karla(
color: isSelected ? AppColors.onPrimary : AppColors.onSurfaceVariant, color: isSelected ? AppColors.onPrimary : AppColors.onSurfaceVariant,
fontSize: 12, fontSize: 12,
), ),
@@ -224,7 +225,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text( child: Text(
'Season $_selectedSeason - ${currentEpisodes.length} Episodes', 'Season $_selectedSeason - ${currentEpisodes.length} Episodes',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -236,7 +237,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
? Center( ? Center(
child: Text( child: Text(
'No episodes found', 'No episodes found',
style: GoogleFonts.roboto(color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
), ),
) )
: ListView.builder( : ListView.builder(
@@ -323,7 +324,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
? const Icon(Icons.check, color: AppColors.onSurface, size: 20) ? const Icon(Icons.check, color: AppColors.onSurface, size: 20)
: Text( : Text(
'${episode.episodeNum}', '${episode.episodeNum}',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -340,7 +341,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
children: [ children: [
Text( Text(
episode.title, episode.title,
style: GoogleFonts.roboto( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: isWatched ? AppColors.onSurfaceVariant : AppColors.onSurface, color: isWatched ? AppColors.onSurfaceVariant : AppColors.onSurface,
@@ -351,7 +352,7 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
if (episode.durationSecs != null && episode.durationSecs! > 0) if (episode.durationSecs != null && episode.durationSecs! > 0)
Text( Text(
_formatDuration(episode.durationSecs!), _formatDuration(episode.durationSecs!),
style: GoogleFonts.roboto( style: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
), ),
@@ -123,8 +123,8 @@ class CategoryCarousel<T> extends StatelessWidget {
end: Alignment.centerRight, end: Alignment.centerRight,
colors: [ colors: [
Colors.transparent, Colors.transparent,
Colors.white, AppColors.onSurface,
Colors.white, AppColors.onSurface,
Colors.transparent, Colors.transparent,
], ],
stops: [0.0, 0.02, 0.98, 1.0], stops: [0.0, 0.02, 0.98, 1.0],
+6 -6
View File
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
class ChannelCard extends StatefulWidget { class ChannelCard extends StatefulWidget {
final String channelNumber; final String channelNumber;
@@ -127,7 +127,7 @@ class _ChannelCardState extends State<ChannelCard>
children: [ children: [
Text( Text(
widget.channelNumber, widget.channelNumber,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -139,7 +139,7 @@ class _ChannelCardState extends State<ChannelCard>
widget.channelName, widget.channelName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.textPrimary, color: AppColors.textPrimary,
@@ -175,7 +175,7 @@ class _ChannelCardState extends State<ChannelCard>
widget.currentProgram!, widget.currentProgram!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -185,7 +185,7 @@ class _ChannelCardState extends State<ChannelCard>
SizedBox(height: AppTheme.spacingXs), SizedBox(height: AppTheme.spacingXs),
Text( Text(
widget.programTime!, widget.programTime!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
color: AppColors.textTertiary, color: AppColors.textTertiary,
+1 -1
View File
@@ -52,7 +52,7 @@ class EpgOverlay extends ConsumerWidget {
color: const Color(0xFF1A1A2E) color: const Color(0xFF1A1A2E)
.withOpacity(0.85), // Match Native player controls .withOpacity(0.85), // Match Native player controls
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withOpacity(0.1)), border: Border.all(color: AppColors.onSurface.withOpacity(0.1)),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
+3 -3
View File
@@ -99,7 +99,7 @@ class _EPGWidgetState extends ConsumerState<EPGWidget> {
), ),
child: Text( child: Text(
'LIVE', 'LIVE',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 9, fontSize: 9,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -110,7 +110,7 @@ class _EPGWidgetState extends ConsumerState<EPGWidget> {
Expanded( Expanded(
child: Text( child: Text(
_epg!.nowPlaying!, _epg!.nowPlaying!,
style: GoogleFonts.roboto( style: GoogleFonts.karla(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -151,7 +151,7 @@ class _EPGWidgetState extends ConsumerState<EPGWidget> {
Expanded( Expanded(
child: Text( child: Text(
'Next: ${_epg!.nextPlaying}', 'Next: ${_epg!.nextPlaying}',
style: GoogleFonts.roboto( style: GoogleFonts.karla(
fontSize: 10, fontSize: 10,
color: AppColors.outline, color: AppColors.outline,
), ),
+36 -32
View File
@@ -64,7 +64,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
body: channelsAsync.when( body: channelsAsync.when(
loading: () => const ThemedLoading(), loading: () => const ThemedLoading(),
error: (e, s) => Center( error: (e, s) => Center(
child: Text('Error: $e', style: const TextStyle(color: Colors.white)), child: Text('Error: $e', style: const TextStyle(color: AppColors.onSurface)),
), ),
data: (groupedChannels) { data: (groupedChannels) {
var categories = groupedChannels.keys.toList(); var categories = groupedChannels.keys.toList();
@@ -119,12 +119,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Container( child: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1), color: AppColors.onSurface.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: const Icon(
Icons.arrow_back, Icons.arrow_back,
color: Colors.white, color: AppColors.onSurface,
), ),
), ),
), ),
@@ -138,10 +138,10 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
: _showFavoritesOnly : _showFavoritesOnly
? 'Favorites' ? 'Favorites'
: _selectedCategory ?? 'Live TV', : _selectedCategory ?? 'Live TV',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: AppColors.onSurface,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -153,17 +153,17 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
width: 250, width: 250,
height: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1), color: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: GoogleFonts.inter(color: Colors.white), style: GoogleFonts.karla(color: AppColors.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search channels...', hintText: 'Search channels...',
hintStyle: GoogleFonts.inter(color: Colors.white54), hintStyle: GoogleFonts.karla(color: AppColors.onSurface54),
prefixIcon: prefixIcon:
const Icon(Icons.search, color: Colors.white54), const Icon(Icons.search, color: AppColors.onSurface54),
border: InputBorder.none, border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
@@ -173,7 +173,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
? IconButton( ? IconButton(
icon: const Icon( icon: const Icon(
Icons.clear, Icons.clear,
color: Colors.white, color: AppColors.onSurface,
), ),
onPressed: _searchController.clear, onPressed: _searchController.clear,
) )
@@ -198,7 +198,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
decoration: BoxDecoration( decoration: BoxDecoration(
color: _showFavoritesOnly color: _showFavoritesOnly
? AppColors.live.withOpacity(0.2) ? AppColors.live.withOpacity(0.2)
: Colors.white.withOpacity(0.1), : AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: _showFavoritesOnly border: _showFavoritesOnly
? Border.all(color: AppColors.live) ? Border.all(color: AppColors.live)
@@ -210,7 +210,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
: Icons.favorite_border, : Icons.favorite_border,
color: _showFavoritesOnly color: _showFavoritesOnly
? AppColors.live ? AppColors.live
: Colors.white, : AppColors.onSurface,
), ),
), ),
), ),
@@ -231,13 +231,13 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Icon( Icon(
Icons.tv_off, Icons.tv_off,
size: 64, size: 64,
color: Colors.white.withOpacity(0.3), color: AppColors.onSurface.withOpacity(0.3),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No channels found', 'No channels found',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.white54, color: AppColors.onSurface54,
fontSize: 18, fontSize: 18,
), ),
), ),
@@ -311,7 +311,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
children: [ children: [
Icon( Icon(
Icons.folder_copy_outlined, Icons.folder_copy_outlined,
color: Colors.white.withOpacity(0.9), color: AppColors.onSurface.withOpacity(0.9),
size: 32, size: 32,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -320,10 +320,10 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: Text( child: Text(
category, category,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: AppColors.onSurface,
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -332,9 +332,9 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'$count Channels', '$count Channels',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 13, fontSize: 13,
color: Colors.white54, color: AppColors.onSurface54,
), ),
), ),
], ],
@@ -386,7 +386,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
Expanded( Expanded(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity( color: AppColors.onSurface.withOpacity(
0.05, 0.05,
), // Subtle transparent background ), // Subtle transparent background
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
@@ -398,18 +398,22 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
child: channel.streamIcon.isNotEmpty child: channel.streamIcon.isNotEmpty
? Image.network( ? Image.network(
_getProxiedIconUrl(channel.streamIcon)!, _getProxiedIconUrl(channel.streamIcon)!,
// Logos de chaines : 160 px suffisent
// largement, meme en HiDPI.
cacheWidth: 160,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) => Icon( errorBuilder: (_, __, ___) => Icon(
Icons.tv, Icons.tv,
color: Colors.white.withOpacity(0.3), color: AppColors.onSurface.withOpacity(0.3),
size: 40, size: 40,
), ),
) )
: Text( : Text(
channel.name.characters.first.toUpperCase(), channel.name.characters.first.toUpperCase(),
style: GoogleFonts.outfit( style: GoogleFonts.karla(
fontSize: 40, fontSize: 40,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white24, color: AppColors.onSurface24,
), ),
), ),
), ),
@@ -427,8 +431,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
children: [ children: [
Text( Text(
channel.name, channel.name,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -452,8 +456,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
if (epgList.isEmpty) { if (epgList.isEmpty) {
return Text( return Text(
'No Info', 'No Info',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.white54, color: AppColors.onSurface54,
fontSize: 10, fontSize: 10,
), ),
); );
@@ -480,7 +484,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
return Text( return Text(
currentProgram.title, currentProgram.title,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: const Color(0xFFFFD700), color: const Color(0xFFFFD700),
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -491,14 +495,14 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
}, },
loading: () => Text( loading: () => Text(
'...', '...',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.white24, color: AppColors.onSurface24,
fontSize: 10, fontSize: 10,
), ),
), ),
error: (_, __) => Text( error: (_, __) => Text(
'Err', 'Err',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.red, color: Colors.red,
fontSize: 10, fontSize: 10,
), ),
+11 -6
View File
@@ -219,7 +219,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
children: [ children: [
Text( Text(
'Movies', 'Movies',
style: GoogleFonts.spaceGrotesk( style: GoogleFonts.fraunces(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -251,14 +251,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search movies...', hintText: 'Search movies...',
hintStyle: hintStyle:
GoogleFonts.inter(color: AppColors.outline), GoogleFonts.karla(color: AppColors.outline),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: contentPadding:
@@ -352,6 +352,11 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
? Image.network( ? Image.network(
_getProxiedImageUrl(movie.streamIcon), _getProxiedImageUrl(movie.streamIcon),
fit: BoxFit.cover, fit: BoxFit.cover,
// Borne le decodage a la taille reelle de la
// tuile (x2 pour les ecrans HiDPI). Sans ca,
// chaque jaquette est decodee en 1000x1500.
cacheWidth: 400,
filterQuality: FilterQuality.low,
errorBuilder: (ctx, err, stack) => Container( errorBuilder: (ctx, err, stack) => Container(
color: AppColors.surfaceContainerLow, color: AppColors.surfaceContainerLow,
child: Center( child: Center(
@@ -425,7 +430,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(movie.rating)!, _formatRating(movie.rating)!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -447,7 +452,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
children: [ children: [
Text( Text(
movie.name, movie.name,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -466,7 +471,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
child: Text( child: Text(
'WATCHED', 'WATCHED',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.primary, color: AppColors.primary,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/services/adaptive_bitrate_service.dart'; import '../../../core/services/adaptive_bitrate_service.dart';
import '../../../core/theme/app_colors.dart';
/// Widget for displaying and selecting video quality during playback /// Widget for displaying and selecting video quality during playback
class QualitySelectorWidget extends ConsumerStatefulWidget { class QualitySelectorWidget extends ConsumerStatefulWidget {
@@ -35,7 +36,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
final bandwidthMbps = widget.qualitySelector.getEstimatedBandwidthMbps(); final bandwidthMbps = widget.qualitySelector.getEstimatedBandwidthMbps();
return Dialog( return Dialog(
backgroundColor: Colors.black87, backgroundColor: AppColors.surfaceContainer,
insetPadding: const EdgeInsets.all(16), insetPadding: const EdgeInsets.all(16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -45,7 +46,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: const BoxDecoration( decoration: const BoxDecoration(
border: Border( border: Border(
bottom: BorderSide(color: Colors.white24), bottom: BorderSide(color: AppColors.onSurface24),
), ),
), ),
child: Row( child: Row(
@@ -54,13 +55,13 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
const Text( const Text(
'Video Quality', 'Video Quality',
style: TextStyle( style: TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close, color: Colors.white), icon: const Icon(Icons.close, color: AppColors.onSurface),
onPressed: widget.onClose, onPressed: widget.onClose,
), ),
], ],
@@ -73,7 +74,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white10, color: AppColors.onSurface06,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
@@ -81,12 +82,12 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
children: [ children: [
const Text( const Text(
'Current Bandwidth:', 'Current Bandwidth:',
style: TextStyle(color: Colors.white70, fontSize: 14), style: TextStyle(color: AppColors.onSurfaceVariant, fontSize: 14),
), ),
Text( Text(
'${bandwidthMbps.toStringAsFixed(1)} Mbps', '${bandwidthMbps.toStringAsFixed(1)} Mbps',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -108,22 +109,22 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 8), const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
leading: isSelected leading: isSelected
? const Icon(Icons.check_circle, color: Colors.blue) ? const Icon(Icons.check_circle, color: AppColors.primary)
: const Icon( : const Icon(
Icons.radio_button_unchecked, Icons.radio_button_unchecked,
color: Colors.white30, color: AppColors.onSurface24,
), ),
title: Text( title: Text(
quality.label, quality.label,
style: TextStyle( style: TextStyle(
color: isSelected ? Colors.blue : Colors.white, color: isSelected ? AppColors.primary : AppColors.onSurface,
fontWeight: fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal, isSelected ? FontWeight.bold : FontWeight.normal,
), ),
), ),
subtitle: Text( subtitle: Text(
'${quality.bitrateBps ~/ 1000000} Mbps', '${quality.bitrateBps ~/ 1000000} Mbps',
style: const TextStyle(color: Colors.white70, fontSize: 12), style: const TextStyle(color: AppColors.onSurfaceVariant, fontSize: 12),
), ),
onTap: () { onTap: () {
widget.qualitySelector.setQuality(quality); widget.qualitySelector.setQuality(quality);
@@ -141,7 +142,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
child: Container( child: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
border: Border( border: Border(
top: BorderSide(color: Colors.white24), top: BorderSide(color: AppColors.onSurface24),
), ),
), ),
child: ListTile( child: ListTile(
@@ -157,7 +158,7 @@ class _QualitySelectorWidgetState extends ConsumerState<QualitySelectorWidget> {
), ),
subtitle: const Text( subtitle: const Text(
'Adjusts based on network speed', 'Adjusts based on network speed',
style: TextStyle(color: Colors.white70, fontSize: 12), style: TextStyle(color: AppColors.onSurfaceVariant, fontSize: 12),
), ),
onTap: () { onTap: () {
// Auto mode would be handled by the player // Auto mode would be handled by the player
@@ -194,18 +195,18 @@ class QualityIndicator extends ConsumerWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black54, color: AppColors.baseLevel0,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.high_quality, color: Colors.white, size: 16), const Icon(Icons.high_quality, color: AppColors.onSurface, size: 16),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
quality.resolution.split('x')[1], // Show just the height quality.resolution.split('x')[1], // Show just the height
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -231,7 +232,7 @@ class BandwidthMonitor extends ConsumerWidget {
return Container( return Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black54, color: AppColors.baseLevel0,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Row( child: Row(
@@ -242,7 +243,7 @@ class BandwidthMonitor extends ConsumerWidget {
Text( Text(
'${qualitySelector.getEstimatedBandwidthMbps().toStringAsFixed(1)} Mbps', '${qualitySelector.getEstimatedBandwidthMbps().toStringAsFixed(1)} Mbps',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 11, fontSize: 11,
), ),
), ),
+14 -14
View File
@@ -106,7 +106,7 @@ class _RecordingModalState extends State<RecordingModal> {
Expanded( Expanded(
child: Text( child: Text(
'Programmer un enregistrement', 'Programmer un enregistrement',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -120,12 +120,12 @@ class _RecordingModalState extends State<RecordingModal> {
// Informations Chaine // Informations Chaine
Text( Text(
'Chaîne ciblée :', 'Chaîne ciblée :',
style: GoogleFonts.inter(color: AppColors.onSurfaceVariant, fontSize: 14), style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
widget.channel.name, widget.channel.name,
style: GoogleFonts.inter(color: AppColors.onSurface, fontSize: 18), style: GoogleFonts.karla(color: AppColors.onSurface, fontSize: 18),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -139,7 +139,7 @@ class _RecordingModalState extends State<RecordingModal> {
children: [ children: [
Text( Text(
'Date :', 'Date :',
style: GoogleFonts.inter(color: AppColors.onSurfaceVariant, fontSize: 14), style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TvFocusableCard( TvFocusableCard(
@@ -163,7 +163,7 @@ class _RecordingModalState extends State<RecordingModal> {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1), color: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
@@ -173,7 +173,7 @@ class _RecordingModalState extends State<RecordingModal> {
Expanded( Expanded(
child: Text( child: Text(
'${_startTime.day.toString().padLeft(2, '0')}/${_startTime.month.toString().padLeft(2, '0')}/${_startTime.year}', '${_startTime.day.toString().padLeft(2, '0')}/${_startTime.month.toString().padLeft(2, '0')}/${_startTime.year}',
style: GoogleFonts.inter(color: AppColors.onSurface), style: GoogleFonts.karla(color: AppColors.onSurface),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
@@ -193,7 +193,7 @@ class _RecordingModalState extends State<RecordingModal> {
children: [ children: [
Text( Text(
'Heure :', 'Heure :',
style: GoogleFonts.inter(color: AppColors.onSurfaceVariant, fontSize: 14), style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TvFocusableCard( TvFocusableCard(
@@ -226,7 +226,7 @@ class _RecordingModalState extends State<RecordingModal> {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1), color: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Row(
@@ -236,7 +236,7 @@ class _RecordingModalState extends State<RecordingModal> {
Expanded( Expanded(
child: Text( child: Text(
'${_startTime.hour.toString().padLeft(2, '0')}:${_startTime.minute.toString().padLeft(2, '0')}', '${_startTime.hour.toString().padLeft(2, '0')}:${_startTime.minute.toString().padLeft(2, '0')}',
style: GoogleFonts.inter(color: AppColors.onSurface), style: GoogleFonts.karla(color: AppColors.onSurface),
), ),
), ),
], ],
@@ -255,7 +255,7 @@ class _RecordingModalState extends State<RecordingModal> {
children: [ children: [
Text( Text(
'Durée (min) :', 'Durée (min) :',
style: GoogleFonts.inter(color: AppColors.onSurfaceVariant, fontSize: 14), style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, fontSize: 14),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
GlassContainer.glass( GlassContainer.glass(
@@ -277,7 +277,7 @@ class _RecordingModalState extends State<RecordingModal> {
child: Text( child: Text(
'$_durationMinutes', '$_durationMinutes',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.inter(color: AppColors.onSurface), style: GoogleFonts.karla(color: AppColors.onSurface),
), ),
), ),
IconButton( IconButton(
@@ -308,7 +308,7 @@ class _RecordingModalState extends State<RecordingModal> {
onPressed: _isLoading ? null : () => Navigator.of(context).pop(), onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
child: Text( child: Text(
'Annuler', 'Annuler',
style: GoogleFonts.inter(color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -328,7 +328,7 @@ class _RecordingModalState extends State<RecordingModal> {
) )
: Text( : Text(
'Enregistrer de suite', 'Enregistrer de suite',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -352,7 +352,7 @@ class _RecordingModalState extends State<RecordingModal> {
) )
: Text( : Text(
'Programmer', 'Programmer',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
+78 -76
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../core/theme/app_colors.dart';
import '../../../core/models/iptv_models.dart'; import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
@@ -25,22 +26,22 @@ class _RecordingsTabState extends State<RecordingsTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
color: Colors.black, color: AppColors.background,
child: Column( child: Column(
children: [ children: [
Container( Container(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
color: Colors.grey[900], color: AppColors.surfaceContainerLow,
child: const Row( child: const Row(
children: [ children: [
Icon(Icons.videocam, color: Colors.white, size: 28), Icon(Icons.videocam, color: AppColors.onSurface, size: 28),
SizedBox(width: 12), SizedBox(width: 12),
Text( Text(
'Enregistrements', 'Enregistrements',
style: TextStyle( style: TextStyle(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: AppColors.onSurface,
), ),
), ),
], ],
@@ -182,8 +183,8 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
children: [ children: [
Text( Text(
'GROUPES', 'GROUPES',
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white38, color: AppColors.onSurface38,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
letterSpacing: 1.2, letterSpacing: 1.2,
@@ -210,16 +211,16 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
margin: const EdgeInsets.only(bottom: 4), margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? Colors.white.withOpacity(0.1) ? AppColors.onSurface.withOpacity(0.1)
: Colors.transparent, : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
cat, cat,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: isSelected color: isSelected
? Colors.white ? AppColors.onSurface
: Colors.white60, : AppColors.onSurface54,
fontSize: 12, fontSize: 12,
fontWeight: isSelected fontWeight: isSelected
? FontWeight.bold ? FontWeight.bold
@@ -237,7 +238,7 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
), ),
), ),
const VerticalDivider(width: 32, color: Colors.white10), const VerticalDivider(width: 32, color: AppColors.onSurface06),
// ── Colonne 2 : CHAÎNES ── // ── Colonne 2 : CHAÎNES ──
SizedBox( SizedBox(
@@ -247,20 +248,20 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
// Barre de recherche // Barre de recherche
TextField( TextField(
controller: _searchCtrl, controller: _searchCtrl,
style: const TextStyle(color: Colors.white, fontSize: 13), style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Rechercher...', hintText: 'Rechercher...',
hintStyle: const TextStyle( hintStyle: const TextStyle(
color: Colors.white38, color: AppColors.onSurface38,
fontSize: 13, fontSize: 13,
), ),
prefixIcon: const Icon( prefixIcon: const Icon(
Icons.search, Icons.search,
color: Colors.white38, color: AppColors.onSurface38,
size: 18, size: 18,
), ),
filled: true, filled: true,
fillColor: Colors.white.withOpacity(0.07), fillColor: AppColors.onSurface.withOpacity(0.07),
contentPadding: const EdgeInsets.symmetric(vertical: 8), contentPadding: const EdgeInsets.symmetric(vertical: 8),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
@@ -275,7 +276,7 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
child: Text( child: Text(
'Aucune chaîne', 'Aucune chaîne',
style: TextStyle( style: TextStyle(
color: Colors.white24, color: AppColors.onSurface24,
fontSize: 12, fontSize: 12,
), ),
), ),
@@ -298,7 +299,7 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? Colors.redAccent.withOpacity(0.2) ? Colors.redAccent.withOpacity(0.2)
: Colors.white.withOpacity(0.05), : AppColors.onSurface.withOpacity(0.05),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: isSelected color: isSelected
@@ -314,26 +315,27 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
width: 24, width: 24,
height: 16, height: 16,
fit: BoxFit.contain, fit: BoxFit.contain,
cacheWidth: 64,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) =>
const Icon( const Icon(
Icons.tv, Icons.tv,
color: Colors.white24, color: AppColors.onSurface24,
size: 16, size: 16,
), ),
) )
: const Icon( : const Icon(
Icons.tv, Icons.tv,
color: Colors.white24, color: AppColors.onSurface24,
size: 16, size: 16,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
ch.name, ch.name,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: isSelected color: isSelected
? Colors.white ? AppColors.onSurface
: Colors.white70, : AppColors.onSurfaceVariant,
fontSize: 12, fontSize: 12,
), ),
maxLines: 1, maxLines: 1,
@@ -351,7 +353,7 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
), ),
), ),
const VerticalDivider(width: 32, color: Colors.white10), const VerticalDivider(width: 32, color: AppColors.onSurface06),
// ── Colonne droite : programmes EPG ── // ── Colonne droite : programmes EPG ──
Expanded( Expanded(
@@ -369,8 +371,8 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
Text( Text(
'Sélectionnez une chaîne\npour voir son guide des programmes', 'Sélectionnez une chaîne\npour voir son guide des programmes',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white38, color: AppColors.onSurface38,
fontSize: 15, fontSize: 15,
), ),
), ),
@@ -384,8 +386,8 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
children: [ children: [
Text( Text(
_selectedChannel!.name, _selectedChannel!.name,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
), ),
@@ -394,7 +396,7 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons.refresh, Icons.refresh,
color: Colors.white54, color: AppColors.onSurface54,
size: 18, size: 18,
), ),
tooltip: 'Recharger l\'EPG', tooltip: 'Recharger l\'EPG',
@@ -402,7 +404,7 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
), ),
], ],
), ),
const Divider(color: Colors.white12), const Divider(color: AppColors.onSurface12),
Expanded( Expanded(
child: _loadingEpg child: _loadingEpg
? const Center( ? const Center(
@@ -424,8 +426,8 @@ class _EpgGuideViewState extends ConsumerState<_EpgGuideView>
child: Text( child: Text(
'Aucun programme EPG disponible\npour cette chaîne', 'Aucun programme EPG disponible\npour cette chaîne',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white38, color: AppColors.onSurface38,
), ),
), ),
) )
@@ -524,7 +526,7 @@ class _ProgrammeCard extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
'Enregistrer', 'Enregistrer',
style: GoogleFonts.outfit(color: Colors.white), style: GoogleFonts.karla(color: AppColors.onSurface),
), ),
), ),
], ],
@@ -535,8 +537,8 @@ class _ProgrammeCard extends StatelessWidget {
children: [ children: [
Text( Text(
title.isEmpty ? channel.name : title, title.isEmpty ? channel.name : title,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
), ),
@@ -544,7 +546,7 @@ class _ProgrammeCard extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'${_fmt(_start)} → ${_fmt(_end)}', '${_fmt(_start)} → ${_fmt(_end)}',
style: const TextStyle(color: Colors.white70), style: const TextStyle(color: AppColors.onSurfaceVariant),
), ),
if (_description.isNotEmpty) ...[ if (_description.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -552,7 +554,7 @@ class _ProgrammeCard extends StatelessWidget {
_description, _description,
maxLines: 3, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white38, fontSize: 12), style: const TextStyle(color: AppColors.onSurface38, fontSize: 12),
), ),
], ],
], ],
@@ -631,13 +633,13 @@ class _ProgrammeCard extends StatelessWidget {
color: isNow color: isNow
? Colors.red.withOpacity(0.15) ? Colors.red.withOpacity(0.15)
: isPast : isPast
? Colors.white.withOpacity(0.03) ? AppColors.onSurface.withOpacity(0.03)
: Colors.white.withOpacity(0.06), : AppColors.onSurface.withOpacity(0.06),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all( border: Border.all(
color: isNow color: isNow
? Colors.redAccent.withOpacity(0.4) ? Colors.redAccent.withOpacity(0.4)
: Colors.white.withOpacity(0.07), : AppColors.onSurface.withOpacity(0.07),
), ),
), ),
child: ListTile( child: ListTile(
@@ -657,8 +659,8 @@ class _ProgrammeCard extends StatelessWidget {
), ),
child: Text( child: Text(
'LIVE', 'LIVE',
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontSize: 8, fontSize: 8,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -667,8 +669,8 @@ class _ProgrammeCard extends StatelessWidget {
else else
Text( Text(
_fmt(_start), _fmt(_start),
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: isPast ? Colors.white24 : Colors.white70, color: isPast ? AppColors.onSurface24 : AppColors.onSurfaceVariant,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -677,15 +679,15 @@ class _ProgrammeCard extends StatelessWidget {
Text( Text(
_fmt(_end), _fmt(_end),
style: style:
GoogleFonts.outfit(color: Colors.white24, fontSize: 10), GoogleFonts.karla(color: AppColors.onSurface24, fontSize: 10),
), ),
], ],
), ),
), ),
title: Text( title: Text(
_title.isEmpty ? '—' : _title, _title.isEmpty ? '—' : _title,
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: isPast ? Colors.white38 : Colors.white, color: isPast ? AppColors.onSurface38 : AppColors.onSurface,
fontWeight: isNow ? FontWeight.bold : FontWeight.normal, fontWeight: isNow ? FontWeight.bold : FontWeight.normal,
), ),
maxLines: 2, maxLines: 2,
@@ -696,7 +698,7 @@ class _ProgrammeCard extends StatelessWidget {
_description, _description,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white38, fontSize: 11), style: const TextStyle(color: AppColors.onSurface38, fontSize: 11),
) )
: null, : null,
trailing: !isPast trailing: !isPast
@@ -826,7 +828,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
backgroundColor: const Color(0xFF1E1E2E), backgroundColor: const Color(0xFF1E1E2E),
title: Text( title: Text(
title, title,
style: GoogleFonts.outfit(color: Colors.white, fontSize: 14), style: GoogleFonts.karla(color: AppColors.onSurface, fontSize: 14),
), ),
content: SizedBox( content: SizedBox(
width: 500, width: 500,
@@ -835,7 +837,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
child: Text( child: Text(
content, content,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: AppColors.onSurfaceVariant,
fontSize: 11, fontSize: 11,
fontFamily: 'monospace', fontFamily: 'monospace',
), ),
@@ -862,7 +864,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
'recording' => Colors.redAccent, 'recording' => Colors.redAccent,
'completed' => Colors.green, 'completed' => Colors.green,
'failed' => Colors.orangeAccent, 'failed' => Colors.orangeAccent,
_ => Colors.blueAccent, _ => AppColors.primary,
}; };
String _statusLabel(String status) => switch (status) { String _statusLabel(String status) => switch (status) {
@@ -894,7 +896,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.refresh, color: Colors.white70), icon: const Icon(Icons.refresh, color: AppColors.onSurfaceVariant),
onPressed: _fetchRecordings, onPressed: _fetchRecordings,
tooltip: 'Rafraîchir', tooltip: 'Rafraîchir',
), ),
@@ -923,8 +925,8 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Aucun enregistrement', 'Aucun enregistrement',
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white38, color: AppColors.onSurface38,
fontSize: 16, fontSize: 16,
), ),
), ),
@@ -941,10 +943,10 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05), color: AppColors.onSurface.withOpacity(0.05),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: Colors.white.withOpacity(0.1), color: AppColors.onSurface.withOpacity(0.1),
), ),
), ),
child: ListTile( child: ListTile(
@@ -961,8 +963,8 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
), ),
title: Text( title: Text(
rec['title'] ?? '—', rec['title'] ?? '—',
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@@ -973,7 +975,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
Text( Text(
'${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}', '${_fmtDate(rec['start_time'])} → ${_fmtDate(rec['end_time'])}',
style: const TextStyle( style: const TextStyle(
color: Colors.white54, color: AppColors.onSurface54,
fontSize: 12, fontSize: 12,
), ),
), ),
@@ -1038,7 +1040,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons.description_outlined, Icons.description_outlined,
color: Colors.blueAccent, color: AppColors.primary,
size: 20, size: 20,
), ),
tooltip: 'Logs', tooltip: 'Logs',
@@ -1050,7 +1052,7 @@ class _RecordingsListViewState extends State<_RecordingsListView> {
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons.delete_outline, Icons.delete_outline,
color: Colors.white38, color: AppColors.onSurface38,
size: 20, size: 20,
), ),
tooltip: 'Supprimer', tooltip: 'Supprimer',
@@ -1131,7 +1133,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'Nouveau Season Pass', 'Nouveau Season Pass',
style: GoogleFonts.outfit(color: Colors.white), style: GoogleFonts.karla(color: AppColors.onSurface),
), ),
], ],
), ),
@@ -1142,7 +1144,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
children: [ children: [
Text( Text(
'Enregistre automatiquement toutes les nouvelles diffusions d\'une émission.', 'Enregistre automatiquement toutes les nouvelles diffusions d\'une émission.',
style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13), style: GoogleFonts.karla(color: AppColors.onSurface54, fontSize: 13),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildField( _buildField(
@@ -1205,12 +1207,12 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
Widget _buildField(TextEditingController c, String label) { Widget _buildField(TextEditingController c, String label) {
return TextField( return TextField(
controller: c, controller: c,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: AppColors.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
labelStyle: const TextStyle(color: Colors.white54, fontSize: 12), labelStyle: const TextStyle(color: AppColors.onSurface54, fontSize: 12),
enabledBorder: const UnderlineInputBorder( enabledBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24), borderSide: BorderSide(color: AppColors.onSurface24),
), ),
focusedBorder: const UnderlineInputBorder( focusedBorder: const UnderlineInputBorder(
borderSide: BorderSide(color: Colors.purpleAccent), borderSide: BorderSide(color: Colors.purpleAccent),
@@ -1230,7 +1232,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
children: [ children: [
Text( Text(
'Enregistrements automatiques', 'Enregistrements automatiques',
style: GoogleFonts.outfit(color: Colors.white54, fontSize: 13), style: GoogleFonts.karla(color: AppColors.onSurface54, fontSize: 13),
), ),
ElevatedButton.icon( ElevatedButton.icon(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -1261,7 +1263,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
Expanded( Expanded(
child: Text( child: Text(
'Scanne l\'EPG toutes les 4h et programme automatiquement les nouvelles diffusions. Seuls les nouveaux épisodes sont enregistrés.', 'Scanne l\'EPG toutes les 4h et programme automatiquement les nouvelles diffusions. Seuls les nouveaux épisodes sont enregistrés.',
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.purpleAccent.shade100, color: Colors.purpleAccent.shade100,
fontSize: 12, fontSize: 12,
), ),
@@ -1287,16 +1289,16 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Aucun Season Pass actif', 'Aucun Season Pass actif',
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white38, color: AppColors.onSurface38,
fontSize: 16, fontSize: 16,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Créez-en un pour enregistrer automatiquement vos émissions préférées', 'Créez-en un pour enregistrer automatiquement vos émissions préférées',
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white24, color: AppColors.onSurface24,
fontSize: 12, fontSize: 12,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -1324,8 +1326,8 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
), ),
title: Text( title: Text(
p['show_title'] ?? '—', p['show_title'] ?? '—',
style: GoogleFonts.outfit( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@@ -1335,14 +1337,14 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
Text( Text(
'Chaîne : ${p['channel_id']}', 'Chaîne : ${p['channel_id']}',
style: const TextStyle( style: const TextStyle(
color: Colors.white54, color: AppColors.onSurface54,
fontSize: 11, fontSize: 11,
), ),
), ),
Text( Text(
'Flux : ${p['stream_url']}', 'Flux : ${p['stream_url']}',
style: const TextStyle( style: const TextStyle(
color: Colors.white38, color: AppColors.onSurface38,
fontSize: 10, fontSize: 10,
), ),
), ),
@@ -1351,7 +1353,7 @@ class _SeasonPassesViewState extends State<_SeasonPassesView> {
trailing: IconButton( trailing: IconButton(
icon: const Icon( icon: const Icon(
Icons.delete_outline, Icons.delete_outline,
color: Colors.white38, color: AppColors.onSurface38,
), ),
tooltip: 'Supprimer', tooltip: 'Supprimer',
onPressed: () => onPressed: () =>
+7 -5
View File
@@ -199,7 +199,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
children: [ children: [
Text( Text(
'TV Series', 'TV Series',
style: GoogleFonts.spaceGrotesk( style: GoogleFonts.fraunces(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -229,14 +229,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search series...', hintText: 'Search series...',
hintStyle: hintStyle:
GoogleFonts.inter(color: AppColors.outline), GoogleFonts.karla(color: AppColors.outline),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: contentPadding:
@@ -343,6 +343,8 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
? Image.network( ? Image.network(
_getProxiedImageUrl(serie.cover), _getProxiedImageUrl(serie.cover),
fit: BoxFit.cover, fit: BoxFit.cover,
cacheWidth: 400,
filterQuality: FilterQuality.low,
errorBuilder: (ctx, err, stack) => Container( errorBuilder: (ctx, err, stack) => Container(
color: AppColors.surfaceContainerLow, color: AppColors.surfaceContainerLow,
child: Center( child: Center(
@@ -396,7 +398,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
bottom: 12, bottom: 12,
child: Text( child: Text(
serie.name, serie.name,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -439,7 +441,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(serie.rating)!, _formatRating(serie.rating)!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
+18 -18
View File
@@ -155,7 +155,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Text(
'Thème', 'Thème',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -212,7 +212,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
), ),
title: Text( title: Text(
'Mode actuel', 'Mode actuel',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
@@ -279,7 +279,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
children: [ children: [
Text( Text(
title, title,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isSelected color: isSelected
? colorScheme.primary ? colorScheme.primary
@@ -288,7 +288,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
), ),
Text( Text(
subtitle, subtitle,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -327,11 +327,11 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
leading: const Icon(Icons.person, color: AppColors.onSurface), leading: const Icon(Icons.person, color: AppColors.onSurface),
title: Text( title: Text(
'Connecté en tant que', 'Connecté en tant que',
style: GoogleFonts.inter(fontSize: 12, color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(fontSize: 12, color: AppColors.onSurfaceVariant),
), ),
subtitle: Text( subtitle: Text(
currentUser?.username ?? 'Unknown', currentUser?.username ?? 'Unknown',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
@@ -340,7 +340,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
? Chip( ? Chip(
label: Text( label: Text(
'Admin', 'Admin',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 11, fontSize: 11,
color: AppColors.onPrimaryContainer, color: AppColors.onPrimaryContainer,
), ),
@@ -355,7 +355,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
// Category filters section // Category filters section
Text( Text(
'Filtres de Catégories', 'Filtres de Catégories',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -364,7 +364,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Seules les catégories contenant un de ces mots-clés seront affichées. Séparez par des virgules.', 'Seules les catégories contenant un de ces mots-clés seront affichées. Séparez par des virgules.',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
), ),
@@ -430,7 +430,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
leading: const Icon(Icons.playlist_play, color: AppColors.onSurface), leading: const Icon(Icons.playlist_play, color: AppColors.onSurface),
title: Text( title: Text(
'Changer de Playlist', 'Changer de Playlist',
style: GoogleFonts.inter(color: AppColors.onSurface), style: GoogleFonts.karla(color: AppColors.onSurface),
), ),
trailing: const Icon(Icons.chevron_right, color: AppColors.onSurfaceVariant), trailing: const Icon(Icons.chevron_right, color: AppColors.onSurfaceVariant),
onTap: () => context.go('/playlists'), onTap: () => context.go('/playlists'),
@@ -449,11 +449,11 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
leading: const Icon(Icons.info_outline, color: AppColors.onSurface), leading: const Icon(Icons.info_outline, color: AppColors.onSurface),
title: Text( title: Text(
'À propos', 'À propos',
style: GoogleFonts.inter(color: AppColors.onSurface), style: GoogleFonts.karla(color: AppColors.onSurface),
), ),
subtitle: Text( subtitle: Text(
'XtremFlow IPTV v1.0.0', 'XtremFlow IPTV v1.0.0',
style: GoogleFonts.inter(fontSize: 12, color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(fontSize: 12, color: AppColors.onSurfaceVariant),
), ),
), ),
), ),
@@ -470,11 +470,11 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
leading: const Icon(Icons.cleaning_services, color: AppColors.warning), leading: const Icon(Icons.cleaning_services, color: AppColors.warning),
title: Text( title: Text(
'Maintenance', 'Maintenance',
style: GoogleFonts.inter(color: AppColors.onSurface), style: GoogleFonts.karla(color: AppColors.onSurface),
), ),
subtitle: Text( subtitle: Text(
'En cas de problèmes d\'affichage ou de mise à jour', 'En cas de problèmes d\'affichage ou de mise à jour',
style: GoogleFonts.inter(fontSize: 12, color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(fontSize: 12, color: AppColors.onSurfaceVariant),
), ),
trailing: FilledButton.icon( trailing: FilledButton.icon(
onPressed: () { onPressed: () {
@@ -573,7 +573,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
title, title,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 13, fontSize: 13,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -587,7 +587,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Ex: FR,FRANCE,HD,SPORT', hintText: 'Ex: FR,FRANCE,HD,SPORT',
hintStyle: hintStyle:
GoogleFonts.inter(fontSize: 12, color: AppColors.outline), GoogleFonts.karla(fontSize: 12, color: AppColors.outline),
filled: true, filled: true,
fillColor: AppColors.surfaceContainerHigh, fillColor: AppColors.surfaceContainerHigh,
border: OutlineInputBorder( border: OutlineInputBorder(
@@ -612,7 +612,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
) )
: null, : null,
), ),
style: GoogleFonts.inter(fontSize: 12, color: AppColors.onSurface), style: GoogleFonts.karla(fontSize: 12, color: AppColors.onSurface),
onChanged: onChanged, onChanged: onChanged,
), ),
if (keywords.isNotEmpty) ...[ if (keywords.isNotEmpty) ...[
@@ -633,7 +633,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab>
child: Text( child: Text(
keyword, keyword,
style: style:
GoogleFonts.inter(fontSize: 11, color: AppColors.onSurface), GoogleFonts.karla(fontSize: 11, color: AppColors.onSurface),
), ),
); );
}).toList(), }).toList(),
@@ -132,10 +132,10 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
// Title // Title
Text( Text(
'🎬 Record "${widget.channel.name}"', '🎬 Record "${widget.channel.name}"',
style: GoogleFonts.poppins( style: GoogleFonts.fraunces(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: AppColors.onSurface,
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -150,9 +150,9 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
), ),
child: Text( child: Text(
_status, _status,
style: GoogleFonts.poppins( style: GoogleFonts.fraunces(
fontSize: 14, fontSize: 14,
color: Colors.white70, color: AppColors.onSurfaceVariant,
), ),
), ),
), ),
@@ -162,10 +162,10 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
if (!_isRecording) ...[ if (!_isRecording) ...[
Text( Text(
'⚡ Quick Record', '⚡ Quick Record',
style: GoogleFonts.poppins( style: GoogleFonts.fraunces(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: AppColors.onSurface,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -202,7 +202,7 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
label: const Text('STOP RECORDING'), label: const Text('STOP RECORDING'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, backgroundColor: Colors.red,
foregroundColor: Colors.white, foregroundColor: AppColors.onSurface,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
), ),
), ),
@@ -212,10 +212,10 @@ class _SimpleRecordingWidgetState extends State<SimpleRecordingWidget> {
ExpansionTile( ExpansionTile(
title: Text( title: Text(
'🔵 Schedule for Later', '🔵 Schedule for Later',
style: GoogleFonts.poppins( style: GoogleFonts.fraunces(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: AppColors.onSurface,
), ),
), ),
children: [ children: [
@@ -19,7 +19,7 @@ class StreamingSettingsTab extends ConsumerWidget {
// Header // Header
Text( Text(
'Paramètres Streaming Live TV', 'Paramètres Streaming Live TV',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -28,7 +28,7 @@ class StreamingSettingsTab extends ConsumerWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Ces paramètres s\'appliquent uniquement aux flux TV en direct.', 'Ces paramètres s\'appliquent uniquement aux flux TV en direct.',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
), ),
@@ -101,18 +101,18 @@ class StreamingSettingsTab extends ConsumerWidget {
ConnectionTimeout.long => 'Long (60s)', ConnectionTimeout.long => 'Long (60s)',
}, },
), ),
Divider(height: 1, color: Colors.white.withOpacity(0.1)), Divider(height: 1, color: AppColors.onSurface.withOpacity(0.1)),
SwitchListTile( SwitchListTile(
title: Text( title: Text(
'Reconnexion automatique', 'Reconnexion automatique',
style: GoogleFonts.inter(fontSize: 14, color: AppColors.onSurface), style: GoogleFonts.karla(fontSize: 14, color: AppColors.onSurface),
), ),
subtitle: Text( subtitle: Text(
'Se reconnecter en cas de coupure', 'Se reconnecter en cas de coupure',
style: GoogleFonts.inter(fontSize: 12, color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(fontSize: 12, color: AppColors.onSurfaceVariant),
), ),
value: settings.autoReconnect, value: settings.autoReconnect,
activeThumbColor: Colors.blueAccent, activeThumbColor: AppColors.primary,
onChanged: (v) => notifier.setAutoReconnect(v), onChanged: (v) => notifier.setAutoReconnect(v),
), ),
], ],
@@ -137,18 +137,18 @@ class StreamingSettingsTab extends ConsumerWidget {
TranscodingMode.disabled => 'Désactivé (direct)', TranscodingMode.disabled => 'Désactivé (direct)',
}, },
), ),
Divider(height: 1, color: Colors.white.withOpacity(0.1)), Divider(height: 1, color: AppColors.onSurface.withOpacity(0.1)),
SwitchListTile( SwitchListTile(
title: Text( title: Text(
'Préférer lecture directe', 'Préférer lecture directe',
style: GoogleFonts.inter(fontSize: 14, color: AppColors.onSurface), style: GoogleFonts.karla(fontSize: 14, color: AppColors.onSurface),
), ),
subtitle: Text( subtitle: Text(
'Utiliser le flux original si compatible', 'Utiliser le flux original si compatible',
style: GoogleFonts.inter(fontSize: 12, color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(fontSize: 12, color: AppColors.onSurfaceVariant),
), ),
value: settings.preferDirectPlay, value: settings.preferDirectPlay,
activeThumbColor: Colors.blueAccent, activeThumbColor: AppColors.primary,
onChanged: (v) => notifier.setPreferDirectPlay(v), onChanged: (v) => notifier.setPreferDirectPlay(v),
), ),
], ],
@@ -186,11 +186,11 @@ class StreamingSettingsTab extends ConsumerWidget {
SwitchListTile( SwitchListTile(
title: Text( title: Text(
'Accélération NVIDIA GPU', 'Accélération NVIDIA GPU',
style: GoogleFonts.inter(fontSize: 14, color: AppColors.onSurface), style: GoogleFonts.karla(fontSize: 14, color: AppColors.onSurface),
), ),
subtitle: Text( subtitle: Text(
'Utiliser NVENC/NVDEC pour le transcodage (requiert GPU NVIDIA)', 'Utiliser NVENC/NVDEC pour le transcodage (requiert GPU NVIDIA)',
style: GoogleFonts.inter(fontSize: 12, color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(fontSize: 12, color: AppColors.onSurfaceVariant),
), ),
value: settings.enableNvidiaGpu, value: settings.enableNvidiaGpu,
activeThumbColor: Colors.green, activeThumbColor: Colors.green,
@@ -204,20 +204,20 @@ class StreamingSettingsTab extends ConsumerWidget {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1), color: AppColors.primaryContainer.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blue.withOpacity(0.3)), border: Border.all(color: AppColors.primaryContainer.withValues(alpha: 0.32)),
), ),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.info_outline, color: Colors.blue), const Icon(Icons.info_outline, color: AppColors.primary),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
'Les changements prennent effet immédiatement pour les nouveaux flux.', 'Les changements prennent effet immédiatement pour les nouveaux flux.',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
color: Colors.blue.shade700, color: AppColors.primary,
), ),
), ),
), ),
@@ -236,9 +236,9 @@ class StreamingSettingsTab extends ConsumerWidget {
}) { }) {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05), color: AppColors.onSurface.withOpacity(0.05),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withOpacity(0.1)), border: Border.all(color: AppColors.onSurface.withOpacity(0.1)),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -247,11 +247,11 @@ class StreamingSettingsTab extends ConsumerWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Row(
children: [ children: [
Icon(icon, size: 20, color: Colors.blueAccent), Icon(icon, size: 20, color: AppColors.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
title, title,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -260,7 +260,7 @@ class StreamingSettingsTab extends ConsumerWidget {
], ],
), ),
), ),
Divider(height: 1, color: Colors.white.withOpacity(0.1)), Divider(height: 1, color: AppColors.onSurface.withOpacity(0.1)),
...children, ...children,
], ],
), ),
@@ -278,11 +278,11 @@ class StreamingSettingsTab extends ConsumerWidget {
return ListTile( return ListTile(
title: Text( title: Text(
title, title,
style: GoogleFonts.inter(fontSize: 14, color: AppColors.onSurface), style: GoogleFonts.karla(fontSize: 14, color: AppColors.onSurface),
), ),
subtitle: Text( subtitle: Text(
subtitle, subtitle,
style: GoogleFonts.inter(fontSize: 12, color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(fontSize: 12, color: AppColors.onSurfaceVariant),
), ),
trailing: DropdownButton<T>( trailing: DropdownButton<T>(
value: value, value: value,
@@ -293,12 +293,12 @@ class StreamingSettingsTab extends ConsumerWidget {
value: item, value: item,
child: Text( child: Text(
labelBuilder(item), labelBuilder(item),
style: GoogleFonts.inter(fontSize: 13, color: AppColors.onSurface), style: GoogleFonts.karla(fontSize: 13, color: AppColors.onSurface),
), ),
); );
}).toList(), }).toList(),
onChanged: onChanged, onChanged: onChanged,
style: GoogleFonts.inter(color: AppColors.onSurface), style: GoogleFonts.karla(color: AppColors.onSurface),
iconEnabledColor: AppColors.onSurfaceVariant, iconEnabledColor: AppColors.onSurfaceVariant,
), ),
); );
@@ -124,7 +124,7 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'XtremFlow', 'XtremFlow',
style: GoogleFonts.spaceGrotesk( style: GoogleFonts.fraunces(
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -133,7 +133,7 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
), ),
Text( Text(
'Mobile Edition', 'Mobile Edition',
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
letterSpacing: 0.5, letterSpacing: 0.5,
@@ -173,7 +173,7 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
), ),
child: Text( child: Text(
authState.errorMessage!, authState.errorMessage!,
style: GoogleFonts.inter(color: AppColors.error, fontSize: 13), style: GoogleFonts.karla(color: AppColors.error, fontSize: 13),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -199,7 +199,7 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
onPressed: authState.isLoading ? null : _handleLogin, onPressed: authState.isLoading ? null : _handleLogin,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Colors.white, foregroundColor: AppColors.onSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0, elevation: 0,
).copyWith( ).copyWith(
@@ -209,11 +209,11 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
? const SizedBox( ? const SizedBox(
width: 24, width: 24,
height: 24, height: 24,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.onSurface),
) )
: Text( : Text(
'Sign In', 'Sign In',
style: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16, color: Colors.white), style: GoogleFonts.karla(fontWeight: FontWeight.w600, fontSize: 16, color: AppColors.onSurface),
), ),
), ),
), ),
@@ -243,10 +243,10 @@ class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
return TextFormField( return TextFormField(
controller: controller, controller: controller,
obscureText: obscureText, obscureText: obscureText,
style: GoogleFonts.inter(color: AppColors.onSurface), style: GoogleFonts.karla(color: AppColors.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
labelStyle: GoogleFonts.inter(color: AppColors.onSurfaceVariant), labelStyle: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
prefixIcon: Icon(icon, color: AppColors.onSurfaceVariant), prefixIcon: Icon(icon, color: AppColors.onSurfaceVariant),
suffixIcon: isPassword suffixIcon: isPassword
? IconButton( ? IconButton(
@@ -71,7 +71,7 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
color: AppColors.error, color: AppColors.error,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Error loading playlists', style: GoogleFonts.inter()), Text('Error loading playlists', style: GoogleFonts.karla()),
TextButton( TextButton(
onPressed: () => ref.refresh(playlistsProvider), onPressed: () => ref.refresh(playlistsProvider),
child: const Text('Retry'), child: const Text('Retry'),
@@ -93,7 +93,7 @@ class MobilePlaylistSelectionScreen extends ConsumerWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No playlists available', 'No playlists available',
style: GoogleFonts.inter(color: AppColors.textSecondary), style: GoogleFonts.karla(color: AppColors.textSecondary),
), ),
], ],
), ),
@@ -178,7 +178,7 @@ class _MobilePlaylistCard extends StatelessWidget {
children: [ children: [
Text( Text(
playlist.name, playlist.name,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary, color: AppColors.textPrimary,
@@ -189,7 +189,7 @@ class _MobilePlaylistCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
Uri.tryParse(playlist.dns)?.host ?? playlist.dns, Uri.tryParse(playlist.dns)?.host ?? playlist.dns,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 13, fontSize: 13,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
@@ -110,7 +110,7 @@ class _MobileSeriesDetailScreenState
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Error: $_error', 'Error: $_error',
style: GoogleFonts.inter(color: AppColors.onSurfaceVariant), style: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
), ),
TextButton( TextButton(
onPressed: _loadSeriesInfo, onPressed: _loadSeriesInfo,
@@ -157,6 +157,7 @@ class _MobileSeriesDetailScreenState
CachedNetworkImage( CachedNetworkImage(
imageUrl: _seriesInfo!.cover!, imageUrl: _seriesInfo!.cover!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 800,
errorWidget: (_, __, ___) => errorWidget: (_, __, ___) =>
Container(color: AppColors.surfaceContainerLow), Container(color: AppColors.surfaceContainerLow),
), ),
@@ -186,7 +187,7 @@ class _MobileSeriesDetailScreenState
children: [ children: [
Text( Text(
_seriesInfo!.name, _seriesInfo!.name,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -204,7 +205,7 @@ class _MobileSeriesDetailScreenState
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(_seriesInfo!.rating!)!, _formatRating(_seriesInfo!.rating!)!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
fontSize: 13, fontSize: 13,
), ),
@@ -213,7 +214,7 @@ class _MobileSeriesDetailScreenState
], ],
Text( Text(
'${_seriesInfo!.episodes.keys.length} Seasons', '${_seriesInfo!.episodes.keys.length} Seasons',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
fontSize: 13, fontSize: 13,
), ),
@@ -235,7 +236,7 @@ class _MobileSeriesDetailScreenState
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Text( child: Text(
_seriesInfo!.plot!, _seriesInfo!.plot!,
style: GoogleFonts.inter(color: AppColors.onSurfaceVariant, height: 1.4), style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, height: 1.4),
maxLines: 4, maxLines: 4,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -262,7 +263,7 @@ class _MobileSeriesDetailScreenState
}, },
backgroundColor: AppColors.surfaceContainer, backgroundColor: AppColors.surfaceContainer,
selectedColor: AppColors.primary, selectedColor: AppColors.primary,
labelStyle: GoogleFonts.inter( labelStyle: GoogleFonts.karla(
color: isSelected ? AppColors.onPrimary : AppColors.onSurfaceVariant, color: isSelected ? AppColors.onPrimary : AppColors.onSurfaceVariant,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
), ),
@@ -357,7 +358,7 @@ class _MobileSeriesDetailScreenState
children: [ children: [
Text( Text(
'E${episode.episodeNum} - ${episode.title}', 'E${episode.episodeNum} - ${episode.title}',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 14,
@@ -369,7 +370,7 @@ class _MobileSeriesDetailScreenState
episode.durationSecs! > 0) episode.durationSecs! > 0)
Text( Text(
_formatDuration(episode.durationSecs!), _formatDuration(episode.durationSecs!),
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
fontSize: 12, fontSize: 12,
), ),
@@ -50,7 +50,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
'Loading channels...', 'Loading channels...',
style: TextStyle(color: Colors.white70), style: TextStyle(color: AppColors.onSurfaceVariant),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
FutureBuilder( FutureBuilder(
@@ -80,8 +80,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Failed to load channels', 'Failed to load channels',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -89,7 +89,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'$e', '$e',
style: const TextStyle(color: Colors.white54), style: const TextStyle(color: AppColors.onSurface54),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -153,15 +153,15 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: GoogleFonts.inter(color: Colors.white), style: GoogleFonts.karla(color: AppColors.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search channels...', hintText: 'Search channels...',
hintStyle: hintStyle:
GoogleFonts.inter(color: Colors.white54), GoogleFonts.karla(color: AppColors.onSurface54),
border: InputBorder.none, border: InputBorder.none,
prefixIcon: const Icon( prefixIcon: const Icon(
Icons.search, Icons.search,
color: Colors.white54, color: AppColors.onSurface54,
), ),
isDense: true, isDense: true,
), ),
@@ -171,7 +171,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
GestureDetector( GestureDetector(
onTap: () => _searchController.clear(), onTap: () => _searchController.clear(),
child: child:
const Icon(Icons.close, color: Colors.white54), const Icon(Icons.close, color: AppColors.onSurface54),
), ),
], ],
), ),
@@ -201,15 +201,15 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? Colors.white ? AppColors.onSurface
: Colors.black.withOpacity(0.4), : Colors.black.withOpacity(0.4),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
category, category,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: color:
isSelected ? Colors.black : Colors.white70, isSelected ? Colors.black : AppColors.onSurfaceVariant,
fontWeight: isSelected fontWeight: isSelected
? FontWeight.w600 ? FontWeight.w600
: FontWeight.w500, : FontWeight.w500,
@@ -233,8 +233,8 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
: _showFavoritesOnly : _showFavoritesOnly
? 'Favorites' ? 'Favorites'
: _selectedCategory ?? 'Channels', : _selectedCategory ?? 'Channels',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 18, fontSize: 18,
), ),
@@ -249,7 +249,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: _showFavoritesOnly color: _showFavoritesOnly
? AppColors.live.withOpacity(0.2) ? AppColors.live.withOpacity(0.2)
: Colors.white.withOpacity(0.1), : AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: _showFavoritesOnly border: _showFavoritesOnly
? Border.all(color: AppColors.live) ? Border.all(color: AppColors.live)
@@ -261,7 +261,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
: Icons.favorite_border, : Icons.favorite_border,
color: _showFavoritesOnly color: _showFavoritesOnly
? AppColors.live ? AppColors.live
: Colors.white, : AppColors.onSurface,
size: 20, size: 20,
), ),
), ),
@@ -279,13 +279,13 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
children: [ children: [
const Icon( const Icon(
Icons.tv_off, Icons.tv_off,
color: Colors.white24, color: AppColors.onSurface24,
size: 48, size: 48,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'No channels found', 'No channels found',
style: GoogleFonts.inter(color: Colors.white38), style: GoogleFonts.karla(color: AppColors.onSurface38),
), ),
], ],
), ),
@@ -365,7 +365,7 @@ class _MobileChannelTile extends StatelessWidget {
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1), color: AppColors.onSurface.withOpacity(0.1),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
@@ -373,17 +373,19 @@ class _MobileChannelTile extends StatelessWidget {
? Image.network( ? Image.network(
iconUrl, iconUrl,
fit: BoxFit.contain, fit: BoxFit.contain,
cacheWidth: 120,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white24), const Icon(Icons.tv, color: AppColors.onSurface24),
) )
: const Icon(Icons.tv, color: Colors.white24), : const Icon(Icons.tv, color: AppColors.onSurface24),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Text( child: Text(
channel.name, channel.name,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: Colors.white, color: AppColors.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
fontSize: 14, fontSize: 14,
), ),
@@ -394,12 +396,12 @@ class _MobileChannelTile extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1), color: AppColors.onSurface.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: const Icon(
Icons.play_arrow_rounded, Icons.play_arrow_rounded,
color: Colors.white, color: AppColors.onSurface,
size: 16, size: 16,
), ),
), ),
@@ -206,13 +206,13 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search Movies...', hintText: 'Search Movies...',
hintStyle: GoogleFonts.inter(color: AppColors.onSurfaceVariant), hintStyle: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: const EdgeInsets.only(bottom: 11), contentPadding: const EdgeInsets.only(bottom: 11),
@@ -300,6 +300,8 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
? Image.network( ? Image.network(
_getProxiedImageUrl(movie.streamIcon, ref), _getProxiedImageUrl(movie.streamIcon, ref),
fit: BoxFit.cover, fit: BoxFit.cover,
cacheWidth: 360,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) =>
Container(color: AppColors.surfaceContainerLow), Container(color: AppColors.surfaceContainerLow),
) )
@@ -346,7 +348,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
children: [ children: [
Text( Text(
movie.name, movie.name,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -357,7 +359,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
if (isWatched) if (isWatched)
Text( Text(
'WATCHED', 'WATCHED',
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.primary, color: AppColors.primary,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -389,7 +391,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(movie.rating)!, _formatRating(movie.rating)!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -197,13 +197,13 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search Series...', hintText: 'Search Series...',
hintStyle: GoogleFonts.inter(color: AppColors.onSurfaceVariant), hintStyle: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: const EdgeInsets.only(bottom: 11), contentPadding: const EdgeInsets.only(bottom: 11),
@@ -290,6 +290,8 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
? Image.network( ? Image.network(
_getProxiedImageUrl(series.cover, ref), _getProxiedImageUrl(series.cover, ref),
fit: BoxFit.cover, fit: BoxFit.cover,
cacheWidth: 360,
filterQuality: FilterQuality.low,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) =>
Container(color: AppColors.surfaceContainerLow), Container(color: AppColors.surfaceContainerLow),
) )
@@ -332,7 +334,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
right: 12, right: 12,
child: Text( child: Text(
series.name, series.name,
style: GoogleFonts.inter( style: GoogleFonts.karla(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -364,7 +366,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_formatRating(series.rating)!, _formatRating(series.rating)!,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -399,7 +399,7 @@ class _SectionHeader extends StatelessWidget {
padding: const EdgeInsets.only(left: 8, bottom: 8), padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text( child: Text(
title, title,
style: GoogleFonts.inter( style: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
+35 -30
View File
@@ -2,24 +2,24 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
/// XtremFlow Mobile Theme /// Thème mobile XtremFlow — déclinaison tactile de « Warm Cinema ».
/// ///
/// Adapted from AppTheme for touch-first screens. /// Mêmes tokens que [AppTheme] (charbon chaud / crème / braise), échelles
/// Uses the same Cyber-Cinematic Glass design system. /// typographiques réduites et cibles tactiles élargies.
class MobileTheme { class MobileTheme {
MobileTheme._(); MobileTheme._();
// ============ SPACING (8px rhythmic system) ============ // ============ ESPACEMENT ============
static const double spacingXs = 4.0; static const double spacingXs = 4.0;
static const double spacingSm = 8.0; static const double spacingSm = 8.0;
static const double spacingMd = 16.0; static const double spacingMd = 16.0;
static const double spacingLg = 24.0; static const double spacingLg = 24.0;
static const double spacingXl = 32.0; static const double spacingXl = 32.0;
// ============ RADIUS ============ // ============ RAYONS (alignés sur AppTheme) ============
static const double radiusSm = 8.0; static const double radiusSm = 6.0;
static const double radiusMd = 12.0; static const double radiusMd = 10.0;
static const double radiusLg = 16.0; static const double radiusLg = 14.0;
static const double radiusXl = 20.0; static const double radiusXl = 20.0;
// ============ DARK THEME (Mobile Main) ============ // ============ DARK THEME (Mobile Main) ============
@@ -32,69 +32,69 @@ class MobileTheme {
// Typography: Scaled down for mobile // Typography: Scaled down for mobile
textTheme: TextTheme( textTheme: TextTheme(
displayLarge: GoogleFonts.spaceGrotesk( displayLarge: GoogleFonts.fraunces(
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.02, letterSpacing: -0.02,
height: 1.1, height: 1.1,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
displayMedium: GoogleFonts.spaceGrotesk( displayMedium: GoogleFonts.fraunces(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.01, letterSpacing: -0.01,
height: 1.2, height: 1.2,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
displaySmall: GoogleFonts.spaceGrotesk( displaySmall: GoogleFonts.fraunces(
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
height: 1.3, height: 1.3,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
headlineMedium: GoogleFonts.spaceGrotesk( headlineMedium: GoogleFonts.fraunces(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
height: 1.3, height: 1.3,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
titleLarge: GoogleFonts.spaceGrotesk( titleLarge: GoogleFonts.fraunces(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
titleMedium: GoogleFonts.inter( titleMedium: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
bodyLarge: GoogleFonts.inter( bodyLarge: GoogleFonts.karla(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.5, height: 1.5,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
), ),
bodyMedium: GoogleFonts.inter( bodyMedium: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.5, height: 1.5,
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
), ),
labelLarge: GoogleFonts.inter( labelLarge: GoogleFonts.karla(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.05, letterSpacing: 0.05,
height: 1.2, height: 1.2,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
labelMedium: GoogleFonts.inter( labelMedium: GoogleFonts.karla(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
letterSpacing: 0.02, letterSpacing: 0.02,
height: 1.2, height: 1.2,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
labelSmall: GoogleFonts.inter( labelSmall: GoogleFonts.karla(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
letterSpacing: 0.02, letterSpacing: 0.02,
@@ -110,7 +110,7 @@ class MobileTheme {
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
titleTextStyle: GoogleFonts.spaceGrotesk( titleTextStyle: GoogleFonts.fraunces(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -119,14 +119,16 @@ class MobileTheme {
// Bottom Navigation Bar // Bottom Navigation Bar
bottomNavigationBarTheme: BottomNavigationBarThemeData( bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: AppColors.surfaceContainerLow.withOpacity(0.9), // Opaque : une barre translucide impose une couche de composition
selectedItemColor: AppColors.onSurface, // supplementaire sur toute la largeur, a chaque frame.
backgroundColor: AppColors.surfaceContainerLowest,
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.outline, unselectedItemColor: AppColors.outline,
selectedLabelStyle: GoogleFonts.inter( selectedLabelStyle: GoogleFonts.karla(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
unselectedLabelStyle: GoogleFonts.inter( unselectedLabelStyle: GoogleFonts.karla(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -157,7 +159,7 @@ class MobileTheme {
width: 2, width: 2,
), ),
), ),
hintStyle: GoogleFonts.inter( hintStyle: GoogleFonts.karla(
color: AppColors.outline, color: AppColors.outline,
fontSize: 14, fontSize: 14,
), ),
@@ -166,14 +168,16 @@ class MobileTheme {
// Buttons // Buttons
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.primaryContainer, // Braise profonde : seule variante qui tient le contraste AA
foregroundColor: AppColors.onPrimaryContainer, // (4,5:1) avec du texte blanc.
backgroundColor: AppColors.primaryFill,
foregroundColor: Colors.white,
elevation: 0, elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
), ),
textStyle: GoogleFonts.inter( textStyle: GoogleFonts.karla(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 14,
), ),
@@ -182,7 +186,8 @@ class MobileTheme {
// Cards // Cards
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: AppColors.glassLevel1Bg, color: AppColors.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
elevation: 0, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
+2 -2
View File
@@ -60,9 +60,9 @@ class MobileScaffold extends ConsumerWidget {
selectedItemColor: AppColors.primary, selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textSecondary, unselectedItemColor: AppColors.textSecondary,
selectedLabelStyle: selectedLabelStyle:
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 11), GoogleFonts.karla(fontWeight: FontWeight.w600, fontSize: 11),
unselectedLabelStyle: unselectedLabelStyle:
GoogleFonts.inter(fontWeight: FontWeight.w500, fontSize: 11), GoogleFonts.karla(fontWeight: FontWeight.w500, fontSize: 11),
items: const [ items: const [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.live_tv_rounded), icon: Icon(Icons.live_tv_rounded),
+5
View File
@@ -63,3 +63,8 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true uses-material-design: true
# Polices embarquees : google_fonts detecte ces fichiers via l'AssetManifest
# et n'effectue AUCUNE requete reseau vers fonts.gstatic.com au demarrage.
assets:
- assets/google_fonts/
+83 -47
View File
@@ -1,25 +1,29 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="fr">
<head> <head>
<base href="$FLUTTER_BASE_HREF"> <base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="XtremFlow - Modern IPTV Streaming Application"> <meta name="description" content="XtremFlow — streaming IPTV">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="XtremFlow"> <meta name="apple-mobile-web-app-title" content="XtremFlow">
<meta name="theme-color" content="#0A0E1A"> <meta name="theme-color" content="#0B0908">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport"
<title>XtremFlow IPTV</title> content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>XtremFlow</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"> <link rel="icon" type="image/png" href="favicon.png">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<style> <style>
/* =============================================================
* Amorce « Warm Cinema » — charbon chaud, crème, braise.
* Aucune police web, aucun script tiers : rien ne retarde le boot.
* ============================================================= */
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -27,16 +31,15 @@
} }
body { body {
background: linear-gradient(135deg, #0F1014 0%, #181920 100%); background: radial-gradient(120% 90% at 22% 0%, #1A1310 0%, #0B0908 62%);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-height: 100vh; min-height: 100vh;
font-family: 'Inter', 'Roboto', sans-serif;
overflow: hidden; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
} }
/* Fullscreen mode - prevent scrollbars */
:fullscreen, :fullscreen,
::backdrop { ::backdrop {
overflow: hidden !important; overflow: hidden !important;
@@ -47,68 +50,101 @@
overflow: hidden !important; overflow: hidden !important;
} }
.loading-container { .boot {
text-align: center; text-align: center;
animation: fadeIn 0.5s ease-out; animation: rise 620ms cubic-bezier(0.16, 1, 0.3, 1) both;
} }
.logo { .mark {
font-size: 48px; font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: clamp(34px, 7vw, 58px);
font-weight: 600;
letter-spacing: 0.14em;
color: #F3E9DF;
text-transform: uppercase;
line-height: 1;
}
/* Le « W » d'XtremFlow porte la braise */
.mark em {
font-style: normal;
color: #D9541F;
}
/* Barre d'amorce : balayage lent, comme une pellicule qui se lance */
.leader {
position: relative;
width: min(240px, 56vw);
height: 2px;
margin: 26px auto 0;
background: rgba(243, 233, 223, 0.12);
overflow: hidden;
}
.leader::after {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 42%;
background: linear-gradient(90deg, transparent, #D9541F, transparent);
animation: wipe 1150ms cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.caption {
margin-top: 16px;
font-size: 10px;
font-weight: 700; font-weight: 700;
background: linear-gradient(135deg, #007AFF 0%, #00C6FF 100%); letter-spacing: 0.32em;
-webkit-background-clip: text; text-transform: uppercase;
-webkit-text-fill-color: transparent; color: rgba(243, 233, 223, 0.38);
background-clip: text;
margin-bottom: 24px;
letter-spacing: -1px;
} }
.spinner { @keyframes wipe {
width: 40px; from {
height: 40px; transform: translateX(-100%);
margin: 0 auto 16px; }
border: 3px solid rgba(0, 122, 255, 0.2);
border-top-color: #007AFF;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
font-weight: 500;
font-family: 'Outfit', sans-serif;
}
@keyframes spin {
to { to {
transform: rotate(360deg); transform: translateX(340%);
} }
} }
@keyframes fadeIn { @keyframes rise {
from { from {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(12px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.boot,
.leader::after {
animation-duration: 2.4s;
} }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="loading-container"> <div class="boot">
<div class="logo">XtremFlow</div> <div class="mark">Xtrem<em>Flow</em></div>
<div class="spinner"></div> <div class="leader"></div>
<div class="loading-text">Loading...</div> <div class="caption">Projection</div>
</div> </div>
<!-- HLS.js for HLS video playback on Chrome/Firefox -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script> <!--
hls.js n'est plus chargé ici. Il était récupéré depuis un CDN de façon
BLOQUANTE avant flutter_bootstrap.js alors qu'aucun code Dart ne
l'utilise : seuls les iframes de lecture en ont besoin, et elles le
chargent désormais elles-mêmes, en local et à la demande.
-->
<script src="flutter_bootstrap.js" async></script> <script src="flutter_bootstrap.js" async></script>
</body> </body>
</html> </html>
+71 -339
View File
@@ -1,127 +1,21 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>XtremFlow Player</title> <title>XtremFlow Player</title>
<!-- HLS.js and mpegts.js for MPEG-TS support --> <!--
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12/dist/hls.min.js"></script> Les libs hls.js / mpegts.js ne sont PLUS chargées depuis un CDN ni de
<script src="https://cdn.jsdelivr.net/npm/mpegts.js@1.7.3/dist/mpegts.min.js"></script> façon bloquante : xf-player-core.js ne récupère que celle réellement
<style> nécessaire au flux courant, en local. Deux requêtes bloquantes en moins
* { et environ 200 Ko de JS jamais téléchargés dans le cas courant.
margin: 0; -->
padding: 0; <link rel="stylesheet" href="xf-player.css">
box-sizing: border-box; <link rel="preload" href="vendor/hls.min.js" as="script">
-webkit-tap-highlight-color: transparent;
}
html,
body {
background: #000;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
#player-container {
width: 100%;
height: 100%;
position: relative;
}
#video {
width: 100%;
height: 100%;
background: #000;
}
#error {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
text-align: center;
font-family: Arial, sans-serif;
z-index: 30;
}
#error-icon {
font-size: 64px;
color: #f44336;
margin-bottom: 16px;
}
#loading {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
text-align: center;
font-family: Arial, sans-serif;
z-index: 20;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Safari Autoplay Hint Overlay */
#play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90px;
height: 90px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: none;
justify-content: center;
align-items: center;
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
z-index: 25;
transition: transform 0.2s;
}
#play-overlay:active {
transform: translate(-50%, -50%) scale(0.9);
}
#play-overlay svg {
width: 44px;
height: 44px;
fill: white;
margin-left: 5px;
}
</style>
</head> </head>
<body> <body>
<div id="player-container"> <div id="player-container">
<!-- iOS/Safari core attributes -->
<video id="video" autoplay playsinline webkit-playsinline preload="auto"></video> <video id="video" autoplay playsinline webkit-playsinline preload="auto"></video>
<div id="play-overlay"> <div id="play-overlay">
@@ -130,243 +24,81 @@
</svg> </svg>
</div> </div>
<div id="loading"> <div id="loading" class="xf-center">
<div class="spinner"></div> <div class="xf-leader">
<div id="loading-text">Chargement du flux...</div> <div class="xf-leader-cross"></div>
</div>
<div class="xf-label" id="loading-text">Ouverture du flux</div>
<div class="xf-hint" id="loading-hint"></div>
</div> </div>
<div id="error">
<div id="error-icon">⚠</div> <div id="error" class="xf-center">
<div id="error-message">Erreur de chargement</div> <div class="xf-error-mark">!</div>
<div class="xf-error-title" id="error-message">Erreur de chargement</div>
<div class="xf-error-sub">Vérifiez le lien ou votre connexion</div>
</div> </div>
</div> </div>
<script src="xf-player-core.js"></script>
<script> <script>
const video = document.getElementById('video'); (function () {
const loading = document.getElementById('loading'); var video = document.getElementById('video');
const loadingText = document.getElementById('loading-text'); var loading = document.getElementById('loading');
const error = document.getElementById('error'); var loadingText = document.getElementById('loading-text');
const errorMessage = document.getElementById('error-message'); var loadingHint = document.getElementById('loading-hint');
const playOverlay = document.getElementById('play-overlay'); var errorBox = document.getElementById('error');
var errorMessage = document.getElementById('error-message');
var playOverlay = document.getElementById('play-overlay');
const urlParams = new URLSearchParams(window.location.search); function showLoading(msg) {
let streamUrl = urlParams.get('url'); loading.style.display = 'block';
const startTime = parseFloat(urlParams.get('t') || '0'); loadingText.textContent = msg || 'Ouverture du flux';
const streamType = urlParams.get('type') || ''; errorBox.style.display = 'none';
const injectedDuration = parseFloat(urlParams.get('duration') || '0');
function log(msg) { console.log('[Player] ' + msg); }
function showLoading(msg) {
loading.style.display = 'block';
loadingText.textContent = msg || 'Chargement du flux...';
error.style.display = 'none';
}
function hideLoading() { loading.style.display = 'none'; }
function showError(message) {
hideLoading();
error.style.display = 'block';
errorMessage.textContent = message || 'Erreur de chargement';
playOverlay.style.display = 'none';
}
playOverlay.addEventListener('click', () => {
log('Manual play triggered');
video.play();
playOverlay.style.display = 'none';
});
video.addEventListener('loadedmetadata', () => {
if (startTime > 0 && startTime < video.duration) {
video.currentTime = startTime;
}
});
let lastReportedTime = 0;
function reportPosition() {
if (video.currentTime > 0 && !video.paused && video.readyState > 2) {
if (Math.abs(video.currentTime - lastReportedTime) >= 1) {
lastReportedTime = video.currentTime;
let duration = video.duration;
const hlsDuration = window.currentHlsDuration || 0;
if (!isFinite(duration) || isNaN(duration) || duration < 1) {
duration = hlsDuration > 0 ? hlsDuration : (injectedDuration > 0 ? injectedDuration : 0);
} else if (hlsDuration > duration) {
duration = hlsDuration;
} else if (injectedDuration > 0 && duration < injectedDuration * 0.9) {
duration = injectedDuration;
}
window.parent.postMessage({ type: 'playback_position', currentTime: video.currentTime, duration: duration }, '*');
}
}
}
setInterval(reportPosition, 5000);
video.addEventListener('pause', () => {
window.parent.postMessage({ type: 'playback_status', status: 'paused' }, '*');
});
video.addEventListener('playing', () => {
window.parent.postMessage({ type: 'playback_status', status: 'playing' }, '*');
hideLoading();
playOverlay.style.display = 'none';
});
video.addEventListener('ended', () => {
window.parent.postMessage({ type: 'playback_ended', duration: isFinite(video.duration) ? video.duration : injectedDuration }, '*');
});
function reportActivity() { window.parent.postMessage({ type: 'user_activity' }, '*'); }
document.addEventListener('mousemove', reportActivity);
document.addEventListener('touchstart', reportActivity);
document.addEventListener('click', reportActivity);
window.addEventListener('message', (event) => {
const data = event.data;
if (!data) return;
switch (data.type) {
case 'play': video.play().catch(() => playOverlay.style.display = 'flex'); break;
case 'pause': video.pause(); break;
case 'seek': if (Number.isFinite(data.value)) video.currentTime = data.value; break;
case 'set_volume': video.volume = Math.max(0, Math.min(1, data.value)); video.muted = video.volume === 0; break;
}
});
async function playWithHLS(url) {
log('Trying HLS.js with: ' + url);
showLoading('Chargement HLS...');
if (!Hls.isSupported()) {
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
video.load();
video.play().catch(() => {
loading.style.display = 'none';
playOverlay.style.display = 'flex';
});
return true;
}
return false;
} }
const isTurbo = urlParams.get('turbo') === 'true'; function hideLoading() {
const isRecording = urlParams.get('is_recording') === 'true'; loading.style.display = 'none';
loadingHint.textContent = '';
const hlsOptions = {
maxBufferLength: 120,
enableWorker: true,
nudgeOffset: 0.8, // More aggressive jump over corrupted frames
nudgeMaxRetries: 30,
maxLiveSyncPlaybackRate: 1.1, // Slower catch-up to avoid chaos
initialLiveManifestSize: 3, // Wait for more segments before starting
abrEwmaDefaultEstimate: 1000000,
manifestLoadingRetryDelay: 1000,
levelLoadingRetryDelay: 1000
};
// Limit live sync only for actual LIVE IPTV, not recordings
if (!isRecording && streamType === 'live') {
hlsOptions.liveSyncDurationCount = 10;
hlsOptions.liveMaxLatencyDurationCount = 20;
} }
const hls = new Hls(hlsOptions); function showError(msg) {
window.hlsInstance = hls;
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
hideLoading(); hideLoading();
// Wait for a small buffer before playing to avoid chaos errorBox.style.display = 'block';
const checkBuffer = setInterval(() => { errorMessage.textContent = msg || 'Erreur de chargement';
if (video.buffered.length > 0) { playOverlay.style.display = 'none';
const bufferEnd = video.buffered.end(0);
if (bufferEnd > 1.5) { // 1.5s buffer-first
clearInterval(checkBuffer);
video.play().catch(() => playOverlay.style.display = 'flex');
}
}
}, 200);
});
hls.on(Hls.Events.LEVEL_LOADED, (e, d) => {
if (d.details && d.details.totalduration > injectedDuration) window.currentHlsDuration = d.details.totalduration;
});
hls.on(Hls.Events.ERROR, (e, d) => {
if (d.fatal) {
if (d.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else showError('HLS Error: ' + d.details);
}
});
return true;
}
async function playWithMpegTS(url, isLive = true) {
log('Trying mpegts.js with: ' + url);
showLoading('Chargement MPEG-TS...');
if (!mpegts.isSupported()) return false;
const player = mpegts.createPlayer({ type: 'mpegts', isLive: isLive, url: url }, {
enableWorker: true,
stashInitialSize: 512 * 1024, // Buffer-First: 512KB
enableStashBuffer: true,
liveBufferLatencyChasing: false, // Avoid chaotic catch-up
maxStashSize: 30 * 1024 * 1024
});
player.attachMediaElement(video);
player.load();
// Wait for buffer to fill slightly before playing
const checkBuffer = setInterval(() => {
if (video.buffered.length > 0) {
if (video.buffered.end(0) > 1.0) {
clearInterval(checkBuffer);
player.play().catch(() => playOverlay.style.display = 'flex');
}
}
}, 200);
player.on(mpegts.Events.ERROR, (type, detail) => {
log('MpegTS Error: ' + type + ' - ' + detail);
setTimeout(() => {
player.destroy();
playWithMpegTS(url, isLive);
}, 2000);
});
return true;
}
async function playDirect(url) {
log('Direct play logic for Safari stability: ' + url);
showLoading('Lancement...');
video.src = url;
video.load();
video.play().catch(async () => {
log('Direct play blocked, trying fallbacks...');
if (url.toLowerCase().includes('.m3u8')) await playWithHLS(url);
else playOverlay.style.display = 'flex';
});
video.addEventListener('canplay', () => hideLoading(), { once: true });
video.addEventListener('error', () => showError('Format non supporté'), { once: true });
return true;
}
async function initPlayer() {
if (!streamUrl) return showError('No URL');
const lowerUrl = streamUrl.toLowerCase();
if (streamType === 'live') {
if (lowerUrl.includes('.m3u8')) await playWithHLS(streamUrl);
else await playWithMpegTS(streamUrl, true);
} else if (streamType === 'vod') {
if (lowerUrl.includes('.m3u8')) await playWithHLS(streamUrl);
else await playDirect(streamUrl);
} else {
if (lowerUrl.includes('.m3u8')) await playWithHLS(streamUrl);
else await playDirect(streamUrl);
} }
}
initPlayer(); var player = new XFPlayer({
video: video,
logPrefix: '[Player]',
onLoading: showLoading,
onReady: function () {
hideLoading();
playOverlay.style.display = 'none';
},
onError: showError,
onBlocked: function () {
hideLoading();
playOverlay.style.display = 'flex';
},
onProfileChange: function (name) {
// Retour visuel discret : l'utilisateur comprend pourquoi
// la latence augmente légèrement après des coupures.
loadingHint.textContent =
name === 'safe' ? 'Réseau instable — buffer maximal'
: 'Réseau instable — buffer élargi';
}
});
playOverlay.addEventListener('click', function () {
video.play();
playOverlay.style.display = 'none';
});
player.start();
window.xfPlayer = player;
})();
</script> </script>
</body> </body>
</html> </html>
+70 -326
View File
@@ -1,120 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>IPTV Player Lite</title> <title>XtremFlow Player Lite</title>
<!-- HLS.js and mpegts.js for MPEG-TS support --> <link rel="stylesheet" href="xf-player.css">
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12/dist/hls.min.js"></script> <link rel="preload" href="vendor/hls.min.js" as="script">
<script src="https://cdn.jsdelivr.net/npm/mpegts.js@1.7.3/dist/mpegts.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
background: #000;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
#player-container {
width: 100%;
height: 100%;
position: relative;
}
#video {
width: 100%;
height: 100%;
background: #000;
}
#error {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
text-align: center;
z-index: 30;
}
#error-icon {
font-size: 64px;
color: #f44336;
margin-bottom: 16px;
}
#loading {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
text-align: center;
z-index: 20;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Safari Autoplay Hint Overlay */
#play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80px;
height: 80px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: none;
justify-content: center;
align-items: center;
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
z-index: 25;
}
#play-overlay svg {
width: 40px;
height: 40px;
fill: white;
margin-left: 4px;
}
</style>
</head> </head>
<body> <body>
<div id="player-container"> <div id="player-container">
<!-- iOS/Safari core attributes -->
<video id="video" autoplay playsinline webkit-playsinline preload="auto"></video> <video id="video" autoplay playsinline webkit-playsinline preload="auto"></video>
<div id="play-overlay"> <div id="play-overlay">
@@ -123,238 +18,87 @@
</svg> </svg>
</div> </div>
<div id="loading"> <div id="loading" class="xf-center">
<div class="spinner"></div> <div class="xf-leader">
<div id="loading-text">Chargement du flux...</div> <div class="xf-leader-cross"></div>
</div>
<div class="xf-label" id="loading-text">Ouverture du flux</div>
<div class="xf-hint" id="loading-hint"></div>
</div> </div>
<div id="error">
<div id="error-icon">⚠</div> <div id="error" class="xf-center">
<div id="error-message">Erreur de chargement</div> <div class="xf-error-mark">!</div>
<div class="xf-error-title" id="error-message">Erreur de chargement</div>
<div class="xf-error-sub">Vérifiez le lien ou votre connexion</div>
</div> </div>
</div> </div>
<script src="xf-player-core.js"></script>
<script> <script>
const video = document.getElementById('video'); (function () {
const loading = document.getElementById('loading'); var video = document.getElementById('video');
const loadingText = document.getElementById('loading-text'); var loading = document.getElementById('loading');
const error = document.getElementById('error'); var loadingText = document.getElementById('loading-text');
const errorMessage = document.getElementById('error-message'); var loadingHint = document.getElementById('loading-hint');
const playOverlay = document.getElementById('play-overlay'); var errorBox = document.getElementById('error');
var errorMessage = document.getElementById('error-message');
var playOverlay = document.getElementById('play-overlay');
// Get parameters from query string function showLoading(msg) {
const urlParams = new URLSearchParams(window.location.search); loading.style.display = 'block';
let streamUrl = urlParams.get('url'); loadingText.textContent = msg || 'Ouverture du flux';
const startTime = parseFloat(urlParams.get('t') || '0'); errorBox.style.display = 'none';
const streamType = urlParams.get('type') || ''; // 'live' or 'vod'
function log(msg) {
console.log('[LitePlayer] ' + msg);
}
function showLoading(msg) {
loading.style.display = 'block';
loadingText.textContent = msg || 'Chargement du flux...';
error.style.display = 'none';
}
function hideLoading() {
loading.style.display = 'none';
}
function showError(message) {
hideLoading();
error.style.display = 'block';
errorMessage.textContent = message || 'Erreur de chargement';
}
playOverlay.addEventListener('click', () => {
log('Manual play triggered');
video.play();
playOverlay.style.display = 'none';
});
// Seek to startTime when video is ready
video.addEventListener('loadedmetadata', () => {
if (startTime > 0 && startTime < video.duration) {
video.currentTime = startTime;
} }
});
// Report position to parent function hideLoading() {
let lastReportedTime = 0;
function reportPosition() {
if (video.currentTime > 0 && !video.paused && video.readyState > 2) {
if (Math.abs(video.currentTime - lastReportedTime) >= 5) {
lastReportedTime = video.currentTime;
window.parent.postMessage({
type: 'playback_position',
currentTime: video.currentTime,
duration: isFinite(video.duration) ? video.duration : 0
}, '*');
}
}
}
setInterval(reportPosition, 5000);
video.addEventListener('pause', () => {
window.parent.postMessage({ type: 'playback_status', status: 'paused' }, '*');
if (video.currentTime === 0 || video.paused) {
loading.style.display = 'none'; loading.style.display = 'none';
playOverlay.style.display = 'flex'; loadingHint.textContent = '';
}
});
video.addEventListener('playing', () => {
window.parent.postMessage({ type: 'playback_status', status: 'playing' }, '*');
hideLoading();
playOverlay.style.display = 'none';
});
video.addEventListener('ended', () => {
window.parent.postMessage({
type: 'playback_ended',
duration: isFinite(video.duration) ? video.duration : 0
}, '*');
});
// Activity reporting
function reportActivity() {
window.parent.postMessage({ type: 'user_activity' }, '*');
}
document.addEventListener('mousemove', reportActivity);
document.addEventListener('touchstart', reportActivity);
document.addEventListener('click', reportActivity);
async function playWithHLS(url) {
log('Trying HLS.js with: ' + url);
showLoading('Chargement HLS...');
if (!Hls.isSupported()) {
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url;
video.load();
video.play().catch(() => {
loading.style.display = 'none';
playOverlay.style.display = 'flex';
});
return true;
}
return false;
} }
const isTurbo = urlParams.get('turbo') === 'true'; function showError(msg) {
const hls = new Hls({
maxBufferLength: 120,
maxMaxBufferLength: 240,
enableWorker: true,
liveSyncDurationCount: 10,
liveMaxLatencyDurationCount: 30,
lowLatencyMode: false,
nudgeOffset: 0.8,
nudgeMaxRetries: 30,
maxLiveSyncPlaybackRate: 1.1,
initialLiveManifestSize: 3,
abrEwmaDefaultEstimate: 1000000,
manifestLoadingRetryDelay: 1000,
levelLoadingRetryDelay: 1000
});
window.hlsInstance = hls;
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
hideLoading(); hideLoading();
const checkBuffer = setInterval(() => { errorBox.style.display = 'block';
if (video.buffered.length > 0 && video.buffered.end(0) > 1.5) { errorMessage.textContent = msg || 'Erreur de chargement';
clearInterval(checkBuffer); playOverlay.style.display = 'none';
video.play().catch(() => playOverlay.style.display = 'flex');
}
}, 200);
});
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else showError('HLS Error: ' + data.details);
}
});
return true;
}
async function playWithMpegTS(url, isLive = true) {
log('Trying mpegts.js with: ' + url);
showLoading('Chargement MPEG-TS...');
if (!mpegts.isSupported()) return false;
const isTurbo = urlParams.get('turbo') === 'true';
const player = mpegts.createPlayer({
type: 'mpegts',
isLive: isLive,
url: url
}, {
enableWorker: true,
stashInitialSize: 512 * 1024,
enableStashBuffer: true,
liveBufferLatencyChasing: false,
maxStashSize: 30 * 1024 * 1024
});
player.attachMediaElement(video);
player.load();
const checkBuffer = setInterval(() => {
if (video.buffered.length > 0 && video.buffered.end(0) > 1.0) {
clearInterval(checkBuffer);
player.play().catch(() => playOverlay.style.display = 'flex');
}
}, 200);
player.on(mpegts.Events.ERROR, (type, detail, info) => {
log('MpegTS Error: ' + type + ' - ' + detail);
// Auto-recover after 2 seconds
setTimeout(() => {
try {
player.unload();
player.detachMediaElement();
player.destroy();
playWithMpegTS(url, isLive);
} catch (e) {
showError('MpegTS Recovery Failed');
}
}, 2000);
});
return true;
}
async function initPlayer() {
if (!streamUrl) return showError('No URL');
log('Stream URL: ' + streamUrl);
const lowerUrl = streamUrl.toLowerCase();
const isHLS = lowerUrl.includes('.m3u8') || lowerUrl.includes('/playlist.m3u8');
if (isHLS) {
await playWithHLS(streamUrl);
} else {
await playWithMpegTS(streamUrl, streamType === 'live');
} }
}
window.addEventListener('message', (event) => { var player = new XFPlayer({
const data = event.data; video: video,
if (data.type === 'play') video.play().catch(() => playOverlay.style.display = 'flex'); logPrefix: '[LitePlayer]',
else if (data.type === 'pause') video.pause(); onLoading: showLoading,
else if (data.type === 'set_volume') { video.volume = Math.max(0, Math.min(1, data.value)); video.muted = video.volume === 0; } onReady: function () {
}); hideLoading();
playOverlay.style.display = 'none';
},
onError: showError,
onBlocked: function () {
hideLoading();
playOverlay.style.display = 'flex';
},
onProfileChange: function (name) {
loadingHint.textContent =
name === 'safe' ? 'Réseau instable — buffer maximal'
: 'Réseau instable — buffer élargi';
}
});
initPlayer(); playOverlay.addEventListener('click', function () {
video.play();
playOverlay.style.display = 'none';
});
// Contrat historique du lite : mise en pause manuelle → overlay lecture.
video.addEventListener('pause', function () {
if (video.currentTime === 0 || video.paused) {
loading.style.display = 'none';
playOverlay.style.display = 'flex';
}
});
player.start();
window.xfPlayer = player;
})();
</script> </script>
</body> </body>
</html> </html>
+102 -284
View File
@@ -1,316 +1,134 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport"
<title>Mobile IPTV Player</title> content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12/dist/hls.min.js"></script> <meta name="apple-mobile-web-app-capable" content="yes">
<script src="https://cdn.jsdelivr.net/npm/mpegts.js@1.7.3/dist/mpegts.min.js"></script> <title>XtremFlow Player Mobile</title>
<style> <link rel="stylesheet" href="xf-player.css">
* { <link rel="preload" href="vendor/hls.min.js" as="script">
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
background: #000;
width: 100%;
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
#player-container {
width: 100%;
height: 100%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
#video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
#loading {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
color: white;
z-index: 20;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
margin-bottom: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Unmute button — shown when iOS plays muted */
#unmute-btn {
display: none;
position: absolute;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
z-index: 30;
background: rgba(0, 0, 0, 0.75);
color: white;
border: 2px solid rgba(255, 255, 255, 0.6);
border-radius: 32px;
padding: 12px 28px;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.5px;
cursor: pointer;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
pointer-events: auto;
}
#unmute-btn:active {
background: rgba(255, 255, 255, 0.2);
}
#error {
display: none;
position: absolute;
color: white;
text-align: center;
padding: 24px;
background: rgba(0, 0, 0, 0.85);
border-radius: 16px;
z-index: 30;
max-width: 80%;
border: 1px solid rgba(255, 255, 255, 0.1);
}
</style>
</head> </head>
<body> <body>
<div id="player-container"> <div id="player-container">
<!-- muted + autoplay: iOS Safari autorise l'autoplay uniquement si muted --> <!-- muted au départ : seul moyen fiable d'obtenir l'autoplay sur iOS -->
<video id="video" autoplay muted playsinline webkit-playsinline preload="auto"></video> <video id="video" autoplay muted playsinline webkit-playsinline preload="auto"></video>
<!-- Bouton son — affiché après démarrage muted sur iOS --> <div id="loading" class="xf-center">
<button id="unmute-btn">🔇 Appuyer pour activer le son</button> <div class="xf-leader">
<div class="xf-leader-cross"></div>
<div id="loading"> </div>
<div class="spinner"></div> <div class="xf-label" id="l-text">Ouverture du flux</div>
<div id="l-text" style="font-size: 14px; font-weight: 500;">Chargement...</div> <div class="xf-hint" id="loading-hint"></div>
</div> </div>
<div id="error"> <button id="unmute-btn" class="xf-sound-btn" type="button">
<div style="font-size: 40px; margin-bottom: 12px;">⚠️</div> <svg viewBox="0 0 24 24">
<div id="err-msg" style="font-weight: 600;">Erreur de lecture</div> <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z" />
<div id="err-sub" style="font-size: 12px; color: rgba(255,255,255,0.6); margin-top: 8px;">Vérifiez votre connexion</div> </svg>
Activer le son
</button>
<div id="error" class="xf-center">
<div class="xf-error-mark">!</div>
<div class="xf-error-title" id="err-msg">Erreur de lecture</div>
<div class="xf-error-sub" id="err-sub">Vérifiez votre connexion</div>
</div> </div>
</div> </div>
<script src="xf-player-core.js"></script>
<script> <script>
const video = document.getElementById('video'); (function () {
const loading = document.getElementById('loading'); var video = document.getElementById('video');
const unmuteBtn = document.getElementById('unmute-btn'); var loading = document.getElementById('loading');
const error = document.getElementById('error'); var loadingText = document.getElementById('l-text');
var loadingHint = document.getElementById('loading-hint');
var unmuteBtn = document.getElementById('unmute-btn');
var errorBox = document.getElementById('error');
const params = new URLSearchParams(window.location.search); var retryCount = 0;
const streamUrl = params.get('url'); var maxRetries = 3;
const streamType = params.get('type') || 'live'; var player = null;
function log(m) { console.log('[MobilePlayer] ' + m); } function showLoading(msg) {
loading.style.display = 'block';
loadingText.textContent = msg || 'Ouverture du flux';
errorBox.style.display = 'none';
}
let retryCount = 0; function hideLoading() {
const maxRetries = 3; loading.style.display = 'none';
loadingHint.textContent = '';
}
function showError(code, msg) { function showError(msg) {
loading.style.display = 'none'; hideLoading();
unmuteBtn.style.display = 'none'; unmuteBtn.style.display = 'none';
error.style.display = 'block'; errorBox.style.display = 'block';
document.getElementById('err-msg').textContent = 'Erreur ' + code; document.getElementById('err-sub').textContent =
document.getElementById('err-sub').textContent = msg || 'Format non supporté ou lien expiré'; msg || 'Format non supporté ou lien expiré';
} }
// Bouton son : user gesture DIRECT dans l'iframe → iOS accepte le unmute // Le geste utilisateur doit avoir lieu DANS l'iframe : iOS
unmuteBtn.addEventListener('click', (e) => { // n'accepte le démutage que dans ce contexte.
e.preventDefault(); unmuteBtn.addEventListener('click', function (e) {
video.muted = false; e.preventDefault();
video.volume = 1.0;
unmuteBtn.style.display = 'none';
log('Unmuted by user');
window.parent.postMessage({ type: 'unmuted' }, '*');
});
video.addEventListener('touchend', () => {
window.parent.postMessage({ type: 'user_activity' }, '*');
});
video.addEventListener('playing', () => {
log('Video playing, muted=' + video.muted);
loading.style.display = 'none';
retryCount = 0;
// Si muted (iOS autoplay), afficher le bouton son
if (video.muted) {
unmuteBtn.style.display = 'block';
// Tenter de démueter automatiquement (fonctionne parfois après interaction)
video.muted = false; video.muted = false;
if (video.muted) { video.volume = 1.0;
// iOS a refusé le unmute automatique — garder le bouton affiché unmuteBtn.style.display = 'none';
log('Unmute blocked by iOS, showing button'); window.parent.postMessage({ type: 'unmuted' }, '*');
} else {
unmuteBtn.style.display = 'none';
log('Auto-unmuted successfully');
}
}
});
video.addEventListener('error', () => {
const code = video.error?.code || '?';
const msg = video.error?.message || '';
log('Video error code=' + code + ' msg=' + msg + ' retry=' + retryCount);
if (retryCount < maxRetries) {
retryCount++;
log('Retry ' + retryCount + '/' + maxRetries + ' in 2s');
loading.style.display = 'flex';
error.style.display = 'none';
document.getElementById('l-text').textContent = 'Reconnexion ' + retryCount + '/' + maxRetries + '...';
setTimeout(startPlayback, 2000);
} else {
showError(code, msg);
}
});
function startPlayback() {
log('startPlayback: ' + streamUrl);
video.src = '';
video.load();
video.src = streamUrl;
video.load();
video.play().catch(e => {
log('play() rejected: ' + e);
// Ne pas montrer erreur ici — laisser l'event error gérer
}); });
}
function initHlsJs() { function buildPlayer() {
log('Using HLS.js'); if (player) { try { player.destroy(); } catch (e) {} }
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
liveSyncDurationCount: 5,
liveMaxLatencyDurationCount: 10,
enableWorker: true,
lowLatencyMode: false,
nudgeOffset: 0.8,
nudgeMaxRetries: 10,
manifestLoadingRetryDelay: 1000,
levelLoadingRetryDelay: 1000
});
hls.loadSource(streamUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
log('HLS manifest parsed');
video.play().catch(() => log('HLS autoplay blocked'));
});
hls.on(Hls.Events.ERROR, (e, d) => {
log('HLS error type=' + d.type + ' fatal=' + d.fatal);
if (d.fatal) {
if (d.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else showError(d.type, 'Erreur HLS fatale');
}
});
}
function initMpegts() { player = new XFPlayer({
log('Using MpegTS'); video: video,
const player = mpegts.createPlayer({ logPrefix: '[MobilePlayer]',
type: 'mpegts', onLoading: showLoading,
isLive: streamType === 'live', onReady: function () {
url: streamUrl hideLoading();
}, { retryCount = 0;
enableWorker: true, // Tentative de démutage auto ; si iOS refuse, on propose le bouton.
stashInitialSize: 512 * 1024, if (video.muted) {
enableStashBuffer: true, video.muted = false;
liveBufferLatencyChasing: false, unmuteBtn.style.display = video.muted ? 'flex' : 'none';
maxStashSize: 30 * 1024 * 1024 }
}); },
player.on(mpegts.Events.ERROR, (type, detail) => { onError: function (msg) {
log('MpegTS error type=' + type); if (retryCount < maxRetries) {
showError(type, JSON.stringify(detail)); retryCount++;
}); showLoading('Reconnexion ' + retryCount + '/' + maxRetries);
player.attachMediaElement(video); setTimeout(buildPlayer, 1500);
player.load(); } else {
player.play().catch(() => log('MpegTS autoplay blocked')); showError(msg);
} }
},
onBlocked: function () {
// Autoplay refusé même en sourdine : le bouton son sert
// aussi de déclencheur de lecture.
hideLoading();
unmuteBtn.style.display = 'flex';
},
onProfileChange: function (name) {
loadingHint.textContent =
name === 'safe' ? 'Réseau instable — buffer maximal'
: 'Réseau instable — buffer élargi';
}
});
function init() { player.start();
if (!streamUrl) { window.xfPlayer = player;
log('No stream URL');
showError('URL', 'Pas de lien de flux');
return;
}
log('Init: ' + streamUrl + ' type=' + streamType);
const isHLS = streamUrl.toLowerCase().includes('m3u8');
const isTS = streamUrl.toLowerCase().endsWith('.ts');
log('isHLS=' + isHLS + ' isTS=' + isTS);
// iOS Safari — HLS natif avec muted autoplay
if (isHLS && video.canPlayType('application/vnd.apple.mpegurl')) {
log('iOS Safari native HLS (muted autoplay)');
startPlayback();
return;
} }
// Android/Chrome — HLS.js video.addEventListener('touchend', function () {
if (isHLS && Hls.isSupported()) { window.parent.postMessage({ type: 'user_activity' }, '*');
initHlsJs(); }, { passive: true });
return;
}
// MpegTS fallback buildPlayer();
if (mpegts.isSupported()) { })();
initMpegts();
return;
}
// Direct fallback
log('Direct fallback');
startPlayback();
}
// Messages depuis Flutter
window.addEventListener('message', (e) => {
const d = e.data;
if (!d) return;
if (d.type === 'play') video.play().catch(() => {});
if (d.type === 'pause') video.pause();
if (d.type === 'set_volume') {
video.muted = d.value === 0;
video.volume = d.value;
if (d.value > 0) unmuteBtn.style.display = 'none';
}
});
init();
</script> </script>
</body> </body>
+2
View File
@@ -0,0 +1,2 @@
!function t(e){var r,i;r=this,i=function(){"use strict";function r(t,e){var r=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),r.push.apply(r,i)}return r}function i(t){for(var e=1;e<arguments.length;e++){var i=null!=arguments[e]?arguments[e]:{};e%2?r(Object(i),!0).forEach((function(e){s(t,e,i[e])})):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(i)):r(Object(i)).forEach((function(e){Object.defineProperty(t,e,Object.getOwnPropertyDescriptor(i,e))}))}return t}function n(t,e){for(var r=0;r<e.length;r++){var i=e[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,m(i.key),i)}}function a(t,e,r){return e&&n(t.prototype,e),r&&n(t,r),Object.defineProperty(t,"prototype",{writable:!1}),t}function s(t,e,r){return(e=m(e))in t?Object.defineProperty(t,e,{value:r,enumerable:!0,configurable:!0,writable:!0}):t[e]=r,t}function o(){return o=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var r=arguments[e];for(var i in r)Object.prototype.hasOwnProperty.call(r,i)&&(t[i]=r[i])}return t},o.apply(this,arguments)}function l(t,e){t.prototype=Object.create(e.prototype),t.prototype.constructor=t,h(t,e)}function u(t){return u=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(t){return t.__proto__||Object.getPrototypeOf(t)},u(t)}function h(t,e){return h=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,e){return t.__proto__=e,t},h(t,e)}function d(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}function c(t,e,r){return c=d()?Reflect.construct.bind():function(t,e,r){var i=[null];i.push.apply(i,e);var n=new(Function.bind.apply(t,i));return r&&h(n,r.prototype),n},c.apply(null,arguments)}function f(t){var e="function"==typeof Map?new Map:void 0;return f=function(t){if(null===t||(r=t,-1===Function.toString.call(r).indexOf("[native code]")))return t;var r;if("function"!=typeof t)throw new TypeError("Super expression must either be null or a function");if(void 0!==e){if(e.has(t))return e.get(t);e.set(t,i)}function i(){return c(t,arguments,u(this).constructor)}return i.prototype=Object.create(t.prototype,{constructor:{value:i,enumerable:!1,writable:!0,configurable:!0}}),h(i,t)},f(t)}function g(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,i=new Array(e);r<e;r++)i[r]=t[r];return i}function v(t,e){var r="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(r)return(r=r.call(t)).next.bind(r);if(Array.isArray(t)||(r=function(t,e){if(t){if("string"==typeof t)return g(t,e);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?g(t,e):void 0}}(t))||e&&t&&"number"==typeof t.length){r&&(t=r);var i=0;return function(){return i>=t.length?{done:!0}:{done:!1,value:t[i++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function m(t){var e=function(t,e){if("object"!=typeof t||null===t)return t;var r=t[Symbol.toPrimitive];if(void 0!==r){var i=r.call(t,e||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:String(e)}function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var y={exports:{}};!function(t,e){var r,i,n,a,s;r=/^(?=((?:[a-zA-Z0-9+\-.]+:)?))\1(?=((?:\/\/[^\/?#]*)?))\2(?=((?:(?:[^?#\/]*\/)*[^;?#\/]*)?))\3((?:;[^?#]*)?)(\?[^#]*)?(#[^]*)?$/,i=/^(?=([^\/?#]*))\1([^]*)$/,n=/(?:\/|^)\.(?=\/)/g,a=/(?:\/|^)\.\.\/(?!\.\.\/)[^\/]*(?=\/)/g,s={buildAbsoluteURL:function(t,e,r){if(r=r||{},t=t.trim(),!(e=e.trim())){if(!r.alwaysNormalize)return t;var n=s.parseURL(t);if(!n)throw new Error("Error trying to parse base URL.");return n.path=s.normalizePath(n.path),s.buildURLFromParts(n)}var a=s.parseURL(e);if(!a)throw new Error("Error trying to parse relative URL.");if(a.scheme)return r.alwaysNormalize?(a.path=s.normalizePath(a.path),s.buildURLFromParts(a)):e;var o=s.parseURL(t);if(!o)throw new Error("Error trying to parse base URL.");if(!o.netLoc&&o.path&&"/"!==o.path[0]){var l=i.exec(o.path);o.netLoc=l[1],o.path=l[2]}o.netLoc&&!o.path&&(o.path="/");var u={scheme:o.scheme,netLoc:a.netLoc,path:null,params:a.params,query:a.query,fragment:a.fragment};if(!a.netLoc&&(u.netLoc=o.netLoc,"/"!==a.path[0]))if(a.path){var h=o.path,d=h.substring(0,h.lastIndexOf("/")+1)+a.path;u.path=s.normalizePath(d)}else u.path=o.path,a.params||(u.params=o.params,a.query||Line truncated
//# sourceMappingURL=hls.min.js.map
+15
View File
@@ -0,0 +1,15 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/mpegts.js@1.7.3/dist/mpegts.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.mpegts=t():e.mpegts=t()}(window,(function(){return function(e){var t={};function i(n){if(t[n])return t[n].exports;var a=t[n]={i:n,l:!1,exports:{}};return e[n].call(a.exports,a,a.exports,i),a.l=!0,a.exports}return i.m=e,i.c=t,i.d=function(e,t,n){i.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,t){if(1&t&&(e=i(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var a in e)i.d(n,a,function(t){return e[t]}.bind(null,a));return n},i.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(t,"a",t),t},i.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},i.p="",i(i.s=14)}([function(e,t,i){"use strict";var n=i(6),a=i.n(n),r=function(){function e(){}return e.e=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","error",n),e.ENABLE_ERROR&&(console.error?console.error(n):console.warn?console.warn(n):console.log(n))},e.i=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","info",n),e.ENABLE_INFO&&(console.info?console.info(n):console.log(n))},e.w=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","warn",n),e.ENABLE_WARN&&(console.warn?console.warn(n):console.log(n))},e.d=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","debug",n),e.ENABLE_DEBUG&&(console.debug?console.debug(n):console.log(n))},e.v=function(t,i){t&&!e.FORCE_GLOBAL_TAG||(t=e.GLOBAL_TAG);var n="["+t+"] > "+i;e.ENABLE_CALLBACK&&e.emitter.emit("log","verbose",n),e.ENABLE_VERBOSE&&console.log(n)},e}();r.GLOBAL_TAG="mpegts.js",r.FORCE_GLOBAL_TAG=!1,r.ENABLE_ERROR=!0,r.ENABLE_INFO=!0,r.ENABLE_WARN=!0,r.ENABLE_DEBUG=!0,r.ENABLE_VERBOSE=!0,r.ENABLE_CALLBACK=!1,r.emitter=new a.a,t.a=r},function(e,t,i){"use strict";t.a={IO_ERROR:"io_error",DEMUX_ERROR:"demux_error",INIT_SEGMENT:"init_segment",MEDIA_SEGMENT:"media_segment",LOADING_COMPLETE:"loading_complete",RECOVERED_EARLY_EOF:"recovered_early_eof",MEDIA_INFO:"media_info",METADATA_ARRIVED:"metadata_arrived",SCRIPTDATA_ARRIVED:"scriptdata_arrived",TIMED_ID3_METADATA_ARRIVED:"timed_id3_metadata_arrived",SMPTE2038_METADATA_ARRIVED:"smpte2038_metadata_arrived",SCTE35_METADATA_ARRIVED:"scte35_metadata_arrived",PES_PRIVATE_DATA_DESCRIPTOR:"pes_private_data_descriptor",PES_PRIVATE_DATA_ARRIVED:"pes_private_data_arrived",STATISTICS_INFO:"statistics_info",RECOMMEND_SEEKPOINT:"recommend_seekpoint"}},function(e,t,i){"use strict";i.d(t,"c",(function(){return a})),i.d(t,"b",(function(){return r})),i.d(t,"a",(function(){return s}));var n=i(3),a={kIdle:0,kConnecting:1,kBuffering:2,kError:3,kComplete:4},r={OK:"OK",EXCEPTION:"Exception",HTTP_STATUS_CODE_INVALID:"HttpStatusCodeInvalid",CONNECTING_TIMEOUT:"ConnectingTimeout",EARLY_EOF:"EarlyEof",UNRECOVERABLE_EARLY_EOF:"UnrecoverableEarlyEof"},s=function(){function e(e){this._type=e||"undefined",this._status=a.kIdle,this._needStash=!1,this._onContentLengthKnown=null,this._onURLRedirect=null,this._onDataArrival=null,this._onError=null,this._onComplete=null}return e.prototype.destroy=function(){this._status=a.kIdle,this._onContentLengthKnown=null,this._onURLRedirect=null,this._onDataArrival=null,this._onError=null,this._onComplete=null},e.prototype.isWorking=function(){return this._status===a.kConnecting||this._status===a.kBuffering},Object.defineProperty(e.prototype,"type",{get:function(){return this._type},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"status",{get:function(){return this._status},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"needStashBuffer",{get:function(){return this._needStash},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onContentLengthKnown",{get:function(){return this._onContentLengthKnown},set:function(e){this._onContentLengthKnown=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onURLRedirect",{get:function(){return this._onURLRedirect},set:function(e){this._onURLRedirect=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onDataArrival",{get:function(){return this._onDataArrival},set:function(e){this._onDataArrival=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onError",{get:function(){return this._onError},set:function(e){this._onError=e},enumerable:!1,configurable:!0}),Object.defineProperty(e.prototype,"onComplete",{get:fuLine truncated
/*!
* @overview es6-promise - a tiny implementation of Promises/A+.
* @copyright Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors (Conversion to ES6 API by Jake Archibald)
* @license Licensed under MIT license
* See https://raw.githubusercontent.com/stefanpenner/es6-promise/master/LICENSE
* @version v4.2.8+1e68dce6
*/var n;n=function(){"use strict";function e(e){return"function"==typeof e}var n=Array.isArray?Array.isArray:function(e){return"[object Array]"===Object.prototype.toString.call(e)},a=0,r=void 0,s=void 0,o=function(e,t){f[a]=e,f[a+1]=t,2===(a+=2)&&(s?s(p):b())},d="undefined"!=typeof window?window:void 0,_=d||{},h=_.MutationObserver||_.WebKitMutationObserver,c="undefined"==typeof self&&void 0!==t&&"[object process]"==={}.toString.call(t),u="undefined"!=typeof Uint8ClampedArray&&"undefined"!=typeof importScripts&&"undefined"!=typeof MessageChannel;function l(){var e=setTimeout;return function(){return e(p,1)}}var f=new Array(1e3);function p(){for(var e=0;e<a;e+=2)(0,f[e])(f[e+1]),f[e]=void 0,f[e+1]=void 0;a=0}var m,g,v,y,b=void 0;function S(e,t){var i=this,n=new this.constructor(R);void 0===n[A]&&P(n);var a=i._state;if(a){var r=arguments[a-1];o((function(){return I(a,n,r,i._result)}))}else C(i,n,e,t);return n}function E(e){if(e&&"object"==typeof e&&e.constructor===this)return e;var t=new this(R);return L(t,e),t}c?b=function(){return t.nextTick(p)}:h?(g=0,v=new h(p),y=document.createTextNode(""),v.observe(y,{characterData:!0}),b=function(){y.data=g=++g%2}):u?((m=new MessageChannel).port1.onmessage=p,b=function(){return m.port2.postMessage(0)}):b=void 0===d?function(){try{var e=Function("return this")().require("vertx");return void 0!==(r=e.runOnLoop||e.runOnContext)?function(){r(p)}:l()}catch(e){return l()}}():l();var A=Math.random().toString(36).substring(2);function R(){}function T(t,i,n){i.constructor===t.constructor&&n===S&&i.constructor.resolve===E?function(e,t){1===t._state?k(e,t._result):2===t._state?D(e,t._result):C(t,void 0,(function(t){return L(e,t)}),(function(t){return D(e,t)}))}(t,i):void 0===n?k(t,i):e(n)?function(e,t,i){o((function(e){var n=!1,a=function(e,t,i,n){try{e.call(t,i,n)}catch(e){return e}}(i,t,(function(i){n||(n=!0,t!==i?L(e,i):k(e,i))}),(function(t){n||(n=!0,D(e,t))}),e._label);!n&&a&&(n=!0,D(e,a))}),e)}(t,i,n):k(t,i)}function L(e,t){if(e===t)D(e,new TypeError("You cannot resolve a promise with itself"));else if(a=typeof(n=t),null===n||"object"!==a&&"function"!==a)k(e,t);else{var i=void 0;try{i=t.then}catch(t){return void D(e,t)}T(e,t,i)}var n,a}function w(e){e._onerror&&e._onerror(e._result),B(e)}function k(e,t){void 0===e._state&&(e._result=t,e._state=1,0!==e._subscribers.length&&o(B,e))}function D(e,t){void 0===e._state&&(e._state=2,e._result=t,o(w,e))}function C(e,t,i,n){var a=e._subscribers,r=a.length;e._onerror=null,a[r]=t,a[r+1]=i,a[r+2]=n,0===r&&e._state&&o(B,e)}function B(e){var t=e._subscribers,i=e._state;if(0!==t.length){for(var n=void 0,a=void 0,r=e._result,s=0;s<t.length;s+=3)n=t[s],a=t[s+i],n?I(i,n,a,r):a(r);e._subscribers.length=0}}function I(t,i,n,a){var r=e(n),s=void 0,o=void 0,d=!0;if(r){try{s=n(a)}catch(e){d=!1,o=e}if(i===s)return void D(i,new TypeError("A promises callback cannot return that same promise."))}else s=a;void 0!==i._state||(r&&d?L(i,s):!1===d?D(i,o):1===t?k(i,s):2===t&&D(i,s))}var O=0;function P(e){e[A]=O++,e._state=void 0,e._result=void 0,e._subscribers=[]}var M=function(){function e(e,t){this._instanceConstructor=e,this.promise=new e(R),this.promise[A]||P(this.promise),n(t)?(this.length=t.length,this._remaining=t.length,this._result=new Array(this.length),0===this.length?k(this.promise,this._result):(this.length=this.length||0,this._enumerate(t),0===this._remaining&&k(this.promise,this._result))):D(this.promise,new Error("Array Methods must be provided an Array"))}return e.prototype._enumerate=function(e){for(var t=0;void 0===this._state&&t<e.length;t++)this._eachEntry(e[t],t)},e.prototype._eachEntry=function(e,t){var i=this._instanceConstructor,n=i.resolve;if(n===E){var a=void 0,r=void 0,s=!1;try{a=e.then}catch(e){s=!0,r=e}if(a===S&&void 0!==e._state)this._settledAt(e._state,t,e._result);else if("function"!=typeof a)this._remaining--,this._result[t]=e;else if(i===x){var o=new i(R);s?D(o,r):T(o,e,a),this._willSettleAt(o,t)}else this._willSettleAt(new i((function(t){return t(e)})),t)}else this._willSettleAt(n(e),t)},e.prototype._settledAt=function(e,t,i){var n=this.promise;void 0===n._state&&(this._remaining--,2===e?D(n,i):this._result[t]=i),0===this._remaining&&k(n,this._result)},e.prototype._willSettleAt=function(e,t){var i=this;C(e,void 0,(function(e){return i._settledAt(1,t,e)}),(function(e){return i._settledAt(2,t,e)}))},e}(),x=function(){function t(e){this[A]=O++,this._result=this._state=void 0,this._subscribers=[],R!==e&&("function"!=typeof e&&function(){throw new TypeError("You must pass a resolver function as the first argument to the promise constructor")}(),this instanceof t?function(e,t){try{t((function(t){L(e,t)}),(function(t){D(e,t)}))}catch(t){D(e,t)}}(this,e):function(){throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function.")}())}return t.prototype.catch=function(e){return this.then(null,e)},t.prototype.finally=function(t){var i=this.constructor;returLine truncated
//# sourceMappingURL=mpegts.js.map
+495
View File
@@ -0,0 +1,495 @@
/* =====================================================================
* XtremFlow — Moteur de lecture partagé
* ---------------------------------------------------------------------
* Objectifs :
* 1. ZAP RAPIDE — premier frame le plus tôt possible.
* 2. ADAPTATIF — si le réseau décroche, le buffer s'élargit tout seul.
* 3. ZÉRO CDN — hls.js / mpegts.js servis en local, chargés à la demande.
*
* Utilisé par player.html, player_lite.html et player_mobile.html.
* ===================================================================== */
(function (global) {
'use strict';
// ------------------------------------------------------------------
// Profils de buffer — escalade automatique en cas de micro-coupures
// ------------------------------------------------------------------
var PROFILES = {
// Démarrage : le plus agressif possible.
fast: {
name: 'fast',
hls: {
maxBufferLength: 24,
maxMaxBufferLength: 60,
liveSyncDurationCount: 2,
liveMaxLatencyDurationCount: 8,
initialLiveManifestSize: 1
},
mpegtsStash: 64 * 1024
},
// 2 coupures : on respire.
balanced: {
name: 'balanced',
hls: {
maxBufferLength: 60,
maxMaxBufferLength: 120,
liveSyncDurationCount: 4,
liveMaxLatencyDurationCount: 14,
initialLiveManifestSize: 2
},
mpegtsStash: 192 * 1024
},
// 4 coupures : priorité absolue à la stabilité.
safe: {
name: 'safe',
hls: {
maxBufferLength: 120,
maxMaxBufferLength: 240,
liveSyncDurationCount: 8,
liveMaxLatencyDurationCount: 24,
initialLiveManifestSize: 3
},
mpegtsStash: 512 * 1024
}
};
var ORDER = ['fast', 'balanced', 'safe'];
// ------------------------------------------------------------------
// Chargement paresseux des libs (aucune requête inutile)
// ------------------------------------------------------------------
var _scriptCache = {};
function loadScript(src) {
if (_scriptCache[src]) return _scriptCache[src];
_scriptCache[src] = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = src;
s.async = true;
s.onload = resolve;
s.onerror = function () { reject(new Error('Échec du chargement : ' + src)); };
document.head.appendChild(s);
});
return _scriptCache[src];
}
/** Ouvre la connexion vers l'hôte du flux avant même de connaître l'URL finale. */
function preconnect(url) {
try {
var origin = new URL(url, location.href).origin;
if (origin === location.origin) return;
['preconnect', 'dns-prefetch'].forEach(function (rel) {
var l = document.createElement('link');
l.rel = rel;
l.href = origin;
l.crossOrigin = 'anonymous';
document.head.appendChild(l);
});
} catch (e) { /* URL relative ou invalide : rien à préconnecter */ }
}
// ------------------------------------------------------------------
// Moteur
// ------------------------------------------------------------------
function XFPlayer(options) {
this.video = options.video;
this.vendorPath = options.vendorPath || 'vendor/';
this.logPrefix = options.logPrefix || '[XFPlayer]';
// Callbacks UI (toutes optionnelles)
this.onLoading = options.onLoading || function () {};
this.onReady = options.onReady || function () {};
this.onError = options.onError || function () {};
this.onBlocked = options.onBlocked || function () {};
this.onProfileChange = options.onProfileChange || function () {};
var p = new URLSearchParams(location.search);
this.params = p;
this.url = options.url || p.get('url');
this.type = options.type || p.get('type') || '';
this.startTime = parseFloat(p.get('t') || '0');
this.injectedDuration = parseFloat(p.get('duration') || '0');
this.isRecording = p.get('is_recording') === 'true';
// Profil de départ : forçable via ?buffer=balanced|safe
var forced = p.get('buffer');
this.profile = PROFILES[forced] ? PROFILES[forced] : PROFILES.fast;
this.profileLocked = !!PROFILES[forced];
this.hls = null;
this.mpegts = null;
this._started = false;
this._stalls = 0;
this._stallTimes = [];
this._destroyed = false;
this._reportedTime = 0;
this._hlsDuration = 0;
}
XFPlayer.prototype.log = function (m) {
if (global.console && console.log) console.log(this.logPrefix + ' ' + m);
};
XFPlayer.prototype.send = function (msg) {
try { global.parent.postMessage(msg, '*'); } catch (e) {}
};
// ---------------- Démarrage ----------------
XFPlayer.prototype.start = function () {
var self = this;
if (!this.url) { this.onError('Aucun flux fourni'); return Promise.resolve(); }
preconnect(this.url);
this._wireVideoEvents();
this._wireParentMessages();
var lower = this.url.toLowerCase();
var isHls = lower.indexOf('.m3u8') !== -1;
var isLive = this.type === 'live';
this.onLoading('Ouverture du flux');
// Safari / iOS : HLS natif = le chemin le plus rapide, aucune lib à charger.
if (isHls && this._canPlayNativeHls()) {
this.log('HLS natif (Safari/iOS)');
return Promise.resolve(this._playDirect(this.url));
}
if (isHls) return this._startHls();
if (isLive || lower.indexOf('.ts') !== -1) return this._startMpegts(isLive || true);
// VOD progressif (mp4, mkv…) : lecture directe.
return Promise.resolve(this._playDirect(this.url));
};
XFPlayer.prototype._canPlayNativeHls = function () {
return this.video.canPlayType('application/vnd.apple.mpegurl') !== '';
};
// ---------------- HLS ----------------
XFPlayer.prototype._hlsConfig = function () {
var prof = this.profile.hls;
return {
enableWorker: true,
lowLatencyMode: false,
// ---- Fenêtre de buffer (pilotée par le profil) ----
maxBufferLength: prof.maxBufferLength,
maxMaxBufferLength: prof.maxMaxBufferLength,
backBufferLength: 30,
maxBufferSize: 30 * 1000 * 1000,
// ---- Démarrage rapide ----
// 1 seul segment suffit pour commencer (au lieu de 3) : ~2 segments
// de latence en moins au zap, soit souvent 4 à 12 secondes gagnées.
initialLiveManifestSize: prof.initialLiveManifestSize,
startFragPrefetch: true, // précharge le 1er fragment pendant le parse
testBandwidth: false, // pas de mesure préalable : on joue tout de suite
// ---- Live ----
liveSyncDurationCount: prof.liveSyncDurationCount,
liveMaxLatencyDurationCount: prof.liveMaxLatencyDurationCount,
maxLiveSyncPlaybackRate: 1.15,
// ---- Robustesse ----
nudgeOffset: 0.6,
nudgeMaxRetries: 20,
abrEwmaDefaultEstimate: 2000000,
manifestLoadingRetryDelay: 500,
levelLoadingRetryDelay: 500,
fragLoadingRetryDelay: 500,
manifestLoadingMaxRetry: 4,
levelLoadingMaxRetry: 4,
fragLoadingMaxRetry: 6
};
};
XFPlayer.prototype._startHls = function () {
var self = this;
return loadScript(this.vendorPath + 'hls.min.js').then(function () {
if (!global.Hls || !global.Hls.isSupported()) {
if (self._canPlayNativeHls()) return self._playDirect(self.url);
self.onError('HLS non supporté par ce navigateur');
return;
}
var Hls = global.Hls;
var hls = new Hls(self._hlsConfig());
self.hls = hls;
global.hlsInstance = hls; // compat : code existant qui inspecte l'instance
hls.loadSource(self.url);
hls.attachMedia(self.video);
hls.on(Hls.Events.MANIFEST_PARSED, function () {
// Démarrage immédiat : on ne sonde plus le buffer toutes les 200 ms.
// Si le réseau ne suit pas, l'escalade de profil s'en chargera.
self._attemptPlay();
});
hls.on(Hls.Events.LEVEL_LOADED, function (e, d) {
if (d.details && d.details.totalduration > self._hlsDuration) {
self._hlsDuration = d.details.totalduration;
global.currentHlsDuration = self._hlsDuration;
}
});
hls.on(Hls.Events.ERROR, function (e, d) {
if (!d.fatal) return;
self.log('Erreur HLS fatale : ' + d.details);
if (d.type === Hls.ErrorTypes.NETWORK_ERROR) {
self._escalate('erreur réseau');
hls.startLoad();
} else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) {
self._escalate('erreur média');
hls.recoverMediaError();
} else {
self.onError('Erreur HLS : ' + d.details);
}
});
}).catch(function (err) {
self.onError(err.message);
});
};
// ---------------- MPEG-TS ----------------
XFPlayer.prototype._startMpegts = function (isLive) {
var self = this;
return loadScript(this.vendorPath + 'mpegts.min.js').then(function () {
if (!global.mpegts || !global.mpegts.isSupported()) {
return self._playDirect(self.url);
}
self._createMpegts(isLive);
}).catch(function (err) {
self.onError(err.message);
});
};
XFPlayer.prototype._createMpegts = function (isLive) {
var self = this;
var mpegts = global.mpegts;
var player = mpegts.createPlayer(
{ type: 'mpegts', isLive: isLive, url: this.url },
{
enableWorker: true,
enableStashBuffer: true,
// Le stash initial conditionne directement le délai avant la 1re image.
// 64 Ko en profil « fast » contre 512 Ko auparavant.
stashInitialSize: this.profile.mpegtsStash,
maxStashSize: 30 * 1024 * 1024,
liveBufferLatencyChasing: false,
lazyLoad: false
}
);
this.mpegts = player;
this._mpegtsIsLive = isLive;
player.on(mpegts.Events.ERROR, function (type, detail) {
self.log('Erreur MPEG-TS : ' + type + ' / ' + detail);
if (self._destroyed) return;
self._escalate('erreur MPEG-TS');
// Recréation propre avec le nouveau profil.
setTimeout(function () {
if (self._destroyed) return;
try {
player.unload();
player.detachMediaElement();
player.destroy();
} catch (e) {}
self._createMpegts(self._mpegtsIsLive);
}, 1200);
});
player.attachMediaElement(this.video);
player.load();
this._attemptPlay();
};
// ---------------- Lecture directe ----------------
XFPlayer.prototype._playDirect = function (url) {
var self = this;
this.video.src = url;
this.video.load();
this._attemptPlay();
this.video.addEventListener('error', function () {
self.onError('Format non supporté');
}, { once: true });
return true;
};
XFPlayer.prototype._attemptPlay = function () {
var self = this;
var p = this.video.play();
if (p && typeof p.catch === 'function') {
p.catch(function () {
// Autoplay refusé (Safari/iOS) : on tente en sourdine, sinon overlay.
self.video.muted = true;
var q = self.video.play();
if (q && typeof q.catch === 'function') {
q.catch(function () { self.onBlocked(); });
}
});
}
};
// ---------------- Escalade adaptative ----------------
/**
* Passe au profil supérieur après des coupures répétées.
* Fenêtre glissante de 60 s : deux stalls rapprochés déclenchent l'escalade,
* des stalls isolés dans le temps ne pénalisent pas la latence.
*/
XFPlayer.prototype._escalate = function (reason) {
if (this.profileLocked || this._destroyed) return;
var idx = ORDER.indexOf(this.profile.name);
if (idx >= ORDER.length - 1) return; // déjà au maximum
var next = PROFILES[ORDER[idx + 1]];
this.profile = next;
this.log('Escalade buffer → ' + next.name + ' (' + reason + ')');
this.onProfileChange(next.name, reason);
// HLS : les fenêtres de buffer sont modifiables à chaud, pas besoin de
// recharger le flux — donc aucune coupure visible pour l'utilisateur.
if (this.hls) {
try {
this.hls.config.maxBufferLength = next.hls.maxBufferLength;
this.hls.config.maxMaxBufferLength = next.hls.maxMaxBufferLength;
this.hls.config.liveSyncDurationCount = next.hls.liveSyncDurationCount;
this.hls.config.liveMaxLatencyDurationCount =
next.hls.liveMaxLatencyDurationCount;
} catch (e) {}
}
// MPEG-TS : le stash est figé à la création — appliqué à la prochaine
// recréation du lecteur (déclenchée par le handler d'erreur).
};
XFPlayer.prototype._noteStall = function () {
if (!this._started) return; // les attentes d'amorçage ne comptent pas
var now = Date.now();
this._stallTimes.push(now);
this._stallTimes = this._stallTimes.filter(function (t) {
return now - t < 60000;
});
if (this._stallTimes.length >= 2) {
this._stallTimes = [];
this._escalate('coupures répétées');
}
};
// ---------------- Événements vidéo & pont Flutter ----------------
XFPlayer.prototype._wireVideoEvents = function () {
var self = this;
var v = this.video;
v.addEventListener('loadedmetadata', function () {
if (self.startTime > 0 && self.startTime < v.duration) {
v.currentTime = self.startTime;
}
});
v.addEventListener('playing', function () {
self._started = true;
self.onReady();
self.send({ type: 'playback_status', status: 'playing' });
});
v.addEventListener('pause', function () {
self.send({ type: 'playback_status', status: 'paused' });
});
v.addEventListener('waiting', function () { self._noteStall(); });
v.addEventListener('stalled', function () { self._noteStall(); });
v.addEventListener('ended', function () {
self.send({
type: 'playback_ended',
duration: isFinite(v.duration) ? v.duration : self.injectedDuration
});
});
// Remontée de position (5 s) — même contrat qu'avant.
this._posTimer = setInterval(function () { self._reportPosition(); }, 5000);
// Activité utilisateur → masquage auto des contrôles côté Flutter.
function activity() { self.send({ type: 'user_activity' }); }
document.addEventListener('mousemove', activity, { passive: true });
document.addEventListener('touchstart', activity, { passive: true });
document.addEventListener('click', activity, { passive: true });
};
XFPlayer.prototype._reportPosition = function () {
var v = this.video;
if (!(v.currentTime > 0) || v.paused || v.readyState <= 2) return;
if (Math.abs(v.currentTime - this._reportedTime) < 1) return;
this._reportedTime = v.currentTime;
var duration = v.duration;
var hlsDur = this._hlsDuration || 0;
if (!isFinite(duration) || isNaN(duration) || duration < 1) {
duration = hlsDur > 0 ? hlsDur : (this.injectedDuration > 0 ? this.injectedDuration : 0);
} else if (hlsDur > duration) {
duration = hlsDur;
} else if (this.injectedDuration > 0 && duration < this.injectedDuration * 0.9) {
duration = this.injectedDuration;
}
this.send({
type: 'playback_position',
currentTime: v.currentTime,
duration: duration
});
};
XFPlayer.prototype._wireParentMessages = function () {
var self = this;
global.addEventListener('message', function (event) {
var d = event.data;
if (!d || !d.type) return;
var v = self.video;
switch (d.type) {
case 'play':
self._attemptPlay();
break;
case 'pause':
v.pause();
break;
case 'seek':
if (isFinite(d.value)) v.currentTime = d.value;
break;
case 'set_volume':
v.volume = Math.max(0, Math.min(1, d.value));
v.muted = v.volume === 0;
break;
case 'unmute':
v.muted = false;
if (v.volume === 0) v.volume = 1;
break;
}
});
};
XFPlayer.prototype.destroy = function () {
this._destroyed = true;
clearInterval(this._posTimer);
try { if (this.hls) this.hls.destroy(); } catch (e) {}
try {
if (this.mpegts) {
this.mpegts.unload();
this.mpegts.detachMediaElement();
this.mpegts.destroy();
}
} catch (e) {}
};
global.XFPlayer = XFPlayer;
global.XFPlayerProfiles = PROFILES;
})(window);
+255
View File
@@ -0,0 +1,255 @@
/* =====================================================================
* XtremFlow — Habillage des lecteurs, thème « Warm Cinema »
* Charbon chaud (#0B0908), crème (#F3E9DF), braise (#D9541F).
*
* PERF : aucun backdrop-filter. Le flou de fond force le compositeur à
* relire le framebuffer à chaque frame — inacceptable par-dessus une vidéo.
* ===================================================================== */
:root {
--xf-ink: #0B0908;
--xf-cream: #F3E9DF;
--xf-cream-dim: #C5B3A5;
--xf-ember: #D9541F;
--xf-ember-light: #FFB68C;
--xf-danger: #E5484D;
--xf-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
background: var(--xf-ink);
width: 100%;
height: 100%;
overflow: hidden;
font-family: var(--xf-ui);
color: var(--xf-cream);
}
#player-container {
width: 100%;
height: 100%;
position: relative;
background: var(--xf-ink);
}
#video {
width: 100%;
height: 100%;
background: var(--xf-ink);
display: block;
}
/* ---------------------------------------------------------------
* Amorce de projection — l'anneau de compte à rebours d'une bobine
* --------------------------------------------------------------- */
.xf-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 20;
}
#loading {
display: none;
}
.xf-leader {
width: 64px;
height: 64px;
margin: 0 auto 20px;
position: relative;
}
/* Anneau fixe, très discret */
.xf-leader::before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid rgba(243, 233, 223, 0.14);
}
/* Balayage braise */
.xf-leader::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: var(--xf-ember);
border-right-color: var(--xf-ember);
animation: xf-sweep 900ms cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
/* Réticule central : croix d'amorce de pellicule */
.xf-leader-cross,
.xf-leader-cross::before {
position: absolute;
background: rgba(243, 233, 223, 0.22);
}
.xf-leader-cross {
top: 50%;
left: 12px;
right: 12px;
height: 1px;
transform: translateY(-0.5px);
}
.xf-leader-cross::before {
content: "";
top: -14px;
bottom: -14px;
left: 50%;
width: 1px;
height: 28px;
transform: translateX(-0.5px);
}
@keyframes xf-sweep {
to {
transform: rotate(360deg);
}
}
.xf-label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--xf-cream-dim);
}
/* Mention discrète quand le buffer s'élargit tout seul */
.xf-hint {
margin-top: 10px;
font-size: 11px;
letter-spacing: 0.08em;
color: rgba(243, 233, 223, 0.42);
min-height: 14px;
}
/* ---------------------------------------------------------------
* Erreur
* --------------------------------------------------------------- */
#error {
display: none;
max-width: 78vw;
}
.xf-error-mark {
width: 52px;
height: 52px;
margin: 0 auto 18px;
border: 2px solid var(--xf-danger);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
line-height: 1;
color: var(--xf-danger);
}
.xf-error-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--xf-cream);
}
.xf-error-sub {
margin-top: 8px;
font-size: 12px;
letter-spacing: 0.04em;
color: rgba(243, 233, 223, 0.5);
}
/* ---------------------------------------------------------------
* Overlay lecture (autoplay bloqué)
* --------------------------------------------------------------- */
#play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 92px;
height: 92px;
border-radius: 50%;
background: rgba(11, 9, 8, 0.82);
border: 2px solid var(--xf-ember);
box-shadow: 0 0 40px rgba(217, 84, 31, 0.35);
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 25;
transition: transform 160ms cubic-bezier(0.16, 1, 0.3, 1);
}
#play-overlay:hover {
transform: translate(-50%, -50%) scale(1.06);
}
#play-overlay:active {
transform: translate(-50%, -50%) scale(0.94);
}
#play-overlay svg {
width: 40px;
height: 40px;
fill: var(--xf-cream);
margin-left: 5px;
}
/* ---------------------------------------------------------------
* Bouton « activer le son » (iOS)
* --------------------------------------------------------------- */
.xf-sound-btn {
position: absolute;
bottom: 26px;
left: 50%;
transform: translateX(-50%);
display: none;
align-items: center;
gap: 9px;
padding: 12px 22px;
border: 1px solid var(--xf-ember);
border-radius: 999px;
background: rgba(11, 9, 8, 0.88);
color: var(--xf-cream);
font-family: var(--xf-ui);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
cursor: pointer;
z-index: 26;
}
.xf-sound-btn svg {
width: 17px;
height: 17px;
fill: var(--xf-ember-light);
}
/* Respect des préférences d'accessibilité */
@media (prefers-reduced-motion: reduce) {
.xf-leader::after {
animation-duration: 2s;
}
#play-overlay {
transition: none;
}
}