feat: XMLTV EPG fallback, Warm Cinema theme, background release

Align the Android app with xtremflow on three fronts.

EPG: add XmltvService, a streaming XMLTV parser (gunzip-aware, indexed to a
-3h/+18h window, JSON disk cache with a 6h TTL). XtreamServiceMobile now falls
back through the provider's xmltv.php, a user-supplied URL, then a community
guide whenever get_short_epg returns nothing. Channels are matched on
epg_channel_id first, then on a normalized name (accents, HD/FHD/4K suffixes,
"FR:" and "|FR|" prefixes). A streamId -> Channel index populated by
getLiveChannels supplies those keys, so no getShortEPG call site changed.
EPG requests are also funnelled through a 3-slot semaphore, as in xtremflow:
a 30-tile grid used to fire 30 parallel player_api.php calls, which rate-limited
panels answered with errors, emptying the guide for the whole screen.

Theme: port the Warm Cinema charter (ember/charcoal/cream, Fraunces + Karla)
into app_colors, app_theme and app_decorations. The 21 Apple TV colour names
already in use are kept as compatibility aliases. mobile_theme now derives from
AppTheme instead of duplicating its 390 lines.

Playback lifecycle: stop reacting to AppLifecycleState.inactive, which fires on
transient Android interruptions such as pulling down the notification shade and
was killing playback. Both engines now release on paused/hidden/detached via
_releasePlayback(): stream stopped, decoder released, timers cancelled, wakelock
dropped. An _isReleased latch keeps the watchdog and reconnect paths from
restarting a stream from the background.

Long-run stability: cancel the six player stream subscriptions on dispose, cap
demuxer-max-back-bytes at 8MB (mpv defaults to 50MiB, which combined with the
150MB readahead pushed the native heap toward an Android memory kill after a few
hours), rebuild on position only while the controls are visible, and dispose the
Lite player's focus nodes.

Startup: prewarmHost() pre-resolves DNS and opens then closes a bare TCP/TLS
socket before playback. No HTTP request is issued, so no provider connection
slot is consumed. Capped at 1.5s.

Verified: flutter analyze clean on all touched files; 19/19 tests, including a
run over a real 46MB/69802-programme dump (517 channels indexed in 1.1s, 5/5
name probes matched); flutter build apk --release succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 19:25:33 +02:00
1 parent 63e77c5bc5
commit 8d74bbb320
15 files changed
+2138 -852

No files matched your search

