5 Commits
Author SHA1 Message Date
LogiFlow 05ca6f85be Merge pull request #1 from R0m1k3/claude/video-app-theme-latency-remote-xczp28
Thème gris Apple Glass, latence live réduite, correctif audio, mémoire de focus télécommande
2026-08-30 08:28:25 +02:00
Claude 04e9ab5730 ci: build release APK and publish a GitHub Release on main
Adds a Build APK workflow: on every push to main it builds the release
APK with Flutter 3.35.3 and publishes it as a GitHub Release tagged
with the pubspec version (v1.7.0 → xtremobile-v1.7.0.apk). On pull
requests and manual runs the APK is built and uploaded as an artifact
only. Bumps app version to 1.7.0+14 for this release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156exq7fejVrD3HKU3MmS8X
2026-08-28 07:43:21 +00:00
Claude 9b6b7faf4a feat: Apple glass gray theme, lower live latency, audio fix, remote focus memory
Theme — replace the near-black "Warm Cinema" palette with a neutral
"Apple Glass Gray" system: graphite surfaces (#26262C base, lighter
containers), Apple-white text, Apple system blue accent, white glass
borders/highlights. Noticeably less dark overall; light theme moves
from kraft paper to iOS light gray. All AppColors names kept so no
call site changes.

Player latency — live buffering drops from 8s cache / 10s readahead /
150MB to 3s / 5s / 60MB, keeping enough headroom for network jitter
while staying much closer to the live edge.

Audio — the forced audio filter chain
(af=lavrresample,format=channels=stereo:sample_fmts=s16) mixed FFmpeg
and mpv syntax; when it failed to init for a channel's codec, mpv
muted the stream entirely. Replaced by mpv's native
audio-channels=stereo downmix and AO-negotiated output format. Probe
caps raised (2MB/3s) so late-declared audio tracks (AAC-LATM) are
detected without slowing healthy streams.

Remote control — the player OSD now remembers the last focused button
and restores focus to it whenever the OSD reopens (arrows, OK) and
across channel switches, so one press re-zaps instead of five. On
live TV, OK with hidden controls only reopens the OSD instead of
pausing the stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156exq7fejVrD3HKU3MmS8X
2026-08-28 07:41:18 +00:00
MichaelandClaude Opus 5 694b1257b9 fix(theme): remove every hardcoded Apple/Material colour left in the UI
Porting the palette was not enough: the Warm Cinema charter only reached the
widgets that read from AppColors. Nineteen screen and widget files still painted
Apple TV values inline, so the app kept showing blue — most visibly on the
selected navigation pill.

Swept out:
- 0xFF0A84FF (Apple blue) on the selected nav pill, category card icons and the
  "add playlist" button
