Files
xtremobile/lib/core/theme/app_decorations.dart
T
MichaelandClaude Opus 5 8d74bbb320 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>
2026-08-11 19:25:33 +02:00

166 lines
5.9 KiB
Dart

import 'package:flutter/material.dart';
import 'app_colors.dart';
/// Theme-aware decoration helpers — "Warm Cinema" charter.
///
/// Depth comes from warm shadows and contrast, never from `BackdropFilter`:
/// a blur layer repaints every frame and is the first thing to drop frames on
/// low-end Android.
class AppDecorations {
AppDecorations._();
// Force Dark mode for the IPTV experience regardless of system theme.
static bool _isDark(BuildContext context) => true;
// ─── Backgrounds ────────────────────────────────────────────────────────────
/// Full-screen gradient background for tab content areas.
static BoxDecoration background(BuildContext context) {
if (_isDark(context)) {
return const BoxDecoration(gradient: AppColors.backgroundGradient);
}
return const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFBF3EC), Color(0xFFEFE2D6)],
stops: [0.0, 1.0],
),
);
}
// ─── Nav Pill ────────────────────────────────────────────────────────────────
/// Floating pill navigation bar background.
static BoxDecoration navPill(BuildContext context) {
if (_isDark(context)) {
return BoxDecoration(
color: AppColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(40),
border: Border.all(color: AppColors.glassLevel2Border, width: 1),
boxShadow: AppColors.lift(),
);
}
return BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(40),
border: Border.all(color: const Color(0x1A201A16), width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x1A201A16),
blurRadius: 16,
offset: Offset(0, 4),
),
],
);
}
// ─── Cards ───────────────────────────────────────────────────────────────────
/// Category / channel card — primary card style.
static BoxDecoration glossyCard(
BuildContext context, {
double radius = 16,
}) {
if (_isDark(context)) {
return BoxDecoration(
gradient: AppColors.cardGradient,
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: AppColors.glassLevel2Border, width: 1),
boxShadow: AppColors.lift(intensity: 0.8),
);
}
return BoxDecoration(
color: const Color(0xFFFBF3EC),
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: const Color(0x1A201A16), width: 1),
boxShadow: const [
BoxShadow(
color: Color(0x14201A16),
blurRadius: 8,
offset: Offset(0, 2),
),
],
);
}
/// Warm highlight overlay — placed on top of a card Stack.
static BoxDecoration glossShimmer(
BuildContext context, {
double radius = 16,
}) {
if (_isDark(context)) {
return BoxDecoration(
gradient: AppColors.cardHighlight,
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
);
}
return BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x0A201A16), Color(0x00201A16)],
stops: [0.0, 0.55],
),
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
);
}
/// Smaller channel card base (top area behind the logo).
static BoxDecoration channelCardBase(BuildContext context) {
if (_isDark(context)) {
return const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.surfaceContainerHigh,
AppColors.surfaceContainerLow,
],
),
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
border: Border(
top: BorderSide(color: AppColors.glassLevel2Border, width: 0.5),
),
);
}
return const BoxDecoration(
color: Color(0xFFEFE2D6),
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
);
}
// ─── Search Bar ──────────────────────────────────────────────────────────────
/// Search / text field container.
static BoxDecoration searchBar(BuildContext context) {
if (_isDark(context)) {
return BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.glassLevel2Border, width: 1),
);
}
return BoxDecoration(
color: const Color(0xFFF3E7DB),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x18201A16), width: 1),
);
}
// ─── Colours ─────────────────────────────────────────────────────────────────
static Color textPrimary(BuildContext context) =>
_isDark(context) ? AppColors.textPrimary : const Color(0xFF201A16);
static Color textSecondary(BuildContext context) =>
_isDark(context) ? AppColors.textSecondary : const Color(0xFF52443B);
static Color iconMuted(BuildContext context) =>
_isDark(context) ? AppColors.onSurface38 : Colors.black26;
static Color divider(BuildContext context) =>
_isDark(context) ? AppColors.onSurface12 : const Color(0x1A201A16);
}