Files
MichaelandClaude Opus 5 1feeaae78a feat(design): Warm Cinema theme + event-driven adaptive players
Replaces the Projector Noir palette with "Warm Cinema" and rebuilds the
web players around a shared, event-driven engine.

Design
- app_colors: warm charcoal (#0B0908), cream (#F3E9DF), ember (#D9541F);
  adds posterScrim/heroScrim gradients, lift()/emberFocus() shadows,
  const cream-alpha tokens replacing Colors.whiteNN, and primaryFill,
  a deeper ember reserved for text-bearing fills so white labels clear
  the 4.5:1 AA threshold that #D9541F alone does not
- app_theme / mobile_theme: Fraunces (display serif) + Karla (body),
  tighter radii, 20+ component themes
- glass_container: opaque layered surfaces replace the blurred glass
- themed_loading_screen restyled; players and boot splash follow

Performance
- drop every BackdropFilter: backdrop blur forced a full framebuffer
  read per frame per card, the main source of scroll jank
- bundle Fraunces/Karla as assets so google_fonts stops fetching from
  fonts.gstatic.com at startup
- bound image decode size on 12 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)
- start on media events instead of polling the buffer every 100-200 ms
- hls.js/mpegts.js loaded on demand, so ~213 KB of mpegts is never
  fetched for HLS streams
- liveSyncDurationCount 10 -> 2, startFragPrefetch on, testBandwidth
  off, MPEG-TS initial stash 512 KB -> 64 KB
- adaptive escalation fast -> balanced -> safe after repeated stalls in
  a 60 s window, applied live for HLS so there is no visible cut

Preserved from the recent streaming work on main: server-side quality
selection, stable player viewId, the turbo direct MPEG-TS path, vendored
library cache headers and the CI/Docker changes. Only the theme layer and
the player front-end were replaced; GlassCard stays in ui_components.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:42:56 +02:00