- 0xFF4FC3F7 and 0xFF1565C0 in the player overlays
- Colors.blueAccent / Colors.blue in dialogs
- Apple greys 1C1C1E / 2C2C2E / 3A3A3C / 8E8E93 -> warm charcoals
- 128 Colors.white* -> the onSurfaceNN cream ladder (a pure white on a warm
  ground reads blue, which is the charter's whole point)
- Colors.red / green / amber / yellow / grey* -> error, success, ratingGold,
  outlineVariant
- pure black ink on light fills -> onPrimaryFixed, a warm near-black
- the white focus glow -> an ember glow

Deliberately left true black: the video surface backgrounds and the scrims over
posters and video, where a warm tint would clash with the picture.

Adds the onPrimaryFixed / primaryFixed / onSurface70 tokens the sweep needed.

Verified: no Material or Apple colour literal remains outside lib/core/theme;
flutter analyze reports no errors; 19/19 tests pass; release APK builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:39:58 +02:00
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
31 changed files with 2499 additions and 1062 deletions

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
# Construit l'APK release et publie une GitHub Release à chaque mise à jour
# de main (nouvelle version de l'app). Sur une pull request ou un lancement
# manuel (workflow_dispatch), l'APK est seulement construit et déposé en
# artifact — aucune release n'est publiée.
name: Build APK
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: 3.35.3
cache: true
- name: Read app version from pubspec.yaml
id: version
run: echo "version=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d+ -f1)" >> "$GITHUB_OUTPUT"
- run: flutter pub get
- name: Build release APK
run: flutter build apk --release
- name: Rename APK with version
run: cp build/app/outputs/flutter-apk/app-release.apk "xtremobile-v${{ steps.version.outputs.version }}.apk"
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: xtremobile-v${{ steps.version.outputs.version }}-apk
path: xtremobile-v${{ steps.version.outputs.version }}.apk
# La release n'est créée que sur main : un même numéro de version met à
# jour la release existante au lieu d'en créer une seconde.
- name: Publish GitHub Release
if: github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.version }}
name: XtremMobile v${{ steps.version.outputs.version }}
files: xtremobile-v${{ steps.version.outputs.version }}.apk
generate_release_notes: true
+252 -123
View File
@@ -1,170 +1,299 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// XtremFlow Apple TV Grey Glossy Palette /// XtremFlow — "Apple Glass Gray" Design System
/// ///
/// Premium Apple TV design language: /// Palette : graphite neutre + blanc cassé + bleu système Apple.
/// - Background: Deep dark grey (#1C1C1E) to black (#000000) gradient /// Inspiration : verre dépoli iOS/visionOS — des gris francs, jamais un noir
/// - Surface cards: Glossy dark grey (#2C2C2E to #3A3A3C) with glass reflections /// pur, pour une interface nettement moins sombre que l'ancien thème.
/// - Primary accent: Bright white (#FFFFFF) and light grey (#EBEBF5) ///
/// - Secondary accent: Apple Blue (#0A84FF) /// Règle de perf : AUCUNE couleur de surface n'est translucide. La profondeur
/// - Borders: Subtle white 8% opacity for glass effect /// vient des ombres et du contraste, pas du flou — pas de `BackdropFilter`,
/// - Glossy highlight: White 10-15% at top fading to transparent /// coûteux à chaque frame sur les Android d'entrée de gamme. L'effet "glass"
/// est simulé par des gris élevés + liserés blancs à faible opacité.
class AppColors { class AppColors {
AppColors._(); AppColors._();
// ============ BASE LEVEL 0 ============
/// Graphite profond — le niveau le plus bas, jamais un noir pur.
static const Color baseLevel0 = Color(0xFF1B1B1F);
// ============ BACKGROUNDS ============ // ============ BACKGROUNDS ============
/// Pure black — infinite depth, OLED-friendly static const Color background = Color(0xFF26262C);
static const Color background = Color(0xFF000000); static const Color onBackground = Color(0xFFF5F5F7);
/// Apple deep dark grey — primary app background (alias) static const Color surface = Color(0xFF26262C);
static const Color backgroundSecondary = Color(0xFF1C1C1E); static const Color surfaceDim = Color(0xFF1F1F24);
static const Color surfaceBright = Color(0xFF54545E);
/// Apple deep dark grey — primary surface // Surface Containers (hiérarchie Material 3, toutes opaques)
static const Color surface = Color(0xFF1C1C1E); static const Color surfaceContainerLowest = Color(0xFF1B1B1F);
static const Color surfaceContainerLow = Color(0xFF2C2C33);
static const Color surfaceContainer = Color(0xFF32323A);
static const Color surfaceContainerHigh = Color(0xFF3B3B44);
static const Color surfaceContainerHighest = Color(0xFF45454F);
static const Color surfaceVariant = Color(0xFF32323A);
/// Mid grey — elevated cards, sheets // ============ SURFACE / ON SURFACE ============
static const Color surfaceVariant = Color(0xFF2C2C2E); static const Color onSurface = Color(0xFFF5F5F7); // blanc Apple
static const Color onSurfaceVariant = Color(0xFFC7C7CE); // gris clair
/// Light grey surface — tertiary elevation // ============ PRIMARY (Bleu système Apple) ============
static const Color surfaceElevated = Color(0xFF3A3A3C); static const Color primary = Color(0xFF8AB9FF); // texte & icônes sur sombre
static const Color onPrimary = Color(0xFF00295C);
static const Color primaryContainer = Color(0xFF0A84FF); // remplissage bleu
static const Color onPrimaryContainer = Color(0xFFEAF2FF);
static const Color inversePrimary = Color(0xFF0066CC);
/// Focus/selected element background /// Bleu profond — réservé aux **remplissages portant du texte**.
static const Color focusColor = Color(0xFFFFFFFF); /// `primaryContainer` (#0A84FF) est trop lumineux : du blanc dessus plafonne
/// sous le seuil AA de 4,5:1. Cette variante passe le seuil tout en restant
/// la même couleur perçue.
static const Color primaryFill = Color(0xFF0A6FDC);
// ============ ACCENTS ============ /// Encre froide — remplace le noir pur pour du texte/des icônes posés sur un
/// Primary: Pure white — focus rings, active labels /// remplissage clair (blanc cassé, bleu clair). Garde le contraste sans le
static const Color primary = Color(0xFFFFFFFF); /// décalage d'un noir pur.
static const Color onPrimaryFixed = Color(0xFF002451);
static const Color primaryFixed = Color(0xFFD6E5FF);
static const Color primaryFixedDim = Color(0xFF8AB9FF);
static const Color onPrimaryFixedVariant = Color(0xFF0A5BB5);
/// Light grey — secondary text / inactive elements // ============ SECONDARY (Argent / gris clair) ============
static const Color accent = Color(0xFFEBEBF5); static const Color secondary = Color(0xFFD1D1D6);
static const Color onSecondary = Color(0xFF303036);
static const Color secondaryContainer = Color(0xFF44444D);
static const Color onSecondaryContainer = Color(0xFFE8E8ED);
/// Apple Blue — CTAs, links, active indicators // ============ TERTIARY (Cyan glacé) ============
static const Color appleBlue = Color(0xFF0A84FF); static const Color tertiary = Color(0xFF7DD3F2);
static const Color onTertiary = Color(0xFF003544);
static const Color tertiaryContainer = Color(0xFF1C4D60);
static const Color onTertiaryContainer = Color(0xFFC8ECFA);
// ============ ERROR ============
static const Color error = Color(0xFFFFB4AB);
static const Color onError = Color(0xFF690005);
static const Color errorContainer = Color(0xFF93000A);
static const Color onErrorContainer = Color(0xFFFFDAD6);
// ============ OUTLINE ============
static const Color outline = Color(0xFF8E8E93);
static const Color outlineVariant = Color(0xFF48484E);
// ============ INVERSE SURFACE ============
static const Color inverseSurface = Color(0xFFF2F2F7);
static const Color inverseOnSurface = Color(0xFF2C2C31);
static const Color surfaceTint = Color(0xFF8AB9FF);
// ============ TEXT / CONTENT ============
static const Color textPrimary = Color(0xFFF5F5F7);
static const Color textSecondary = Color(0xFFC7C7CE);
static const Color textTertiary = Color(0xFF98989F);
// ============ BLANC À OPACITÉ ============
// Blanc Apple (#F5F5F7) décliné en opacités pour rester cohérent partout.
static const Color onSurface06 = Color(0x0FF5F5F7);
static const Color onSurface12 = Color(0x1FF5F5F7);
static const Color onSurface24 = Color(0x3DF5F5F7);
static const Color onSurface38 = Color(0x61F5F5F7);
static const Color onSurface54 = Color(0x8AF5F5F7);
static const Color onSurface70 = Color(0xB3F5F5F7);
// ============ STATUS ============
static const Color success = Color(0xFF32D74B); // vert système
static const Color warning = Color(0xFFFFD60A); // jaune système
static const Color info = Color(0xFF64D2FF); // cyan système
// ============ CATÉGORIES ============
static const Color live = Color(0xFFFF453A); // rouge système
static const Color movies = Color(0xFF0A84FF); // bleu système
static const Color series = Color(0xFFBF5AF2); // violet système
// ============ RATING / PREMIUM ============
static const Color ratingGold = Color(0xFFFFD60A);
static const Color premiumGold = Color(0xFFE0B84C);
// ============ SHIMMER (placeholders de chargement) ============
static const Color shimmer = Color(0xFF32323A);
static const Color shimmerHighlight = Color(0xFF45454F);
// ============ BORDERS / FOCUS ============
static const Color border = outlineVariant;
static const Color focusColor = primaryContainer;
static const Color focusBorder = primaryContainer;
static const Color borderFocused = primaryContainer;
/// Liseré blanc 10 % — définit l'arête d'une surface "verre" sans la faire
/// flotter.
static const Color glassBorderColor = Color(0x1AFFFFFF);
// ============ SURFACES "GLASS" — OPAQUES ============
static const Color glassLevel1Bg = Color(0xFF2C2C33);
static const Color glassLevel1Border = Color(0x1AFFFFFF); // blanc 10 %
static const Color glassLevel2Bg = Color(0xFF32323A);
static const Color glassLevel2Border = Color(0x26FFFFFF); // blanc 15 %
static const Color glassLevel2InnerGlow = Color(0x3D0A84FF); // bleu 24 %
static const Color glassBackground = glassLevel2Bg;
static const Color glassBorder = glassLevel2Border;
// ============ GRADIENTS ============ // ============ GRADIENTS ============
/// Bleu système : clair vers profond.
/// Apple TV background: deep dark top → pure black bottom static const LinearGradient primaryGradient = LinearGradient(
static const LinearGradient backgroundGradient = LinearGradient( colors: [Color(0xFF4DA2FF), Color(0xFF0A6FDC)],
begin: Alignment.topCenter, begin: Alignment.topLeft,
end: Alignment.bottomCenter, end: Alignment.bottomRight,
colors: [
Color(0xFF1C1C1E), // Deep dark grey
Color(0xFF000000), // Pure black
],
stops: [0.0, 1.0],
); );
/// Legacy alias for backward compatibility /// Fond "verre dépoli" : gris clair en haut, graphite en bas.
static const RadialGradient appleTvGradient = RadialGradient( static const LinearGradient backgroundGradient = LinearGradient(
colors: [Color(0xFF303038), Color(0xFF1F1F24)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
/// Halo doux depuis le centre de l'écran.
static const RadialGradient projectorGradient = RadialGradient(
center: Alignment.center, center: Alignment.center,
radius: 1.5, radius: 1.5,
colors: [ colors: [Color(0xFF303038), Color(0xFF1F1F24)],
Color(0xFF1A1A1C), // Slightly lighter than pure black
Color(0xFF000000), // Pure black edge
],
stops: [0.0, 1.0], stops: [0.0, 1.0],
); );
/// Glossy card gradient: bright highlight at top fading to dark base /// Carte : arête éclairée en haut, matière grise en bas.
static const LinearGradient glossyCardGradient = LinearGradient( static const LinearGradient cardGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF45454F), Color(0xFF2C2C33)],
stops: [0.0, 1.0],
);
/// Reflet posé sur le haut d'une carte — l'effet "verre".
static const LinearGradient cardHighlight = LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [Color(0x29FFFFFF), Color(0x00FFFFFF)],
Color(0xFF484848), // Lighter top for gloss highlight stops: [0.0, 0.5],
Color(0xFF2C2C2E), // Dark mid );
Color(0xFF1C1C1E), // Deeper bottom
], /// Voile d'affiche : lecture du texte par-dessus une jaquette.
static const LinearGradient posterScrim = LinearGradient(
colors: [Color(0x001F1F24), Color(0xCC1F1F24), Color(0xFF1F1F24)],
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(0xF21F1F24), Color(0x991F1F24), Color(0x001F1F24)],
stops: [0.0, 0.45, 1.0], stops: [0.0, 0.45, 1.0],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
); );
/// Glossy inner highlight (white shimmer layer, overlaid on top of card) // ============ GLOW / OMBRES ============
static const LinearGradient glossyHighlight = LinearGradient( static Color glowPrimary(double opacity) =>
begin: Alignment.topCenter, const Color(0xFF0A84FF).withValues(alpha: opacity);
end: Alignment.bottomCenter, static Color glowPrimaryDim(double opacity) =>
colors: [ const Color(0xFF8AB9FF).withValues(alpha: opacity);
Color(0x26FFFFFF), // White ~15% at top
Color(0x00FFFFFF), // Transparent at bottom
],
stops: [0.0, 0.5],
);
/// Card glossy gradient (Apple TV spec alias — dark base) /// Ombre portée douce — moins dense qu'avant : le fond est plus clair.
static const LinearGradient cardGlossyGradient = LinearGradient( static List<BoxShadow> lift({double intensity = 1.0}) => [
begin: Alignment.topCenter, BoxShadow(
end: Alignment.bottomCenter, color: const Color(0xFF000000).withValues(alpha: 0.40 * intensity),
colors: [Color(0xFF48484A), Color(0xFF2C2C2E)], blurRadius: 28 * intensity,
); spreadRadius: -8,
offset: Offset(0, 12 * intensity),
),
];
/// Card glossy highlight (Apple TV spec alias — top-left shimmer) /// Halo bleu pour l'élément focalisé (navigation TV / télécommande).
static const LinearGradient cardGlossyHighlight = LinearGradient( /// Le nom historique `emberFocus` est conservé pour les sites d'appel.
begin: Alignment.topLeft, static List<BoxShadow> emberFocus({double intensity = 1.0}) => [
end: Alignment.bottomRight, BoxShadow(
colors: [Color(0x26FFFFFF), Color(0x00FFFFFF)], color: const Color(0xFF0A84FF).withValues(alpha: 0.45 * intensity),
stops: [0.0, 0.5], blurRadius: 32 * intensity,
); spreadRadius: -4,
),
BoxShadow(
color: const Color(0xFF000000).withValues(alpha: 0.45),
blurRadius: 24,
spreadRadius: -10,
offset: const Offset(0, 14),
),
];
/// Primary silver gradient (used for buttons/badges) // ============ ALIAS DE COMPATIBILITÉ ============
static const LinearGradient primaryGradient = LinearGradient( // Conservés pour ne pas casser les sites d'appel.
colors: [Color(0xFFFFFFFF), Color(0xFFCCCCCC)], static const Color backgroundSecondary = surfaceContainerLow;
begin: Alignment.topLeft, static const Color surfaceElevated = surfaceContainerHigh;
end: Alignment.bottomRight, static const Color accent = secondary;
);
// ============ TEXT ============ /// Accent bleu Apple portant du texte blanc (contraste AA).
/// Pure white — headings, active labels static const Color appleBlue = primaryFill;
static const Color textPrimary = Color(0xFFFFFFFF);
/// Light grey 60% — secondary info, captions static const RadialGradient appleTvGradient = projectorGradient;
static const Color textSecondary = Color(0x99EBEBF5); // #EBEBF5 @ 60% static const LinearGradient glossyCardGradient = cardGradient;
static const LinearGradient glossyHighlight = cardHighlight;
static const LinearGradient cardGlossyGradient = cardGradient;
static const LinearGradient cardGlossyHighlight = cardHighlight;
/// Darker grey — hints, placeholders // ============ COLOR SCHEMES ============
static const Color textTertiary = Color(0xFF636366);
// ============ FUNCTIONAL COLORS ============
static const Color success = Color(0xFF30D158); // Apple Green
static const Color warning = Color(0xFFFF9F0A); // Apple Orange
static const Color error = Color(0xFFFF453A); // Apple Red
static const Color live = Color(0xFFFF453A); // Apple Red (live badge)
// ============ BORDERS ============
/// Invisible border — unfocused state
static const Color border = Color(0xFF2C2C2E);
/// Glass border: white at 8% opacity for subtle edge definition
static const Color glassBorderColor = Color(0x14FFFFFF); // 8% white
/// Focus border: pure white ring
static const Color focusBorder = Color(0xFFFFFFFF);
/// Alias: focused border — pure white (Apple TV spec)
static const Color borderFocused = Color(0xFFFFFFFF);
// ============ GLASSMORPHISM ============
static final Color glassBackground = const Color(0xFF1E1E1E).withValues(alpha: 0.75);
static final Color glassBorder = const Color(0xFFFFFFFF).withValues(alpha: 0.08);
// ============ THEME SCHEMES ============
static ColorScheme get darkColorScheme => const ColorScheme.dark( static ColorScheme get darkColorScheme => const ColorScheme.dark(
primary: appleBlue, primary: primary,
secondary: accent, onPrimary: onPrimary,
surface: surface, primaryContainer: primaryContainer,
onPrimaryContainer: onPrimaryContainer,
secondary: secondary,
onSecondary: onSecondary,
secondaryContainer: secondaryContainer,
onSecondaryContainer: onSecondaryContainer,
tertiary: tertiary,
onTertiary: onTertiary,
tertiaryContainer: tertiaryContainer,
onTertiaryContainer: onTertiaryContainer,
error: error, error: error,
onPrimary: Colors.white, onError: onError,
onSecondary: Colors.black, errorContainer: errorContainer,
onSurface: textPrimary, onErrorContainer: onErrorContainer,
onError: Colors.white, surface: surface,
onSurface: onSurface,
onSurfaceVariant: onSurfaceVariant,
surfaceContainerLowest: surfaceContainerLowest,
surfaceContainerLow: surfaceContainerLow,
surfaceContainer: surfaceContainer,
surfaceContainerHigh: surfaceContainerHigh,
surfaceContainerHighest: surfaceContainerHighest,
surfaceTint: surfaceTint,
outline: outline,
outlineVariant: outlineVariant,
inverseSurface: inverseSurface,
inversePrimary: inversePrimary,
brightness: Brightness.dark, brightness: Brightness.dark,
); );
/// Thème clair : gris système iOS, même famille neutre.
static ColorScheme get lightColorScheme => const ColorScheme.light( static ColorScheme get lightColorScheme => const ColorScheme.light(
primary: Colors.black, primary: Color(0xFF0066CC),
secondary: Colors.grey,
surface: Color(0xFFF2F2F7),
error: error,
onPrimary: Colors.white, onPrimary: Colors.white,
primaryContainer: Color(0xFFD6E5FF),
onPrimaryContainer: Color(0xFF002451),
secondary: Color(0xFF54545E),
onSecondary: Colors.white, onSecondary: Colors.white,
onSurface: Colors.black, secondaryContainer: Color(0xFFE3E3E8),
onSecondaryContainer: Color(0xFF1B1B1F),
tertiary: Color(0xFF00658E),
onTertiary: Colors.white,
error: Color(0xFFB3261E),
onError: Colors.white, onError: Colors.white,
errorContainer: Color(0xFFFFDAD4),
onErrorContainer: Color(0xFF410E05),
surface: Color(0xFFF2F2F7),
onSurface: Color(0xFF1C1C1E),
onSurfaceVariant: Color(0xFF48484E),
outline: Color(0xFF8E8E93),
outlineVariant: Color(0xFFD1D1D6),
brightness: Brightness.light, brightness: Brightness.light,
); );
} }
+42 -72
View File
@@ -1,12 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Theme-aware decoration helpers. import 'app_colors.dart';
/// Returns dark (Apple TV glossy) or light (iOS clean white) styles
/// based on current [BuildContext] brightness. /// 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 { class AppDecorations {
AppDecorations._(); AppDecorations._();
// Force Dark/Glossy mode for IPTV experience regardless of system theme // Force Dark mode for the IPTV experience regardless of system theme.
static bool _isDark(BuildContext context) => true; static bool _isDark(BuildContext context) => true;
// ─── Backgrounds ──────────────────────────────────────────────────────────── // ─── Backgrounds ────────────────────────────────────────────────────────────
@@ -14,20 +18,13 @@ class AppDecorations {
/// Full-screen gradient background for tab content areas. /// Full-screen gradient background for tab content areas.
static BoxDecoration background(BuildContext context) { static BoxDecoration background(BuildContext context) {
if (_isDark(context)) { if (_isDark(context)) {
return const BoxDecoration( return const BoxDecoration(gradient: AppColors.backgroundGradient);
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF1C1C1E), Color(0xFF000000)],
stops: [0.0, 1.0],
),
);
} }
return const BoxDecoration( return const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Color(0xFFEAEAF0), Color(0xFFD8D8E0)], colors: [Color(0xFFFBF3EC), Color(0xFFEFE2D6)],
stops: [0.0, 1.0], stops: [0.0, 1.0],
), ),
); );
@@ -39,28 +36,20 @@ class AppDecorations {
static BoxDecoration navPill(BuildContext context) { static BoxDecoration navPill(BuildContext context) {
if (_isDark(context)) { if (_isDark(context)) {
return BoxDecoration( return BoxDecoration(
color: const Color(0xFF1C1C1E), color: AppColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(40), borderRadius: BorderRadius.circular(40),
border: Border.all(color: const Color(0x20FFFFFF), width: 1), border: Border.all(color: AppColors.glassLevel2Border, width: 1),
boxShadow: const [ boxShadow: AppColors.lift(),
BoxShadow(
color: Color(0x66000000),
blurRadius: 20,
spreadRadius: 0,
offset: Offset(0, 6),
),
],
); );
} }
return BoxDecoration( return BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(40), borderRadius: BorderRadius.circular(40),
border: Border.all(color: const Color(0x1A000000), width: 1), border: Border.all(color: const Color(0x1A201A16), width: 1),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Color(0x1A000000), color: Color(0x1A201A16),
blurRadius: 16, blurRadius: 16,
spreadRadius: 0,
offset: Offset(0, 4), offset: Offset(0, 4),
), ),
], ],
@@ -69,59 +58,41 @@ class AppDecorations {
// ─── Cards ─────────────────────────────────────────────────────────────────── // ─── Cards ───────────────────────────────────────────────────────────────────
/// Glossy category / channel card — primary card style. /// Category / channel card — primary card style.
static BoxDecoration glossyCard( static BoxDecoration glossyCard(
BuildContext context, { BuildContext context, {
double radius = 16, double radius = 16,
}) { }) {
if (_isDark(context)) { if (_isDark(context)) {
return BoxDecoration( return BoxDecoration(
gradient: const LinearGradient( gradient: AppColors.cardGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF4A4A4E), Color(0xFF1C1C1E)],
stops: [0.0, 1.0],
),
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
border: Border.all(color: const Color(0x28FFFFFF), width: 1), border: Border.all(color: AppColors.glassLevel2Border, width: 1),
boxShadow: const [ boxShadow: AppColors.lift(intensity: 0.8),
BoxShadow(
color: Color(0xCC000000),
blurRadius: 16,
spreadRadius: 2,
offset: Offset(0, 4),
),
],
); );
} }
return BoxDecoration( return BoxDecoration(
color: const Color(0xFFF2F2F7), color: const Color(0xFFFBF3EC),
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
border: Border.all(color: const Color(0x1A000000), width: 1), border: Border.all(color: const Color(0x1A201A16), width: 1),
boxShadow: const [ boxShadow: const [
BoxShadow( BoxShadow(
color: Color(0x14000000), color: Color(0x14201A16),
blurRadius: 8, blurRadius: 8,
spreadRadius: 0,
offset: Offset(0, 2), offset: Offset(0, 2),
), ),
], ],
); );
} }
/// Gloss shimmer overlay — placed on top-left of a card Stack. /// Warm highlight overlay — placed on top of a card Stack.
static BoxDecoration glossShimmer( static BoxDecoration glossShimmer(
BuildContext context, { BuildContext context, {
double radius = 16, double radius = 16,
}) { }) {
if (_isDark(context)) { if (_isDark(context)) {
return BoxDecoration( return BoxDecoration(
gradient: const LinearGradient( gradient: AppColors.cardHighlight,
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x45FFFFFF), Color(0x00FFFFFF)],
stops: [0.0, 0.5],
),
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)), borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
); );
} }
@@ -129,7 +100,7 @@ class AppDecorations {
gradient: const LinearGradient( gradient: const LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Color(0x0A000000), Color(0x00000000)], colors: [Color(0x0A201A16), Color(0x00201A16)],
stops: [0.0, 0.55], stops: [0.0, 0.55],
), ),
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)), borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
@@ -139,20 +110,23 @@ class AppDecorations {
/// Smaller channel card base (top area behind the logo). /// Smaller channel card base (top area behind the logo).
static BoxDecoration channelCardBase(BuildContext context) { static BoxDecoration channelCardBase(BuildContext context) {
if (_isDark(context)) { if (_isDark(context)) {
return BoxDecoration( return const BoxDecoration(
gradient: const LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Color(0xFF2C2C2E), Color(0xFF1C1C1E)], colors: [
AppColors.surfaceContainerHigh,
AppColors.surfaceContainerLow,
],
), ),
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
border: Border( border: Border(
top: BorderSide(color: Colors.white.withOpacity(0.12), width: 0.5), top: BorderSide(color: AppColors.glassLevel2Border, width: 0.5),
), ),
); );
} }
return const BoxDecoration( return const BoxDecoration(
color: Color(0xFFE4E4EC), color: Color(0xFFEFE2D6),
borderRadius: BorderRadius.vertical(top: Radius.circular(12)), borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
); );
} }
@@ -163,33 +137,29 @@ class AppDecorations {
static BoxDecoration searchBar(BuildContext context) { static BoxDecoration searchBar(BuildContext context) {
if (_isDark(context)) { if (_isDark(context)) {
return BoxDecoration( return BoxDecoration(
color: const Color(0xFF2C2C2E), color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x18FFFFFF), width: 1), border: Border.all(color: AppColors.glassLevel2Border, width: 1),
); );
} }
return BoxDecoration( return BoxDecoration(
color: const Color(0xFFEEEEF4), color: const Color(0xFFF3E7DB),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x18000000), width: 1), border: Border.all(color: const Color(0x18201A16), width: 1),
); );
} }
// ─── Colours ───────────────────────────────────────────────────────────────── // ─── Colours ─────────────────────────────────────────────────────────────────
static Color textPrimary(BuildContext context) => static Color textPrimary(BuildContext context) =>
_isDark(context) ? Colors.white : const Color(0xFF000000); _isDark(context) ? AppColors.textPrimary : const Color(0xFF201A16);
static Color textSecondary(BuildContext context) => static Color textSecondary(BuildContext context) =>
_isDark(context) _isDark(context) ? AppColors.textSecondary : const Color(0xFF52443B);
? const Color(0x99EBEBF5)
: const Color(0x993C3C43);
static Color iconMuted(BuildContext context) => static Color iconMuted(BuildContext context) =>
_isDark(context) ? Colors.white38 : Colors.black26; _isDark(context) ? AppColors.onSurface38 : Colors.black26;
static Color divider(BuildContext context) => static Color divider(BuildContext context) =>
_isDark(context) _isDark(context) ? AppColors.onSurface12 : const Color(0x1A201A16);
? const Color(0x1FFFFFFF)
: const Color(0x1A000000);
} }
+339 -214
View File
@@ -2,266 +2,393 @@ 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 Apple TV Theme /// XtremFlow — "Apple Glass Gray" theme.
/// ///
/// Focus-driven, immersive, minimal. /// 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 { class AppTheme {
AppTheme._(); AppTheme._();
// ============ SPACING ============ // ============ SPACING (8pt base) ============
static const double spacing2 = 2.0;
static const double spacing4 = 4.0; static const double spacing4 = 4.0;
static const double spacing8 = 8.0; static const double spacing8 = 8.0;
static const double spacing12 = 12.0; static const double spacing12 = 12.0;
static const double spacing16 = 16.0; static const double spacing16 = 16.0;
static const double spacing20 = 20.0;
static const double spacing24 = 24.0; static const double spacing24 = 24.0;
static const double spacing32 = 32.0; static const double spacing32 = 32.0;
static const double spacing48 = 48.0; // Larger spacing for TV feel 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 ============ // ============ RADIUS ============
// ============ RADIUS ============ static const double radiusNone = 0.0;
static const double radiusSm = 8.0; static const double radiusXs = 4.0;
static const double radiusMd = 12.0; static const double radiusSm = 6.0;
static const double radiusLg = 20.0; static const double radiusMd = 10.0;
static const double radiusXl = 32.0; static const double radiusLg = 14.0;
static const double radiusXl = 20.0;
static const double radiusFull = 9999.0;
// ============ ANIMATION ============ // ============ ANIMATION ============
static const Duration durationFast = Duration(milliseconds: 200); static const Duration durationXs = Duration(milliseconds: 120);
static const Duration durationNormal = Duration(milliseconds: 300); static const Duration durationFast = Duration(milliseconds: 180);
static const Curve curveDefault = static const Duration durationNormal = Duration(milliseconds: 260);
Curves.fastOutSlowIn; // Apple-like snappy curve static const Duration durationMd = Duration(milliseconds: 260);
static const Duration durationLg = Duration(milliseconds: 380);
static const Duration durationXl = Duration(milliseconds: 520);
// ============ DARK THEME (TV Main) ============ 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 { 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),
textTheme: _textTheme(
primary: AppColors.textPrimary,
secondary: AppColors.textSecondary,
),
// Global Text Selection Theme (Fixes black cursor issues)
textSelectionTheme: const TextSelectionThemeData( textSelectionTheme: const TextSelectionThemeData(
cursorColor: Colors.white, cursorColor: AppColors.primary,
selectionColor: Color(0x4DFFFFFF), // 30% White selectionColor: Color(0x590A84FF),
selectionHandleColor: Colors.white, selectionHandleColor: AppColors.primaryContainer,
), ),
// Typography: San Francisco style (using Inter as proxy) // AppBar: transparente — le dégradé de fond passe au travers.
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(
fontSize: 56,
fontWeight: FontWeight.w700,
letterSpacing: -1.0,
color: AppColors.textPrimary,
),
displayMedium: GoogleFonts.inter(
fontSize: 48,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: AppColors.textPrimary,
),
displaySmall: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
headlineMedium: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleLarge: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
bodyLarge: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
bodyMedium: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
labelLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
// AppBar: Transparent / Glass
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: AppColors.textPrimary, foregroundColor: AppColors.textPrimary,
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
titleTextStyle: GoogleFonts.inter( titleTextStyle: display(
fontSize: 17, fontSize: 19,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
), ),
// Inputs: Dark, minimal, no borders unless focused 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( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: AppColors.surface, fillColor: AppColors.surfaceContainer,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: spacing24, horizontal: spacing16,
vertical: spacing16, vertical: spacing16,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none, borderSide: const BorderSide(color: AppColors.outlineVariant),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none, borderSide: const BorderSide(color: AppColors.outlineVariant),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.focusColor, width: 2), borderSide: const BorderSide(
color: AppColors.primaryContainer,
width: 2,
),
),
hintStyle: body(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textTertiary,
), ),
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
), ),
// Buttons: White pill or minimal text // Boutons : remplissage braise, texte crème (contraste AA).
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.focusColor, // White buttons backgroundColor: AppColors.primaryFill,
foregroundColor: Colors.black, // Black text foregroundColor: AppColors.onSurface,
elevation: 0, elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(
horizontal: spacing24,
vertical: spacing16,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
), ),
textStyle: textStyle: body(fontSize: 15, fontWeight: FontWeight.w700),
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
).copyWith(
overlayColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.hovered)) {
return Colors.black.withOpacity(0.1);
}
if (states.contains(WidgetState.pressed)) {
return Colors.black.withOpacity(0.2);
}
return null;
}),
), ),
), ),
// Cards: Transparent by default (content defines look) textButtonTheme: TextButtonThemeData(
// cardTheme: const CardTheme( style: TextButton.styleFrom(
// color: AppColors.surface, foregroundColor: AppColors.primary,
// elevation: 0, textStyle: body(fontSize: 14, fontWeight: FontWeight.w700),
// margin: EdgeInsets.zero, ),
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(radiusMd))), ),
// ),
// Dialogs: Glass/Dark cardTheme: CardThemeData(
// dialogTheme: DialogTheme( color: AppColors.surfaceContainer,
// backgroundColor: const Color(0xFF1C1C1E), // Apple Dark Grey elevation: 0,
// elevation: 24, margin: EdgeInsets.zero,
// shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusLg)), shape: RoundedRectangleBorder(
// titleTextStyle: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary), borderRadius: BorderRadius.circular(radiusLg),
// contentTextStyle: GoogleFonts.inter(fontSize: 16, color: AppColors.textSecondary), 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(0x290A84FF),
),
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,
),
); );
} }
static const double radiusFull = 999.0; // ============ LIGHT THEME (gris système clair) ============
// ============ LIGHT THEME (Apple Clean White) ============
static ThemeData get lightTheme { static ThemeData get lightTheme {
final baseTheme = ThemeData.light(useMaterial3: true); final baseTheme = ThemeData.light(useMaterial3: true);
// Apple light palette const bgColor = Color(0xFFF2F2F7);
const bgColor = Color(0xFFF2F2F7); // Apple system grey6 const surfaceColor = Color(0xFFFFFFFF);
const surfaceColor = Color(0xFFFFFFFF); // Pure white surfaces const primaryColor = Color(0xFF0066CC);
const primaryColor = Color(0xFF007AFF); // Apple Blue (light) const textPrimaryColor = Color(0xFF1C1C1E);
const textPrimaryColor = Color(0xFF000000); const textSecondaryColor = Color(0xFF48484E);
const textSecondaryColor = Color(0x993C3C43); // #3C3C43 @ 60% const textTertiaryColor = Color(0xFF8E8E93);
const textTertiaryColor = Color(0xFFC7C7CC); const borderColor = Color(0xFFD8D8DE);
const borderColor = Color(0x1A000000); // #000000 @ 10%
return baseTheme.copyWith( return baseTheme.copyWith(
colorScheme: AppColors.lightColorScheme.copyWith( colorScheme: AppColors.lightColorScheme,
primary: primaryColor,
secondary: const Color(0xFF34C759), // Apple green
surface: surfaceColor,
onPrimary: Colors.white,
onSurface: textPrimaryColor,
brightness: Brightness.light,
),
scaffoldBackgroundColor: bgColor, scaffoldBackgroundColor: bgColor,
canvasColor: bgColor,
textTheme: _textTheme(
primary: textPrimaryColor,
secondary: textSecondaryColor,
),
// Text selection — blue cursor on light
textSelectionTheme: const TextSelectionThemeData( textSelectionTheme: const TextSelectionThemeData(
cursorColor: primaryColor, cursorColor: primaryColor,
selectionColor: Color(0x4D007AFF), selectionColor: Color(0x4D0066CC),
selectionHandleColor: primaryColor, selectionHandleColor: primaryColor,
), ),
// Typography
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(
fontSize: 56,
fontWeight: FontWeight.w700,
letterSpacing: -1.0,
color: textPrimaryColor,
),
displayMedium: GoogleFonts.inter(
fontSize: 48,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: textPrimaryColor,
),
displaySmall: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
headlineMedium: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
titleLarge: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
bodyLarge: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w400,
color: textSecondaryColor,
),
bodyMedium: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: textSecondaryColor,
),
labelLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: textPrimaryColor,
),
),
// AppBar: White with subtle bottom shadow
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: surfaceColor, backgroundColor: surfaceColor,
foregroundColor: textPrimaryColor, foregroundColor: textPrimaryColor,
@@ -269,99 +396,97 @@ class AppTheme {
scrolledUnderElevation: 1, scrolledUnderElevation: 1,
shadowColor: borderColor, shadowColor: borderColor,
centerTitle: true, centerTitle: true,
titleTextStyle: GoogleFonts.inter( titleTextStyle: display(
fontSize: 17, fontSize: 19,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: textPrimaryColor, color: textPrimaryColor,
), ),
), ),
// Bottom Navigation Bar: white, Apple Blue selected
bottomNavigationBarTheme: BottomNavigationBarThemeData( bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: surfaceColor, backgroundColor: surfaceColor,
selectedItemColor: primaryColor, selectedItemColor: primaryColor,
unselectedItemColor: textSecondaryColor, unselectedItemColor: textSecondaryColor,
selectedLabelStyle: GoogleFonts.inter( selectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w700),
fontSize: 10, unselectedLabelStyle: body(fontSize: 11, fontWeight: FontWeight.w500),
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: GoogleFonts.inter(
fontSize: 10,
fontWeight: FontWeight.w500,
),
type: BottomNavigationBarType.fixed, type: BottomNavigationBarType.fixed,
elevation: 8, elevation: 8,
), ),
// Inputs: white fill, subtle border
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: surfaceColor, fillColor: surfaceColor,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: spacing24, horizontal: spacing16,
vertical: spacing16, vertical: spacing16,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: borderColor, width: 1), borderSide: const BorderSide(color: borderColor),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: borderColor, width: 1), borderSide: const BorderSide(color: borderColor),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: primaryColor, width: 2), borderSide: const BorderSide(color: primaryColor, width: 2),
), ),
hintStyle: GoogleFonts.inter(color: textTertiaryColor), hintStyle: body(
fontSize: 14,
fontWeight: FontWeight.w400,
color: textTertiaryColor,
),
), ),
// Buttons: Apple Blue pill
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: primaryColor, backgroundColor: primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
elevation: 0, elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(
horizontal: spacing24,
vertical: spacing16,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd), borderRadius: BorderRadius.circular(radiusMd),
), ),
textStyle: textStyle: body(fontSize: 15, fontWeight: FontWeight.w700),
GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16),
), ),
), ),
// Cards: white with subtle shadow cardTheme: CardThemeData(
cardTheme: const CardThemeData(
color: surfaceColor, color: surfaceColor,
elevation: 0, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(radiusMd)), borderRadius: BorderRadius.circular(radiusLg),
side: BorderSide(color: borderColor, width: 1), side: const BorderSide(color: borderColor),
), ),
shadowColor: borderColor, shadowColor: borderColor,
), ),
// Dialogs: white, clean
dialogTheme: DialogThemeData( dialogTheme: DialogThemeData(
backgroundColor: surfaceColor, backgroundColor: surfaceColor,
elevation: 8, elevation: 8,
shadowColor: borderColor, shadowColor: borderColor,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg), borderRadius: BorderRadius.circular(radiusXl),
side: const BorderSide(color: borderColor, width: 1), side: const BorderSide(color: borderColor),
), ),
titleTextStyle: GoogleFonts.inter( titleTextStyle: display(
fontSize: 18, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
color: textPrimaryColor, color: textPrimaryColor,
), ),
contentTextStyle: GoogleFonts.inter( contentTextStyle: body(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400,
color: textSecondaryColor, color: textSecondaryColor,
), ),
), ),
dividerTheme: const DividerThemeData(color: borderColor, thickness: 1),
); );
} }
} }
+4 -3
View File
@@ -10,6 +10,7 @@ library;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
/// Standard cache manager with 200MB size limit /// Standard cache manager with 200MB size limit
class AppCacheManager { class AppCacheManager {
@@ -103,7 +104,7 @@ class ImageCacheConfig {
width: width, width: width,
height: height, height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF2C2C2E), color: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
), ),
@@ -111,10 +112,10 @@ class ImageCacheConfig {
width: width, width: width,
height: height, height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF3A3A3C), color: AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: const Icon(Icons.image_not_supported, color: Color(0xFF8E8E93)), child: const Icon(Icons.image_not_supported, color: AppColors.textTertiary),
), ),
); );
} }
@@ -150,7 +150,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
style: GoogleFonts.outfit( style: GoogleFonts.outfit(
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: AppColors.onSurface,
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -161,7 +161,7 @@ class _HeroCarouselState extends State<HeroCarousel> {
item.subtitle!, item.subtitle!,
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 16, fontSize: 16,
color: Colors.white.withOpacity(0.8), color: AppColors.onSurface.withOpacity(0.8),
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -227,8 +227,8 @@ class _HeroButtonState extends State<_HeroButton> {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.isPrimary color: widget.isPrimary
? (_isHovered ? Colors.white : Colors.white.withOpacity(0.9)) ? (_isHovered ? AppColors.onSurface : AppColors.onSurface.withOpacity(0.9))
: (_isHovered ? Colors.white.withOpacity(0.2) : Colors.white.withOpacity(0.1)), : (_isHovered ? AppColors.onSurface.withOpacity(0.2) : AppColors.onSurface.withOpacity(0.1)),
borderRadius: BorderRadius.circular(AppTheme.radiusFull), borderRadius: BorderRadius.circular(AppTheme.radiusFull),
), ),
child: Row( child: Row(
@@ -236,14 +236,14 @@ class _HeroButtonState extends State<_HeroButton> {
children: [ children: [
Icon( Icon(
widget.icon, widget.icon,
color: widget.isPrimary ? Colors.black : Colors.white, color: widget.isPrimary ? AppColors.onPrimaryFixed : AppColors.onSurface,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
widget.label, widget.label,
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: widget.isPrimary ? Colors.black : Colors.white, color: widget.isPrimary ? AppColors.onPrimaryFixed : AppColors.onSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@@ -134,7 +134,7 @@ class _GradientButtonState extends State<GradientButton>
height: 20, height: 20,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Colors.black, color: AppColors.onPrimaryFixed,
), ),
) )
: Row( : Row(
@@ -143,13 +143,13 @@ class _GradientButtonState extends State<GradientButton>
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (widget.icon != null) ...[ if (widget.icon != null) ...[
Icon(widget.icon, color: Colors.black, size: 20), Icon(widget.icon, color: AppColors.onPrimaryFixed, size: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
Text( Text(
widget.label, widget.label,
style: const TextStyle( style: const TextStyle(
color: Colors.black, color: AppColors.onPrimaryFixed,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 14, fontSize: 14,
), ),
@@ -247,12 +247,12 @@ class LiveBadge extends StatelessWidget {
child: const Row( child: const Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.circle, size: 6, color: Colors.white), Icon(Icons.circle, size: 6, color: AppColors.onSurface),
SizedBox(width: 4), SizedBox(width: 4),
Text( Text(
'LIVE', 'LIVE',
style: TextStyle( style: TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@@ -427,11 +427,11 @@ class _MediaCardState extends State<MediaCard>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.star, size: 10, color: Color(0xFFFFD700)), const Icon(Icons.star, size: 10, color: AppColors.ratingGold),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
widget.rating!, widget.rating!,
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), style: const TextStyle(color: AppColors.onSurface, fontSize: 10, fontWeight: FontWeight.bold),
), ),
], ],
), ),
@@ -449,7 +449,7 @@ class _MediaCardState extends State<MediaCard>
color: AppColors.success, color: AppColors.success,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(Icons.check, size: 12, color: Colors.white), child: const Icon(Icons.check, size: 12, color: AppColors.onSurface),
), ),
), ),
], ],
+4 -3
View File
@@ -2,6 +2,7 @@ import 'dart:async';
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 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class DigitalClock extends StatefulWidget { class DigitalClock extends StatefulWidget {
const DigitalClock({super.key}); const DigitalClock({super.key});
@@ -31,16 +32,16 @@ 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.inter(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 16, fontSize: 16,
color: Colors.white, color: AppColors.onSurface,
), ),
), ),
); );
+3 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class ThemedLoading extends StatelessWidget { class ThemedLoading extends StatelessWidget {
const ThemedLoading({super.key}); const ThemedLoading({super.key});
@@ -11,7 +12,7 @@ class ThemedLoading extends StatelessWidget {
center: Alignment(0, -0.2), center: Alignment(0, -0.2),
radius: 1.5, radius: 1.5,
colors: [ colors: [
Color(0xFF2C2C2E), AppColors.surfaceContainerHigh,
Color(0xFF000000), Color(0xFF000000),
], ],
stops: [0.0, 1.0], stops: [0.0, 1.0],
@@ -19,7 +20,7 @@ class ThemedLoading extends StatelessWidget {
), ),
child: const Center( child: const Center(
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Colors.white, color: AppColors.onSurface,
strokeWidth: 3, strokeWidth: 3,
), ),
), ),
@@ -72,6 +72,10 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
Timer? _controlsTimer; Timer? _controlsTimer;
int _loadId = 0; int _loadId = 0;
/// Set once the app is backgrounded, so a queued reload cannot restart the
/// stream behind the user.
bool _isReleased = false;
// Focus // Focus
final FocusNode _playPauseFocusNode = FocusNode(); final FocusNode _playPauseFocusNode = FocusNode();
final FocusNode _prevFocusNode = FocusNode(); final FocusNode _prevFocusNode = FocusNode();
@@ -98,17 +102,45 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (_controller == null) return; // `inactive` is deliberately NOT handled: on Android it fires for transient
if (state == AppLifecycleState.paused || // interruptions (notification shade, call banner, permission dialog).
state == AppLifecycleState.inactive || final isBackgrounded = state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) { state == AppLifecycleState.hidden ||
_controller?.pause(); state == AppLifecycleState.detached;
if (mounted) { if (!isBackgrounded) return;
Navigator.of(context).pop();
} _releasePlayback();
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
Navigator.of(context).pop();
} }
} }
/// Stop playback and release the decoder when the app leaves the foreground.
///
/// `pause()` alone is not enough: ExoPlayer keeps filling its buffer, so the
/// stream would still be pulled (and metered) in the background.
void _releasePlayback() {
if (_isReleased) return;
_isReleased = true;
_clockTimer?.cancel();
_clockTimer = null;
_epgTimer?.cancel();
_epgTimer = null;
_controlsTimer?.cancel();
_controlsTimer = null;
_stabilizationTimer?.cancel();
_stabilizationTimer = null;
final controller = _controller;
_controller = null;
controller?.pause();
controller?.dispose();
WakelockPlus.disable();
}
void _startClock() { void _startClock() {
_clockTimer?.cancel(); _clockTimer?.cancel();
_updateTime(); _updateTime();
@@ -132,6 +164,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory();
_xtreamService = XtreamServiceMobile(dir.path); _xtreamService = XtreamServiceMobile(dir.path);
await _xtreamService!.setPlaylistAsync(widget.playlist); await _xtreamService!.setPlaylistAsync(widget.playlist);
// Warm DNS + TCP/TLS to the panel host before the first request.
await _xtreamService!.prewarmHost().timeout(
const Duration(milliseconds: 1500),
onTimeout: () {},
);
_loadStream(); _loadStream();
if (widget.streamType == model.StreamType.live) { if (widget.streamType == model.StreamType.live) {
@@ -160,6 +199,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
Future<void> _loadStream() async { Future<void> _loadStream() async {
if (_xtreamService == null) return; if (_xtreamService == null) return;
// Backgrounded while a (re)load was queued — do not resurrect the stream.
if (_isReleased || !mounted) return;
await _controller?.dispose(); await _controller?.dispose();
_controller = null; _controller = null;
@@ -359,6 +400,11 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_controller?.pause(); // Force stop audio immediately _controller?.pause(); // Force stop audio immediately
_controller?.dispose(); _controller?.dispose();
_xtreamService?.dispose(); _xtreamService?.dispose();
_playPauseFocusNode.dispose();
_prevFocusNode.dispose();
_nextFocusNode.dispose();
super.dispose(); super.dispose();
} }
@@ -370,6 +416,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
: widget.title; : widget.title;
return Scaffold( return Scaffold(
// True black behind the video surface, never the warm ink.
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: GestureDetector( body: GestureDetector(
onTap: _onUserInteraction, onTap: _onUserInteraction,
@@ -407,7 +454,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
), ),
child: const Icon( child: const Icon(
Icons.arrow_back, Icons.arrow_back,
color: Colors.white, color: AppColors.onSurface,
size: 28, size: 28,
), ),
), ),
@@ -426,7 +473,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
child: Text( child: Text(
_currentTime, _currentTime,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -449,7 +496,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
), ),
child: Text( child: Text(
_errorMessage!, _errorMessage!,
style: const TextStyle(color: Colors.red), style: const TextStyle(color: AppColors.error),
), ),
), ),
), ),
@@ -508,7 +555,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7), color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10), border: Border.all(color: AppColors.onSurface12),
), ),
child: IntrinsicHeight( child: IntrinsicHeight(
child: Row( child: Row(
@@ -533,7 +580,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
), ),
child: const Icon( child: const Icon(
Icons.skip_previous, Icons.skip_previous,
color: Colors.white, color: AppColors.onSurface,
size: 36, size: 36,
), ),
), ),
@@ -545,7 +592,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_isPlaying _isPlaying
? Icons.pause_circle_filled ? Icons.pause_circle_filled
: Icons.play_circle_filled, : Icons.play_circle_filled,
color: Colors.white, color: AppColors.onSurface,
size: 56, size: 56,
), ),
), ),
@@ -559,7 +606,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
), ),
child: const Icon( child: const Icon(
Icons.skip_next, Icons.skip_next,
color: Colors.white, color: AppColors.onSurface,
size: 36, size: 36,
), ),
), ),
@@ -596,7 +643,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
child: const Icon( child: const Icon(
Icons Icons
.grid_on, // Icon representing interlaced grid/mesh .grid_on, // Icon representing interlaced grid/mesh
color: Colors.white, color: AppColors.onSurface,
size: 32, size: 32,
), ),
), ),
@@ -638,7 +685,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
child: Text( child: Text(
title, title,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -664,7 +711,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: progress, value: progress,
backgroundColor: Colors.white10, backgroundColor: AppColors.onSurface12,
color: AppColors.primary, color: AppColors.primary,
minHeight: 4, minHeight: 4,
), ),
@@ -672,7 +719,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
next, next,
style: const TextStyle(color: Colors.white54, fontSize: 12), style: const TextStyle(color: AppColors.onSurface54, fontSize: 12),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -712,7 +759,7 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
min: 0, min: 0,
max: _duration.inSeconds.toDouble(), max: _duration.inSeconds.toDouble(),
activeColor: AppColors.primary, activeColor: AppColors.primary,
inactiveColor: Colors.white24, inactiveColor: AppColors.onSurface24,
onChanged: (v) => setState(() { onChanged: (v) => setState(() {
_position = Duration(seconds: v.toInt()); _position = Duration(seconds: v.toInt());
_isSeeking = true; _isSeeking = true;
@@ -236,7 +236,7 @@ class _MobilePlaylistScreenState extends ConsumerState<MobilePlaylistScreen> {
? 'Playlist modifiée' ? 'Playlist modifiée'
: 'Playlist ajoutée', : 'Playlist ajoutée',
), ),
backgroundColor: Colors.green, backgroundColor: AppColors.success,
), ),
); );
} }
@@ -125,7 +125,7 @@ class _MobilePlaylistSelectionScreenState
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Erreur de chargement', 'Erreur de chargement',
style: GoogleFonts.inter(color: Colors.white), style: GoogleFonts.inter(color: AppColors.onSurface),
), ),
TextButton( TextButton(
onPressed: () => ref.invalidate(playlistsProvider), onPressed: () => ref.invalidate(playlistsProvider),
@@ -143,14 +143,14 @@ class _MobilePlaylistSelectionScreenState
const Icon( const Icon(
Icons.playlist_add, Icons.playlist_add,
size: 64, size: 64,
color: Colors.white54, color: AppColors.onSurface54,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Aucune playlist configurée', 'Aucune playlist configurée',
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 18, fontSize: 18,
color: Colors.white70, color: AppColors.onSurface70,
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -163,18 +163,18 @@ class _MobilePlaylistSelectionScreenState
vertical: 14, vertical: 14,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF0A84FF), color: AppColors.primaryFill,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: const Row( child: const Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.add, color: Colors.white, size: 20), Icon(Icons.add, color: AppColors.onSurface, size: 20),
SizedBox(width: 10), SizedBox(width: 10),
Text( Text(
'Ajouter une Playlist', 'Ajouter une Playlist',
style: TextStyle( style: TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 15, fontSize: 15,
), ),
@@ -268,21 +268,21 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
title: Text(label, style: const TextStyle(color: Colors.white)), title: Text(label, style: const TextStyle(color: AppColors.onSurface)),
content: TextField( content: TextField(
controller: controller, controller: controller,
autofocus: true, autofocus: true,
obscureText: obscure, obscureText: obscure,
keyboardType: keyboardType, keyboardType: keyboardType,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
style: const TextStyle(color: Colors.white), style: const TextStyle(color: AppColors.onSurface),
decoration: const InputDecoration( decoration: const InputDecoration(
labelStyle: TextStyle(color: Colors.white70), labelStyle: TextStyle(color: AppColors.onSurface70),
enabledBorder: UnderlineInputBorder( enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24), borderSide: BorderSide(color: AppColors.onSurface24),
), ),
focusedBorder: UnderlineInputBorder( focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white), borderSide: BorderSide(color: AppColors.onSurface),
), ),
), ),
onSubmitted: (val) => Navigator.pop(ctx, val), onSubmitted: (val) => Navigator.pop(ctx, val),
@@ -327,7 +327,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withOpacity(0.07), color: AppColors.onSurface.withOpacity(0.07),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Row( child: Row(
@@ -341,7 +341,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
Text( Text(
label, label,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: AppColors.onSurface70,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -350,7 +350,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
Text( Text(
displayValue, displayValue,
style: TextStyle( style: TextStyle(
color: value.isEmpty ? Colors.white38 : Colors.white, color: value.isEmpty ? AppColors.onSurface38 : AppColors.onSurface,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -360,7 +360,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
], ],
), ),
), ),
const Icon(Icons.edit, color: Colors.white38, size: 18), const Icon(Icons.edit, color: AppColors.onSurface38, size: 18),
], ],
), ),
), ),
@@ -374,7 +374,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
title: Text( title: Text(
widget.isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist', widget.isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
style: GoogleFonts.inter(color: Colors.white), style: GoogleFonts.inter(color: AppColors.onSurface),
), ),
content: SizedBox( content: SizedBox(
width: 480, width: 480,
@@ -435,8 +435,8 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
title: const Text('Confirmer', style: TextStyle(color: Colors.white)), title: const Text('Confirmer', style: TextStyle(color: AppColors.onSurface)),
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: Colors.white70)), content: const Text('Supprimer cette playlist ?', style: TextStyle(color: AppColors.onSurface70)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, false), onPressed: () => Navigator.pop(ctx, false),
@@ -444,7 +444,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
), ),
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.red), style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('Supprimer'), child: const Text('Supprimer'),
), ),
], ],
@@ -458,7 +458,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: const Padding( child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('Supprimer', style: TextStyle(color: Colors.red)), child: Text('Supprimer', style: TextStyle(color: AppColors.error)),
), ),
), ),
TVFocusable( TVFocusable(
@@ -466,7 +466,7 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: const Padding( child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('Annuler', style: TextStyle(color: Colors.white70)), child: Text('Annuler', style: TextStyle(color: AppColors.onSurface70)),
), ),
), ),
TVFocusable( TVFocusable(
@@ -482,14 +482,14 @@ class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _name.isEmpty || _dns.isEmpty color: _name.isEmpty || _dns.isEmpty
? Colors.white12 ? AppColors.onSurface12
: AppColors.primary, : AppColors.primary,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
_isSaving ? '...' : 'Enregistrer', _isSaving ? '...' : 'Enregistrer',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
@@ -129,7 +129,7 @@ class _MobileSeriesDetailScreenState
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white), icon: const Icon(Icons.arrow_back, color: AppColors.onSurface),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
tooltip: 'Back', tooltip: 'Back',
), ),
@@ -149,7 +149,7 @@ class _MobileSeriesDetailScreenState
maxHeightDiskCache: 720, maxHeightDiskCache: 720,
cacheManager: AppCacheManager.instance, cacheManager: AppCacheManager.instance,
errorWidget: (_, __, ___) => errorWidget: (_, __, ___) =>
Container(color: Colors.grey.shade900), Container(color: AppColors.surfaceContainerLowest),
), ),
// Gradient Overlay // Gradient Overlay
Container( Container(
@@ -190,7 +190,7 @@ class _MobileSeriesDetailScreenState
const Icon( const Icon(
Icons.star, Icons.star,
size: 16, size: 16,
color: Colors.amber, color: AppColors.ratingGold,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -261,15 +261,15 @@ class _MobileSeriesDetailScreenState
} }
}, },
backgroundColor: isDark backgroundColor: isDark
? const Color(0xFF1C1C1E) ? AppColors.surfaceContainer
: Colors.grey.shade300, : AppColors.surfaceContainerHigh,
selectedColor: isDark ? Colors.white : Colors.blue, selectedColor: isDark ? AppColors.onSurface : AppColors.primaryFill,
labelStyle: TextStyle( labelStyle: TextStyle(
color: isSelected color: isSelected
? (isDark ? Colors.black : Colors.white) ? (isDark ? AppColors.onPrimaryFixed : AppColors.onSurface)
: (isDark : (isDark
? Colors.grey.shade400 ? AppColors.textSecondary
: Colors.grey.shade700), : AppColors.outlineVariant),
fontWeight: fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400, isSelected ? FontWeight.w600 : FontWeight.w400,
), ),
@@ -323,10 +323,10 @@ class _MobileSeriesDetailScreenState
} }
// Force dark mode colors since background is hardcoded dark // Force dark mode colors since background is hardcoded dark
const textColor = Colors.white; const textColor = AppColors.onSurface;
final secondaryTextColor = Colors.grey.shade400; final secondaryTextColor = AppColors.textSecondary;
const surfaceColor = Color(0xFF1C1C1E); const surfaceColor = AppColors.surfaceContainer;
const primaryColor = Colors.white; // Or accent color if preferred const primaryColor = AppColors.onSurface; // Or accent color if preferred
return InkWell( return InkWell(
onTap: () { onTap: () {
@@ -356,7 +356,7 @@ class _MobileSeriesDetailScreenState
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration( decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.white10)), border: Border(bottom: BorderSide(color: AppColors.onSurface12)),
), ),
child: Row( child: Row(
children: [ children: [
@@ -374,7 +374,7 @@ class _MobileSeriesDetailScreenState
child: Icon( child: Icon(
isWatched ? Icons.check : Icons.play_arrow, isWatched ? Icons.check : Icons.play_arrow,
color: isWatched color: isWatched
? Colors.black // Checkmark on white bg ? AppColors.onPrimaryFixed // Checkmark on white bg
: primaryColor, // Play icon : primaryColor, // Play icon
size: 20, size: 20,
), ),
@@ -413,7 +413,7 @@ class _MobileSeriesDetailScreenState
LinearProgressIndicator( LinearProgressIndicator(
value: progress, value: progress,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red), valueColor: const AlwaysStoppedAnimation<Color>(AppColors.error),
minHeight: 2, minHeight: 2,
), ),
], ],
@@ -96,6 +96,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Timer? _controlsTimer; // Auto-hide timer Timer? _controlsTimer; // Auto-hide timer
Timer? _epgTimer; // Periodic EPG refresh for live TV Timer? _epgTimer; // Periodic EPG refresh for live TV
Timer? _liveWatchdog; // Watchdog for live stream reconnection Timer? _liveWatchdog; // Watchdog for live stream reconnection
/// Every player stream subscription, so none survives this screen.
/// Left dangling they keep firing setState against a dead element — the
/// classic slow leak behind "crashes after a few hours".
final List<StreamSubscription<dynamic>> _playerSubscriptions = [];
/// Set once the app is backgrounded, to stop any in-flight reload from
/// resurrecting playback behind the user's back.
bool _isReleased = false;
int _reconnectAttempts = 0; int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5; static const int _maxReconnectAttempts = 5;
String _currentTime = ''; String _currentTime = '';
@@ -126,6 +135,35 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_resetControlsTimer(); _resetControlsTimer();
} }
/// Dernier contrôle de l'OSD qui avait le focus télécommande.
///
/// Quand l'OSD se masque (auto-hide 5s), ses FocusNodes sont démontés et le
/// focus part « on ne sait où » : à la réouverture l'utilisateur devait
/// re-naviguer depuis un point aléatoire jusqu'au bouton de zapping. On
/// mémorise donc le dernier bouton utilisé pour le re-focaliser d'office.
FocusNode? _lastFocusedControl;
void _noteFocus(FocusNode node) {
_lastFocusedControl = node;
_resetControlsTimer();
}
/// Réaffiche l'OSD en redonnant le focus au dernier contrôle utilisé
/// (Play/Pause par défaut) — un seul appui suffit alors pour re-zapper.
void _showControlsRestoringFocus() {
setState(() => _showControls = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls) return;
final target = _lastFocusedControl ?? _playPauseFocusNode;
if (target.canRequestFocus) {
target.requestFocus();
} else {
_playPauseFocusNode.requestFocus();
}
});
_resetControlsTimer();
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -180,11 +218,21 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Principe TiviMate : jouer à partir de là où on est, JAMAIS chasser le live edge. // Principe TiviMate : jouer à partir de là où on est, JAMAIS chasser le live edge.
// ExoPlayer n'utilise pas les PTS du stream pour se recaler — on fait pareil. // ExoPlayer n'utilise pas les PTS du stream pour se recaler — on fait pareil.
// --- Buffering stable (TiviMate Medium ~ 8-10s) --- // --- Buffering basse latence (TiviMate Low ~ 3-5s) ---
(_player.platform as dynamic)?.setProperty('cache-secs', '8'); // 8s/10s de readahead ajoutaient autant de retard sur le direct : 3s de
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '150000000'); // 150MB RAM max // cache + 5s de readahead suffisent à absorber la gigue réseau tout en
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '10'); // restant nettement plus près du live edge.
(_player.platform as dynamic)?.setProperty('cache-secs', '3');
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '60000000'); // 60MB RAM max
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '5');
(_player.platform as dynamic)?.setProperty('demuxer-thread', 'yes'); (_player.platform as dynamic)?.setProperty('demuxer-thread', 'yes');
// --- Plafond du buffer ARRIÈRE (stabilité longue durée) ---
// mpv garde par défaut 50 Mio de flux déjà lu pour permettre un retour
// arrière. Sur du live ce buffer ne sert à rien : cumulé aux 150 Mo de
// readahead il pousse le tas natif vers ~200 Mo et finit par déclencher
// un kill mémoire Android après plusieurs heures. 8 Mo suffisent aux
// resynchronisations internes du démuxeur.
(_player.platform as dynamic)?.setProperty('demuxer-max-back-bytes', '8000000');
// Si le buffer se vide : courte pause (0.5s) puis reprise — freeze minimal, SANS rattrapage // Si le buffer se vide : courte pause (0.5s) puis reprise — freeze minimal, SANS rattrapage
// 0.5s vs 1.5s : moins de gel visible, moins de PTS gap à rattraper // 0.5s vs 1.5s : moins de gel visible, moins de PTS gap à rattraper
(_player.platform as dynamic)?.setProperty('cache-pause', 'yes'); (_player.platform as dynamic)?.setProperty('cache-pause', 'yes');
@@ -218,15 +266,19 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
(_player.platform as dynamic)?.setProperty('video-sync', 'audio'); (_player.platform as dynamic)?.setProperty('video-sync', 'audio');
} }
// [V11.0 Perfect Restoration] Recovering settings from working commit a1628a6b // [Audio Fix] L'ancienne chaîne `af=lavrresample,format=channels=stereo:
// Forced software re-sampling handles ANY stubborn 5.1/LATM/24-bit audio track. // sample_fmts=s16` mélangeait la syntaxe FFmpeg et mpv : `lavrresample`
// n'existe plus dans les builds récents et `sample_fmts` n'est pas une
// option du filtre `format` de mpv. Quand la chaîne échoue à s'initialiser
// pour un codec donné (AC3/EAC3/LATM selon la chaîne TV), mpv coupe la
// sortie audio ⇒ « certaines chaînes n'ont pas de son ».
// Le downmix natif `audio-channels=stereo` fait la même chose (5.1 → 2.0)
// sans chaîne de filtres fragile, et l'AO négocie lui-même le format.
(_player.platform as dynamic)?.setProperty('ad', 'lavc:*'); (_player.platform as dynamic)?.setProperty('ad', 'lavc:*');
(_player.platform as dynamic)?.setProperty('af', 'lavrresample,format=channels=stereo:sample_fmts=s16'); (_player.platform as dynamic)?.setProperty('audio-channels', 'stereo');
(_player.platform as dynamic)?.setProperty('audio-format', 's16');
(_player.platform as dynamic)?.setProperty('ao', 'audiotrack'); (_player.platform as dynamic)?.setProperty('ao', 'audiotrack');
(_player.platform as dynamic)?.setProperty('audio-pitch-correction', 'no'); (_player.platform as dynamic)?.setProperty('audio-pitch-correction', 'no');
(_player.platform as dynamic)?.setProperty('audio-buffer', '0.2'); // Live: buffer audio court (réduit le désync pendant les cache-pauses) (_player.platform as dynamic)?.setProperty('audio-buffer', '0.2'); // Live: buffer audio court (réduit le désync pendant les cache-pauses)
(_player.platform as dynamic)?.setProperty('audio-samplerate', '48000');
(_player.platform as dynamic)?.setProperty('aid', 'auto'); (_player.platform as dynamic)?.setProperty('aid', 'auto');
// --- Seeking & Sync globaux --- // --- Seeking & Sync globaux ---
@@ -254,11 +306,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
); );
// --- Probing du flux (une seule entrée, consolidée) --- // --- Probing du flux (une seule entrée, consolidée) ---
// Précédent : 2 setProperty('demuxer-lavf-o') en conflit
// fflags=+discardcorrupt supprimé : peut causer des skips sur certains streams IPTV // fflags=+discardcorrupt supprimé : peut causer des skips sur certains streams IPTV
// Ces valeurs sont des PLAFONDS : FFmpeg s'arrête dès que toutes les pistes
// sont identifiées, donc les relever ne ralentit pas les flux sains.
// 1MB/2s ratait la piste audio (AAC-LATM déclaré tard) sur certaines
// chaînes ⇒ pas de son. 2MB/3s les récupère sans pénaliser le zapping.
(_player.platform as dynamic)?.setProperty( (_player.platform as dynamic)?.setProperty(
'demuxer-lavf-o', 'demuxer-lavf-o',
'analyzeduration=2000000,probesize=1000000', 'analyzeduration=3000000,probesize=2000000',
); );
// --- Décodeur lavc : threads libres, sans fast-mode (qualité prioritaire) --- // --- Décodeur lavc : threads libres, sans fast-mode (qualité prioritaire) ---
@@ -284,7 +339,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_controller = VideoController(_player); _controller = VideoController(_player);
// Listen to player state // Listen to player state
_player.stream.playing.listen((playing) { _playerSubscriptions.add(_player.stream.playing.listen((playing) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_isPlaying = playing; _isPlaying = playing;
@@ -302,18 +357,25 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_resetControlsTimer(); // Start auto-hide timer _resetControlsTimer(); // Start auto-hide timer
} }
} }
}); }));
_player.stream.position.listen((position) { _playerSubscriptions.add(_player.stream.position.listen((position) {
if (mounted && !_isSeeking) { if (mounted && !_isSeeking) {
setState(() => _position = position);
// [v23.0] Track last position change time for time-based watchdog // [v23.0] Track last position change time for time-based watchdog
if (position != _lastKnownPosition) { if (position != _lastKnownPosition) {
_lastKnownPosition = position; _lastKnownPosition = position;
_lastPositionChangeTime = DateTime.now(); _lastPositionChangeTime = DateTime.now();
} }
// Only rebuild when the position is actually on screen. mpv emits this
// several times per second; rebuilding the whole player tree for a
// hidden readout burns CPU and GC for hours on a live channel.
if (_showControls) {
setState(() => _position = position);
} else {
_position = position;
}
// Check if 80% of content watched (for VOD/Series only) // Check if 80% of content watched (for VOD/Series only)
_checkAndMarkWatched(position); _checkAndMarkWatched(position);
@@ -335,9 +397,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
} }
} }
} }
}); }));
_player.stream.duration.listen((duration) { _playerSubscriptions.add(_player.stream.duration.listen((duration) {
if (mounted) { if (mounted) {
setState(() { setState(() {
_duration = duration; _duration = duration;
@@ -348,16 +410,16 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
} }
}); });
} }
}); }));
_player.stream.width.listen((width) { _playerSubscriptions.add(_player.stream.width.listen((width) {
if (mounted && (width ?? 0) > 0 && _isLoading) { if (mounted && (width ?? 0) > 0 && _isLoading) {
debugPrint('MediaKitPlayer: Video width detected ($width), clearing loading overlay'); debugPrint('MediaKitPlayer: Video width detected ($width), clearing loading overlay');
setState(() => _isLoading = false); setState(() => _isLoading = false);
} }
}); }));
_player.stream.error.listen((error) async { _playerSubscriptions.add(_player.stream.error.listen((error) async {
if (!mounted) return; if (!mounted) return;
if (error.isEmpty) return; if (error.isEmpty) return;
@@ -385,10 +447,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_errorMessage = error; _errorMessage = error;
_isLoading = false; _isLoading = false;
}); });
}); }));
// Listen for stream completion // Listen for stream completion
_player.stream.completed.listen((completed) { _playerSubscriptions.add(_player.stream.completed.listen((completed) {
if (completed && mounted) { if (completed && mounted) {
// For Live TV: do NOT reconnect immediately — let mpv handle it internally. // For Live TV: do NOT reconnect immediately — let mpv handle it internally.
// Only reconnect after a long pause (handled by watchdog). // Only reconnect after a long pause (handled by watchdog).
@@ -409,7 +471,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
} }
} }
} }
}); }));
// Lock to landscape for video playback // Lock to landscape for video playback
SystemChrome.setPreferredOrientations([ SystemChrome.setPreferredOrientations([
@@ -435,13 +497,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
} }
void _updateTime() { void _updateTime() {
if (mounted) { if (!mounted) return;
final now = DateTime.now(); final now = DateTime.now();
setState(() { final formatted =
_currentTime = "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}";
"${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}"; // The clock ticks every second but only changes once a minute — rebuild
}); // only when the displayed value actually differs.
} if (formatted == _currentTime) return;
setState(() => _currentTime = formatted);
} }
@override @override
@@ -453,23 +516,52 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || // `inactive` is deliberately NOT handled: on Android it fires for transient
state == AppLifecycleState.inactive || // interruptions (notification shade, incoming-call banner, permission
state == AppLifecycleState.detached) { // dialog, split-screen resize). Treating it as "backgrounded" killed
if (widget.streamType == model.StreamType.live) { // playback every time the user swiped the shade down.
// Force stop for Live TV final isBackgrounded = state == AppLifecycleState.paused ||
_player.stop(); state == AppLifecycleState.hidden ||
} else { state == AppLifecycleState.detached;
_player.pause(); if (!isBackgrounded) return;
}
// Exit player screen so user returns to dashboard on resume _releasePlayback();
if (mounted) {
Navigator.of(context).pop(); // Exit the player screen so the user comes back to the dashboard rather
} // than to a frozen frame.
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
Navigator.of(context).pop();
} }
} }
/// Fully stop playback and release the decoder.
///
/// Android reclaims MediaCodec instances from backgrounded apps: holding one
/// while hidden is what leaves the app with a dead surface (black screen, no
/// audio) on return. Stopping releases it deterministically, and also drops
/// the network reader so no bandwidth is consumed in the background.
void _releasePlayback() {
if (_isReleased) return;
_isReleased = true;
// Kill the timers first: a watchdog firing mid-release would call
// _loadStream() and restart the stream we are tearing down.
_liveWatchdog?.cancel();
_liveWatchdog = null;
_epgTimer?.cancel();
_epgTimer = null;
_controlsTimer?.cancel();
_controlsTimer = null;
_stopClock();
if (widget.streamType != model.StreamType.live) {
_saveResumePositionOnExit();
}
_player.stop();
WakelockPlus.disable();
}
@override @override
void dispose() { void dispose() {
// Remove lifecycle observer // Remove lifecycle observer
@@ -481,8 +573,11 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Disable wakelock when leaving player // Disable wakelock when leaving player
WakelockPlus.disable(); WakelockPlus.disable();
// Save resume position before cleanup (only for VOD/Series) // Save resume position before cleanup (only for VOD/Series).
_saveResumePositionOnExit(); // Skipped when _releasePlayback already did it on backgrounding.
if (!_isReleased) {
_saveResumePositionOnExit();
}
_clockTimer?.cancel(); _clockTimer?.cancel();
_controlsTimer?.cancel(); _controlsTimer?.cancel();
@@ -491,6 +586,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_channelListTimer?.cancel(); _channelListTimer?.cancel();
_channelListScrollController.dispose(); _channelListScrollController.dispose();
// Detach from the player streams BEFORE disposing it, so no late event can
// land on a deactivated element.
for (final subscription in _playerSubscriptions) {
subscription.cancel();
}
_playerSubscriptions.clear();
// Stop playback first to prevent audio continuing in background // Stop playback first to prevent audio continuing in background
_player.stop(); _player.stop();
_player.dispose(); _player.dispose();
@@ -591,7 +693,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
'Reprise à ${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}', 'Reprise à ${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
), ),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
backgroundColor: Colors.green.shade700, backgroundColor: AppColors.success,
), ),
); );
} else { } else {
@@ -605,7 +707,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
/// Attempt to reconnect a live stream that stopped /// Attempt to reconnect a live stream that stopped
void _attemptReconnect() { void _attemptReconnect() {
if (!mounted) return; if (!mounted || _isReleased) return;
if (_reconnectAttempts >= _maxReconnectAttempts) { if (_reconnectAttempts >= _maxReconnectAttempts) {
setState(() { setState(() {
_errorMessage = 'Flux interrompu après plusieurs tentatives'; _errorMessage = 'Flux interrompu après plusieurs tentatives';
@@ -693,7 +795,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Vérifie toutes les 10s, mais seuil de reconnexion à 60s de position gelée // Vérifie toutes les 10s, mais seuil de reconnexion à 60s de position gelée
_liveWatchdog = Timer.periodic(const Duration(seconds: 10), (_) { _liveWatchdog = Timer.periodic(const Duration(seconds: 10), (_) {
if (!mounted) return; if (!mounted || _isReleased) return;
// Si le player joue et la position avance : tout va bien, reset // Si le player joue et la position avance : tout va bien, reset
if (_isPlaying && _lastPositionChangeTime != null) { if (_isPlaying && _lastPositionChangeTime != null) {
@@ -727,6 +829,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_xtreamService = XtreamServiceMobile(dir.path); _xtreamService = XtreamServiceMobile(dir.path);
await _xtreamService!.setPlaylistAsync(widget.playlist); await _xtreamService!.setPlaylistAsync(widget.playlist);
// Resolve DNS and open (then drop) a bare socket to the panel host so
// mpv's first request skips resolution and the TCP/TLS handshake.
// Capped hard: playback must never wait more than a moment on this.
await _xtreamService!.prewarmHost().timeout(
const Duration(milliseconds: 1500),
onTimeout: () {},
);
await _loadStream(); await _loadStream();
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -745,6 +855,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Future<void> _loadStream({Duration? startAt}) async { Future<void> _loadStream({Duration? startAt}) async {
try { try {
if (_xtreamService == null) return; if (_xtreamService == null) return;
// The app went to background while a (re)load was queued — do not bring
// the stream back up behind the user.
if (_isReleased || !mounted) return;
setState(() { setState(() {
if (_isFirstLoad) { if (_isFirstLoad) {
@@ -923,6 +1036,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_showChannelList = false; // close sidebar on switch _showChannelList = false; // close sidebar on switch
_showControls = true; // show regular OSD _showControls = true; // show regular OSD
}); });
// Garde le focus sur le bouton de zap qui vient d'être pressé : l'appui
// suivant sur OK rezappe immédiatement, sans re-navigation dans l'OSD.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_showControls) return;
final target = _lastFocusedControl;
if (target != null && target.canRequestFocus && !target.hasFocus) {
target.requestFocus();
}
});
_resetControlsTimer(); _resetControlsTimer();
_loadStream(); _loadStream();
} }
@@ -975,12 +1097,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
void _toggleControls() { void _toggleControls() {
// Always show controls on tap, then start auto-hide timer // Always show controls on tap, then start auto-hide timer
if (!_showControls) { if (!_showControls) {
setState(() => _showControls = true); // Reopen on the last focused control (falls back to Play/Pause)
// Request focus on Play/Pause button when opening _showControlsRestoringFocus();
WidgetsBinding.instance.addPostFrameCallback((_) { return;
// If controls opened, default to Play/Pause
_playPauseFocusNode.requestFocus();
});
} }
_resetControlsTimer(); _resetControlsTimer();
} }
@@ -1013,8 +1132,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
key == LogicalKeyboardKey.numpadEnter || key == LogicalKeyboardKey.numpadEnter ||
key == LogicalKeyboardKey.gameButtonA || key == LogicalKeyboardKey.gameButtonA ||
key == LogicalKeyboardKey.mediaPlayPause) { key == LogicalKeyboardKey.mediaPlayPause) {
// If controls are hidden, toggle them + play/pause
if (!_showControls) { if (!_showControls) {
// Live : OK réaffiche seulement l'OSD (focus restauré sur le dernier
// bouton utilisé) — surtout ne PAS mettre le direct en pause.
if (widget.streamType == model.StreamType.live &&
key != LogicalKeyboardKey.mediaPlayPause) {
_showControlsRestoringFocus();
return true;
}
// VOD : comportement historique — toggle play/pause + OSD
_togglePlayPause(); _togglePlayPause();
_toggleControls(); _toggleControls();
return true; return true;
@@ -1042,8 +1168,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (widget.streamType != model.StreamType.live) { if (widget.streamType != model.StreamType.live) {
final newPos = _position - const Duration(seconds: 10); final newPos = _position - const Duration(seconds: 10);
_player.seek(newPos < Duration.zero ? Duration.zero : newPos); _player.seek(newPos < Duration.zero ? Duration.zero : newPos);
_onUserInteraction();
} else {
_showControlsRestoringFocus();
} }
_onUserInteraction();
return true; return true;
} }
return false; // Let Focus handle navigation return false; // Let Focus handle navigation
@@ -1057,8 +1185,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (newPos < _duration) { if (newPos < _duration) {
_player.seek(newPos); _player.seek(newPos);
} }
_onUserInteraction();
} else {
_showControlsRestoringFocus();
} }
_onUserInteraction();
return true; return true;
} }
return false; // Let Focus handle navigation return false; // Let Focus handle navigation
@@ -1068,7 +1198,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (key == LogicalKeyboardKey.arrowUp || if (key == LogicalKeyboardKey.arrowUp ||
key == LogicalKeyboardKey.arrowDown) { key == LogicalKeyboardKey.arrowDown) {
if (!_showControls) { if (!_showControls) {
_onUserInteraction(); _showControlsRestoringFocus();
return true; return true;
} }
return false; // Let Focus handle navigation return false; // Let Focus handle navigation
@@ -1219,6 +1349,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Loading Overlay — black only on FIRST load // Loading Overlay — black only on FIRST load
if (_isLoading && _errorMessage == null) if (_isLoading && _errorMessage == null)
Container( Container(
// True black, not the warm ink: this sits over the video surface.
color: Colors.black, color: Colors.black,
child: _buildLoadingView(), child: _buildLoadingView(),
), ),
@@ -1239,7 +1370,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
height: 20, height: 20,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: Colors.white70, color: AppColors.onSurface70,
), ),
), ),
), ),
@@ -1275,7 +1406,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: IconButton( child: IconButton(
icon: const Icon( icon: const Icon(
Icons.arrow_back, Icons.arrow_back,
color: Colors.white, color: AppColors.onSurface,
), ),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
@@ -1306,7 +1437,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text( Text(
title, title,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -1318,7 +1449,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text( Text(
_currentTime, _currentTime,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: AppColors.onSurface70,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -1411,7 +1542,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7), color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10), border: Border.all(color: AppColors.onSurface12),
), ),
child: IntrinsicHeight( child: IntrinsicHeight(
child: Row( child: Row(
@@ -1429,10 +1560,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable( TVFocusable(
focusNode: _prevFocusNode, focusNode: _prevFocusNode,
onPressed: _playPrevious, onPressed: _playPrevious,
onFocus: _resetControlsTimer, onFocus: () => _noteFocus(_prevFocusNode),
child: const Icon( child: const Icon(
Icons.skip_previous, Icons.skip_previous,
color: Colors.white, color: AppColors.onSurface,
size: 36, size: 36,
), ),
), ),
@@ -1442,12 +1573,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable( TVFocusable(
focusNode: _playPauseFocusNode, focusNode: _playPauseFocusNode,
onPressed: _togglePlayPause, onPressed: _togglePlayPause,
onFocus: _resetControlsTimer, onFocus: () => _noteFocus(_playPauseFocusNode),
child: Icon( child: Icon(
_isPlaying _isPlaying
? Icons.pause_circle_filled ? Icons.pause_circle_filled
: Icons.play_circle_filled, : Icons.play_circle_filled,
color: Colors.white, color: AppColors.onSurface,
size: 56, size: 56,
), ),
), ),
@@ -1457,10 +1588,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable( TVFocusable(
focusNode: _nextFocusNode, focusNode: _nextFocusNode,
onPressed: _playNext, onPressed: _playNext,
onFocus: _resetControlsTimer, onFocus: () => _noteFocus(_nextFocusNode),
child: const Icon( child: const Icon(
Icons.skip_next, Icons.skip_next,
color: Colors.white, color: AppColors.onSurface,
size: 36, size: 36,
), ),
), ),
@@ -1496,7 +1627,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
onFocus: _resetControlsTimer, onFocus: _resetControlsTimer,
child: const Icon( child: const Icon(
Icons.grid_off, Icons.grid_off,
color: Colors.white, color: AppColors.onSurface,
size: 32, size: 32,
), ),
), ),
@@ -1544,7 +1675,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
imageUrl: widget.channels![_currentIndex].streamIcon, imageUrl: widget.channels![_currentIndex].streamIcon,
fit: BoxFit.contain, fit: BoxFit.contain,
errorWidget: (_, __, ___) => errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white38), const Icon(Icons.tv, color: AppColors.onSurface38),
), ),
), ),
Expanded( Expanded(
@@ -1554,7 +1685,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text( Text(
title, title,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: AppColors.onSurface70,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -1565,7 +1696,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text( Text(
nowPlaying, nowPlaying,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -1580,7 +1711,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text( Text(
startTime, startTime,
style: const TextStyle( style: const TextStyle(
color: Colors.white54, fontSize: 11), color: AppColors.onSurface54, fontSize: 11),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
@@ -1589,9 +1720,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: epgProgress.clamp(0.0, 1.0), value: epgProgress.clamp(0.0, 1.0),
minHeight: 4, minHeight: 4,
backgroundColor: Colors.white24, backgroundColor: AppColors.onSurface24,
valueColor: const AlwaysStoppedAnimation<Color>( valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF4FC3F7), AppColors.primary,
), ),
), ),
), ),
@@ -1600,7 +1731,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text( Text(
endTime, endTime,
style: const TextStyle( style: const TextStyle(
color: Colors.white54, fontSize: 11), color: AppColors.onSurface54, fontSize: 11),
), ),
], ],
), ),
@@ -1640,7 +1771,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.82), color: Colors.black.withOpacity(0.82),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white12), border: Border.all(color: AppColors.onSurface12),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -1654,7 +1785,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
margin: const EdgeInsets.only(right: 16), margin: const EdgeInsets.only(right: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Colors.white10, color: AppColors.onSurface12,
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -1662,7 +1793,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
imageUrl: ch.streamIcon, imageUrl: ch.streamIcon,
fit: BoxFit.contain, fit: BoxFit.contain,
errorWidget: (_, __, ___) => errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white38, size: 32), const Icon(Icons.tv, color: AppColors.onSurface38, size: 32),
), ),
), ),
) )
@@ -1673,9 +1804,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
margin: const EdgeInsets.only(right: 16), margin: const EdgeInsets.only(right: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
color: Colors.white10, color: AppColors.onSurface12,
), ),
child: const Icon(Icons.tv, color: Colors.white38, size: 32), child: const Icon(Icons.tv, color: AppColors.onSurface38, size: 32),
), ),
// Channel info // Channel info
@@ -1691,13 +1822,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
horizontal: 6, vertical: 2), horizontal: 6, vertical: 2),
margin: const EdgeInsets.only(right: 8), margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1565C0), color: const AppColors.primaryFill,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( child: Text(
'CH $chNum', 'CH $chNum',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -1707,7 +1838,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: Text( child: Text(
ch.name, ch.name,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -1723,9 +1854,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: SizedBox( child: SizedBox(
height: 3, height: 3,
child: LinearProgressIndicator( child: LinearProgressIndicator(
backgroundColor: Colors.white24, backgroundColor: AppColors.onSurface24,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF4FC3F7)), AppColors.primary),
), ),
), ),
) )
@@ -1734,7 +1865,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text( Text(
nowPlaying, nowPlaying,
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: AppColors.onSurface70,
fontSize: 13, fontSize: 13,
), ),
maxLines: 1, maxLines: 1,
@@ -1746,9 +1877,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: epgProgress.clamp(0.0, 1.0), value: epgProgress.clamp(0.0, 1.0),
minHeight: 3, minHeight: 3,
backgroundColor: Colors.white24, backgroundColor: AppColors.onSurface24,
valueColor: const AlwaysStoppedAnimation<Color>( valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF4FC3F7)), AppColors.primary),
), ),
), ),
], ],
@@ -1787,18 +1918,18 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
decoration: const BoxDecoration( decoration: const BoxDecoration(
border: Border( border: Border(
bottom: BorderSide(color: Colors.white12), bottom: BorderSide(color: AppColors.onSurface12),
), ),
), ),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.list, color: Colors.white70, size: 18), const Icon(Icons.list, color: AppColors.onSurface70, size: 18),
const SizedBox(width: 8), const SizedBox(width: 8),
const Expanded( const Expanded(
child: Text( child: Text(
'Chaînes', 'Chaînes',
style: TextStyle( style: TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -1807,7 +1938,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
GestureDetector( GestureDetector(
onTap: () => setState(() => _showChannelList = false), onTap: () => setState(() => _showChannelList = false),
child: const Icon(Icons.close, child: const Icon(Icons.close,
color: Colors.white54, size: 18), color: AppColors.onSurface54, size: 18),
), ),
], ],
), ),
@@ -1829,12 +1960,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: isActive color: isActive
? const Color(0xFF1565C0).withOpacity(0.6) ? AppColors.primaryFill.withValues(alpha: 0.6)
: Colors.transparent, : Colors.transparent,
border: isActive border: isActive
? const Border( ? const Border(
left: BorderSide( left: BorderSide(
color: Color(0xFF4FC3F7), color: AppColors.primary,
width: 3, width: 3,
), ),
) )
@@ -1850,7 +1981,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
height: 40, height: 40,
margin: const EdgeInsets.only(right: 10), margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white10, color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: ch.streamIcon.isNotEmpty child: ch.streamIcon.isNotEmpty
@@ -1861,12 +1992,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
fit: BoxFit.contain, fit: BoxFit.contain,
errorWidget: (_, __, ___) => errorWidget: (_, __, ___) =>
const Icon(Icons.tv, const Icon(Icons.tv,
color: Colors.white38, color: AppColors.onSurface38,
size: 20), size: 20),
), ),
) )
: const Icon(Icons.tv, : const Icon(Icons.tv,
color: Colors.white38, size: 20), color: AppColors.onSurface38, size: 20),
), ),
// Name + num // Name + num
Expanded( Expanded(
@@ -1878,8 +2009,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
ch.name, ch.name,
style: TextStyle( style: TextStyle(
color: isActive color: isActive
? Colors.white ? AppColors.onSurface
: Colors.white70, : AppColors.onSurface70,
fontSize: 13, fontSize: 13,
fontWeight: isActive fontWeight: isActive
? FontWeight.bold ? FontWeight.bold
@@ -1891,7 +2022,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text( Text(
'CH ${index + 1}', 'CH ${index + 1}',
style: const TextStyle( style: const TextStyle(
color: Colors.white38, color: AppColors.onSurface38,
fontSize: 11, fontSize: 11,
), ),
), ),
@@ -1902,7 +2033,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
if (isActive) if (isActive)
const Icon( const Icon(
Icons.play_arrow, Icons.play_arrow,
color: Color(0xFF4FC3F7), color: AppColors.primary,
size: 18, size: 18,
), ),
], ],
@@ -1942,12 +2073,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_player.seek(pos - const Duration(seconds: 10)); _player.seek(pos - const Duration(seconds: 10));
_resetControlsTimer(); _resetControlsTimer();
}, },
onFocus: _resetControlsTimer, onFocus: () => _noteFocus(_prevFocusNode),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
child: IconButton( child: IconButton(
icon: const Icon( icon: const Icon(
Icons.replay_10, Icons.replay_10,
color: Colors.white, color: AppColors.onSurface,
size: 48, size: 48,
), ),
onPressed: () async { onPressed: () async {
@@ -1963,7 +2094,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
TVFocusable( TVFocusable(
focusNode: _playPauseFocusNode, focusNode: _playPauseFocusNode,
onPressed: _togglePlayPause, onPressed: _togglePlayPause,
onFocus: _resetControlsTimer, onFocus: () => _noteFocus(_playPauseFocusNode),
scale: 1.1, scale: 1.1,
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
child: IconButton( child: IconButton(
@@ -1972,7 +2103,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_isPlaying _isPlaying
? Icons.pause_circle_filled ? Icons.pause_circle_filled
: Icons.play_circle_filled, : Icons.play_circle_filled,
color: Colors.white, color: AppColors.onSurface,
), ),
onPressed: _togglePlayPause, onPressed: _togglePlayPause,
), ),
@@ -1988,12 +2119,12 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_player.seek(pos + const Duration(seconds: 10)); _player.seek(pos + const Duration(seconds: 10));
_resetControlsTimer(); _resetControlsTimer();
}, },
onFocus: _resetControlsTimer, onFocus: () => _noteFocus(_nextFocusNode),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
child: IconButton( child: IconButton(
icon: const Icon( icon: const Icon(
Icons.forward_10, Icons.forward_10,
color: Colors.white, color: AppColors.onSurface,
size: 48, size: 48,
), ),
onPressed: () async { onPressed: () async {
@@ -2029,7 +2160,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text( Text(
_formatDuration(_position), _formatDuration(_position),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 14, fontSize: 14,
), ),
), ),
@@ -2083,14 +2214,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_resetControlsTimer(); _resetControlsTimer();
}, },
activeColor: AppColors.primary, activeColor: AppColors.primary,
inactiveColor: Colors.white24, inactiveColor: AppColors.onSurface24,
), ),
), ),
), ),
Text( Text(
_formatDuration(_duration), _formatDuration(_duration),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 14, fontSize: 14,
), ),
), ),
@@ -2125,7 +2256,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
height: 72, height: 72,
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white10, color: AppColors.onSurface12,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: ClipRRect( child: ClipRRect(
@@ -2134,7 +2265,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
imageUrl: ch.streamIcon, imageUrl: ch.streamIcon,
fit: BoxFit.contain, fit: BoxFit.contain,
errorWidget: (_, __, ___) => errorWidget: (_, __, ___) =>
const Icon(Icons.tv, color: Colors.white38, size: 40), const Icon(Icons.tv, color: AppColors.onSurface38, size: 40),
), ),
), ),
), ),
@@ -2143,7 +2274,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Text( Text(
ch != null ? ch.name : 'Chargement...', ch != null ? ch.name : 'Chargement...',
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: AppColors.onSurface70,
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -2158,18 +2289,18 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red), const Icon(Icons.error_outline, size: 64, color: AppColors.error),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
'Erreur de lecture', 'Erreur de lecture',
style: TextStyle(color: Colors.white, fontSize: 18), style: TextStyle(color: AppColors.onSurface, fontSize: 18),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 32), padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text( child: Text(
_errorMessage ?? 'Une erreur inconnue est survenue', _errorMessage ?? 'Une erreur inconnue est survenue',
style: const TextStyle(color: Colors.white70), style: const TextStyle(color: AppColors.onSurface70),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -2182,8 +2313,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
label: const Text('Retour'), label: const Text('Retour'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.white, backgroundColor: AppColors.onSurface,
foregroundColor: Colors.black, foregroundColor: AppColors.onPrimaryFixed,
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -2193,7 +2324,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
label: const Text('Réessayer'), label: const Text('Réessayer'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
foregroundColor: Colors.black, foregroundColor: AppColors.onPrimaryFixed,
), ),
), ),
], ],
@@ -2254,7 +2385,7 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.7), color: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withOpacity(0.5)), border: Border.all(color: AppColors.success.withOpacity(0.5)),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -2263,7 +2394,7 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
const Text( const Text(
'STATS FOR NERDS 🤓', 'STATS FOR NERDS 🤓',
style: TextStyle( style: TextStyle(
color: Colors.green, color: AppColors.success,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 12, fontSize: 12,
), ),
@@ -2271,11 +2402,11 @@ class _StatsOverlayWidgetState extends ConsumerState<StatsOverlayWidget> {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'Buffer: ${_buffer.inSeconds}s', 'Buffer: ${_buffer.inSeconds}s',
style: const TextStyle(color: Colors.white, fontSize: 12), style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
), ),
Text( Text(
'Decoder: $_decoder', 'Decoder: $_decoder',
style: const TextStyle(color: Colors.white, fontSize: 12), style: const TextStyle(color: AppColors.onSurface, fontSize: 12),
), ),
], ],
), ),
@@ -0,0 +1,628 @@
/// XMLTV EPG fallback service
///
/// Used when the IPTV provider's `get_short_epg` returns nothing for a channel.
/// Resolution order is decided by the caller (see [XtremServiceMobile]); this
/// service only knows how to fetch, parse, index and cache one XMLTV source.
///
/// Design constraints:
/// - XMLTV dumps are routinely 50-300 MB uncompressed. Parsing is done as a
/// **streaming** pass (`toXmlEvents`) so the whole document is never held in
/// memory, and only programmes inside a short time window are kept.
/// - The reduced index is persisted to disk, so a cold start costs one small
/// JSON read instead of a multi-megabyte download.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:xml/xml.dart';
import 'package:xml/xml_events.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
/// One XMLTV source to try, in priority order.
class XmltvSource {
/// Human-readable origin, surfaced in settings/diagnostics.
final String label;
final String url;
final Map<String, String>? headers;
const XmltvSource({
required this.label,
required this.url,
this.headers,
});
}
/// A single programme kept in the in-memory index.
class XmltvProgramme {
final DateTime start;
final DateTime end;
final String title;
final String description;
const XmltvProgramme({
required this.start,
required this.end,
required this.title,
this.description = '',
});
bool covers(DateTime at) => !at.isBefore(start) && at.isBefore(end);
List<dynamic> toCache() => [
start.toIso8601String(),
end.toIso8601String(),
title,
description,
];
static XmltvProgramme? fromCache(dynamic raw) {
if (raw is! List || raw.length < 4) return null;
final start = DateTime.tryParse(raw[0].toString());
final end = DateTime.tryParse(raw[1].toString());
if (start == null || end == null) return null;
return XmltvProgramme(
start: start,
end: end,
title: raw[2].toString(),
description: raw[3].toString(),
);
}
}
class XmltvService {
XmltvService(this.cacheDir);
final String cacheDir;
/// How long a downloaded+indexed guide stays usable before a refetch.
static const Duration cacheTtl = Duration(hours: 6);
/// Only programmes overlapping [now - back, now + forward] are indexed.
/// Keeps a national guide down to a few hundred KB instead of hundreds of MB.
static const Duration windowBack = Duration(hours: 3);
static const Duration windowForward = Duration(hours: 18);
/// Hard ceiling on the download, so a hostile or broken URL can't fill
/// the device storage or spin forever.
static const int maxDownloadBytes = 220 * 1024 * 1024;
final Map<String, List<XmltvProgramme>> _byChannelId = {};
final Map<String, String> _normalizedNameToChannelId = {};
String? _loadedSourceLabel;
DateTime? _loadedAt;
Future<bool>? _inFlight;
/// True once at least one source has been indexed successfully.
bool get isLoaded => _byChannelId.isNotEmpty;
/// Label of the source currently backing the index (`null` if none).
String? get loadedSourceLabel => _loadedSourceLabel;
int get channelCount => _byChannelId.length;
bool get _isStale {
final at = _loadedAt;
if (at == null) return true;
return DateTime.now().difference(at) > cacheTtl;
}
/// Load the first source that yields a usable guide.
///
/// Concurrent callers share a single in-flight attempt. Returns `false` when
/// every source failed — callers should then simply render "no EPG".
Future<bool> ensureLoaded(List<XmltvSource> sources) {
if (isLoaded && !_isStale) return Future.value(true);
return _inFlight ??= _loadFirstAvailable(sources).whenComplete(() {
_inFlight = null;
});
}
Future<bool> _loadFirstAvailable(List<XmltvSource> sources) async {
for (final source in sources) {
if (source.url.trim().isEmpty) continue;
try {
if (await _loadFromDiskCache(source)) {
_loadedSourceLabel = source.label;
if (kDebugMode) {
print(
'✅ [XMLTV] Disk cache hit "${source.label}" '
'($channelCount channels)',
);
}
return true;
}
if (await _download(source)) {
_loadedSourceLabel = source.label;
_loadedAt = DateTime.now();
unawaited(_writeDiskCache(source));
if (kDebugMode) {
print(
'✅ [XMLTV] Indexed "${source.label}" '
'($channelCount channels)',
);
}
return true;
}
if (kDebugMode) {
print('⚠️ [XMLTV] "${source.label}" returned no usable programme');
}
} catch (e) {
if (kDebugMode) print('⚠️ [XMLTV] "${source.label}" failed: $e');
}
}
return false;
}
// ─── Lookup ────────────────────────────────────────────────────────────────
/// Find the programme airing at [at] for a channel.
///
/// Matching is tried on the Xtream `epg_channel_id` first (the reliable key),
/// then on a normalized channel name, which is what rescues providers that
/// ship empty or non-standard `epg_channel_id` values.
XmltvProgramme? programmeFor({
String? epgChannelId,
String? channelName,
DateTime? at,
}) {
final moment = at ?? DateTime.now();
final programmes = _programmesFor(
epgChannelId: epgChannelId,
channelName: channelName,
);
if (programmes == null || programmes.isEmpty) return null;
for (final programme in programmes) {
if (programme.covers(moment)) return programme;
}
// Nothing airing right now (guide gap): fall back to the next entry so the
// UI shows something meaningful rather than an empty slot.
for (final programme in programmes) {
if (programme.start.isAfter(moment)) return programme;
}
return null;
}
/// Same lookup, shaped as the app's [model.ShortEPG].
model.ShortEPG? shortEpgFor({
required String streamId,
String? epgChannelId,
String? channelName,
DateTime? at,
}) {
final programme = programmeFor(
epgChannelId: epgChannelId,
channelName: channelName,
at: at,
);
if (programme == null || programme.title.isEmpty) return null;
return model.ShortEPG(
id: streamId,
title: programme.title,
start: _formatLocal(programme.start),
end: _formatLocal(programme.end),
description: programme.description,
);
}
List<XmltvProgramme>? _programmesFor({
String? epgChannelId,
String? channelName,
}) {
final id = epgChannelId?.trim().toLowerCase();
if (id != null && id.isNotEmpty) {
final direct = _byChannelId[id];
if (direct != null && direct.isNotEmpty) return direct;
}
final normalized = normalizeName(channelName ?? '');
if (normalized.isEmpty) return null;
final mapped = _normalizedNameToChannelId[normalized];
if (mapped == null) return null;
return _byChannelId[mapped];
}
void clear() {
_byChannelId.clear();
_normalizedNameToChannelId.clear();
_loadedSourceLabel = null;
_loadedAt = null;
}
// ─── Download & streaming parse ────────────────────────────────────────────
Future<bool> _download(XmltvSource source) async {
final uri = Uri.tryParse(source.url.trim());
if (uri == null || !uri.hasScheme) return false;
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 20)
// `.xml.gz` bodies arrive as a gzip *payload*, not a gzip
// Content-Encoding, so decompression is handled explicitly below.
..autoUncompress = false;
try {
final request = await client.getUrl(uri);
request.headers.set(HttpHeaders.acceptEncodingHeader, 'gzip');
request.headers.set(HttpHeaders.userAgentHeader, 'XtremFlow/1.0');
source.headers?.forEach(request.headers.set);
final response = await request.close().timeout(
const Duration(seconds: 30),
);
if (response.statusCode != 200) {
if (kDebugMode) {
print('⚠️ [XMLTV] HTTP ${response.statusCode} on ${source.label}');
}
return false;
}
return indexFromBytes(_capped(response));
} on TimeoutException {
if (kDebugMode) print('⏱️ [XMLTV] Timeout on ${source.label}');
return false;
} finally {
client.close(force: true);
}
}
/// Parse and index an XMLTV payload from a byte stream.
///
/// Split out of [_download] so the parsing path is testable without HTTP.
/// Gzip is detected from the payload itself, and only programmes overlapping
/// the current window are retained.
@visibleForTesting
Future<bool> indexFromBytes(
Stream<List<int>> bytes, {
DateTime? now,
}) async {
final window = _Window.around(now ?? DateTime.now());
final channelNames = <String, List<String>>{};
final programmes = <String, List<XmltvProgramme>>{};
final events = _decompressed(bytes)
.transform(const Utf8Decoder(allowMalformed: true))
.toXmlEvents()
.normalizeEvents()
.selectSubtreeEvents(
(event) => event.name == 'channel' || event.name == 'programme',
)
.toXmlNodes()
.flatten();
await for (final node in events) {
if (node is! XmlElement) continue;
if (node.name.local == 'channel') {
_indexChannelElement(node, channelNames);
} else {
_indexProgrammeElement(node, window, programmes);
}
}
if (programmes.isEmpty) return false;
_commit(programmes: programmes, channelNames: channelNames);
_loadedAt = DateTime.now();
return true;
}
void _indexChannelElement(
XmlElement node,
Map<String, List<String>> channelNames,
) {
final id = node.getAttribute('id')?.trim().toLowerCase();
if (id == null || id.isEmpty) return;
final names = node
.findElements('display-name')
.map((e) => e.innerText.trim())
.where((e) => e.isNotEmpty)
.toList();
if (names.isEmpty) return;
channelNames.putIfAbsent(id, () => []).addAll(names);
}
void _indexProgrammeElement(
XmlElement node,
_Window window,
Map<String, List<XmltvProgramme>> programmes,
) {
final channel = node.getAttribute('channel')?.trim().toLowerCase();
if (channel == null || channel.isEmpty) return;
final start = parseXmltvTime(node.getAttribute('start'));
final end = parseXmltvTime(node.getAttribute('stop'));
if (start == null || end == null) return;
if (!window.overlaps(start, end)) return;
final title = node
.findElements('title')
.map((e) => e.innerText.trim())
.firstWhere((e) => e.isNotEmpty, orElse: () => '')
.trim();
if (title.isEmpty) return;
final description = node
.findElements('desc')
.map((e) => e.innerText.trim())
.firstWhere((e) => e.isNotEmpty, orElse: () => '');
programmes.putIfAbsent(channel, () => []).add(
XmltvProgramme(
start: start,
end: end,
title: title,
description: description,
),
);
}
void _commit({
required Map<String, List<XmltvProgramme>> programmes,
required Map<String, List<String>> channelNames,
}) {
_byChannelId
..clear()
..addAll(programmes);
for (final list in _byChannelId.values) {
list.sort((a, b) => a.start.compareTo(b.start));
}
_normalizedNameToChannelId.clear();
// Channel ids are themselves readable labels in most community dumps
// (`France.2..fr`, `Trace.Toca.fr`), so they are indexed as name
// candidates too — this rescues channels that carry no <display-name>.
for (final id in _byChannelId.keys) {
final fromId = normalizeName(_labelFromChannelId(id));
if (fromId.isNotEmpty) {
_normalizedNameToChannelId.putIfAbsent(fromId, () => id);
}
}
channelNames.forEach((id, names) {
if (!_byChannelId.containsKey(id)) return;
for (final name in names) {
final normalized = normalizeName(name);
if (normalized.isEmpty) continue;
_normalizedNameToChannelId[normalized] = id;
}
});
}
/// Enforce [maxDownloadBytes] mid-stream.
Stream<List<int>> _capped(Stream<List<int>> input) async* {
var total = 0;
await for (final chunk in input) {
total += chunk.length;
if (total > maxDownloadBytes) {
throw StateError('XMLTV payload exceeds ${maxDownloadBytes ~/ 1048576}MB');
}
yield chunk;
}
}
/// Transparently gunzip when the payload starts with the gzip magic number.
Stream<List<int>> _decompressed(Stream<List<int>> input) async* {
final iterator = StreamIterator(input);
try {
if (!await iterator.moveNext()) return;
var head = iterator.current;
while (head.length < 2 && await iterator.moveNext()) {
head = [...head, ...iterator.current];
}
Stream<List<int>> replayed() async* {
yield head;
while (await iterator.moveNext()) {
yield iterator.current;
}
}
final isGzip = head.length >= 2 && head[0] == 0x1f && head[1] == 0x8b;
yield* isGzip ? gzip.decoder.bind(replayed()) : replayed();
} finally {
await iterator.cancel();
}
}
// ─── Disk cache ────────────────────────────────────────────────────────────
File _cacheFile(XmltvSource source) {
final key = source.url.hashCode.toUnsigned(32).toRadixString(16);
return File('$cacheDir${Platform.pathSeparator}xmltv_$key.json');
}
Future<bool> _loadFromDiskCache(XmltvSource source) async {
final file = _cacheFile(source);
if (!await file.exists()) return false;
final fetchedAt = await file.lastModified();
if (DateTime.now().difference(fetchedAt) > cacheTtl) return false;
try {
final decoded = json.decode(await file.readAsString());
if (decoded is! Map) return false;
final rawChannels = decoded['channels'];
if (rawChannels is! Map) return false;
final programmes = <String, List<XmltvProgramme>>{};
rawChannels.forEach((id, entries) {
if (entries is! List) return;
final parsed = entries
.map(XmltvProgramme.fromCache)
.whereType<XmltvProgramme>()
.toList();
if (parsed.isNotEmpty) programmes[id.toString()] = parsed;
});
if (programmes.isEmpty) return false;
final rawNames = decoded['names'];
final channelNames = <String, List<String>>{};
if (rawNames is Map) {
rawNames.forEach((normalized, id) {
channelNames
.putIfAbsent(id.toString(), () => [])
.add(normalized.toString());
});
}
_commit(programmes: programmes, channelNames: channelNames);
_loadedAt = fetchedAt;
return true;
} catch (e) {
if (kDebugMode) print('⚠️ [XMLTV] Corrupt disk cache, ignoring: $e');
return false;
}
}
Future<void> _writeDiskCache(XmltvSource source) async {
try {
final payload = {
'source': source.label,
'url': source.url,
'fetchedAt': DateTime.now().toIso8601String(),
'channels': _byChannelId.map(
(id, list) => MapEntry(id, list.map((p) => p.toCache()).toList()),
),
'names': _normalizedNameToChannelId,
};
await _cacheFile(source).writeAsString(json.encode(payload), flush: true);
} catch (e) {
if (kDebugMode) print('⚠️ [XMLTV] Could not persist cache: $e');
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────
/// Parse an XMLTV timestamp: `20260811183000 +0200` (offset optional).
static DateTime? parseXmltvTime(String? raw) {
final value = raw?.trim();
if (value == null || value.length < 14) return null;
int? part(int start, int end) => int.tryParse(value.substring(start, end));
final year = part(0, 4);
final month = part(4, 6);
final day = part(6, 8);
final hour = part(8, 10);
final minute = part(10, 12);
final second = part(12, 14);
if (year == null ||
month == null ||
day == null ||
hour == null ||
minute == null ||
second == null) {
return null;
}
final utc = DateTime.utc(year, month, day, hour, minute, second);
final offset = value.substring(14).trim();
if (offset.isEmpty) return utc.toLocal();
final match = RegExp(r'^([+-])(\d{2})(\d{2})$').firstMatch(offset);
if (match == null) return utc.toLocal();
final sign = match.group(1) == '-' ? -1 : 1;
final offsetDuration = Duration(
hours: int.parse(match.group(2)!),
minutes: int.parse(match.group(3)!),
);
return utc.subtract(offsetDuration * sign).toLocal();
}
/// Turn an XMLTV channel id into the label it encodes.
///
/// `France.2..fr` → `France 2`, `Trace.Toca.fr` → `Trace Toca`.
static String _labelFromChannelId(String id) {
var value = id.replaceAll(
RegExp(
r'\.(fr|uk|us|de|es|it|be|ch|ca|nl|pt|pl|ar|br|tr|ma|dz|tn)$',
caseSensitive: false,
),
'',
);
return value.replaceAll('.', ' ').trim();
}
/// Reduce a channel label to a comparable key.
///
/// Strips accents, quality suffixes (`HD`, `FHD`, `4K`, …) and any
/// punctuation, so `TF1 HD`, `tf1-fhd` and `TF1` all collapse to `tf1`.
static String normalizeName(String raw) {
var value = raw.toLowerCase().trim();
if (value.isEmpty) return '';
const accents = 'àáâãäåçèéêëìíîïñòóôõöùúûüýÿ';
const plain = 'aaaaaaceeeeiiiinooooouuuuyy';
final buffer = StringBuffer();
for (final rune in value.runes) {
final char = String.fromCharCode(rune);
final index = accents.indexOf(char);
buffer.write(index >= 0 ? plain[index] : char);
}
value = buffer.toString();
// Drop the country prefix providers prepend.
// Bracketed form — "|FR| TF1", "[FR] TF1", "(FR) TF1".
value = value.replaceFirst(
RegExp(r'^\s*[\|\[\(][a-z]{2,4}[\|\]\)]\s*'),
'',
);
// Bare form — "FR: TF1". A colon is required: accepting a dash too would
// eat legitimate names like "Ciné - Premier".
value = value.replaceFirst(RegExp(r'^\s*[a-z]{2,3}\s*:\s*'), '');
value = value.replaceAll(RegExp(r'[^a-z0-9+]'), '');
for (final suffix in const [
'fullhd',
'ultrahd',
'uhd',
'fhd',
'hdr',
'4k',
'1080p',
'720p',
'hd',
'sd',
]) {
if (value.length > suffix.length && value.endsWith(suffix)) {
value = value.substring(0, value.length - suffix.length);
break;
}
}
return value;
}
static String _formatLocal(DateTime value) {
String two(int n) => n.toString().padLeft(2, '0');
return '${value.year}-${two(value.month)}-${two(value.day)} '
'${two(value.hour)}:${two(value.minute)}:${two(value.second)}';
}
}
class _Window {
final DateTime from;
final DateTime to;
const _Window(this.from, this.to);
factory _Window.around(DateTime now) => _Window(
now.subtract(XmltvService.windowBack),
now.add(XmltvService.windowForward),
);
bool overlaps(DateTime start, DateTime end) =>
end.isAfter(from) && start.isBefore(to);
}
@@ -7,8 +7,38 @@ library;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:async'; import 'dart:async';
import 'dart:io';
import '../models/xtream_models.dart' as model; import '../models/xtream_models.dart' as model;
import 'package:xtremobile/core/api/dns_resolver.dart';
import 'package:xtremobile/core/models/playlist_config.dart'; import 'package:xtremobile/core/models/playlist_config.dart';
import 'xmltv_service.dart';
/// Counting semaphore used to cap concurrent EPG requests.
class _Semaphore {
_Semaphore(this.maxConcurrent);
final int maxConcurrent;
int _current = 0;
final _waiting = <Completer<void>>[];
Future<void> acquire() {
if (_current < maxConcurrent) {
_current++;
return Future.value();
}
final completer = Completer<void>();
_waiting.add(completer);
return completer.future;
}
void release() {
if (_waiting.isNotEmpty) {
_waiting.removeAt(0).complete();
return;
}
if (_current > 0) _current--;
}
}
/// EPG cache entry with TTL /// EPG cache entry with TTL
class _EpgCacheEntry { class _EpgCacheEntry {
@@ -22,19 +52,32 @@ class _EpgCacheEntry {
DateTime.now().difference(timestamp).inSeconds > ttlSeconds; DateTime.now().difference(timestamp).inSeconds > ttlSeconds;
} }
/// Default community XMLTV guides, used only when the provider has no EPG of
/// its own. Ordered by preference; the first one that yields programmes wins.
const List<XmltvSource> kDefaultPublicXmltvSources = [
XmltvSource(
label: 'EPGShare FR1',
url: 'https://epgshare01.online/epgshare01/epg_ripper_FR1.xml.gz',
),
];
/// Xtream API Service for Mobile /// Xtream API Service for Mobile
/// ///
/// Optimized for Android with: /// Optimized for Android with:
/// - Batch EPG loading (N+1 prevention) /// - Batch EPG loading (N+1 prevention)
/// - Provider-level caching with TTL /// - Provider-level caching with TTL
/// - XMLTV fallback when the provider serves no EPG
/// - Configurable timeouts and retries /// - Configurable timeouts and retries
/// - Memory-efficient streaming /// - Memory-efficient streaming
class XtreamServiceMobile { class XtreamServiceMobile {
final String cacheDir; final String cacheDir;
late Dio _dio;
late String _baseUrl; /// Replaced with the configured instance in [setPlaylistAsync]; initialised
late String _username; /// eagerly so [dispose] is safe even if the service was never configured.
late String _password; Dio _dio = Dio();
String _baseUrl = '';
String _username = '';
String _password = '';
// Batch EPG cache to prevent N+1 queries // Batch EPG cache to prevent N+1 queries
final Map<String, _EpgCacheEntry> _epgBatchCache = {}; final Map<String, _EpgCacheEntry> _epgBatchCache = {};
@@ -42,8 +85,54 @@ class XtreamServiceMobile {
// In-flight batch requests to deduplicate concurrent calls // In-flight batch requests to deduplicate concurrent calls
final Map<String, Future<Map<String, model.ShortEPG>>> _inFlightBatches = {}; final Map<String, Future<Map<String, model.ShortEPG>>> _inFlightBatches = {};
/// A channel grid asks for EPG one tile at a time, so a screenful fires ~30
/// simultaneous `player_api.php` calls. Many Xtream panels rate-limit past a
/// handful of parallel connections and answer with errors, which empties the
/// EPG of the whole grid. Requests are therefore funnelled in small packets.
static final _Semaphore _epgGate = _Semaphore(3);
/// streamId → channel, populated by [getLiveChannels].
///
/// The XMLTV fallback matches on `epg_channel_id` / channel name, neither of
/// which the per-stream EPG calls carry — this index supplies them without
/// changing any call site.
final Map<String, model.Channel> _channelIndex = {};
late final XmltvService _xmltv = XmltvService(cacheDir);
/// User-provided XMLTV URL (empty = not configured).
String _customXmltvUrl = '';
/// Whether the community guides may be used as the last resort.
bool _allowPublicXmltv = true;
/// Set once every XMLTV source has failed, so a channel grid does not retry
/// a multi-megabyte download for each of its tiles.
bool _xmltvExhausted = false;
XtreamServiceMobile(this.cacheDir); XtreamServiceMobile(this.cacheDir);
/// Label of the XMLTV source currently backing the fallback, if any.
String? get xmltvSourceLabel => _xmltv.loadedSourceLabel;
/// Configure the XMLTV fallback. Safe to call at any time.
void setXmltvConfig({String? customUrl, bool? allowPublicFallback}) {
final normalizedUrl = customUrl?.trim() ?? _customXmltvUrl;
final changed = normalizedUrl != _customXmltvUrl ||
(allowPublicFallback ?? _allowPublicXmltv) != _allowPublicXmltv;
_customXmltvUrl = normalizedUrl;
_allowPublicXmltv = allowPublicFallback ?? _allowPublicXmltv;
if (changed) {
// Sources changed — drop the "nothing works" latch and any indexed guide
// so the new configuration is actually tried.
_xmltvExhausted = false;
_xmltv.clear();
_epgBatchCache.clear();
}
}
/// Initialize with playlist configuration /// Initialize with playlist configuration
Future<void> setPlaylistAsync(PlaylistConfig config) async { Future<void> setPlaylistAsync(PlaylistConfig config) async {
_baseUrl = config.dns; _baseUrl = config.dns;
@@ -59,11 +148,56 @@ class XtreamServiceMobile {
), ),
); );
// Warm the DNS entry for the panel host now, so the first zap does not pay
// for resolution. Fire-and-forget: playback must never wait on this.
unawaited(prewarmHost());
if (kDebugMode) { if (kDebugMode) {
print('✅ [XtreamServiceMobile] Initialized with URL: $_baseUrl'); print('✅ [XtreamServiceMobile] Initialized with URL: $_baseUrl');
} }
} }
/// Resolve and pre-connect to the panel host.
///
/// Opens a bare TCP/TLS socket and closes it immediately: no HTTP request is
/// issued, so this never consumes one of the provider's connection slots,
/// while still leaving the DNS entry cached and the route warm.
Future<void> prewarmHost() async {
final uri = Uri.tryParse(_baseUrl);
if (uri == null || uri.host.isEmpty) return;
try {
await DnsResolver.resolve(uri.host).timeout(
const Duration(seconds: 3),
onTimeout: () => null,
);
} catch (_) {
// DNS pre-resolution is best effort.
}
final isSecure = uri.scheme == 'https';
final port = uri.hasPort ? uri.port : (isSecure ? 443 : 80);
try {
final socket = isSecure
? await SecureSocket.connect(
uri.host,
port,
timeout: const Duration(milliseconds: 1200),
onBadCertificate: (_) => true,
)
: await Socket.connect(
uri.host,
port,
timeout: const Duration(milliseconds: 1200),
);
socket.destroy();
if (kDebugMode) print('🔥 [Prewarm] ${uri.host}:$port ready');
} catch (e) {
if (kDebugMode) print('⚠️ [Prewarm] ${uri.host}:$port failed: $e');
}
}
/// Get live channels for a category (with batch EPG support) /// Get live channels for a category (with batch EPG support)
/// If categoryId is empty, fetches ALL channels from all categories /// If categoryId is empty, fetches ALL channels from all categories
Future<List<model.Channel>> getLiveChannels(String categoryId) async { Future<List<model.Channel>> getLiveChannels(String categoryId) async {
@@ -98,6 +232,11 @@ class XtreamServiceMobile {
.map((e) => model.Channel.fromJson(e)) .map((e) => model.Channel.fromJson(e))
.toList(); .toList();
// Keep the EPG-matching keys reachable from a bare streamId.
for (final channel in channels) {
_channelIndex[channel.streamId] = channel;
}
if (kDebugMode) { if (kDebugMode) {
final categories = channels.map((c) => c.categoryName).toSet(); final categories = channels.map((c) => c.categoryName).toSet();
print( print(
@@ -195,6 +334,7 @@ class XtreamServiceMobile {
// Load EPG for each stream_id individually (API doesn't support true batch) // Load EPG for each stream_id individually (API doesn't support true batch)
for (final streamId in streamIds) { for (final streamId in streamIds) {
await _epgGate.acquire();
try { try {
final response = await _dio final response = await _dio
.get( .get(
@@ -244,6 +384,22 @@ class XtreamServiceMobile {
} }
} catch (e) { } catch (e) {
if (kDebugMode) print('⚠️ EPG failed for $streamId: $e'); if (kDebugMode) print('⚠️ EPG failed for $streamId: $e');
} finally {
_epgGate.release();
}
}
// ── Fallback: whatever the provider could not answer for ──────────────
final missing =
streamIds.where((id) => !result.containsKey(id)).toList(growable: false);
if (missing.isNotEmpty) {
final recovered = await _resolveFromXmltv(missing);
result.addAll(recovered);
if (kDebugMode && recovered.isNotEmpty) {
print(
'📺 XMLTV fallback recovered ${recovered.length}/${missing.length} '
'channels via "${_xmltv.loadedSourceLabel}"',
);
} }
} }
@@ -253,6 +409,59 @@ class XtreamServiceMobile {
return result; return result;
} }
/// XMLTV sources to try, in the order requested: the provider's own dump
/// first (it is often populated even when `get_short_epg` is not), then the
/// user's URL, then the community guides.
List<XmltvSource> _xmltvSources() {
return [
XmltvSource(
label: 'Fournisseur (xmltv.php)',
url: '$_baseUrl/xmltv.php?username=$_username&password=$_password',
),
if (_customXmltvUrl.isNotEmpty)
XmltvSource(label: 'URL personnalisée', url: _customXmltvUrl),
if (_allowPublicXmltv) ...kDefaultPublicXmltvSources,
];
}
/// Resolve missing EPG entries from the XMLTV index, loading it on demand.
Future<Map<String, model.ShortEPG>> _resolveFromXmltv(
List<String> streamIds,
) async {
if (_xmltvExhausted) return {};
// Nothing to match on: the channel list has not been loaded in this
// session, so neither epg_channel_id nor the name is known.
final known = streamIds
.map((id) => _channelIndex[id])
.whereType<model.Channel>()
.toList();
if (known.isEmpty) return {};
try {
final loaded = await _xmltv.ensureLoaded(_xmltvSources());
if (!loaded) {
_xmltvExhausted = true;
return {};
}
} catch (e) {
if (kDebugMode) print('⚠️ XMLTV load failed: $e');
_xmltvExhausted = true;
return {};
}
final resolved = <String, model.ShortEPG>{};
for (final channel in known) {
final epg = _xmltv.shortEpgFor(
streamId: channel.streamId,
epgChannelId: channel.epgChannelId,
channelName: channel.name,
);
if (epg != null) resolved[channel.streamId] = epg;
}
return resolved;
}
/// Get live TV categories /// Get live TV categories
Future<List<model.Category>> getLiveCategories() async { Future<List<model.Category>> getLiveCategories() async {
try { try {
@@ -530,6 +739,13 @@ class XtreamServiceMobile {
_inFlightBatches.clear(); _inFlightBatches.clear();
} }
/// Drop the XMLTV index (keeps its disk cache) and allow a fresh attempt.
void resetXmltv() {
_xmltv.clear();
_xmltvExhausted = false;
_epgBatchCache.clear();
}
/// Get Live Stream URL /// Get Live Stream URL
String getLiveStreamUrl(String streamId) { String getLiveStreamUrl(String streamId) {
return '$_baseUrl/live/$_username/$_password/$streamId.ts'; return '$_baseUrl/live/$_username/$_password/$streamId.ts';
@@ -564,5 +780,8 @@ class XtreamServiceMobile {
/// Dispose service resources /// Dispose service resources
void dispose() { void dispose() {
clearCache(); clearCache();
_channelIndex.clear();
_xmltv.clear();
_dio.close(force: true);
} }
} }
@@ -16,6 +16,7 @@ import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:xtremobile/core/services/ip_service.dart'; import 'package:xtremobile/core/services/ip_service.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model; import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileLiveTVTab extends ConsumerStatefulWidget { class MobileLiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist; final PlaylistConfig playlist;
@@ -130,14 +131,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
final shouldExit = await showDialog<bool>( final shouldExit = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: Colors.grey[900], backgroundColor: AppColors.surfaceContainerLowest,
title: const Text( title: const Text(
"Quitter l'application", "Quitter l'application",
style: TextStyle(color: Colors.white), style: TextStyle(color: AppColors.onSurface),
), ),
content: const Text( content: const Text(
"Voulez-vous vraiment quitter l'application ?", "Voulez-vous vraiment quitter l'application ?",
style: TextStyle(color: Colors.white70), style: TextStyle(color: AppColors.onSurface70),
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -145,14 +146,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
autofocus: true, autofocus: true,
child: const Text( child: const Text(
'Annuler', 'Annuler',
style: TextStyle(color: Colors.blueAccent), style: TextStyle(color: AppColors.primary),
), ),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: const Text( child: const Text(
'Quitter', 'Quitter',
style: TextStyle(color: Colors.redAccent), style: TextStyle(color: AppColors.error),
), ),
), ),
], ],
@@ -171,7 +172,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text( child: Text(
'Erreur: $e', 'Erreur: $e',
style: const TextStyle(color: Colors.red), style: const TextStyle(color: AppColors.error),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -235,7 +236,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
// Search Bar // Search Bar
TVFocusable( TVFocusable(
scale: 1.0, scale: 1.0,
focusColor: Colors.white, focusColor: AppColors.onSurface,
onPressed: () { onPressed: () {
setState(() => _isSearchEditing = true); setState(() => _isSearchEditing = true);
WidgetsBinding.instance WidgetsBinding.instance
@@ -263,7 +264,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
child: ExcludeFocus( child: ExcludeFocus(
excluding: !_isSearchEditing, excluding: !_isSearchEditing,
child: TextField( child: TextField(
cursorColor: Colors.white, cursorColor: AppColors.onSurface,
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode, focusNode: _searchFocusNode,
readOnly: !_isSearchEditing, readOnly: !_isSearchEditing,
@@ -357,7 +358,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
? Icons.favorite ? Icons.favorite
: Icons.favorite_border, : Icons.favorite_border,
color: _showFavoritesOnly color: _showFavoritesOnly
? const Color(0xFFFF453A) ? AppColors.live
: AppDecorations.textSecondary( : AppDecorations.textSecondary(
context), context),
), ),
@@ -535,7 +536,7 @@ class _ChannelListView extends ConsumerWidget {
child: Center( child: Center(
child: Text( child: Text(
'Aucune catégorie sélectionnée', 'Aucune catégorie sélectionnée',
style: TextStyle(color: Colors.white70), style: TextStyle(color: AppColors.onSurface70),
), ),
), ),
); );
@@ -557,7 +558,7 @@ class _ChannelListView extends ConsumerWidget {
showFavoritesOnly showFavoritesOnly
? 'Sélectionnez une catégorie pour voir vos favoris' ? 'Sélectionnez une catégorie pour voir vos favoris'
: 'Sélectionnez une catégorie pour rechercher', : 'Sélectionnez une catégorie pour rechercher',
style: const TextStyle(color: Colors.white70), style: const TextStyle(color: AppColors.onSurface70),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -575,7 +576,7 @@ class _ChannelListView extends ConsumerWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text( child: Text(
'Erreur: $e', 'Erreur: $e',
style: const TextStyle(color: Colors.red), style: const TextStyle(color: AppColors.error),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -600,7 +601,7 @@ class _ChannelListView extends ConsumerWidget {
child: Center( child: Center(
child: Text( child: Text(
'Aucune chaîne trouvée', 'Aucune chaîne trouvée',
style: TextStyle(color: Colors.white70), style: TextStyle(color: AppColors.onSurface70),
), ),
), ),
); );
@@ -754,12 +755,12 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red, color: AppColors.error,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: const Icon( child: const Icon(
Icons.favorite, Icons.favorite,
color: Colors.white, color: AppColors.onSurface,
size: 10, size: 10,
), ),
), ),
@@ -779,7 +780,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
Text( Text(
widget.channel.name, widget.channel.name,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 10, fontSize: 10,
), ),
@@ -792,7 +793,7 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
Text( Text(
_epgTitle!, _epgTitle!,
style: const TextStyle( style: const TextStyle(
color: Colors.yellow, color: AppColors.ratingGold,
fontSize: 8, fontSize: 8,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -11,6 +11,7 @@ import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart'; import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model; import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/features/iptv/screens/native_player_screen.dart'; import 'package:xtremobile/features/iptv/screens/native_player_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileMoviesTab extends ConsumerStatefulWidget { class MobileMoviesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist; final PlaylistConfig playlist;
@@ -285,14 +286,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
final shouldExit = await showDialog<bool>( final shouldExit = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: Colors.grey[900], backgroundColor: AppColors.surfaceContainerLowest,
title: const Text( title: const Text(
"Quitter l'application", "Quitter l'application",
style: TextStyle(color: Colors.white), style: TextStyle(color: AppColors.onSurface),
), ),
content: const Text( content: const Text(
"Voulez-vous vraiment quitter l'application ?", "Voulez-vous vraiment quitter l'application ?",
style: TextStyle(color: Colors.white70), style: TextStyle(color: AppColors.onSurface70),
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -300,14 +301,14 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
autofocus: true, autofocus: true,
child: const Text( child: const Text(
'Annuler', 'Annuler',
style: TextStyle(color: Colors.blueAccent), style: TextStyle(color: AppColors.primary),
), ),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: const Text( child: const Text(
'Quitter', 'Quitter',
style: TextStyle(color: Colors.redAccent), style: TextStyle(color: AppColors.error),
), ),
), ),
], ],
@@ -346,7 +347,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
// Search Bar // Search Bar
TVFocusable( TVFocusable(
scale: 1.0, scale: 1.0,
focusColor: Colors.white, focusColor: AppColors.onSurface,
onPressed: () { onPressed: () {
setState(() => _isSearchEditing = true); setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -367,7 +368,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
child: ExcludeFocus( child: ExcludeFocus(
excluding: !_isSearchEditing, excluding: !_isSearchEditing,
child: TextField( child: TextField(
cursorColor: Colors.white, cursorColor: AppColors.onSurface,
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode, focusNode: _searchFocusNode,
readOnly: !_isSearchEditing, readOnly: !_isSearchEditing,
@@ -11,6 +11,7 @@ import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart'; import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model; import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/features/iptv/screens/mobile_series_detail_screen.dart'; import 'package:xtremobile/features/iptv/screens/mobile_series_detail_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileSeriesTab extends ConsumerStatefulWidget { class MobileSeriesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist; final PlaylistConfig playlist;
@@ -269,18 +270,18 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
final shouldExit = await showDialog<bool>( final shouldExit = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
backgroundColor: Colors.grey[900], backgroundColor: AppColors.surfaceContainerLowest,
title: const Text("Quitter l'application", style: TextStyle(color: Colors.white)), title: const Text("Quitter l'application", style: TextStyle(color: AppColors.onSurface)),
content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: Colors.white70)), content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: AppColors.onSurface70)),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: () => Navigator.of(context).pop(false),
autofocus: true, autofocus: true,
child: const Text('Annuler', style: TextStyle(color: Colors.blueAccent)), child: const Text('Annuler', style: TextStyle(color: AppColors.primary)),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: const Text('Quitter', style: TextStyle(color: Colors.redAccent)), child: const Text('Quitter', style: TextStyle(color: AppColors.error)),
), ),
], ],
), ),
@@ -316,7 +317,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
// Search Bar // Search Bar
TVFocusable( TVFocusable(
scale: 1.0, scale: 1.0,
focusColor: Colors.white, focusColor: AppColors.onSurface,
onPressed: () { onPressed: () {
setState(() => _isSearchEditing = true); setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -337,7 +338,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
child: ExcludeFocus( child: ExcludeFocus(
excluding: !_isSearchEditing, excluding: !_isSearchEditing,
child: TextField( child: TextField(
cursorColor: Colors.white, cursorColor: AppColors.onSurface,
controller: _searchController, controller: _searchController,
focusNode: _searchFocusNode, focusNode: _searchFocusNode,
readOnly: !_isSearchEditing, readOnly: !_isSearchEditing,
@@ -5,8 +5,10 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/theme/app_decorations.dart'; import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/core/theme/app_theme.dart';
import 'package:xtremobile/core/providers/theme_provider.dart'; import 'package:xtremobile/core/providers/theme_provider.dart';
import 'package:xtremobile/features/iptv/screens/mobile_playlist_selection_screen.dart'; import 'package:xtremobile/features/iptv/screens/mobile_playlist_selection_screen.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
/// Simplified mobile settings tab - optimized for TV remote control /// Simplified mobile settings tab - optimized for TV remote control
class MobileSettingsTab extends ConsumerStatefulWidget { class MobileSettingsTab extends ConsumerStatefulWidget {
@@ -20,6 +22,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
late TextEditingController _liveTvController; late TextEditingController _liveTvController;
late TextEditingController _moviesController; late TextEditingController _moviesController;
late TextEditingController _seriesController; late TextEditingController _seriesController;
late TextEditingController _xmltvController;
bool _initialized = false; bool _initialized = false;
bool _isRefreshingCache = false; bool _isRefreshingCache = false;
@@ -29,6 +32,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_liveTvController = TextEditingController(); _liveTvController = TextEditingController();
_moviesController = TextEditingController(); _moviesController = TextEditingController();
_seriesController = TextEditingController(); _seriesController = TextEditingController();
_xmltvController = TextEditingController();
} }
@override @override
@@ -36,6 +40,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_liveTvController.dispose(); _liveTvController.dispose();
_moviesController.dispose(); _moviesController.dispose();
_seriesController.dispose(); _seriesController.dispose();
_xmltvController.dispose();
super.dispose(); super.dispose();
} }
@@ -44,6 +49,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_liveTvController.text = settings.liveTvKeywords.join(', '); _liveTvController.text = settings.liveTvKeywords.join(', ');
_moviesController.text = settings.moviesKeywords.join(', '); _moviesController.text = settings.moviesKeywords.join(', ');
_seriesController.text = settings.seriesKeywords.join(', '); _seriesController.text = settings.seriesKeywords.join(', ');
_xmltvController.text = settings.xmltvUrl;
_initialized = true; _initialized = true;
} }
} }
@@ -100,7 +106,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
), ),
), ),
Text( Text(
'Version 1.5.1', 'Version 1.6.1',
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 13, fontSize: 13,
color: AppDecorations.textSecondary(context), color: AppDecorations.textSecondary(context),
@@ -167,6 +173,38 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
const SizedBox(height: 24), const SizedBox(height: 24),
// === GUIDE TV (EPG) ===
_buildSectionHeader('Guide TV (EPG)'),
_buildSettingItem(
icon: Icons.public,
title: 'EPG communautaire',
subtitle:
'Repli quand le fournisseur ne renvoie aucun programme',
value: settings.useCommunityEpg ? 'Activé' : 'Désactivé',
onTap: () => ref
.read(mobileSettingsProvider.notifier)
.toggleCommunityEpg(!settings.useCommunityEpg),
),
_buildSettingItem(
icon: Icons.link,
title: 'URL XMLTV personnalisée',
subtitle: settings.xmltvUrl.isEmpty
? 'Aucune — xmltv.php du fournisseur, puis source communautaire'
: settings.xmltvUrl,
value: settings.xmltvUrl.isEmpty ? 'Ajouter' : 'Modifier',
onTap: () => _showKeyboardDialog(
'URL XMLTV',
_xmltvController,
(val) => ref
.read(mobileSettingsProvider.notifier)
.setXmltvUrl(val),
),
),
const SizedBox(height: 24),
// === FILTRES === // === FILTRES ===
_buildSectionHeader('Filtres de Contenu'), _buildSectionHeader('Filtres de Contenu'),
@@ -290,7 +328,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Cache actualisé ! Relancez l\'app pour recharger.'), content: Text('Cache actualisé ! Relancez l\'app pour recharger.'),
backgroundColor: Colors.green, backgroundColor: AppColors.success,
duration: Duration(seconds: 3), duration: Duration(seconds: 3),
), ),
); );
@@ -298,7 +336,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red), SnackBar(content: Text('Erreur: $e'), backgroundColor: AppColors.error),
); );
} }
} finally { } finally {
@@ -378,7 +416,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
icon, icon,
size: 16, size: 16,
color: selected color: selected
? Colors.white ? AppColors.onSurface
: AppDecorations.textSecondary(context), : AppDecorations.textSecondary(context),
), ),
const SizedBox(height: 3), const SizedBox(height: 3),
@@ -390,7 +428,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
? FontWeight.w700 ? FontWeight.w700
: FontWeight.w500, : FontWeight.w500,
color: selected color: selected
? Colors.white ? AppColors.onSurface
: AppDecorations.textSecondary(context), : AppDecorations.textSecondary(context),
), ),
), ),
@@ -417,11 +455,9 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8), padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8),
child: Text( child: Text(
title.toUpperCase(), title.toUpperCase(),
style: GoogleFonts.inter( style: AppTheme.eyebrow(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
letterSpacing: 1.2,
), ),
), ),
); );
@@ -18,6 +18,12 @@ class MobileSettings {
final List<String> final List<String>
deinterlacedChannels; // IDs of channels with forced deinterlacing 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({ const MobileSettings({
this.liveTvKeywords = const [], this.liveTvKeywords = const [],
this.moviesKeywords = const [], this.moviesKeywords = const [],
@@ -30,6 +36,8 @@ class MobileSettings {
this.deinterlace = false, this.deinterlace = false,
this.bufferDuration = 0, this.bufferDuration = 0,
this.deinterlacedChannels = const [], this.deinterlacedChannels = const [],
this.xmltvUrl = '',
this.useCommunityEpg = true,
}); });
MobileSettings copyWith({ MobileSettings copyWith({
@@ -44,6 +52,8 @@ class MobileSettings {
bool? deinterlace, bool? deinterlace,
int? bufferDuration, int? bufferDuration,
List<String>? deinterlacedChannels, List<String>? deinterlacedChannels,
String? xmltvUrl,
bool? useCommunityEpg,
}) { }) {
return MobileSettings( return MobileSettings(
liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords, liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords,
@@ -57,6 +67,8 @@ class MobileSettings {
deinterlace: deinterlace ?? this.deinterlace, deinterlace: deinterlace ?? this.deinterlace,
bufferDuration: bufferDuration ?? this.bufferDuration, bufferDuration: bufferDuration ?? this.bufferDuration,
deinterlacedChannels: deinterlacedChannels ?? this.deinterlacedChannels, 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 buffer = _box?.get('bufferDuration', defaultValue: 0) as int?;
final deinterlacedChannels = final deinterlacedChannels =
_box?.get('deinterlacedChannels', defaultValue: <String>[]) as List?; _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( state = MobileSettings(
liveTvKeywords: liveTv?.cast<String>() ?? [], liveTvKeywords: liveTv?.cast<String>() ?? [],
@@ -134,6 +149,8 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
deinterlace: deinterlace ?? false, deinterlace: deinterlace ?? false,
bufferDuration: buffer ?? 0, bufferDuration: buffer ?? 0,
deinterlacedChannels: deinterlacedChannels?.cast<String>() ?? [], deinterlacedChannels: deinterlacedChannels?.cast<String>() ?? [],
xmltvUrl: xmltvUrl ?? '',
useCommunityEpg: useCommunityEpg ?? true,
); );
} }
@@ -187,6 +204,17 @@ class MobileSettingsNotifier extends StateNotifier<MobileSettings> {
_box?.put('bufferDuration', seconds); _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) { void toggleChannelDeinterlace(String streamId) {
final currentList = List<String>.from(state.deinterlacedChannels); final currentList = List<String>.from(state.deinterlacedChannels);
if (currentList.contains(streamId)) { if (currentList.contains(streamId)) {
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/playlist_config.dart'; import '../../core/models/playlist_config.dart';
import '../../core/models/iptv_models.dart' as model; import '../../core/models/iptv_models.dart' as model;
import '../../features/iptv/services/xtream_service_mobile.dart'; import '../../features/iptv/services/xtream_service_mobile.dart';
import 'mobile_settings_providers.dart';
/// Mobile-specific Xtream service provider /// Mobile-specific Xtream service provider
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -13,6 +14,23 @@ final mobileXtreamServiceProvider =
final service = XtreamServiceMobile(dir.path); final service = XtreamServiceMobile(dir.path);
await service await service
.setPlaylistAsync(playlist); // Async to resolve DNS before API calls .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; return service;
}); });
+63 -373
View File
@@ -1,390 +1,80 @@
import 'package:flutter/material.dart'; 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) import '../../core/theme/app_colors.dart';
/// + Apple Clean White (light) 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 { class MobileTheme {
MobileTheme._(); MobileTheme._();
// ============ SPACING ============ // ============ SPACING ============
static const double spacing4 = 4.0; static const double spacing4 = AppTheme.spacing4;
static const double spacing8 = 8.0; static const double spacing8 = AppTheme.spacing8;
static const double spacing12 = 12.0; static const double spacing12 = AppTheme.spacing12;
static const double spacing16 = 16.0; static const double spacing16 = AppTheme.spacing16;
static const double spacing24 = 24.0; static const double spacing24 = AppTheme.spacing24;
// ============ RADIUS ============ // ============ RADIUS ============
static const double radiusSm = 8.0; static const double radiusSm = AppTheme.radiusSm;
static const double radiusMd = 12.0; static const double radiusMd = AppTheme.radiusMd;
static const double radiusLg = 16.0; 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) ============ // ============ DARK THEME (Mobile Main) ============
static ThemeData get darkTheme { static ThemeData get darkTheme => _scaled(AppTheme.darkTheme);
final baseTheme = ThemeData.dark(useMaterial3: true);
return baseTheme.copyWith( // ============ LIGHT THEME ============
colorScheme: AppColors.darkColorScheme, static ThemeData get lightTheme => _scaled(AppTheme.lightTheme);
scaffoldBackgroundColor: AppColors.background,
// Global text selection — white cursor on dark /// Returns the appropriate MobileTheme based on the current [BuildContext]
textSelectionTheme: const TextSelectionThemeData( /// brightness.
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.
static ThemeData themeOf(BuildContext context) { static ThemeData themeOf(BuildContext context) {
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
return brightness == Brightness.dark ? darkTheme : lightTheme; return brightness == Brightness.dark ? darkTheme : lightTheme;
} }
/// Accent used for active/selected affordances on mobile.
static const Color accent = AppColors.primaryContainer;
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:xtremobile/core/theme/app_decorations.dart'; import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileCategoryCard extends StatelessWidget { class MobileCategoryCard extends StatelessWidget {
final String title; final String title;
@@ -13,7 +14,7 @@ class MobileCategoryCard extends StatelessWidget {
required this.title, required this.title,
required this.onTap, required this.onTap,
this.icon = Icons.folder_rounded, this.icon = Icons.folder_rounded,
this.iconColor = const Color(0xFF0A84FF), this.iconColor = AppColors.primary,
}); });
@override @override
+5 -4
View File
@@ -3,6 +3,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:xtremobile/core/theme/app_decorations.dart'; import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/core/utils/image_cache_config.dart'; import 'package:xtremobile/core/utils/image_cache_config.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobilePosterCard extends StatelessWidget { class MobilePosterCard extends StatelessWidget {
final String title; final String title;
@@ -134,12 +135,12 @@ class MobilePosterCard extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.star, color: Colors.amber, size: 10), const Icon(Icons.star, color: AppColors.ratingGold, size: 10),
const SizedBox(width: 2), const SizedBox(width: 2),
Text( Text(
rating!, rating!,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: AppColors.onSurface,
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -157,13 +158,13 @@ class MobilePosterCard extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Color(0xFF30D158), color: AppColors.success,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon( child: const Icon(
Icons.check, Icons.check,
size: 10, size: 10,
color: Colors.white, color: AppColors.onSurface,
), ),
), ),
); );
+5 -4
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../core/theme/app_decorations.dart'; import '../../core/theme/app_decorations.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class MobileScaffold extends ConsumerStatefulWidget { class MobileScaffold extends ConsumerStatefulWidget {
final Widget child; final Widget child;
@@ -71,7 +72,7 @@ class _MobileScaffoldState extends ConsumerState<MobileScaffold> {
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon( errorBuilder: (_, __, ___) => const Icon(
Icons.play_circle_filled, Icons.play_circle_filled,
color: Colors.white, color: AppColors.onSurface,
size: 40, size: 40,
), ),
), ),
@@ -201,12 +202,12 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const Color selectedColor = Color(0xFF0A84FF); const Color selectedColor = AppColors.primaryFill;
final Color unselectedColor = AppDecorations.textSecondary(context); final Color unselectedColor = AppDecorations.textSecondary(context);
final Color textColor = final Color textColor =
widget.isSelected ? Colors.white : unselectedColor; widget.isSelected ? AppColors.onSurface : unselectedColor;
final Color iconColor = final Color iconColor =
widget.isSelected ? Colors.white : unselectedColor; widget.isSelected ? AppColors.onSurface : unselectedColor;
final FontWeight fontWeight = final FontWeight fontWeight =
widget.isSelected ? FontWeight.w700 : FontWeight.w500; widget.isSelected ? FontWeight.w700 : FontWeight.w500;
+3 -2
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
class TVFocusable extends StatefulWidget { class TVFocusable extends StatefulWidget {
final Widget child; final Widget child;
@@ -25,7 +26,7 @@ class TVFocusable extends StatefulWidget {
this.autofocus = false, this.autofocus = false,
this.scale = 1.05, // Apple TV subtle lift this.scale = 1.05, // Apple TV subtle lift
this.borderWidth = 2.0, this.borderWidth = 2.0,
this.focusColor = Colors.white, this.focusColor = AppColors.onSurface,
this.borderRadius, this.borderRadius,
this.longPressDuration = const Duration(seconds: 3), this.longPressDuration = const Duration(seconds: 3),
}); });
@@ -162,7 +163,7 @@ class _TVFocusableState extends State<TVFocusable> {
offset: const Offset(0, 6), offset: const Offset(0, 6),
), ),
const BoxShadow( const BoxShadow(
color: Color(0x40FFFFFF), // White glow color: Color(0x590A84FF), // Apple blue glow
blurRadius: 12, blurRadius: 12,
spreadRadius: 0, spreadRadius: 0,
), ),
+4 -1
View File
@@ -1,7 +1,7 @@
name: xtremobile name: xtremobile
description: High-performance IPTV Android Application with Xtream Codes API description: High-performance IPTV Android Application with Xtream Codes API
publish_to: 'none' publish_to: 'none'
version: 1.5.1+11 version: 1.7.0+14
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -23,6 +23,9 @@ dependencies:
dio_cache_interceptor: ^3.5.1 dio_cache_interceptor: ^3.5.1
dio_cache_interceptor_hive_store: ^3.2.2 dio_cache_interceptor_hive_store: ^3.2.2
http: ^1.2.2 http: ^1.2.2
# EPG (XMLTV fallback parsing)
xml: ^6.5.0
# Routing # Routing
go_router: ^13.2.5 go_router: ^13.2.5
+71 -16
View File
@@ -1,25 +1,80 @@
# Task: Corrections Fire Stick & UI - TERMINÉ # Task: Alignement sur xtremflow — EPG XMLTV, thème, stabilité des flux
## Bugs corrigés ## 1. EPG — repli XMLTV quand le fournisseur ne répond pas
### Session 1 (07h45) - [x] Nouveau `lib/features/iptv/services/xmltv_service.dart` : téléchargement,
gunzip transparent, parse **en flux** (`toXmlEvents`), index réduit à une
fenêtre `-3 h / +18 h`, cache disque JSON (TTL 6 h)
- [x] Chaîne de repli dans `XtreamServiceMobile` :
`get_short_epg` → `{dns}/xmltv.php` du fournisseur → URL personnalisée →
source communautaire (`epgshare01` FR1)
- [x] Correspondance par `epg_channel_id`, puis par nom normalisé (accents,
suffixes `HD/FHD/4K`, préfixes `FR:` / `|FR|`)
- [x] Index `streamId → Channel` alimenté par `getLiveChannels` : aucun site
d'appel de `getShortEPG` n'a changé
- [x] Sémaphore EPG (3 requêtes max), comme xtremflow — une grille de 30 tuiles
ne noie plus le panel et ne vide plus l'EPG de tout l'écran
- [x] Réglages : « EPG communautaire » (on/off) + « URL XMLTV personnalisée »
1. ✅ **Mise en veille pendant lecture** - Ajout de `wakelock_plus` dans les deux players ## 2. Thème — port de la charte « Warm Cinema » de xtremflow
2. ✅ **Settings surbrillance invisible** - Toutes les box utilisent TVFocusable avec bordure blanche
3. ✅ **Player Lite bouton pause** - Descendu et rendu semi-transparent
### Session 2 (18h15) - [x] `app_colors.dart` : palette ember/charbon/crème, alias de compatibilité
conservés pour les 21 symboles Apple TV déjà utilisés
- [x] `app_theme.dart` : typo **Fraunces** (display) + **Karla** (UI), tokens
spacing/radius/durations/curves alignés sur xtremflow
- [x] `mobile_theme.dart` : dérive désormais de `AppTheme` (échelle typo mobile)
au lieu de dupliquer 390 lignes
- [x] `app_decorations.dart` : hexs Apple codés en dur remplacés par la palette
- [x] **Passe 2 (v1.6.1)** — le thème ne suffisait pas : du bleu restait codé en
dur dans 19 fichiers d'écran/widget, hors palette. Balayage complet :
- `0xFF0A84FF` (bleu Apple) dans la **pilule de navigation sélectionnée**
(`mobile_scaffold`), les icônes de catégorie et le bouton « ajouter »
- `0xFF4FC3F7` / `0xFF1565C0` (bleus) dans les overlays du lecteur
- `Colors.blueAccent` / `Colors.blue` dans les boîtes de dialogue
- gris Apple `1C1C1E` / `2C2C2E` / `3A3A3C` / `8E8E93` → charbons chauds
- `Colors.white*` (128 occurrences) → paliers crème `onSurfaceNN`
- `Colors.red/green/amber/yellow/grey*` → `error` / `success` /
`ratingGold` / `outlineVariant`
- noir pur sur remplissage clair → encre chaude `onPrimaryFixed`
- halo de focus blanc → halo ember
- Conservés en noir vrai : fonds de surface vidéo et voiles (`scrims`)
- Vérification : plus **aucune** couleur Material/Apple codée en dur hors
de `lib/core/theme/`
4. ✅ **Retour depuis player TV** - Le bouton retour ramène maintenant à la liste des chaînes (pas aux catégories) ## 3. Lecture — arrêt en arrière-plan et tenue dans la durée
## Fichiers modifiés - [x] `AppLifecycleState.inactive` retiré du déclencheur d'arrêt : il se
déclenchait au moindre volet de notifications
- [x] `_releasePlayback()` sur `paused` / `hidden` / `detached` — arrêt du flux,
libération du décodeur, annulation des timers, wakelock coupé
(les deux moteurs : `native_player_screen` et `lite_player_screen`)
- [x] Verrou `_isReleased` : watchdog et reconnexions ne peuvent plus relancer
un flux depuis l'arrière-plan
- [x] Abonnements aux `Stream` du player stockés et annulés au `dispose`
- [x] `demuxer-max-back-bytes` plafonné à 8 Mo (défaut mpv : 50 Mio) — évite la
dérive mémoire qui finissait en kill Android après plusieurs heures
- [x] `setState` de position limité aux moments où les contrôles sont visibles ;
horloge ne reconstruit qu'au changement de minute
- [x] FocusNodes du lecteur Lite libérés au `dispose`
- `pubspec.yaml` - Ajout de `wakelock_plus` ## 4. Vitesse de chargement
- `native_player_screen.dart` - WakeLock activé/désactivé
- `lite_player_screen.dart` - WakeLock + nouveau design boutons + seek réduit
- `mobile_settings_tab.dart` - TVFocusable sur toutes les options
- `mobile_live_tv_tab.dart` - Correction navigation retour depuis player
## APK généré - [x] `prewarmHost()` : pré-résolution DNS + socket TCP/TLS ouvert puis fermé
avant l'ouverture du flux (aucune requête HTTP, donc aucun slot de
connexion consommé chez le fournisseur), plafonné à 1,5 s
`build\app\outputs\flutter-apk\app-release.apk` (92.3MB) > Non retenu par l'utilisateur : décodage matériel VOD, réduction du probe,
> audio multicanal natif. Le profil de décodage reste inchangé.
## Vérifications
- [x] `flutter analyze` : 0 erreur / 0 warning sur les fichiers touchés
(3 warnings de code mort restants, déjà présents dans HEAD)
- [x] `flutter test` : 19/19 (18 unitaires + 1 sur un vrai dump)
- [x] Vrai dump `epg_ripper_FR1.xml.gz` (5,4 Mo gz / 46 Mo XML / 69 802
programmes) : 517 chaînes indexées en 1,1 s, 5/5 correspondances
- [x] `flutter build apk --release` → `app-release.apk` (94,3 Mo)
Non corrigé (pré-existant, hors périmètre) : erreurs d'analyse dans
`lib/core/utils/memory_profiler.dart` et `hive_encryption_benchmark.dart`
(orphelins, hors graphe d'import) et `test/widget_test.dart`.
+71
View File
@@ -0,0 +1,71 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xtremobile/features/iptv/services/xmltv_service.dart';
/// Parses a real community XMLTV dump end to end.
///
/// Skipped unless `XMLTV_FIXTURE` points at a `.xml`/`.xml.gz` file, so CI and
/// day-to-day runs stay offline and fast:
///
/// flutter test test/xmltv_real_dump_test.dart \
/// --dart-define=XMLTV_FIXTURE=C:\path\epg_ripper_FR1.xml.gz
const String _fixturePath = String.fromEnvironment('XMLTV_FIXTURE');
void main() {
test(
'indexes a real XMLTV dump within a mobile memory budget',
() async {
final file = File(_fixturePath);
expect(file.existsSync(), isTrue, reason: 'fixture not found');
final service = XmltvService(Directory.systemTemp.path);
final started = DateTime.now();
final indexed = await service.indexFromBytes(file.openRead());
final elapsed = DateTime.now().difference(started);
final peakMb = ProcessInfo.maxRss / 1048576;
// ignore: avoid_print
print(
'channels=${service.channelCount} '
'time=${elapsed.inMilliseconds}ms '
'peakRss=${peakMb.toStringAsFixed(1)}MB',
);
expect(indexed, isTrue);
expect(service.channelCount, greaterThan(100));
// The whole point of the streaming parse: a ~46 MB document must never
// be resident. Generous ceiling to stay stable across VM versions.
expect(peakMb, lessThan(400));
// Spot-check the matching paths with labels shaped the way an Xtream
// provider reports them.
const probes = <List<String>>[
['', 'TF1 HD'],
['', 'FR: France 2'],
['', 'M6 FHD'],
['', '|FR| France 5'],
['', 'Arte'],
];
var hits = 0;
for (final probe in probes) {
final epg = service.shortEpgFor(
streamId: 'x',
epgChannelId: probe[0],
channelName: probe[1],
);
// ignore: avoid_print
print(' ${probe[1].padRight(16)} -> ${epg?.title ?? "NO MATCH"}');
if (epg != null) hits++;
}
expect(hits, greaterThanOrEqualTo(4),
reason: 'name matching should resolve mainstream French channels');
},
skip: _fixturePath.isEmpty ? 'set --dart-define=XMLTV_FIXTURE' : false,
);
}
+216
View File
@@ -0,0 +1,216 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xtremobile/features/iptv/services/xmltv_service.dart';
/// Fixture shaped exactly like the community dumps the fallback consumes
/// (channel ids with dots, `+0200` offsets, base-less titles).
String _fixture(DateTime now) {
String stamp(DateTime value) {
String two(int n) => n.toString().padLeft(2, '0');
final utc = value.toUtc();
return '${utc.year}${two(utc.month)}${two(utc.day)}'
'${two(utc.hour)}${two(utc.minute)}${two(utc.second)} +0000';
}
final airingStart = now.subtract(const Duration(minutes: 20));
final airingEnd = now.add(const Duration(minutes: 40));
final nextEnd = airingEnd.add(const Duration(minutes: 50));
final farFuture = now.add(const Duration(days: 4));
return '''<?xml version="1.0" encoding="UTF-8" ?>
<tv generator-info-name="none">
<channel id="TF1.fr">
<display-name lang="fr">TF1</display-name>
</channel>
<channel id="France.3.-.Nord.Pas-de-Calais.fr">
<display-name lang="fr">France 3 - Nord Pas-de-Calais</display-name>
</channel>
<channel id="Cine.Premier.fr">
<display-name lang="fr">Ciné+ Premier</display-name>
</channel>
<programme channel="TF1.fr" start="${stamp(airingStart)}" stop="${stamp(airingEnd)}">
<title lang="fr">Journal de 20h</title>
<desc lang="fr">Le rendez-vous d'information.</desc>
</programme>
<programme channel="TF1.fr" start="${stamp(airingEnd)}" stop="${stamp(nextEnd)}">
<title lang="fr">Film du mardi</title>
</programme>
<programme channel="France.3.-.Nord.Pas-de-Calais.fr" start="${stamp(airingStart)}" stop="${stamp(airingEnd)}">
<title lang="fr">Ici 19/20</title>
</programme>
<programme channel="Cine.Premier.fr" start="${stamp(farFuture)}" stop="${stamp(farFuture.add(const Duration(hours: 1)))}">
<title lang="fr">Hors fenêtre</title>
</programme>
</tv>''';
}
Stream<List<int>> _bytes(String xml) => Stream.value(utf8.encode(xml));
void main() {
group('parseXmltvTime', () {
test('applies the trailing UTC offset', () {
final parsed = XmltvService.parseXmltvTime('20260811183000 +0200');
expect(parsed, isNotNull);
expect(
parsed!.toUtc(),
DateTime.utc(2026, 8, 11, 16, 30),
reason: '18:30 at +0200 is 16:30 UTC',
);
});
test('handles a negative offset', () {
final parsed = XmltvService.parseXmltvTime('20260811183000 -0430');
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 23, 0));
});
test('treats a missing offset as UTC', () {
final parsed = XmltvService.parseXmltvTime('20260811183000');
expect(parsed!.toUtc(), DateTime.utc(2026, 8, 11, 18, 30));
});
test('rejects malformed input', () {
expect(XmltvService.parseXmltvTime(null), isNull);
expect(XmltvService.parseXmltvTime(''), isNull);
expect(XmltvService.parseXmltvTime('2026081118'), isNull);
expect(XmltvService.parseXmltvTime('not-a-timestamp!'), isNull);
});
});
group('normalizeName', () {
test('collapses quality suffixes', () {
expect(XmltvService.normalizeName('TF1 HD'), 'tf1');
expect(XmltvService.normalizeName('tf1-fhd'), 'tf1');
expect(XmltvService.normalizeName('TF1 4K'), 'tf1');
expect(XmltvService.normalizeName('TF1'), 'tf1');
});
test('strips country prefixes in both forms', () {
expect(XmltvService.normalizeName('FR: TF1'), 'tf1');
expect(XmltvService.normalizeName('|FR| TF1 HD'), 'tf1');
expect(XmltvService.normalizeName('[FR] TF1'), 'tf1');
});
test('keeps a dash-separated name intact', () {
// "Ciné - Premier" must not lose its first word to prefix stripping.
expect(XmltvService.normalizeName('Ciné - Premier'), 'cinepremier');
});
test('folds accents and keeps the plus sign', () {
expect(XmltvService.normalizeName('Ciné+ Émotion'), 'cine+emotion');
});
test('is empty for a blank label', () {
expect(XmltvService.normalizeName(' '), '');
});
});
group('indexFromBytes + lookup', () {
late XmltvService service;
late DateTime now;
setUp(() async {
service = XmltvService(Directory.systemTemp.path);
now = DateTime.now();
final indexed = await service.indexFromBytes(
_bytes(_fixture(now)),
now: now,
);
expect(indexed, isTrue);
});
test('matches on epg_channel_id', () {
final epg = service.shortEpgFor(
streamId: '123',
epgChannelId: 'TF1.fr',
channelName: 'anything',
);
expect(epg, isNotNull);
expect(epg!.title, 'Journal de 20h');
expect(epg.id, '123');
expect(epg.description, "Le rendez-vous d'information.");
});
test('falls back to the channel name when the id is unknown', () {
final epg = service.shortEpgFor(
streamId: '456',
epgChannelId: '',
channelName: 'FR: TF1 HD',
);
expect(epg, isNotNull);
expect(epg!.title, 'Journal de 20h');
});
test('matches a name reconstructed from a dotted channel id', () {
final epg = service.shortEpgFor(
streamId: '789',
epgChannelId: 'not-in-guide',
channelName: 'France 3 - Nord Pas-de-Calais',
);
expect(epg, isNotNull);
expect(epg!.title, 'Ici 19/20');
});
test('returns the currently airing programme, not the next one', () {
final programme = service.programmeFor(
epgChannelId: 'TF1.fr',
at: now,
);
expect(programme!.title, 'Journal de 20h');
expect(programme.covers(now), isTrue);
});
test('falls forward to the next entry when nothing is airing', () {
// 10 minutes into the gap-free schedule's second slot.
final during = now.add(const Duration(minutes: 50));
final programme = service.programmeFor(
epgChannelId: 'TF1.fr',
at: during,
);
expect(programme!.title, 'Film du mardi');
});
test('drops programmes outside the retention window', () {
// The Ciné+ entry is 4 days out — beyond windowForward, so the channel
// holds no programme at all.
final epg = service.shortEpgFor(
streamId: '999',
epgChannelId: 'Cine.Premier.fr',
);
expect(epg, isNull);
});
test('returns null for a channel absent from the guide', () {
final epg = service.shortEpgFor(
streamId: '000',
epgChannelId: 'unknown.channel',
channelName: 'Chaîne Inconnue',
);
expect(epg, isNull);
});
test('parses a gzipped payload transparently', () async {
final gzipped = XmltvService(Directory.systemTemp.path);
final compressed = gzip.encode(utf8.encode(_fixture(now)));
final indexed = await gzipped.indexFromBytes(
Stream.value(compressed),
now: now,
);
expect(indexed, isTrue);
expect(
gzipped.shortEpgFor(streamId: '1', epgChannelId: 'TF1.fr')?.title,
'Journal de 20h',
);
});
test('reports failure on a payload with no programme', () async {
final empty = XmltvService(Directory.systemTemp.path);
final indexed = await empty.indexFromBytes(
_bytes('<?xml version="1.0"?><tv></tv>'),
);
expect(indexed, isFalse);
expect(empty.isLoaded, isFalse);
});
});
}