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

Align the Android app with xtremflow on three fronts.

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

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

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

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

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

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

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

No files matched your search

@@ -72,6 +72,10 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
Timer? _controlsTimer;
int _loadId = 0;
/// Set once the app is backgrounded, so a queued reload cannot restart the
/// stream behind the user.
bool _isReleased = false;
// Focus
final FocusNode _playPauseFocusNode = FocusNode();
final FocusNode _prevFocusNode = FocusNode();
@@ -98,17 +102,45 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (_controller == null) return;
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
_controller?.pause();
if (mounted) {
Navigator.of(context).pop();
}
// `inactive` is deliberately NOT handled: on Android it fires for transient
// interruptions (notification shade, call banner, permission dialog).
final isBackgrounded = state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden ||
state == AppLifecycleState.detached;
if (!isBackgrounded) return;
_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() {
_clockTimer?.cancel();
_updateTime();
@@ -132,6 +164,13 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
final dir = await getApplicationDocumentsDirectory();
_xtreamService = XtreamServiceMobile(dir.path);
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();
if (widget.streamType == model.StreamType.live) {
@@ -160,6 +199,8 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
Future<void> _loadStream() async {
if (_xtreamService == null) return;
// Backgrounded while a (re)load was queued — do not resurrect the stream.
if (_isReleased || !mounted) return;
await _controller?.dispose();
_controller = null;
@@ -359,6 +400,11 @@ class _LitePlayerScreenState extends ConsumerState<LitePlayerScreen>
_controller?.pause(); // Force stop audio immediately
_controller?.dispose();
_xtreamService?.dispose();
_playPauseFocusNode.dispose();
_prevFocusNode.dispose();
_nextFocusNode.dispose();
super.dispose();
}
@@ -96,6 +96,15 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Timer? _controlsTimer; // Auto-hide timer
Timer? _epgTimer; // Periodic EPG refresh for live TV
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;
static const int _maxReconnectAttempts = 5;
String _currentTime = '';
@@ -185,6 +194,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
(_player.platform as dynamic)?.setProperty('demuxer-max-bytes', '150000000'); // 150MB RAM max
(_player.platform as dynamic)?.setProperty('demuxer-readahead-secs', '10');
(_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
// 0.5s vs 1.5s : moins de gel visible, moins de PTS gap à rattraper
(_player.platform as dynamic)?.setProperty('cache-pause', 'yes');
@@ -284,7 +300,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_controller = VideoController(_player);
// Listen to player state
_player.stream.playing.listen((playing) {
_playerSubscriptions.add(_player.stream.playing.listen((playing) {
if (mounted) {
setState(() {
_isPlaying = playing;
@@ -302,18 +318,25 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_resetControlsTimer(); // Start auto-hide timer
}
}
});
}));
_player.stream.position.listen((position) {
_playerSubscriptions.add(_player.stream.position.listen((position) {
if (mounted && !_isSeeking) {
setState(() => _position = position);
// [v23.0] Track last position change time for time-based watchdog
if (position != _lastKnownPosition) {
_lastKnownPosition = position;
_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)
_checkAndMarkWatched(position);
@@ -335,9 +358,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
}
}
}
});
}));
_player.stream.duration.listen((duration) {
_playerSubscriptions.add(_player.stream.duration.listen((duration) {
if (mounted) {
setState(() {
_duration = duration;
@@ -348,16 +371,16 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
}
});
}
});
}));
_player.stream.width.listen((width) {
_playerSubscriptions.add(_player.stream.width.listen((width) {
if (mounted && (width ?? 0) > 0 && _isLoading) {
debugPrint('MediaKitPlayer: Video width detected ($width), clearing loading overlay');
setState(() => _isLoading = false);
}
});
}));
_player.stream.error.listen((error) async {
_playerSubscriptions.add(_player.stream.error.listen((error) async {
if (!mounted) return;
if (error.isEmpty) return;
@@ -385,10 +408,10 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_errorMessage = error;
_isLoading = false;
});
});
}));
// Listen for stream completion
_player.stream.completed.listen((completed) {
_playerSubscriptions.add(_player.stream.completed.listen((completed) {
if (completed && mounted) {
// For Live TV: do NOT reconnect immediately — let mpv handle it internally.
// Only reconnect after a long pause (handled by watchdog).
@@ -409,7 +432,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
}
}
}
});
}));
// Lock to landscape for video playback
SystemChrome.setPreferredOrientations([
@@ -435,13 +458,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
}
void _updateTime() {
if (mounted) {
final now = DateTime.now();
setState(() {
_currentTime =
"${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}";
});
}
if (!mounted) return;
final now = DateTime.now();
final formatted =
"${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
@@ -453,23 +477,52 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
if (widget.streamType == model.StreamType.live) {
// Force stop for Live TV
_player.stop();
} else {
_player.pause();
}
// `inactive` is deliberately NOT handled: on Android it fires for transient
// interruptions (notification shade, incoming-call banner, permission
// dialog, split-screen resize). Treating it as "backgrounded" killed
// playback every time the user swiped the shade down.
final isBackgrounded = state == AppLifecycleState.paused ||
state == AppLifecycleState.hidden ||
state == AppLifecycleState.detached;
if (!isBackgrounded) return;
// Exit player screen so user returns to dashboard on resume
if (mounted) {
Navigator.of(context).pop();
}
_releasePlayback();
// 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
void dispose() {
// Remove lifecycle observer
@@ -481,8 +534,11 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Disable wakelock when leaving player
WakelockPlus.disable();
// Save resume position before cleanup (only for VOD/Series)
_saveResumePositionOnExit();
// Save resume position before cleanup (only for VOD/Series).
// Skipped when _releasePlayback already did it on backgrounding.
if (!_isReleased) {
_saveResumePositionOnExit();
}
_clockTimer?.cancel();
_controlsTimer?.cancel();
@@ -491,6 +547,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_channelListTimer?.cancel();
_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
_player.stop();
_player.dispose();
@@ -605,7 +668,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
/// Attempt to reconnect a live stream that stopped
void _attemptReconnect() {
if (!mounted) return;
if (!mounted || _isReleased) return;
if (_reconnectAttempts >= _maxReconnectAttempts) {
setState(() {
_errorMessage = 'Flux interrompu après plusieurs tentatives';
@@ -693,7 +756,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
// Vérifie toutes les 10s, mais seuil de reconnexion à 60s de position gelée
_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
if (_isPlaying && _lastPositionChangeTime != null) {
@@ -727,6 +790,14 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_xtreamService = XtreamServiceMobile(dir.path);
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();
} catch (e) {
if (mounted) {
@@ -745,6 +816,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Future<void> _loadStream({Duration? startAt}) async {
try {
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(() {
if (_isFirstLoad) {
@@ -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:flutter/foundation.dart';
import 'dart:async';
import 'dart:io';
import '../models/xtream_models.dart' as model;
import 'package:xtremobile/core/api/dns_resolver.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
class _EpgCacheEntry {
@@ -22,19 +52,32 @@ class _EpgCacheEntry {
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
///
/// Optimized for Android with:
/// - Batch EPG loading (N+1 prevention)
/// - Provider-level caching with TTL
/// - XMLTV fallback when the provider serves no EPG
/// - Configurable timeouts and retries
/// - Memory-efficient streaming
class XtreamServiceMobile {
final String cacheDir;
late Dio _dio;
late String _baseUrl;
late String _username;
late String _password;
/// Replaced with the configured instance in [setPlaylistAsync]; initialised
/// eagerly so [dispose] is safe even if the service was never configured.
Dio _dio = Dio();
String _baseUrl = '';
String _username = '';
String _password = '';
// Batch EPG cache to prevent N+1 queries
final Map<String, _EpgCacheEntry> _epgBatchCache = {};
@@ -42,8 +85,54 @@ class XtreamServiceMobile {
// In-flight batch requests to deduplicate concurrent calls
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);
/// 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
Future<void> setPlaylistAsync(PlaylistConfig config) async {
_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) {
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)
/// If categoryId is empty, fetches ALL channels from all categories
Future<List<model.Channel>> getLiveChannels(String categoryId) async {
@@ -98,6 +232,11 @@ class XtreamServiceMobile {
.map((e) => model.Channel.fromJson(e))
.toList();
// Keep the EPG-matching keys reachable from a bare streamId.
for (final channel in channels) {
_channelIndex[channel.streamId] = channel;
}
if (kDebugMode) {
final categories = channels.map((c) => c.categoryName).toSet();
print(
@@ -195,6 +334,7 @@ class XtreamServiceMobile {
// Load EPG for each stream_id individually (API doesn't support true batch)
for (final streamId in streamIds) {
await _epgGate.acquire();
try {
final response = await _dio
.get(
@@ -244,6 +384,22 @@ class XtreamServiceMobile {
}
} catch (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;
}
/// 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
Future<List<model.Category>> getLiveCategories() async {
try {
@@ -530,6 +739,13 @@ class XtreamServiceMobile {
_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
String getLiveStreamUrl(String streamId) {
return '$_baseUrl/live/$_username/$_password/$streamId.ts';
@@ -564,5 +780,8 @@ class XtreamServiceMobile {
/// Dispose service resources
void dispose() {
clearCache();
_channelIndex.clear();
_xmltv.clear();
_dio.close(force: true);
}
}
@@ -5,6 +5,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.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/features/iptv/screens/mobile_playlist_selection_screen.dart';
@@ -20,6 +21,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
late TextEditingController _liveTvController;
late TextEditingController _moviesController;
late TextEditingController _seriesController;
late TextEditingController _xmltvController;
bool _initialized = false;
bool _isRefreshingCache = false;
@@ -29,6 +31,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_liveTvController = TextEditingController();
_moviesController = TextEditingController();
_seriesController = TextEditingController();
_xmltvController = TextEditingController();
}
@override
@@ -36,6 +39,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_liveTvController.dispose();
_moviesController.dispose();
_seriesController.dispose();
_xmltvController.dispose();
super.dispose();
}
@@ -44,6 +48,7 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
_liveTvController.text = settings.liveTvKeywords.join(', ');
_moviesController.text = settings.moviesKeywords.join(', ');
_seriesController.text = settings.seriesKeywords.join(', ');
_xmltvController.text = settings.xmltvUrl;
_initialized = true;
}
}
@@ -167,6 +172,38 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
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 ===
_buildSectionHeader('Filtres de Contenu'),
@@ -417,11 +454,9 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8),
child: Text(
title.toUpperCase(),
style: GoogleFonts.inter(
style: AppTheme.eyebrow(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
letterSpacing: 1.2,
),
),
);