diff --git a/bin/api/epg_api.dart b/bin/api/epg_api.dart index b7630b2..62a2ad4 100644 --- a/bin/api/epg_api.dart +++ b/bin/api/epg_api.dart @@ -8,14 +8,26 @@ import '../models/playlist_config.dart'; class EpgApi { final Future Function(Request) _getPlaylist; - // Cache simple en mémoire : channelId → {data, expiresAt} + /// Cache mémoire : « dns|username|channelId » → {data, expiresAt}. + /// + /// La clé inclut le compte : les `stream_id` sont propres à chaque panneau, + /// deux playlists distinctes peuvent parfaitement partager le même + /// identifiant et se servaient alors le guide l'une de l'autre. final Map _cache = {}; EpgApi(this._getPlaylist); Future handleGetEpg(Request request, String channelId) async { - // Vérifier le cache - final cached = _cache[channelId]; + final playlist = await _getPlaylist(request); + if (playlist == null) { + return Response.forbidden( + json.encode({'error': 'Playlist non trouvée'}), + headers: {'Content-Type': 'application/json'}, + ); + } + + final cacheKey = '${playlist.dns}|${playlist.username}|$channelId'; + final cached = _cache[cacheKey]; if (cached != null && DateTime.now().isBefore(cached.expiresAt)) { return Response.ok( cached.data, @@ -24,49 +36,45 @@ class EpgApi { } try { - final playlist = await _getPlaylist(request); - if (playlist == null) { - return Response.forbidden( - json.encode({'error': 'Playlist non trouvée'}), - headers: {'Content-Type': 'application/json'}, - ); - } - final dns = playlist.dns; - // 1. Tenter d'abord l'EPG complet (48h) - var url = - '$dns/player_api.php?username=${playlist.username}&password=${playlist.password}' - '&action=get_epg&stream_id=$channelId&limit=48'; - - var response = - await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60)); Map epgData = { 'channel_id': channelId, 'programmes': [], }; - if (response.statusCode == 200) { - final raw = json.decode(response.body); - epgData = _transformEpgData(raw, channelId); - } + // Ordre d'essai des actions Xtream. + // + // `get_simple_data_table` d'abord : c'est la seule action réellement + // universelle. `get_epg` n'existe pas sur beaucoup de panneaux — au lieu + // d'une erreur, ils renvoient poliment le payload d'authentification, + // sans champ `epg_listings`, ce qui produisait un guide vide impossible + // à distinguer d'une chaîne sans programme. + const actions = [ + 'get_simple_data_table', + 'get_short_epg', + ]; - // 2. Fallback EPG court si le complet est vide - if ((epgData['programmes'] as List).isEmpty) { - url = + for (final action in actions) { + final url = '$dns/player_api.php?username=${playlist.username}&password=${playlist.password}' - '&action=get_short_epg&stream_id=$channelId'; - response = + '&action=$action&stream_id=$channelId'; + + final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 60)); - if (response.statusCode == 200) { - final raw = json.decode(response.body); - epgData = _transformEpgData(raw, channelId); + if (response.statusCode != 200) continue; + + try { + epgData = transformEpgData(json.decode(response.body), channelId); + } catch (_) { + continue; } + if ((epgData['programmes'] as List).isNotEmpty) break; } final jsonStr = json.encode(epgData); // Mettre en cache 30 minutes - _cache[channelId] = _CacheEntry( + _cache[cacheKey] = _CacheEntry( data: jsonStr, expiresAt: DateTime.now().add(const Duration(minutes: 30)), ); @@ -83,7 +91,9 @@ class EpgApi { } } - Map _transformEpgData(dynamic raw, String channelId) { + /// Normalise une réponse Xtream (`epg_listings`) vers le format servi au + /// client. Public pour être couvert par `bin/test/epg_api_test.dart`. + Map transformEpgData(dynamic raw, String channelId) { try { List listings = []; @@ -94,16 +104,18 @@ class EpgApi { } final programmes = listings.map((item) { - final startRaw = item['start'] as String? ?? ''; - final endRaw = item['stop'] as String? ?? item['end'] as String? ?? ''; - - // Normaliser les dates pour le frontend (Xtream format support) - final start = startRaw.contains(' ') && !startRaw.contains('T') - ? startRaw.replaceFirst(' ', 'T') - : startRaw; - final end = endRaw.contains(' ') && !endRaw.contains('T') - ? endRaw.replaceFirst(' ', 'T') - : endRaw; + // Les champs texte `start`/`end` sont exprimés dans le fuseau du + // panneau (souvent Europe/Amsterdam), sans indicateur de zone : le + // client les relisait comme de l'heure locale et décalait tout le + // guide. Les `*_timestamp` sont de l'epoch UTC — on s'en sert dès + // qu'ils sont présents et on émet de l'ISO-8601 UTC explicite. + final start = _isoUtc(item['start_timestamp']) ?? + _normalizeDate(item['start'] as String? ?? ''); + final end = _isoUtc(item['stop_timestamp']) ?? + _isoUtc(item['end_timestamp']) ?? + _normalizeDate( + (item['stop'] as String?) ?? (item['end'] as String?) ?? '', + ); // Décoder le titre (base64 si nécessaire) String title = item['title'] as String? ?? ''; @@ -136,6 +148,23 @@ class EpgApi { return {'channel_id': channelId, 'programmes': [], 'error': e.toString()}; } } + + /// Epoch (secondes, String ou int) → ISO-8601 UTC, ou `null` si absent. + static String? _isoUtc(dynamic timestamp) { + if (timestamp == null) return null; + final seconds = timestamp is int ? timestamp : int.tryParse('$timestamp'); + if (seconds == null || seconds <= 0) return null; + return DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true) + .toIso8601String(); + } + + /// Repli : « YYYY-MM-DD HH:MM:SS » → « YYYY-MM-DDTHH:MM:SS ». + static String _normalizeDate(String raw) { + if (raw.contains(' ') && !raw.contains('T')) { + return raw.replaceFirst(' ', 'T'); + } + return raw; + } } class _CacheEntry { diff --git a/bin/test/epg_api_test.dart b/bin/test/epg_api_test.dart new file mode 100644 index 0000000..fc65a74 --- /dev/null +++ b/bin/test/epg_api_test.dart @@ -0,0 +1,74 @@ +import 'package:test/test.dart'; +import '../api/epg_api.dart'; + +/// Extrait réel d'une réponse `get_simple_data_table` (panneau Xtream) : +/// titres/descriptions en base64, horodatages epoch UTC doublés d'une chaîne +/// exprimée dans le fuseau du panneau — ici Europe/Amsterdam (UTC+2). +const _sampleListing = { + 'epg_listings': [ + { + 'id': '336987829', + 'epg_id': '104', + 'title': 'Sm91cm5hbCBkZSAyMGg=', // "Journal de 20h" + 'lang': '', + 'start': '2026-08-11 20:00:00', + 'end': '2026-08-11 20:35:00', + 'description': 'TCdpbmZvIGR1IHNvaXI=', // "L'info du soir" + 'channel_id': 'TF1.fr', + 'start_timestamp': '1786471200', // 2026-08-11T18:00:00Z + 'stop_timestamp': '1786473300', // 2026-08-11T18:35:00Z + }, + ], +}; + +void main() { + final api = EpgApi((_) async => null); + + group('transformEpgData', () { + test('convertit les horodatages epoch en ISO-8601 UTC', () { + final result = api.transformEpgData(_sampleListing, '845452'); + final programme = (result['programmes'] as List).single as Map; + + // Sans cette conversion, le client relisait « 2026-08-11T20:00:00 » + // comme de l'heure locale et décalait tout le guide de 2 h. + expect(programme['start'], '2026-08-11T18:00:00.000Z'); + expect(programme['end'], '2026-08-11T18:35:00.000Z'); + }); + + test('décode les titres et descriptions base64', () { + final result = api.transformEpgData(_sampleListing, '845452'); + final programme = (result['programmes'] as List).single as Map; + + expect(programme['title'], 'Journal de 20h'); + expect(programme['description'], "L'info du soir"); + expect(programme['channel_id'], '845452'); + }); + + test('retombe sur les champs texte quand les epoch manquent', () { + final result = api.transformEpgData({ + 'epg_listings': [ + { + 'title': 'Sm91cm5hbCBkZSAyMGg=', + 'start': '2026-08-11 20:00:00', + 'stop': '2026-08-11 20:35:00', + }, + ], + }, '845452'); + final programme = (result['programmes'] as List).single as Map; + + expect(programme['start'], '2026-08-11T20:00:00'); + expect(programme['end'], '2026-08-11T20:35:00'); + }); + + test('rend une liste vide sur un payload sans epg_listings', () { + // Ce que renvoie un panneau qui ne connaît pas l'action demandée : + // un 200 contenant le bloc d'authentification. + final result = api.transformEpgData({ + 'user_info': {'auth': 1}, + 'server_info': {'url': 'tit.example'}, + }, '845452'); + + expect(result['programmes'], isEmpty); + }); + }); +} diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index e66bb7f..288dc31 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -46,24 +46,18 @@ class _DashboardScreenState extends ConsumerState { body: Stack( fit: StackFit.expand, children: [ - // 1. Cinematic Deep Space Background - Premium Apple TV Style + // 1. Fond « salle de projection » — dégradé chaud du design system. Positioned.fill( child: Container( decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - AppColors.surfaceContainerLowest, // Very dark top - AppColors.background, // Black bottom - AppColors.baseLevel0, // Slightly darker for depth - ], - ), + gradient: AppColors.backgroundGradient, ), ), ), - // Ambient Glow Top-Left - Teal (Primary) - VISIBLE + // Faisceau du projecteur, en haut à gauche. + // Intensité volontairement basse (8 %) : au-delà, le halo délave la + // sidebar et les cartes en pêche et sort le rendu de la palette. Positioned( top: -300, left: -300, @@ -74,8 +68,8 @@ class _DashboardScreenState extends ConsumerState { shape: BoxShape.circle, gradient: RadialGradient( colors: [ - AppColors.primary.withOpacity(0.4), - AppColors.primary.withOpacity(0.1), + AppColors.glowPrimary(0.08), + AppColors.glowPrimary(0.03), Colors.transparent, ], ), @@ -83,7 +77,9 @@ class _DashboardScreenState extends ConsumerState { ), ), - // Ambient Glow Bottom-Right - Blue - VISIBLE + // Braise résiduelle en bas à droite. L'ancien halo utilisait + // `AppColors.info` (bleu-gris) : une teinte froide dans une palette + // entièrement chaude, d'où le rendu grisâtre. Positioned( bottom: -250, right: -250, @@ -94,8 +90,8 @@ class _DashboardScreenState extends ConsumerState { shape: BoxShape.circle, gradient: RadialGradient( colors: [ - AppColors.info.withOpacity(0.35), - AppColors.info.withOpacity(0.1), + AppColors.glowPrimary(0.06), + AppColors.glowPrimary(0.02), Colors.transparent, ], ), @@ -124,9 +120,12 @@ class _DashboardScreenState extends ConsumerState { top: 24, bottom: 24, width: 80, - child: GlassContainer.glass( + // Niveau « flottant » : le niveau 1 (#181310) se confondait avec + // le haut du dégradé de fond (#1A1310) — la barre n'existait plus + // que par sa bordure. Le niveau 2 apporte l'arête ember et + // l'ombre profonde qui la détachent vraiment. + child: GlassContainer.floating( borderRadius: 24, - borderColor: AppColors.border, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -141,7 +140,7 @@ class _DashboardScreenState extends ConsumerState { borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: AppColors.primary.withOpacity(0.4), + color: AppColors.glowPrimary(0.35), blurRadius: 10, offset: const Offset(0, 4), ), @@ -149,7 +148,7 @@ class _DashboardScreenState extends ConsumerState { ), child: const Icon( Icons.play_arrow_rounded, - color: AppColors.textPrimary, + color: AppColors.onPrimary, size: 28, ), ), @@ -172,21 +171,26 @@ class _DashboardScreenState extends ConsumerState { width: 48, height: 48, decoration: BoxDecoration( + // Remplissage ember (`primaryContainer`) et non + // `primary` : cette dernière est la teinte claire + // réservée au texte/icône, pas aux surfaces. color: isSelected - ? AppColors.primary.withOpacity(0.2) + ? AppColors.primaryContainer + .withValues(alpha: 0.20) : Colors.transparent, borderRadius: BorderRadius.circular(16), border: isSelected ? Border.all( - color: AppColors.primary.withOpacity(0.5), + color: AppColors.primary + .withValues(alpha: 0.55), ) : null, ), child: Icon( _icons[index], color: isSelected - ? AppColors.textPrimary - : AppColors.textSecondary, + ? AppColors.primary + : AppColors.textTertiary, size: 24, ), ), @@ -203,10 +207,10 @@ class _DashboardScreenState extends ConsumerState { onTap: () {}, borderRadius: 50, scaleFactor: 1.1, - child: CircleAvatar( + child: const CircleAvatar( radius: 20, - backgroundColor: AppColors.primary.withOpacity(0.1), - child: const Icon( + backgroundColor: AppColors.surfaceContainerHigh, + child: Icon( Icons.person, color: AppColors.textSecondary, size: 20, diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index d3aa05e..4ed4511 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -45,6 +45,18 @@ class XtreamService { /// quelques connexions parallèles, ce qui vide l'EPG de toute la grille. /// On sérialise donc par petits paquets. static final _Semaphore _epgGate = _Semaphore(3); + + /// Action EPG retenue pour ce panneau. + /// + /// `get_short_epg` est la requête légère (2-3 programmes) mais une partie + /// des panneaux répond `{"epg_listings":[]}` en permanence ; seule + /// `get_simple_data_table` y renvoie le guide. On teste la première, et + /// dès qu'un panneau se révèle incapable de la servir on bascule + /// définitivement, pour ne pas payer deux requêtes par tuile de grille. + static const _epgActionShort = 'get_short_epg'; + static const _epgActionTable = 'get_simple_data_table'; + static String _epgAction = _epgActionShort; + late final Dio _dio; late final CacheOptions _cacheOptions; @@ -662,6 +674,22 @@ class XtreamService { /// /// Returns "Now" and "Next" program info Future> getShortEpg(String streamId) async { + final listings = await _fetchEpgListings(streamId); + return listings.map(EpgEntry.fromJson).toList(); + } + + /// Get short EPG as ShortEPG object (for EPGWidget) + Future getShortEPG(String streamId) async { + final listings = await _fetchEpgListings(streamId); + if (listings.isEmpty) return const xm.ShortEPG(); + return xm.ShortEPG.fromJson({'epg_listings': listings}); + } + + /// Récupère le guide d'une chaîne, normalisé et réduit à la fenêtre utile. + /// + /// Bascule automatiquement de `get_short_epg` vers `get_simple_data_table` + /// quand le panneau ne sert pas la première : voir [_epgAction]. + Future>> _fetchEpgListings(String streamId) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); if (isPlaybackLoading) { @@ -670,10 +698,33 @@ class XtreamService { } await _epgGate.acquire(); + try { + var listings = await _requestEpg(streamId, _epgAction); + + // Panneau muet sur l'action légère : on retente avec le tableau complet + // et on mémorise le choix pour toutes les chaînes suivantes. + if (listings.isEmpty && _epgAction == _epgActionShort) { + listings = await _requestEpg(streamId, _epgActionTable); + if (listings.isNotEmpty) { + debugPrint( + '[XtreamService] EPG : $_epgActionShort vide sur ce panneau, ' + 'bascule définitive vers $_epgActionTable', + ); + _epgAction = _epgActionTable; + } + } + + return _normalizeListings(listings); + } finally { + _epgGate.release(); + } + } + + Future> _requestEpg(String streamId, String action) async { try { final response = await _apiGet( { - 'action': 'get_short_epg', + 'action': action, 'stream_id': streamId, }, options: Options( @@ -693,7 +744,7 @@ class XtreamService { if (data is String) { if (data.trim().isEmpty) { debugPrint('[XtreamService] EPG $streamId : réponse vide'); - return []; + return const []; } try { data = json.decode(data); @@ -703,76 +754,90 @@ class XtreamService { '(${response.headers.value('content-type')}) — ' '${data.length > 120 ? '${data.substring(0, 120)}…' : data}', ); - return []; + return const []; } } if (data == null) { debugPrint('[XtreamService] EPG $streamId : corps nul'); - return []; + return const []; } - if (data is! Map || data['epg_listings'] == null) { + if (data is! Map || data['epg_listings'] is! List) { + // Cas typique de `get_epg` / `get_short_epg` non supportés : le + // panneau répond 200 avec le payload d'authentification. debugPrint( - '[XtreamService] EPG $streamId : pas de champ epg_listings ' - '(clés: ${data is Map ? data.keys.toList() : data.runtimeType})', + '[XtreamService] EPG $streamId ($action) : pas de champ ' + 'epg_listings (clés: ' + '${data is Map ? data.keys.toList() : data.runtimeType})', ); - return []; + return const []; } - final List epgData = data['epg_listings'] as List; - return epgData - .map((entry) => EpgEntry.fromJson(entry as Map)) - .toList(); + return data['epg_listings'] as List; } on DioException catch (e) { // L'EPG reste optionnel — mais avaler l'erreur en silence rendait toute // panne indiscernable d'une grille réellement vide. debugPrint( - '[XtreamService] EPG $streamId en échec : ' + '[XtreamService] EPG $streamId ($action) en échec : ' 'HTTP ${e.response?.statusCode} ${e.type} — ${e.message}', ); - return []; + return const []; } catch (e) { - debugPrint('[XtreamService] EPG $streamId en échec : $e'); - return []; - } finally { - _epgGate.release(); + debugPrint('[XtreamService] EPG $streamId ($action) en échec : $e'); + return const []; } } - /// Get short EPG as ShortEPG object (for EPGWidget) - Future getShortEPG(String streamId) async { - if (_currentPlaylist == null) throw Exception('No playlist configured'); + /// Aligne les dates sur de l'ISO-8601 UTC et ne garde que la fenêtre utile. + /// + /// Deux raisons : + /// * `get_simple_data_table` renvoie plusieurs jours de programmes (~35 Ko + /// par chaîne). Les appelants prennent « le premier élément » comme + /// programme courant : sans coupe, ils afficheraient un programme + /// d'avant-hier. + /// * Les champs texte `start`/`end` sont dans le fuseau du panneau et sans + /// indicateur de zone ; seuls les `*_timestamp` (epoch UTC) sont fiables. + static List> _normalizeListings(List raw) { + final now = DateTime.now(); + final result = >[]; - if (isPlaybackLoading) { - debugPrint('[XtreamService] Throttling short EPG metadata for $streamId'); - await Future.delayed(_throttleDelay); - } + for (final item in raw) { + if (item is! Map) continue; + final entry = Map.from(item); - try { - final response = await _apiGet( - { - 'action': 'get_short_epg', - 'stream_id': streamId, - }, - options: Options( - extra: CacheOptions( - store: _cacheOptions.store, - policy: CachePolicy.request, - maxStale: const Duration(minutes: 5), - ).toExtra(), - ), + final start = _epgDate(entry['start_timestamp'], entry['start']); + final end = _epgDate( + entry['stop_timestamp'] ?? entry['end_timestamp'], + entry['stop'] ?? entry['end'], ); + if (start == null || end == null) continue; - if (response.data == null) { - return const xm.ShortEPG(); - } + // Programme déjà terminé : sans intérêt pour un « en cours / à suivre ». + if (end.isBefore(now)) continue; - return xm.ShortEPG.fromJson(response.data as Map); - } catch (e) { - // EPG is optional, don't throw on failure - return const xm.ShortEPG(); + entry['start'] = start.toIso8601String(); + entry['end'] = end.toIso8601String(); + entry['stop'] = entry['end']; + result.add(entry); + + if (result.length >= 8) break; } + + return result; + } + + /// Epoch UTC prioritaire, repli sur la chaîne « YYYY-MM-DD HH:MM:SS ». + static DateTime? _epgDate(dynamic timestamp, dynamic text) { + final seconds = timestamp is int ? timestamp : int.tryParse('$timestamp'); + if (seconds != null && seconds > 0) { + return DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); + } + final str = text?.toString() ?? ''; + if (str.isEmpty) return null; + return DateTime.tryParse( + str.contains(' ') && !str.contains('T') ? str.replaceFirst(' ', 'T') : str, + ); } /// Dispose resources