@@ -18,6 +18,12 @@ class MobileSettings {
final List<String>
deinterlacedChannels; // IDs of channels with forced deinterlacing
/// Custom XMLTV guide URL, tried after the provider's own `xmltv.php`.
final String xmltvUrl;
/// Allow the built-in community XMLTV guides as the last EPG resort.
final bool useCommunityEpg;
const MobileSettings({
this.liveTvKeywords = const [],
this.moviesKeywords = const [],
@@ -30,6 +36,8 @@ class MobileSettings {
this.deinterlace = false,
this.bufferDuration = 0,
this.deinterlacedChannels = const [],
this.xmltvUrl = '',
this.useCommunityEpg = true,
});
MobileSettings copyWith({
@@ -44,6 +52,8 @@ class MobileSettings {
bool? deinterlace,
int? bufferDuration,
List<String>? deinterlacedChannels,
String? xmltvUrl,
bool? useCommunityEpg,
}) {
return MobileSettings(
liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords,
@@ -57,6 +67,8 @@ class MobileSettings {
deinterlace: deinterlace ?? this.deinterlace,
bufferDuration: bufferDuration ?? this.bufferDuration,
deinterlacedChannels: deinterlacedChannels ?? this.deinterlacedChannels,
xmltvUrl: xmltvUrl ?? this.xmltvUrl,
useCommunityEpg: useCommunityEpg ?? this.useCommunityEpg,
);
}
@@ -121,6 +133,9 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?;
final deinterlacedChannels =
_box?.get('deinterlacedChannels', defaultValue: <String>[]) as List?;
final xmltvUrl = _box?.get('xmltvUrl', defaultValue: '') as String?;
final useCommunityEpg =
_box?.get('useCommunityEpg', defaultValue: true) as bool?;
state = MobileSettings(
liveTvKeywords: liveTv?.cast<String>() ?? [],
@@ -134,6 +149,8 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
deinterlace: deinterlace ?? false,
bufferDuration: buffer ?? 0,
deinterlacedChannels: deinterlacedChannels?.cast<String>() ?? [],
xmltvUrl: xmltvUrl ?? '',
useCommunityEpg: useCommunityEpg ?? true,
);
}
@@ -187,6 +204,17 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
_box?.put('bufferDuration', seconds);
}
void setXmltvUrl(String url) {
final trimmed = url.trim();
state = state.copyWith(xmltvUrl: trimmed);
_box?.put('xmltvUrl', trimmed);
}
void toggleCommunityEpg(bool value) {
state = state.copyWith(useCommunityEpg: value);
_box?.put('useCommunityEpg', value);
}
void toggleChannelDeinterlace(String streamId) {
final currentList = List<String>.from(state.deinterlacedChannels);
if (currentList.contains(streamId)) {
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/playlist_config.dart';
import '../../core/models/iptv_models.dart' as model;
import '../../features/iptv/services/xtream_service_mobile.dart';
import 'mobile_settings_providers.dart';
/// Mobile-specific Xtream service provider
import 'package:path_provider/path_provider.dart';
@@ -13,6 +14,23 @@ final mobileXtreamServiceProvider =
final service = XtreamServiceMobile(dir.path);
await service
.setPlaylistAsync(playlist); // Async to resolve DNS before API calls
// EPG fallback configuration follows the settings live, without tearing the
// service (and its caches) down on every unrelated settings change.
void applyEpgSettings(MobileSettings settings) => service.setXmltvConfig(
customUrl: settings.xmltvUrl,
allowPublicFallback: settings.useCommunityEpg,
);
applyEpgSettings(ref.read(mobileSettingsProvider));
ref.listen<MobileSettings>(mobileSettingsProvider, (previous, next) {
if (previous?.xmltvUrl != next.xmltvUrl ||
previous?.useCommunityEpg != next.useCommunityEpg) {
applyEpgSettings(next);
}
});
ref.onDispose(service.dispose);
return service;
});
+63 -373
View File
@@ -1,390 +1,80 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/theme/app_colors.dart';
/// XtremFlow Mobile Theme — Apple TV Grey Glossy Style (dark)
/// + Apple Clean White (light)
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
/// XtremFlow Mobile Theme — "Warm Cinema", phone-scaled.
///
/// Derived from [AppTheme] rather than redefined: the palette, typography and
/// component styling stay in one place, and this layer only shrinks the type
/// scale and tightens paddings for a handset.
class MobileTheme {
MobileTheme._();
// ============ SPACING ============
static const double spacing4 = 4.0;
static const double spacing8 = 8.0;
static const double spacing12 = 12.0;
static const double spacing16 = 16.0;
static const double spacing24 = 24.0;
static const double spacing4 = AppTheme.spacing4;
static const double spacing8 = AppTheme.spacing8;
static const double spacing12 = AppTheme.spacing12;
static const double spacing16 = AppTheme.spacing16;
static const double spacing24 = AppTheme.spacing24;
// ============ RADIUS ============
static const double radiusSm = 8.0;
static const double radiusMd = 12.0;
static const double radiusLg = 16.0;
static const double radiusSm = AppTheme.radiusSm;
static const double radiusMd = AppTheme.radiusMd;
static const double radiusLg = AppTheme.radiusLg;
/// Phone type scale: display sizes come down ~20%, body stays legible.
static TextTheme _mobileTextTheme(TextTheme base) {
TextStyle? shrink(TextStyle? style, double factor) {
if (style?.fontSize == null) return style;
return style!.copyWith(fontSize: style.fontSize! * factor);
}
return base.copyWith(
displayLarge: shrink(base.displayLarge, 0.70),
displayMedium: shrink(base.displayMedium, 0.76),
displaySmall: shrink(base.displaySmall, 0.84),
headlineLarge: shrink(base.headlineLarge, 0.78),
headlineMedium: shrink(base.headlineMedium, 0.86),
headlineSmall: shrink(base.headlineSmall, 0.92),
titleLarge: shrink(base.titleLarge, 0.94),
bodyLarge: shrink(base.bodyLarge, 0.90),
bodyMedium: shrink(base.bodyMedium, 0.92),
);
}
static ThemeData _scaled(ThemeData base) {
return base.copyWith(
textTheme: _mobileTextTheme(base.textTheme),
inputDecorationTheme: base.inputDecorationTheme.copyWith(
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing16,
vertical: spacing12,
),
),
filledButtonTheme: FilledButtonThemeData(
style: base.filledButtonTheme.style?.copyWith(
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: spacing16, vertical: spacing12),
),
),
),
);
}
// ============ DARK THEME (Mobile Main) ============
static ThemeData get darkTheme {
final baseTheme = ThemeData.dark(useMaterial3: true);
static ThemeData get darkTheme => _scaled(AppTheme.darkTheme);
return baseTheme.copyWith(
colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background,
// ============ LIGHT THEME ============
static ThemeData get lightTheme => _scaled(AppTheme.lightTheme);
// Global text selection — white cursor on dark
textSelectionTheme: const TextSelectionThemeData(
cursorColor: Colors.white,
selectionColor: Color(0x4DFFFFFF),
selectionHandleColor: Colors.white,
),
// Typography: San Francisco proxy via Inter — clean and modern
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -1.0,
color: AppColors.textPrimary,
),
displayMedium: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: AppColors.textPrimary,
),
displaySmall: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
headlineMedium: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleLarge: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
bodyLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
bodyMedium: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
labelLarge: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
// AppBar: Transparent — background gradient shows through
appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent,
foregroundColor: AppColors.textPrimary,
elevation: 0,
centerTitle: true,
scrolledUnderElevation: 0,
titleTextStyle: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
// Bottom Navigation Bar: glossy dark surface, Apple Blue selected
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: AppColors.surface,
selectedItemColor: AppColors.appleBlue,
unselectedItemColor: AppColors.textSecondary,
selectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w500,
),
type: BottomNavigationBarType.fixed,
elevation: 0,
),
// Inputs: dark surface fill, white focus border
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surfaceVariant,
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing16,
vertical: spacing12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(
color: AppColors.glassBorderColor,
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(
color: AppColors.glassBorderColor,
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: Colors.white, width: 1.5),
),
hintStyle: GoogleFonts.inter(
color: AppColors.textTertiary,
fontSize: 14,
),
),
// Buttons: Apple Blue fill — modern CTA style
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.appleBlue,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
textStyle: GoogleFonts.inter(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
// Cards: glossy dark surface with subtle glass border
cardTheme: const CardThemeData(
color: AppColors.surfaceVariant,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(radiusMd)),
side: BorderSide(color: AppColors.glassBorderColor, width: 1),
),
),
// Dialogs: glass/dark
dialogTheme: DialogThemeData(
backgroundColor: AppColors.surface,
elevation: 24,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
side: const BorderSide(color: AppColors.glassBorderColor, width: 1),
),
titleTextStyle: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
contentTextStyle: GoogleFonts.inter(
fontSize: 14,
color: AppColors.textSecondary,
),
),
);
}
// ============ LIGHT THEME (Apple Clean White) ============
static ThemeData get lightTheme {
final baseTheme = ThemeData.light(useMaterial3: true);
const bgColor = Color(0xFFEAEAF0);
const surfaceColor = Color(0xFFF2F2F7);
const primaryColor = Color(0xFF007AFF);
const textPrimaryColor = Color(0xFF000000);
const textSecondaryColor = Color(0x993C3C43);
const textTertiaryColor = Color(0xFFC7C7CC);
const borderColor = Color(0x1A000000);
return baseTheme.copyWith(
colorScheme: const ColorScheme.light(
primary: primaryColor,
secondary: Color(0xFF34C759),
surface: surfaceColor,
error: AppColors.error,
onPrimary: Colors.white,
onSecondary: Colors.white,
onSurface: textPrimaryColor,
onError: Colors.white,
brightness: Brightness.light,
),
scaffoldBackgroundColor: bgColor,
textSelectionTheme: const TextSelectionThemeData(
cursorColor: primaryColor,
selectionColor: Color(0x4D007AFF),
selectionHandleColor: primaryColor,
),
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -1.0,
color: textPrimaryColor,
),
displayMedium: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: textPrimaryColor,
),
displaySmall: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
headlineMedium: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
titleLarge: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
bodyLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: textSecondaryColor,
),
bodyMedium: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: textSecondaryColor,
),
labelLarge: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
),
appBarTheme: AppBarTheme(
backgroundColor: surfaceColor,
foregroundColor: textPrimaryColor,
elevation: 0,
scrolledUnderElevation: 1,
shadowColor: borderColor,
centerTitle: true,
titleTextStyle: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: surfaceColor,
selectedItemColor: primaryColor,
unselectedItemColor: textSecondaryColor,
selectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w500,
),
type: BottomNavigationBarType.fixed,
elevation: 8,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceColor,
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing16,
vertical: spacing12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: borderColor, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: borderColor, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
hintStyle: GoogleFonts.inter(
color: textTertiaryColor,
fontSize: 14,
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
textStyle: GoogleFonts.inter(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
cardTheme: const CardThemeData(
color: surfaceColor,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(radiusMd)),
side: BorderSide(color: borderColor, width: 1),
),
shadowColor: borderColor,
),
dialogTheme: DialogThemeData(
backgroundColor: surfaceColor,
elevation: 8,
shadowColor: borderColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
side: const BorderSide(color: borderColor, width: 1),
),
titleTextStyle: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.bold,
color: textPrimaryColor,
),
contentTextStyle: GoogleFonts.inter(
fontSize: 14,
color: textSecondaryColor,
),
),
);
}
/// Returns the appropriate MobileTheme based on the current [BuildContext] brightness.
/// Returns the appropriate MobileTheme based on the current [BuildContext]
/// brightness.
static ThemeData themeOf(BuildContext context) {
final brightness = Theme.of(context).brightness;
return brightness == Brightness.dark ? darkTheme : lightTheme;
}
/// Accent used for active/selected affordances on mobile.
static const Color accent = AppColors.primaryContainer;
}