mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
Align the Android app with xtremflow on three fronts. EPG: add XmltvService, a streaming XMLTV parser (gunzip-aware, indexed to a -3h/+18h window, JSON disk cache with a 6h TTL). XtreamServiceMobile now falls back through the provider's xmltv.php, a user-supplied URL, then a community guide whenever get_short_epg returns nothing. Channels are matched on epg_channel_id first, then on a normalized name (accents, HD/FHD/4K suffixes, "FR:" and "|FR|" prefixes). A streamId -> Channel index populated by getLiveChannels supplies those keys, so no getShortEPG call site changed. EPG requests are also funnelled through a 3-slot semaphore, as in xtremflow: a 30-tile grid used to fire 30 parallel player_api.php calls, which rate-limited panels answered with errors, emptying the guide for the whole screen. Theme: port the Warm Cinema charter (ember/charcoal/cream, Fraunces + Karla) into app_colors, app_theme and app_decorations. The 21 Apple TV colour names already in use are kept as compatibility aliases. mobile_theme now derives from AppTheme instead of duplicating its 390 lines. Playback lifecycle: stop reacting to AppLifecycleState.inactive, which fires on transient Android interruptions such as pulling down the notification shade and was killing playback. Both engines now release on paused/hidden/detached via _releasePlayback(): stream stopped, decoder released, timers cancelled, wakelock dropped. An _isReleased latch keeps the watchdog and reconnect paths from restarting a stream from the background. Long-run stability: cancel the six player stream subscriptions on dispose, cap demuxer-max-back-bytes at 8MB (mpv defaults to 50MiB, which combined with the 150MB readahead pushed the native heap toward an Android memory kill after a few hours), rebuild on position only while the controls are visible, and dispose the Lite player's focus nodes. Startup: prewarmHost() pre-resolves DNS and opens then closes a bare TCP/TLS socket before playback. No HTTP request is issued, so no provider connection slot is consumed. Capped at 1.5s. Verified: flutter analyze clean on all touched files; 19/19 tests, including a run over a real 46MB/69802-programme dump (517 channels indexed in 1.1s, 5/5 name probes matched); flutter build apk --release succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
494 lines
15 KiB
Dart
494 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'app_colors.dart';
|
|
|
|
/// XtremFlow — "Warm Cinema" theme (ported from the web app).
|
|
///
|
|
/// Typography: **Fraunces** (serif éditorial) for display/headline, **Karla**
|
|
/// (humaniste chaud) for UI and body — very legible at TV distance and on a
|
|
/// phone held at arm's length.
|
|
class AppTheme {
|
|
AppTheme._();
|
|
|
|
// ============ SPACING (8pt base) ============
|
|
static const double spacing2 = 2.0;
|
|
static const double spacing4 = 4.0;
|
|
static const double spacing8 = 8.0;
|
|
static const double spacing12 = 12.0;
|
|
static const double spacing16 = 16.0;
|
|
static const double spacing20 = 20.0;
|
|
static const double spacing24 = 24.0;
|
|
static const double spacing32 = 32.0;
|
|
static const double spacing48 = 48.0;
|
|
|
|
// Semantic aliases
|
|
static const double spacingXs = spacing4;
|
|
static const double spacingSm = spacing8;
|
|
static const double spacingMd = spacing16;
|
|
static const double spacingLg = spacing24;
|
|
static const double spacingXl = spacing32;
|
|
|
|
// ============ RADIUS ============
|
|
static const double radiusNone = 0.0;
|
|
static const double radiusXs = 4.0;
|
|
static const double radiusSm = 6.0;
|
|
static const double radiusMd = 10.0;
|
|
static const double radiusLg = 14.0;
|
|
static const double radiusXl = 20.0;
|
|
static const double radiusFull = 9999.0;
|
|
|
|
// ============ ANIMATION ============
|
|
static const Duration durationXs = Duration(milliseconds: 120);
|
|
static const Duration durationFast = Duration(milliseconds: 180);
|
|
static const Duration durationNormal = Duration(milliseconds: 260);
|
|
static const Duration durationMd = Duration(milliseconds: 260);
|
|
static const Duration durationLg = Duration(milliseconds: 380);
|
|
static const Duration durationXl = Duration(milliseconds: 520);
|
|
|
|
static const Curve curveDefault = Curves.easeOutCubic;
|
|
static const Curve curveSmooth = Curves.easeOutCubic;
|
|
|
|
/// Ouverture de rideau — décélération très longue, "cinéma".
|
|
static const Curve curveCinema = Cubic(0.16, 1.0, 0.3, 1.0);
|
|
|
|
// ============ TYPOGRAPHY HELPERS ============
|
|
|
|
/// Display serif éditorial — titres, héros, chiffres marquants.
|
|
static TextStyle display({
|
|
required double fontSize,
|
|
required FontWeight fontWeight,
|
|
double? letterSpacing,
|
|
double? height,
|
|
Color color = AppColors.onSurface,
|
|
FontStyle? fontStyle,
|
|
}) {
|
|
return GoogleFonts.fraunces(
|
|
fontSize: fontSize,
|
|
fontWeight: fontWeight,
|
|
letterSpacing: letterSpacing,
|
|
height: height,
|
|
color: color,
|
|
fontStyle: fontStyle,
|
|
);
|
|
}
|
|
|
|
/// Corps / UI — humaniste chaud, très lisible à distance.
|
|
static TextStyle body({
|
|
required double fontSize,
|
|
required FontWeight fontWeight,
|
|
double? letterSpacing,
|
|
double? height,
|
|
Color color = AppColors.onSurface,
|
|
}) {
|
|
return GoogleFonts.karla(
|
|
fontSize: fontSize,
|
|
fontWeight: fontWeight,
|
|
letterSpacing: letterSpacing,
|
|
height: height,
|
|
color: color,
|
|
);
|
|
}
|
|
|
|
/// Micro-label capitales espacées — « GÉNÉRIQUE DE FILM ».
|
|
static TextStyle eyebrow({
|
|
double fontSize = 11,
|
|
Color color = AppColors.textTertiary,
|
|
FontWeight fontWeight = FontWeight.w700,
|
|
}) {
|
|
return GoogleFonts.karla(
|
|
fontSize: fontSize,
|
|
fontWeight: fontWeight,
|
|
letterSpacing: 1.6,
|
|
height: 1.1,
|
|
color: color,
|
|
);
|
|
}
|
|
|
|
static TextTheme _textTheme({
|
|
required Color primary,
|
|
required Color secondary,
|
|
}) {
|
|
return TextTheme(
|
|
displayLarge: display(
|
|
fontSize: 44,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -1.2,
|
|
height: 1.03,
|
|
color: primary,
|
|
),
|
|
displayMedium: display(
|
|
fontSize: 34,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.8,
|
|
height: 1.08,
|
|
color: primary,
|
|
),
|
|
displaySmall: display(
|
|
fontSize: 26,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.4,
|
|
height: 1.15,
|
|
color: primary,
|
|
),
|
|
headlineLarge: display(
|
|
fontSize: 28,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.5,
|
|
height: 1.12,
|
|
color: primary,
|
|
),
|
|
headlineMedium: display(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.3,
|
|
height: 1.2,
|
|
color: primary,
|
|
),
|
|
headlineSmall: display(
|
|
fontSize: 19,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.25,
|
|
color: primary,
|
|
),
|
|
titleLarge: body(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
height: 1.3,
|
|
color: primary,
|
|
),
|
|
titleMedium: body(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.35,
|
|
color: primary,
|
|
),
|
|
titleSmall: body(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.35,
|
|
color: primary,
|
|
),
|
|
bodyLarge: body(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w400,
|
|
height: 1.5,
|
|
color: secondary,
|
|
),
|
|
bodyMedium: body(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
height: 1.5,
|
|
color: secondary,
|
|
),
|
|
bodySmall: body(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w400,
|
|
height: 1.45,
|
|
color: secondary,
|
|
),
|
|
labelLarge: body(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.2,
|
|
color: primary,
|
|
),
|
|
labelMedium: body(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.3,
|
|
color: primary,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ============ DARK THEME (principal) ============
|
|
static ThemeData get darkTheme {
|
|
final baseTheme = ThemeData.dark(useMaterial3: true);
|
|
|
|
return baseTheme.copyWith(
|
|
colorScheme: AppColors.darkColorScheme,
|
|
scaffoldBackgroundColor: AppColors.background,
|
|
canvasColor: AppColors.background,
|
|
splashColor: AppColors.primaryContainer.withValues(alpha: 0.12),
|
|
highlightColor: AppColors.primaryContainer.withValues(alpha: 0.08),
|
|
|
|
textTheme: _textTheme(
|
|
primary: AppColors.textPrimary,
|
|
secondary: AppColors.textSecondary,
|
|
),
|
|
|
|
// Curseur crème — un curseur blanc pur jure sur fond chaud.
|
|
textSelectionTheme: const TextSelectionThemeData(
|
|
cursorColor: AppColors.primary,
|
|
selectionColor: Color(0x59D9541F),
|
|
selectionHandleColor: AppColors.primaryContainer,
|
|
),
|
|
|
|
// AppBar: transparente — le dégradé de fond passe au travers.
|
|
appBarTheme: AppBarTheme(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: AppColors.textPrimary,
|
|
elevation: 0,
|
|
centerTitle: true,
|
|
scrolledUnderElevation: 0,
|
|
titleTextStyle: display(
|
|
fontSize: 19,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
),
|
|
|
|
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
|
backgroundColor: AppColors.surfaceContainerLow,
|
|
selectedItemColor: AppColors.primary,
|
|
unselectedItemColor: AppColors.textTertiary,
|
|
selectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w700),
|
|
unselectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w500),
|
|
type: BottomNavigationBarType.fixed,
|
|
elevation: 0,
|
|
),
|
|
|
|
inputDecorationTheme: InputDecorationTheme(
|
|
filled: true,
|
|
fillColor: AppColors.surfaceContainer,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: spacing16,
|
|
vertical: spacing16,
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(radiusMd),
|
|
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(radiusMd),
|
|
borderSide: const BorderSide(color: AppColors.outlineVariant),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(radiusMd),
|
|
borderSide: const BorderSide(
|
|
color: AppColors.primaryContainer,
|
|
width: 2,
|
|
),
|
|
),
|
|
hintStyle: body(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color: AppColors.textTertiary,
|
|
),
|
|
),
|
|
|
|
// Boutons : remplissage braise, texte crème (contraste AA).
|
|
filledButtonTheme: FilledButtonThemeData(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: AppColors.primaryFill,
|
|
foregroundColor: AppColors.onSurface,
|
|
elevation: 0,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: spacing24,
|
|
vertical: spacing16,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(radiusMd),
|
|
),
|
|
textStyle: body(fontSize: 15, fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
|
|
textButtonTheme: TextButtonThemeData(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.primary,
|
|
textStyle: body(fontSize: 14, fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
|
|
cardTheme: CardThemeData(
|
|
color: AppColors.surfaceContainer,
|
|
elevation: 0,
|
|
margin: EdgeInsets.zero,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(radiusLg),
|
|
side: const BorderSide(color: AppColors.glassLevel2Border),
|
|
),
|
|
),
|
|
|
|
dialogTheme: DialogThemeData(
|
|
backgroundColor: AppColors.surfaceContainerHigh,
|
|
elevation: 24,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(radiusXl),
|
|
side: const BorderSide(color: AppColors.glassLevel2Border),
|
|
),
|
|
titleTextStyle: display(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
contentTextStyle: body(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
|
|
sliderTheme: const SliderThemeData(
|
|
activeTrackColor: AppColors.primaryContainer,
|
|
inactiveTrackColor: AppColors.onSurface24,
|
|
thumbColor: AppColors.primary,
|
|
overlayColor: Color(0x29D9541F),
|
|
),
|
|
|
|
progressIndicatorTheme: const ProgressIndicatorThemeData(
|
|
color: AppColors.primaryContainer,
|
|
linearTrackColor: AppColors.onSurface12,
|
|
),
|
|
|
|
switchTheme: SwitchThemeData(
|
|
thumbColor: WidgetStateProperty.resolveWith(
|
|
(states) => states.contains(WidgetState.selected)
|
|
? AppColors.primary
|
|
: AppColors.textTertiary,
|
|
),
|
|
trackColor: WidgetStateProperty.resolveWith(
|
|
(states) => states.contains(WidgetState.selected)
|
|
? AppColors.primaryFill
|
|
: AppColors.surfaceContainerHigh,
|
|
),
|
|
),
|
|
|
|
dividerTheme: const DividerThemeData(
|
|
color: AppColors.onSurface12,
|
|
thickness: 1,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ============ LIGHT THEME (« papier kraft ») ============
|
|
static ThemeData get lightTheme {
|
|
final baseTheme = ThemeData.light(useMaterial3: true);
|
|
|
|
const bgColor = Color(0xFFFBF3EC);
|
|
const surfaceColor = Color(0xFFFFFFFF);
|
|
const primaryColor = Color(0xFFA63B10);
|
|
const textPrimaryColor = Color(0xFF201A16);
|
|
const textSecondaryColor = Color(0xFF52443B);
|
|
const textTertiaryColor = Color(0xFF847369);
|
|
const borderColor = Color(0xFFD6C3B7);
|
|
|
|
return baseTheme.copyWith(
|
|
colorScheme: AppColors.lightColorScheme,
|
|
scaffoldBackgroundColor: bgColor,
|
|
canvasColor: bgColor,
|
|
|
|
textTheme: _textTheme(
|
|
primary: textPrimaryColor,
|
|
secondary: textSecondaryColor,
|
|
),
|
|
|
|
textSelectionTheme: const TextSelectionThemeData(
|
|
cursorColor: primaryColor,
|
|
selectionColor: Color(0x4DA63B10),
|
|
selectionHandleColor: primaryColor,
|
|
),
|
|
|
|
appBarTheme: AppBarTheme(
|
|
backgroundColor: surfaceColor,
|
|
foregroundColor: textPrimaryColor,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 1,
|
|
shadowColor: borderColor,
|
|
centerTitle: true,
|
|
titleTextStyle: display(
|
|
fontSize: 19,
|
|
fontWeight: FontWeight.w600,
|
|
color: textPrimaryColor,
|
|
),
|
|
),
|
|
|
|
bottomNavigationBarTheme: BottomNavigationBarThemeData(
|
|
backgroundColor: surfaceColor,
|
|
selectedItemColor: primaryColor,
|
|
unselectedItemColor: textSecondaryColor,
|
|
selectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w700),
|
|
unselectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w500),
|
|
type: BottomNavigationBarType.fixed,
|
|
elevation: 8,
|
|
),
|
|
|
|
inputDecorationTheme: InputDecorationTheme(
|
|
filled: true,
|
|
fillColor: surfaceColor,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: spacing16,
|
|
vertical: spacing16,
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(radiusMd),
|
|
borderSide: const BorderSide(color: borderColor),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(radiusMd),
|
|
borderSide: const BorderSide(color: borderColor),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(radiusMd),
|
|
borderSide: const BorderSide(color: primaryColor, width: 2),
|
|
),
|
|
hintStyle: body(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color: textTertiaryColor,
|
|
),
|
|
),
|
|
|
|
filledButtonTheme: FilledButtonThemeData(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: primaryColor,
|
|
foregroundColor: Colors.white,
|
|
elevation: 0,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: spacing24,
|
|
vertical: spacing16,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(radiusMd),
|
|
),
|
|
textStyle: body(fontSize: 15, fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
|
|
cardTheme: CardThemeData(
|
|
color: surfaceColor,
|
|
elevation: 0,
|
|
margin: EdgeInsets.zero,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(radiusLg),
|
|
side: const BorderSide(color: borderColor),
|
|
),
|
|
shadowColor: borderColor,
|
|
),
|
|
|
|
dialogTheme: DialogThemeData(
|
|
backgroundColor: surfaceColor,
|
|
elevation: 8,
|
|
shadowColor: borderColor,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(radiusXl),
|
|
side: const BorderSide(color: borderColor),
|
|
),
|
|
titleTextStyle: display(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w600,
|
|
color: textPrimaryColor,
|
|
),
|
|
contentTextStyle: body(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color: textSecondaryColor,
|
|
),
|
|
),
|
|
|
|
dividerTheme: const DividerThemeData(color: borderColor, thickness: 1),
|
|
);
|
|
}
|
|
}
|