632 lines
21 KiB
Dart

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
/// XtremFlow — Thème « Warm Cinema »
///
/// Typo : Fraunces (display, serif éditorial) + Karla (corps / UI).
/// Style : charbon chaud, crème, orange brûlé. Profondeur par l'ombre,
/// jamais par le flou — aucun `BackdropFilter` dans tout le thème.
class AppTheme {
AppTheme._();
// ============ ESPACEMENT (rythme 4 / 8) ============
static const double spacingXs = 4.0;
static const double spacingSm = 8.0;
static const double spacingBase = 8.0;
static const double spacingMd = 16.0;
static const double spacingLg = 24.0;
static const double spacingXl = 32.0;
static const double spacing2xl = 48.0;
static const double spacing3xl = 80.0;
// 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 radiusSm = 6.0;
static const double radiusMd = 10.0;
static const double radiusLg = 14.0;
static const double radiusXl = 20.0;
static const double radiusFull = 9999.0;
// ============ ANIMATION ============
// Rythme lent, « fondu de projection ».
static const Duration durationXs = Duration(milliseconds: 120);
static const Duration durationFast = Duration(milliseconds: 180);
static const Duration durationNormal = Duration(milliseconds: 260);
static const Duration durationSm = Duration(milliseconds: 180);
static const Duration durationMd = Duration(milliseconds: 260);
static const Duration durationLg = Duration(milliseconds: 380);
static const Duration durationXl = Duration(milliseconds: 520);
static const Curve curveDefault = Curves.easeOutCubic;
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 FontWeight fontWeight,
double? letterSpacing,
double? height,
Color color = AppColors.onSurface,
}) {
return GoogleFonts.karla(
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: letterSpacing,
height: height,
color: color,
);
}
/// Micro-label capitales espacées — « GÉNÉRIQUE DE FILM ».
static TextStyle eyebrow({
double fontSize = 11,
Color color = AppColors.textTertiary,
FontWeight fontWeight = FontWeight.w700,
}) {
return GoogleFonts.karla(
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: 1.6,
height: 1.1,
color: color,
);
}
// Alias internes historiques
static TextStyle _display({
required double fontSize,
required FontWeight fontWeight,
double? letterSpacing,
double? height,
Color color = AppColors.onSurface,
}) =>
display(
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: letterSpacing,
height: height,
color: color,
);
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 {
final baseTheme = ThemeData.dark(useMaterial3: true);
return baseTheme.copyWith(
colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background,
canvasColor: AppColors.background,
splashColor: AppColors.primaryContainer.withValues(alpha: 0.12),
highlightColor: AppColors.primaryContainer.withValues(alpha: 0.08),
textTheme: TextTheme(
// Display — Fraunces, serrée, autoritaire
displayLarge: _display(
fontSize: 56,
fontWeight: FontWeight.w700,
letterSpacing: -1.4,
height: 1.02,
),
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,
fontWeight: FontWeight.w600,
letterSpacing: -0.6,
height: 1.12,
),
headlineMedium: _display(
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
height: 1.2,
),
headlineSmall: _display(
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.25,
),
titleLarge: _display(
fontSize: 19,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
),
titleMedium: _body(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.1,
),
titleSmall: _body(
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
// Corps — Karla
bodyLarge: _body(
fontSize: 17,
fontWeight: FontWeight.w400,
height: 1.62,
color: AppColors.onSurfaceVariant,
),
bodyMedium: _body(
fontSize: 15,
fontWeight: FontWeight.w400,
height: 1.55,
color: AppColors.onSurfaceVariant,
),
bodySmall: _body(
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.45,
color: AppColors.onSurfaceVariant,
),
// Labels — capitales espacées, esprit générique de film
labelLarge: _body(
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 1.1,
height: 1.2,
),
labelMedium: _body(
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.9,
height: 1.2,
),
labelSmall: _body(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 1.4,
height: 1.2,
color: AppColors.textTertiary,
),
),
// AppBar : plate, sans élévation, le fond fait le travail
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
foregroundColor: AppColors.onSurface,
elevation: 0,
centerTitle: false,
scrolledUnderElevation: 0,
titleTextStyle: _display(
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
// Champs : creusés dans la surface, liseré ember au focus
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surfaceContainerLow,
contentPadding: const EdgeInsets.symmetric(
horizontal: spacingMd,
vertical: spacingMd,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.outlineVariant, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide:
const BorderSide(color: AppColors.primaryContainer, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.error, width: 2),
),
hintStyle: _body(
fontSize: 15,
fontWeight: FontWeight.w400,
color: AppColors.textTertiary,
),
labelStyle: eyebrow(color: AppColors.onSurfaceVariant),
floatingLabelStyle: eyebrow(color: AppColors.primary),
),
// Boutons pleins : ember franc, coins nets
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.primaryFill,
foregroundColor: AppColors.onSurface,
disabledBackgroundColor: AppColors.surfaceContainerHigh,
disabledForegroundColor: AppColors.textTertiary,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
textStyle: _body(
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 1.1,
),
).copyWith(
overlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.pressed)) {
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;
}),
),
),
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(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.onSurface,
side: const BorderSide(color: AppColors.outlineVariant, width: 1),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
textStyle: _body(
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 1.1,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
textStyle: _body(
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
),
),
),
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(
color: AppColors.surfaceContainerLow,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.black,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
side: const BorderSide(color: AppColors.glassLevel1Border, width: 1),
),
),
// Dialogues : bloc opaque flottant
dialogTheme: DialogThemeData(
backgroundColor: AppColors.surfaceContainer,
surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusXl),
side: const BorderSide(color: AppColors.glassLevel2Border, width: 1),
),
titleTextStyle: _display(
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
contentTextStyle: _body(
fontSize: 15,
fontWeight: FontWeight.w400,
height: 1.55,
color: AppColors.onSurfaceVariant,
),
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: AppColors.surfaceContainer,
surfaceTintColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(radiusXl)),
),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.surfaceContainerHighest,
contentTextStyle: _body(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.onSurface,
),
actionTextColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
behavior: SnackBarBehavior.floating,
elevation: 0,
),
// Chips : étiquettes de bobine
chipTheme: ChipThemeData(
backgroundColor: AppColors.surfaceContainerHigh,
deleteIconColor: AppColors.onSurfaceVariant,
disabledColor: AppColors.surfaceContainer,
selectedColor: AppColors.primaryFill,
secondarySelectedColor: AppColors.primaryFill,
checkmarkColor: AppColors.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
side: const BorderSide(color: AppColors.outlineVariant, width: 1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusSm),
),
labelStyle: _body(
fontSize: 12,
fontWeight: FontWeight.w600,
letterSpacing: 0.6,
color: AppColors.onSurfaceVariant,
),
secondaryLabelStyle: _body(
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: AppColors.onSurface,
),
),
// Onglets : soulignement ember épais
tabBarTheme: TabBarThemeData(
labelColor: AppColors.onSurface,
unselectedLabelColor: AppColors.textTertiary,
indicatorColor: AppColors.primaryContainer,
indicatorSize: TabBarIndicatorSize.label,
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,
),
),
),
dividerTheme: const DividerThemeData(
color: AppColors.outlineVariant,
thickness: 1,
space: 1,
),
scrollbarTheme: ScrollbarThemeData(
thumbColor: WidgetStateProperty.all(AppColors.outlineVariant),
trackColor: WidgetStateProperty.all(Colors.transparent),
radius: const Radius.circular(2),
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,
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return AppColors.onSurface;
return AppColors.outline;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.primaryContainer;
}
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),
),
);
}
// ============ THÈME CLAIR (« papier kraft ») ============
static ThemeData get lightTheme {
final scheme = AppColors.lightColorScheme;
return ThemeData.light(useMaterial3: true).copyWith(
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,
),
),
);
}
}