diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 4dd1661..1578ed9 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import '../services/iptv_provider.dart'; @@ -15,8 +14,6 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - int _focusedIndex = 0; - @override void initState() { super.initState(); @@ -25,22 +22,14 @@ class _HomeScreenState extends State { double get _screenWidth => MediaQuery.of(context).size.width; bool get _isLargeScreen => _screenWidth > 900; - bool get _isMediumScreen => _screenWidth > 600 && _screenWidth <= 900; - - int get _gridCrossAxisCount { - if (_screenWidth > 900) return 6; - if (_screenWidth > 600) return 4; - return 3; - } - - double get _titleFontSize => _isLargeScreen ? 32 : (_isMediumScreen ? 28 : 24); - double get _iconSize => _isLargeScreen ? 80 : 60; double get _headerPadding => _isLargeScreen ? 32 : 24; void _showLiveCategories() { Navigator.push( context, - MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.live)), + MaterialPageRoute( + builder: (_) => const ContentListScreen(type: ContentType.live), + ), ); } @@ -51,19 +40,16 @@ class _HomeScreenState extends State { Future _downloadPlaylistAsJson() async { final provider = context.read(); - + try { final filePath = await provider.downloadAndSaveM3UAsJson(); - + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Playlist guardada en: $filePath'), duration: const Duration(seconds: 5), - action: SnackBarAction( - label: 'OK', - onPressed: () {}, - ), + action: SnackBarAction(label: 'OK', onPressed: () {}), ), ); } @@ -86,9 +72,12 @@ class _HomeScreenState extends State { if (provider.vodStreams.isEmpty) { await provider.loadVodStreams(); } + if (!mounted) return; Navigator.push( context, - MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.movies)), + MaterialPageRoute( + builder: (_) => const ContentListScreen(type: ContentType.movies), + ), ); } @@ -98,9 +87,12 @@ class _HomeScreenState extends State { if (provider.seriesList.isEmpty) { await provider.loadSeries(); } + if (!mounted) return; Navigator.push( context, - MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.series)), + MaterialPageRoute( + builder: (_) => const ContentListScreen(type: ContentType.series), + ), ); } @@ -126,10 +118,7 @@ class _HomeScreenState extends State { gradient: RadialGradient( center: Alignment.center, radius: 1.5, - colors: [ - Color(0xFF1a1a2e), - Color(0xFF0f0f1a), - ], + colors: [Color(0xFF1a1a2e), Color(0xFF0f0f1a)], ), ), child: SafeArea( @@ -149,7 +138,10 @@ class _HomeScreenState extends State { final double titleSize = _isLargeScreen ? 28.0 : 24.0; final double iconSize = _isLargeScreen ? 40.0 : 32.0; return Padding( - padding: EdgeInsets.symmetric(horizontal: _headerPadding, vertical: _isLargeScreen ? 24 : 16), + padding: EdgeInsets.symmetric( + horizontal: _headerPadding, + vertical: _isLargeScreen ? 24 : 16, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -170,11 +162,27 @@ class _HomeScreenState extends State { ), Row( children: [ - Text(timeStr, style: TextStyle(color: Colors.white70, fontSize: _isLargeScreen ? 20 : 16)), + Text( + timeStr, + style: TextStyle( + color: Colors.white70, + fontSize: _isLargeScreen ? 20 : 16, + ), + ), const SizedBox(width: 16), - Text(dateStr, style: TextStyle(color: Colors.white54, fontSize: _isLargeScreen ? 16 : 14)), + Text( + dateStr, + style: TextStyle( + color: Colors.white54, + fontSize: _isLargeScreen ? 16 : 14, + ), + ), const SizedBox(width: 24), - Icon(Icons.person, color: Colors.white70, size: _isLargeScreen ? 32 : 24), + Icon( + Icons.person, + color: Colors.white70, + size: _isLargeScreen ? 32 : 24, + ), const SizedBox(width: 16), Consumer( builder: (context, provider, _) { @@ -218,11 +226,18 @@ class _HomeScreenState extends State { decoration: hasFocus ? BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), + border: Border.all( + color: Colors.white, + width: 2, + ), ) : null, child: IconButton( - icon: Icon(Icons.refresh, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24), + icon: Icon( + Icons.refresh, + color: hasFocus ? Colors.white : Colors.white70, + size: _isLargeScreen ? 32 : 24, + ), onPressed: _refreshChannels, tooltip: 'Actualizar canales', ), @@ -245,7 +260,11 @@ class _HomeScreenState extends State { ) : null, child: IconButton( - icon: Icon(Icons.download, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24), + icon: Icon( + Icons.download, + color: hasFocus ? Colors.white : Colors.white70, + size: _isLargeScreen ? 32 : 24, + ), onPressed: () => _downloadPlaylistAsJson(), tooltip: 'Descargar playlist como JSON', ), @@ -266,7 +285,11 @@ class _HomeScreenState extends State { ) : null, child: IconButton( - icon: Icon(Icons.settings, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24), + icon: Icon( + Icons.settings, + color: hasFocus ? Colors.white : Colors.white70, + size: _isLargeScreen ? 32 : 24, + ), onPressed: () { context.read().logout(); }, @@ -348,7 +371,7 @@ class _HomeScreenState extends State { builder: (context, provider, _) { final expDate = provider.userInfo?.expDate; final username = provider.userInfo?.username ?? 'Usuario'; - + return Padding( padding: EdgeInsets.all(footerPadding), child: Row( @@ -400,22 +423,12 @@ class _DashboardCardState extends State<_DashboardCard> { widget.onTap(); } - void _handleKeyEvent(KeyEvent event) { - if (event is KeyDownEvent) { - if (event.logicalKey == LogicalKeyboardKey.enter || - event.logicalKey == LogicalKeyboardKey.select || - event.logicalKey == LogicalKeyboardKey.space) { - _handleTap(); - } - } - } - @override Widget build(BuildContext context) { final iconSize = widget.isLarge ? 80.0 : 60.0; final titleSize = widget.isLarge ? 32.0 : 24.0; final bgIconSize = widget.isLarge ? 200.0 : 150.0; - + return FocusableActionDetector( actions: >{ ActivateIntent: CallbackAction( @@ -445,7 +458,9 @@ class _DashboardCardState extends State<_DashboardCard> { borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: _hasFocus ? Colors.white.withValues(alpha: 0.6) : Colors.black.withValues(alpha: 0.3), + color: _hasFocus + ? Colors.white.withValues(alpha: 0.6) + : Colors.black.withValues(alpha: 0.3), blurRadius: _hasFocus ? 35 : 15, spreadRadius: _hasFocus ? 6 : 0, offset: const Offset(0, 8), @@ -507,12 +522,14 @@ class _ContentListScreenState extends State { final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; String? _selectedCountry; - final FocusNode _gridFocusNode = FocusNode(); + + List? _lastSearchSource; + String _lastSearchQuery = ''; + List? _lastSearchResults; @override void initState() { super.initState(); - print('DEBUG: ContentListScreen.initState() - type: ${widget.type}'); _loadContent(); } @@ -526,15 +543,13 @@ class _ContentListScreenState extends State { return 3; } - double get _titleFontSize => _isLargeScreen ? 32 : (_isMediumScreen ? 28 : 24); - double get _cardTextSize => _isLargeScreen ? 16 : 12; + double get _titleFontSize => + _isLargeScreen ? 32 : (_isMediumScreen ? 28 : 24); double get _headerPadding => _isLargeScreen ? 32 : 16; void _loadContent() { - print('DEBUG: _loadContent() called for type: ${widget.type}'); final provider = context.read(); if (widget.type == ContentType.live) { - print('DEBUG: Loading live streams with country filter: "${_selectedCountry ?? ''}"'); provider.loadLiveStreams(_selectedCountry ?? ''); } else if (widget.type == ContentType.movies) { provider.loadVodStreams(); @@ -551,7 +566,6 @@ class _ContentListScreenState extends State { @override void dispose() { _searchController.dispose(); - _gridFocusNode.dispose(); super.dispose(); } @@ -566,21 +580,8 @@ class _ContentListScreenState extends State { } } - List get _categories { - final provider = context.read(); - switch (widget.type) { - case ContentType.live: - return provider.liveCategories; - case ContentType.movies: - return provider.vodCategories; - case ContentType.series: - return provider.seriesCategories; - } - } - @override Widget build(BuildContext context) { - print('DEBUG: ContentListScreen.build() - type: ${widget.type}, isLive: ${widget.type == ContentType.live}'); return Scaffold( backgroundColor: const Color(0xFF0f0f1a), body: SafeArea( @@ -605,7 +606,9 @@ class _ContentListScreenState extends State { } Widget _buildHeader() { - final searchWidth = _isLargeScreen ? 350.0 : (_isMediumScreen ? 300.0 : 250.0); + final searchWidth = _isLargeScreen + ? 350.0 + : (_isMediumScreen ? 300.0 : 250.0); final searchHeight = _isLargeScreen ? 56.0 : 44.0; final iconSize = _isLargeScreen ? 32.0 : 24.0; return Container( @@ -633,17 +636,34 @@ class _ContentListScreenState extends State { height: searchHeight, child: TextField( controller: _searchController, - style: TextStyle(color: Colors.white, fontSize: _isLargeScreen ? 18 : 14), + style: TextStyle( + color: Colors.white, + fontSize: _isLargeScreen ? 18 : 14, + ), decoration: InputDecoration( hintText: 'Buscar...', - hintStyle: TextStyle(color: Colors.grey, fontSize: _isLargeScreen ? 18 : 14), - prefixIcon: Icon(Icons.search, color: Colors.grey, size: _isLargeScreen ? 28 : 20), + hintStyle: TextStyle( + color: Colors.grey, + fontSize: _isLargeScreen ? 18 : 14, + ), + prefixIcon: Icon( + Icons.search, + color: Colors.grey, + size: _isLargeScreen ? 28 : 20, + ), suffixIcon: _searchQuery.isNotEmpty ? IconButton( - icon: Icon(Icons.clear, color: Colors.grey, size: _isLargeScreen ? 28 : 20), + icon: Icon( + Icons.clear, + color: Colors.grey, + size: _isLargeScreen ? 28 : 20, + ), onPressed: () { _searchController.clear(); - setState(() => _searchQuery = ''); + setState(() { + _searchQuery = ''; + _lastSearchResults = null; + }); }, ) : null, @@ -653,10 +673,15 @@ class _ContentListScreenState extends State { borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none, ), - contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: _isLargeScreen ? 16 : 12), + contentPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: _isLargeScreen ? 16 : 12, + ), ), onChanged: (value) { - setState(() => _searchQuery = value); + setState(() { + _searchQuery = value; + }); }, ), ), @@ -665,20 +690,66 @@ class _ContentListScreenState extends State { ); } - String _getCountryName(String categoryName) { - if (categoryName.contains('|')) { - return categoryName.split('|').first.trim(); + List _buildStreamsForType(IPTVProvider provider) { + if (widget.type == ContentType.live) { + return provider.filteredLiveStreams; } - return categoryName.trim(); + if (widget.type == ContentType.movies) { + return provider.vodStreams; + } + return provider.seriesList + .map( + (s) => XtreamStream( + streamId: s.seriesId, + name: s.name, + streamIcon: s.cover, + plot: s.plot, + rating: s.rating, + ), + ) + .toList(growable: false); + } + + List _applySearchFilter(List streams) { + if (_searchQuery.isEmpty) { + _lastSearchSource = streams; + _lastSearchQuery = ''; + _lastSearchResults = streams; + return streams; + } + + if (identical(streams, _lastSearchSource) && + _searchQuery == _lastSearchQuery && + _lastSearchResults != null) { + return _lastSearchResults!; + } + + final query = _searchQuery.toLowerCase(); + final filtered = streams + .where((stream) { + final name = stream.name.toLowerCase(); + if (query == 'arg|') { + return name.contains('arg|'); + } + return name.contains(query); + }) + .toList(growable: false); + + _lastSearchSource = streams; + _lastSearchQuery = _searchQuery; + _lastSearchResults = filtered; + return filtered; } Widget _buildCountrySidebar() { return Consumer( builder: (context, provider, _) { - print('đŸ”„ BUILDING SIDEBAR - countries: ${provider.countries.length}, loading: ${provider.isLoading}, organizing: ${provider.isOrganizingCountries}'); + final countries = provider.countries; return SimpleCountriesSidebar( - countries: provider.countries, - selectedCountry: provider.selectedCategory.isNotEmpty ? provider.selectedCategory : provider.selectedCountry, + countries: countries, + selectedCountry: provider.selectedCategory.isNotEmpty + ? provider.selectedCategory + : provider.selectedCountry, onCountrySelected: (country) => provider.filterByCountry(country), isLoading: provider.isLoading, isOrganizing: provider.isOrganizingCountries, @@ -699,7 +770,10 @@ class _ContentListScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2), + CircularProgressIndicator( + color: Colors.red, + strokeWidth: _isLargeScreen ? 4 : 2, + ), const SizedBox(height: 16), if (provider.totalChannels > 0) Text( @@ -724,7 +798,9 @@ class _ContentListScreenState extends State { child: LinearProgressIndicator( value: provider.loadingProgress, backgroundColor: Colors.grey[800], - valueColor: const AlwaysStoppedAnimation(Colors.red), + valueColor: const AlwaysStoppedAnimation( + Colors.red, + ), ), ), ], @@ -732,46 +808,26 @@ class _ContentListScreenState extends State { ); } - List streams = []; - if (widget.type == ContentType.live) { - streams = provider.filteredLiveStreams; - } else if (widget.type == ContentType.movies) { - streams = provider.vodStreams; - } else { - streams = provider.seriesList.map((s) => XtreamStream( - streamId: s.seriesId, - name: s.name, - streamIcon: s.cover, - plot: s.plot, - rating: s.rating, - )).toList(); - } - - if (_searchQuery.isNotEmpty) { - // Special case: "arg|" prefix search - exact pattern match for "arg|" in channel name - if (_searchQuery.toLowerCase() == 'arg|') { - streams = streams - .where((s) => s.name.toLowerCase().contains('arg|')) - .toList(); - } else { - // Normal search - contains query anywhere in name - streams = streams - .where((s) => s.name.toLowerCase().contains(_searchQuery.toLowerCase())) - .toList(); - } - } + final baseStreams = _buildStreamsForType(provider); + final streams = _applySearchFilter(baseStreams); if (streams.isEmpty) { return Center( child: Text( - _searchQuery.isNotEmpty ? 'No se encontraron resultados' : 'Sin contenido', - style: TextStyle(color: Colors.grey, fontSize: _isLargeScreen ? 20 : 16), + _searchQuery.isNotEmpty + ? 'No se encontraron resultados' + : 'Sin contenido', + style: TextStyle( + color: Colors.grey, + fontSize: _isLargeScreen ? 20 : 16, + ), ), ); } return GridView.builder( padding: EdgeInsets.all(padding), + cacheExtent: _isLargeScreen ? 1600 : 1100, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: _gridCrossAxisCount, childAspectRatio: 16 / 9, @@ -890,73 +946,82 @@ class _ChannelCardState extends State<_ChannelCard> { : null, ), child: Stack( - children: [ - if (widget.stream.streamIcon != null && widget.stream.streamIcon!.isNotEmpty) - ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Image.network( - widget.stream.streamIcon!, - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildPlaceholder(placeholderIconSize), - ), - ) - else - _buildPlaceholder(placeholderIconSize), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.8), - ], - ), - ), + children: [ + if (widget.stream.streamIcon != null && + widget.stream.streamIcon!.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.network( + widget.stream.streamIcon!, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + cacheWidth: widget.isLarge ? 512 : 384, + cacheHeight: widget.isLarge ? 288 : 216, + filterQuality: FilterQuality.low, + gaplessPlayback: false, + errorBuilder: (context, error, stackTrace) => + _buildPlaceholder(placeholderIconSize), ), - Positioned( - bottom: padding, - left: padding, - right: padding, - child: Text( - widget.stream.name, - style: TextStyle( - color: Colors.white, - fontSize: textSize, - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + ) + else + _buildPlaceholder(placeholderIconSize), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.8), + ], ), - if (widget.stream.rating != null) - Positioned( - top: padding, - right: padding, - child: Container( - padding: EdgeInsets.symmetric(horizontal: ratingPaddingH, vertical: ratingPaddingV), - decoration: BoxDecoration( - color: Colors.amber, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - widget.stream.rating!, - style: TextStyle( - color: Colors.black, - fontSize: ratingFontSize, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], + ), ), - ), + Positioned( + bottom: padding, + left: padding, + right: padding, + child: Text( + widget.stream.name, + style: TextStyle( + color: Colors.white, + fontSize: textSize, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.stream.rating != null) + Positioned( + top: padding, + right: padding, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: ratingPaddingH, + vertical: ratingPaddingV, + ), + decoration: BoxDecoration( + color: Colors.amber, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + widget.stream.rating!, + style: TextStyle( + color: Colors.black, + fontSize: ratingFontSize, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], ), - ); + ), + ), + ); } Widget _buildPlaceholder(double iconSize) { @@ -1009,7 +1074,11 @@ class _SeriesEpisodesScreenState extends State { child: Row( children: [ IconButton( - icon: Icon(Icons.arrow_back, color: Colors.white, size: iconSize), + icon: Icon( + Icons.arrow_back, + color: Colors.white, + size: iconSize, + ), onPressed: () => Navigator.pop(context), iconSize: 48, padding: EdgeInsets.all(_isLargeScreen ? 12 : 8), @@ -1035,7 +1104,10 @@ class _SeriesEpisodesScreenState extends State { builder: (context, provider, _) { if (provider.isLoading) { return Center( - child: CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2), + child: CircularProgressIndicator( + color: Colors.red, + strokeWidth: _isLargeScreen ? 4 : 2, + ), ); } @@ -1044,7 +1116,10 @@ class _SeriesEpisodesScreenState extends State { return Center( child: Text( 'No hay episodios', - style: TextStyle(color: Colors.grey, fontSize: _isLargeScreen ? 20 : 16), + style: TextStyle( + color: Colors.grey, + fontSize: _isLargeScreen ? 20 : 16, + ), ), ); } @@ -1056,9 +1131,13 @@ class _SeriesEpisodesScreenState extends State { final episode = episodes[index]; return Card( color: Colors.grey[900], - margin: EdgeInsets.only(bottom: _isLargeScreen ? 16 : 8), + margin: EdgeInsets.only( + bottom: _isLargeScreen ? 16 : 8, + ), child: ListTile( - contentPadding: EdgeInsets.all(_isLargeScreen ? 16 : 12), + contentPadding: EdgeInsets.all( + _isLargeScreen ? 16 : 12, + ), leading: Icon( Icons.play_circle_fill, color: Colors.red, @@ -1066,7 +1145,10 @@ class _SeriesEpisodesScreenState extends State { ), title: Text( 'S${episode.seasonNumber}E${episode.episodeNumber} - ${episode.title}', - style: TextStyle(color: Colors.white, fontSize: _isLargeScreen ? 20 : 16), + style: TextStyle( + color: Colors.white, + fontSize: _isLargeScreen ? 20 : 16, + ), ), onTap: () { Navigator.push( @@ -1076,7 +1158,8 @@ class _SeriesEpisodesScreenState extends State { stream: XtreamStream( streamId: episode.episodeId, name: episode.title, - containerExtension: episode.containerExtension, + containerExtension: + episode.containerExtension, url: episode.url, ), isLive: false, diff --git a/lib/screens/player_screen.dart b/lib/screens/player_screen.dart index 6b549b1..0e713eb 100644 --- a/lib/screens/player_screen.dart +++ b/lib/screens/player_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:video_player/video_player.dart'; import 'package:chewie/chewie.dart'; import '../models/xtream_models.dart'; @@ -8,18 +7,14 @@ class PlayerScreen extends StatefulWidget { final XtreamStream stream; final bool isLive; - const PlayerScreen({ - super.key, - required this.stream, - this.isLive = true, - }); + const PlayerScreen({super.key, required this.stream, this.isLive = true}); @override State createState() => _PlayerScreenState(); } class _PlayerScreenState extends State { - late VideoPlayerController _videoController; + VideoPlayerController? _videoController; ChewieController? _chewieController; bool _isLoading = true; String? _error; @@ -32,20 +27,32 @@ class _PlayerScreenState extends State { Future _initPlayer() async { try { + _chewieController?.dispose(); + _chewieController = null; + await _videoController?.dispose(); + _videoController = null; + final url = widget.stream.url; if (url == null || url.isEmpty) { throw Exception('No stream URL available'); } - _videoController = VideoPlayerController.networkUrl(Uri.parse(url)); - - await _videoController.initialize(); + final videoController = VideoPlayerController.networkUrl( + Uri.parse(url), + videoPlayerOptions: VideoPlayerOptions( + allowBackgroundPlayback: false, + mixWithOthers: false, + ), + ); + + await videoController.initialize(); + _videoController = videoController; _chewieController = ChewieController( - videoPlayerController: _videoController, + videoPlayerController: videoController, autoPlay: true, looping: widget.isLive, - aspectRatio: _videoController.value.aspectRatio, + aspectRatio: videoController.value.aspectRatio, allowFullScreen: true, allowMuting: true, showControls: true, @@ -76,10 +83,6 @@ class _PlayerScreenState extends State { setState(() { _isLoading = false; }); - - _videoController.addListener(() { - setState(() {}); - }); } catch (e) { setState(() { _error = e.toString(); @@ -90,7 +93,7 @@ class _PlayerScreenState extends State { @override void dispose() { - _videoController.dispose(); + _videoController?.dispose(); _chewieController?.dispose(); super.dispose(); } @@ -112,13 +115,13 @@ class _PlayerScreenState extends State { child: _isLoading ? const CircularProgressIndicator(color: Colors.red) : _error != null - ? _buildError() - : _chewieController != null - ? Chewie(controller: _chewieController!) - : const Text( - 'No video available', - style: TextStyle(color: Colors.white), - ), + ? _buildError() + : _chewieController != null + ? Chewie(controller: _chewieController!) + : const Text( + 'No video available', + style: TextStyle(color: Colors.white), + ), ), ); } diff --git a/lib/services/iptv_provider.dart b/lib/services/iptv_provider.dart index e4605f9..22f853b 100644 --- a/lib/services/iptv_provider.dart +++ b/lib/services/iptv_provider.dart @@ -30,43 +30,44 @@ class IPTVProvider extends ChangeNotifier { List _seriesEpisodes = []; String _selectedLiveCategory = ''; - String _selectedVodCategory = ''; String _selectedCountry = ''; - String _selectedCategory = ''; // For special categories like "FĂștbol Argentino" + String _selectedCategory = + ''; // For special categories like "FĂștbol Argentino" List _countries = []; bool _isOrganizingCountries = false; XtreamSeries? _selectedSeries; + Map? _categoryToCountryMapCache; + List? _filteredLiveStreamsCache; + String _filteredCountryCacheKey = ''; + String _filteredCategoryCacheKey = ''; + int _liveStreamsVersion = 0; + int _filteredCacheVersion = -1; + int _lastProgressUiUpdateMs = 0; + bool get isLoading => _isLoading; String? get error => _error; XtreamUserInfo? get userInfo => _userInfo; XtreamApiService get api => _api; int get loadedChannels => _loadedChannels; int get totalChannels => _totalChannels; - double get loadingProgress => _totalChannels > 0 ? _loadedChannels / _totalChannels : 0.0; + double get loadingProgress => + _totalChannels > 0 ? _loadedChannels / _totalChannels : 0.0; bool get isOrganizingCountries => _isOrganizingCountries; - + List get liveCategories => _liveCategories; List get vodCategories => _vodCategories; List get seriesCategories => _seriesCategories; - + List get liveStreams => _liveStreams; List get vodStreams => _vodStreams; List get seriesList => _seriesList; List get seriesEpisodes => _seriesEpisodes; - + String get selectedLiveCategory => _selectedLiveCategory; String get selectedCountry => _selectedCountry; String get selectedCategory => _selectedCategory; - List get countries { - print('DEBUG: ========================================='); - print('DEBUG: countries getter called'); - print('DEBUG: _countries list length: ${_countries.length}'); - print('DEBUG: _countries list content: $_countries'); - print('DEBUG: _countries is empty: ${_countries.isEmpty}'); - print('DEBUG: ========================================='); - return _countries; - } + List get countries => _countries; /// Get display items for sidebar including special categories /// Returns a list of maps with 'name', 'type', and 'priority' for proper ordering @@ -76,27 +77,28 @@ class IPTVProvider extends ChangeNotifier { // Add all countries with their priority for (final country in _countries) { int priority = _getCountryPriority(country); - items.add({ - 'name': country, - 'type': 'country', - 'priority': priority, - }); + items.add({'name': country, 'type': 'country', 'priority': priority}); } // Add special category: FĂștbol Argentino (priority 2.5 - between PerĂș and other countries) // Only add if there are any Argentine football channels - final hasArgentineFootball = _liveStreams.any((s) => _api.isArgentineFootballChannel(s.name)); + final hasArgentineFootball = _liveStreams.any( + (s) => _api.isArgentineFootballChannel(s.name), + ); if (hasArgentineFootball) { items.add({ 'name': SpecialCategories.argentineFootball, 'type': 'category', - 'priority': 2.5, // Between PerĂș (2) and other South American countries (3) + 'priority': + 2.5, // Between PerĂș (2) and other South American countries (3) }); } // Sort by priority, then alphabetically items.sort((a, b) { - final priorityCompare = (a['priority'] as double).compareTo(b['priority'] as double); + final priorityCompare = (a['priority'] as double).compareTo( + b['priority'] as double, + ); if (priorityCompare != 0) return priorityCompare; return (a['name'] as String).compareTo(b['name'] as String); }); @@ -126,8 +128,33 @@ class IPTVProvider extends ChangeNotifier { return 100; // Low priority for other countries } } + XtreamSeries? get selectedSeries => _selectedSeries; + void _invalidateLiveDerivedCaches() { + _categoryToCountryMapCache = null; + _filteredLiveStreamsCache = null; + _filteredCacheVersion = -1; + } + + void _setLiveStreams(List streams, String categoryId) { + _liveStreams = streams; + _selectedLiveCategory = categoryId; + _liveStreamsVersion++; + _invalidateLiveDerivedCaches(); + } + + void _notifyProgressUpdate() { + final nowMs = DateTime.now().millisecondsSinceEpoch; + final shouldUpdate = + (nowMs - _lastProgressUiUpdateMs) >= 120 || + (_totalChannels > 0 && _loadedChannels >= _totalChannels); + if (shouldUpdate) { + _lastProgressUiUpdateMs = nowMs; + notifyListeners(); + } + } + Future login(String server, String username, String password) async { _isLoading = true; _error = null; @@ -136,9 +163,9 @@ class IPTVProvider extends ChangeNotifier { try { _api.setCredentials(server, username, password); _userInfo = await _api.getUserInfo(); - + // No automatic data loading on startup - data loads on demand only - + await _saveCredentials(server, username, password); } catch (e) { _error = e.toString(); @@ -148,140 +175,144 @@ class IPTVProvider extends ChangeNotifier { notifyListeners(); } - Future _loadCategories() async { - try { - _liveCategories = await _api.getLiveCategories(); - _vodCategories = await _api.getVodCategories(); - _seriesCategories = await _api.getSeriesCategories(); - } catch (e) { - _error = e.toString(); - } - } - Future loadLiveStreams([String categoryId = '']) async { - print('DEBUG: ========================================================='); - print('DEBUG: loadLiveStreams() START - API First Strategy'); - print('DEBUG: ========================================================='); _isLoading = true; _isOrganizingCountries = false; _loadedChannels = 0; _totalChannels = 0; _countries = []; + _lastProgressUiUpdateMs = 0; + _invalidateLiveDerivedCaches(); notifyListeners(); try { // STEP 1: Load from API first (much faster than M3U) - print('DEBUG: Attempting to load from API first...'); - + try { - _liveStreams = await _api.getLiveStreams(categoryId); - _selectedLiveCategory = categoryId; + _setLiveStreams(await _api.getLiveStreams(categoryId), categoryId); _totalChannels = _liveStreams.length; _loadedChannels = _liveStreams.length; - print('DEBUG: API SUCCESS - Loaded ${_liveStreams.length} streams in < 5 seconds'); - + if (_liveStreams.isEmpty) { throw Exception('API returned 0 streams'); } } catch (apiError) { - print('DEBUG: API failed: $apiError'); - print('DEBUG: Falling back to M3U...'); - // Fallback to M3U only if API fails - _liveStreams = await _api.getM3UStreams( - onProgress: (loaded, total) { - _loadedChannels = loaded; - _totalChannels = total; - print('DEBUG: M3U progress: $loaded of $total'); - notifyListeners(); - }, + _setLiveStreams( + await _api.getM3UStreams( + onProgress: (loaded, total) { + _loadedChannels = loaded; + _totalChannels = total; + _notifyProgressUpdate(); + }, + ), + categoryId, ); - _selectedLiveCategory = categoryId; - print('DEBUG: M3U FALLBACK - Loaded ${_liveStreams.length} streams'); - + if (_liveStreams.isEmpty) { throw Exception('No channels available from API or M3U'); } } // STEP 2: Mark loading complete - channels ready to display - print('DEBUG: === CHANNELS READY - Starting background country extraction ==='); _isLoading = false; notifyListeners(); - + // STEP 3: Extract countries in background (using optimized method) _extractCountriesInBackground(); - } catch (e) { _error = e.toString(); - print('DEBUG: ERROR loading streams: $e'); } - print('DEBUG: ========================================================='); - print('DEBUG: loadLiveStreams() END - Loaded ${_liveStreams.length} channels'); - print('DEBUG: ========================================================='); _isLoading = false; notifyListeners(); } - + /// Extract countries from streams in the background to avoid UI freezing void _extractCountriesInBackground() { if (_liveStreams.isEmpty) return; - + _isOrganizingCountries = true; notifyListeners(); - - print('DEBUG: Starting background country extraction from ${_liveStreams.length} streams...'); - + // Use Future.microtask to schedule the extraction after the current frame Future.microtask(() { try { // Use optimized extraction (only sample 2000 channels for speed) - _countries = _api.getCountriesOptimized(_liveStreams, maxChannelsToProcess: 2000); - print('DEBUG: Countries extraction complete. Found ${_countries.length} countries'); - print('DEBUG: Countries list: $_countries'); + _countries = _api.getCountriesOptimized( + _liveStreams, + maxChannelsToProcess: 2000, + ); } catch (e) { - print('DEBUG: Error extracting countries: $e'); _countries = []; } finally { _isOrganizingCountries = false; - print('DEBUG: === CHANNEL LOADING COMPLETE ==='); notifyListeners(); } }); } - - // Extract country names from live categories (format: "Country|XX") - List _extractCountriesFromCategories() { - final countries = {}; - for (final category in _liveCategories) { - final countryName = category.name.split('|').first.trim(); - // Only add if it's a valid country (not a group title) - if (countryName.isNotEmpty && !_isGroupTitle(countryName)) { - countries.add(countryName); - } - } - return countries.toList()..sort(); - } /// Check if a string is a group title (not a country) bool _isGroupTitle(String name) { final normalized = name.toLowerCase().trim(); final groupTitles = { - '24/7', '24/7 ar', '24/7 in', '24/7-es', '24/7-de', '24/7-gr', - '24/7-my', '24/7-pt', '24/7-ro', '24/7-tr', '24/7-latino', - 'vip', 'vip - pk', 'ppv', 'movies', 'cine', 'cine sd', - 'cine y serie', 'latino', 'general', 'music', 'religious', - 'bein', 'mbc', 'tod', 'osn', 'myhd', 'dstv', 'art', - 'icc-ca', 'icc-car', 'icc-dstv', 'icc-in', 'icc-nz', - 'icc-pk', 'icc-uk', 'xmas', 'sin', 'ezd', 'exyu', 'rot', - 'ar-kids', 'ar-sp', 'islam', 'bab', 'as', 'ei' + '24/7', + '24/7 ar', + '24/7 in', + '24/7-es', + '24/7-de', + '24/7-gr', + '24/7-my', + '24/7-pt', + '24/7-ro', + '24/7-tr', + '24/7-latino', + 'vip', + 'vip - pk', + 'ppv', + 'movies', + 'cine', + 'cine sd', + 'cine y serie', + 'latino', + 'general', + 'music', + 'religious', + 'bein', + 'mbc', + 'tod', + 'osn', + 'myhd', + 'dstv', + 'art', + 'icc-ca', + 'icc-car', + 'icc-dstv', + 'icc-in', + 'icc-nz', + 'icc-pk', + 'icc-uk', + 'xmas', + 'sin', + 'ezd', + 'exyu', + 'rot', + 'ar-kids', + 'ar-sp', + 'islam', + 'bab', + 'as', + 'ei', }; return groupTitles.contains(normalized); } // Build a map from category ID to country name for API streams Map _buildCategoryToCountryMap() { + if (_categoryToCountryMapCache != null) { + return _categoryToCountryMapCache!; + } + final map = {}; for (final category in _liveCategories) { final countryName = category.name.split('|').first.trim(); @@ -290,41 +321,68 @@ class IPTVProvider extends ChangeNotifier { map[category.id] = countryName; } } - print('DEBUG: Built category map with ${map.length} entries'); + _categoryToCountryMapCache = map; return map; } void filterByCountry(String country) { - _selectedCountry = country.trim(); + final normalizedCountry = country.trim(); + if (_selectedCountry == normalizedCountry && _selectedCategory.isEmpty) { + return; + } + _selectedCountry = normalizedCountry; _selectedCategory = ''; // Clear special category when country is selected - print('DEBUG: Filter by country: "$_selectedCountry"'); + _filteredLiveStreamsCache = null; notifyListeners(); } void filterByCategory(String category) { - _selectedCategory = category.trim(); + final normalizedCategory = category.trim(); + if (_selectedCategory == normalizedCategory && _selectedCountry.isEmpty) { + return; + } + _selectedCategory = normalizedCategory; _selectedCountry = ''; // Clear country when special category is selected - print('DEBUG: Filter by category: "$_selectedCategory"'); + _filteredLiveStreamsCache = null; notifyListeners(); } List get filteredLiveStreams { - // If a special category is selected, filter by that - if (_selectedCategory.isNotEmpty) { - print('DEBUG: Filtering by special category: "$_selectedCategory"'); - return _api.filterByCategory(_liveStreams, _selectedCategory); + final selectedCountry = _selectedCountry.trim(); + final selectedCategory = _selectedCategory.trim(); + if (_filteredLiveStreamsCache != null && + _filteredCacheVersion == _liveStreamsVersion && + _filteredCountryCacheKey == selectedCountry && + _filteredCategoryCacheKey == selectedCategory) { + return _filteredLiveStreamsCache!; } - // Show all if empty or "Todos"/"All" selected - final normalizedCountry = _selectedCountry.trim(); - if (normalizedCountry.isEmpty || - normalizedCountry.toLowerCase() == 'todos' || - normalizedCountry.toLowerCase() == 'all') { - return _liveStreams; + late final List result; + + // If a special category is selected, filter by that + if (selectedCategory.isNotEmpty) { + result = _api.filterByCategory(_liveStreams, selectedCategory); + } else if (selectedCountry.isEmpty || + selectedCountry.toLowerCase() == 'todos' || + selectedCountry.toLowerCase() == 'all') { + result = _liveStreams; + } else { + // Build category map for API streams that don't have country in name + final categoryMap = _buildCategoryToCountryMap(); + result = _api.filterByCountry( + _liveStreams, + selectedCountry, + categoryToCountryMap: categoryMap, + ); } - // Build category map for API streams that don't have country in name - final categoryMap = _buildCategoryToCountryMap(); - return _api.filterByCountry(_liveStreams, _selectedCountry, categoryToCountryMap: categoryMap); + + _filteredCountryCacheKey = selectedCountry; + _filteredCategoryCacheKey = selectedCategory; + _filteredCacheVersion = _liveStreamsVersion; + _filteredLiveStreamsCache = identical(result, _liveStreams) + ? _liveStreams + : List.unmodifiable(result); + return _filteredLiveStreamsCache!; } Future loadVodStreams([String categoryId = '']) async { @@ -333,7 +391,6 @@ class IPTVProvider extends ChangeNotifier { try { _vodStreams = await _api.getVodStreams(categoryId); - _selectedVodCategory = categoryId; } catch (e) { _error = e.toString(); } @@ -378,40 +435,36 @@ class IPTVProvider extends ChangeNotifier { _loadedChannels = 0; _totalChannels = 0; _countries = []; + _lastProgressUiUpdateMs = 0; + _invalidateLiveDerivedCaches(); notifyListeners(); try { // Try API first, then M3U fallback try { - print('DEBUG: Attempting to reload from API...'); - _liveStreams = await _api.getLiveStreams(''); + _setLiveStreams(await _api.getLiveStreams(''), ''); _totalChannels = _liveStreams.length; _loadedChannels = _liveStreams.length; - print('DEBUG: API reload - Loaded ${_liveStreams.length} streams'); } catch (apiError) { - print('DEBUG: API reload failed: $apiError'); - print('DEBUG: Falling back to M3U...'); - - _liveStreams = await _api.getM3UStreams( - onProgress: (loaded, total) { - _loadedChannels = loaded; - _totalChannels = total; - print('DEBUG: M3U progress: $loaded of $total'); - notifyListeners(); - }, + _setLiveStreams( + await _api.getM3UStreams( + onProgress: (loaded, total) { + _loadedChannels = loaded; + _totalChannels = total; + _notifyProgressUpdate(); + }, + ), + '', ); - print('DEBUG: M3U reload - Loaded ${_liveStreams.length} streams'); } - + // Mark loading as complete - channels are ready to display _isLoading = false; notifyListeners(); - + // Extract countries in background (optimized) _extractCountriesInBackground(); - } catch (e) { - print('DEBUG: Error reloading channels: $e'); _error = 'Error al cargar canales: $e'; _isLoading = false; _isOrganizingCountries = false; @@ -427,20 +480,20 @@ class IPTVProvider extends ChangeNotifier { notifyListeners(); try { - print('DEBUG: Starting M3U download and JSON conversion...'); - // If we already have streams loaded, save those instead of downloading again if (_liveStreams.isNotEmpty) { - print('DEBUG: Using already loaded ${_liveStreams.length} streams'); - // Create M3U result from loaded streams - final channels = _liveStreams.map((stream) => M3UChannel( - name: stream.name, - url: stream.url ?? '', - groupTitle: stream.plot ?? 'Unknown', - tvgLogo: stream.streamIcon, - )).toList(); - + final channels = _liveStreams + .map( + (stream) => M3UChannel( + name: stream.name, + url: stream.url ?? '', + groupTitle: stream.plot ?? 'Unknown', + tvgLogo: stream.streamIcon, + ), + ) + .toList(); + final result = M3UDownloadResult( sourceUrl: '${_api.server}/get.php', downloadTime: DateTime.now(), @@ -448,40 +501,34 @@ class IPTVProvider extends ChangeNotifier { groupsCount: _groupChannelsByCountry(channels), channels: channels, ); - + // Save as JSON file final filePath = await _api.saveM3UAsJson(result); - print('DEBUG: Saved JSON to: $filePath'); - + _isLoading = false; notifyListeners(); - + return filePath; } - + // If no streams loaded, try to download - print('DEBUG: No streams loaded, attempting download...'); final result = await _api.downloadM3UAsJson(); - print('DEBUG: Downloaded ${result.totalChannels} channels from ${result.sourceUrl}'); - print('DEBUG: Groups found: ${result.groupsCount}'); - + // Save as JSON file final filePath = await _api.saveM3UAsJson(result); - print('DEBUG: Saved JSON to: $filePath'); - + _isLoading = false; notifyListeners(); - + return filePath; } catch (e) { - print('DEBUG: Error downloading/saving M3U as JSON: $e'); _error = 'Error al descargar playlist: $e'; _isLoading = false; notifyListeners(); throw Exception(_error); } } - + Map _groupChannelsByCountry(List channels) { final groups = {}; for (final channel in channels) { @@ -494,8 +541,6 @@ class IPTVProvider extends ChangeNotifier { /// Saves all loaded live channels as a text file for analysis Future saveChannelsAsText() async { try { - print('DEBUG: Saving ${_liveStreams.length} channels as text file'); - // Build text content final buffer = StringBuffer(); buffer.writeln('=== XSTREAM TV - LISTA DE CANALES ==='); @@ -517,7 +562,8 @@ class IPTVProvider extends ChangeNotifier { country = countryFromName; } // If not found, try category mapping (API format) - else if (stream.categoryId != null && categoryMap.containsKey(stream.categoryId)) { + else if (stream.categoryId != null && + categoryMap.containsKey(stream.categoryId)) { country = categoryMap[stream.categoryId]; } @@ -549,13 +595,12 @@ class IPTVProvider extends ChangeNotifier { } // Save to file - final fileName = 'xstream_canales_${DateTime.now().millisecondsSinceEpoch}.txt'; + final fileName = + 'xstream_canales_${DateTime.now().millisecondsSinceEpoch}.txt'; final filePath = await _api.saveTextFile(fileName, buffer.toString()); - print('DEBUG: Saved channels list to: $filePath'); return filePath; } catch (e) { - print('DEBUG: Error saving channels as text: $e'); throw Exception('Error al guardar lista: $e'); } } @@ -565,7 +610,11 @@ class IPTVProvider extends ChangeNotifier { notifyListeners(); } - Future _saveCredentials(String server, String username, String password) async { + Future _saveCredentials( + String server, + String username, + String password, + ) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString('server', server); await prefs.setString('username', username); @@ -599,8 +648,12 @@ class IPTVProvider extends ChangeNotifier { _vodStreams = []; _seriesList = []; _countries = []; + _selectedLiveCategory = ''; + _selectedCountry = ''; _selectedCategory = ''; _isOrganizingCountries = false; + _liveStreamsVersion = 0; + _invalidateLiveDerivedCaches(); notifyListeners(); } } diff --git a/lib/services/xtream_api.dart b/lib/services/xtream_api.dart index 4898dfb..319fddf 100644 --- a/lib/services/xtream_api.dart +++ b/lib/services/xtream_api.dart @@ -69,10 +69,18 @@ class M3UDownloadResult { } class XtreamApiService { + XtreamApiService({http.Client? httpClient}) + : _httpClient = httpClient ?? http.Client(); + + final http.Client _httpClient; String? _server; String? _username; String? _password; String? _baseUrl; + final Map _countryExtractionCache = {}; + + static final RegExp _leadingCodeRegex = RegExp(r'^([a-z]{2,3})\s*[-:]\s*'); + static final RegExp _bracketCodeRegex = RegExp(r'[\[\(]([a-z]{2,3})[\]\)]'); // Country normalization mapping: code/variation -> standard name static const Map _countryMapping = { @@ -107,7 +115,7 @@ class XtreamApiService { 've': 'Venezuela', 'ven': 'Venezuela', 'venezuela': 'Venezuela', - + // CentroamĂ©rica y Caribe 'cr': 'Costa Rica', 'cri': 'Costa Rica', @@ -141,7 +149,7 @@ class XtreamApiService { 'haitĂ­': 'HaitĂ­', 'jm': 'Jamaica', 'jamaica': 'Jamaica', - + // NorteamĂ©rica 'us': 'Estados Unidos', 'usa': 'Estados Unidos', @@ -154,7 +162,7 @@ class XtreamApiService { 'mex': 'MĂ©xico', 'mexico': 'MĂ©xico', 'mĂ©xico': 'MĂ©xico', - + // EUROPA 'es': 'España', 'españa': 'España', @@ -176,7 +184,7 @@ class XtreamApiService { 'pt': 'Portugal', 'prt': 'Portugal', 'portugal': 'Portugal', - + // Europa del Norte 'se': 'Suecia', 'sw': 'Suecia', @@ -195,7 +203,7 @@ class XtreamApiService { 'fin': 'Finlandia', 'finland': 'Finlandia', 'finlandia': 'Finlandia', - + // Europa del Este 'ru': 'Rusia', 'rus': 'Rusia', @@ -260,7 +268,7 @@ class XtreamApiService { 'by': 'Bielorrusia', 'blr': 'Bielorrusia', 'belarus': 'Bielorrusia', - + // Europa Occidental 'nl': 'PaĂ­ses Bajos', 'nld': 'PaĂ­ses Bajos', @@ -286,7 +294,7 @@ class XtreamApiService { 'grc': 'Grecia', 'greece': 'Grecia', 'grecia': 'Grecia', - + // ASIA 'in': 'India', 'ind': 'India', @@ -326,7 +334,7 @@ class XtreamApiService { 'sgp': 'Singapur', 'singapore': 'Singapur', 'singapur': 'Singapur', - + // Medio Oriente 'tr': 'TurquĂ­a', 'tur': 'TurquĂ­a', @@ -399,7 +407,7 @@ class XtreamApiService { 'lby': 'Libia', 'libya': 'Libia', 'libia': 'Libia', - + // ÁFRICA 'za': 'SudĂĄfrica', 'zaf': 'SudĂĄfrica', @@ -534,7 +542,7 @@ class XtreamApiService { 'so': 'Somalia', 'som': 'Somalia', 'somalia': 'Somalia', - + // OceanĂ­a 'au': 'Australia', 'aus': 'Australia', @@ -564,7 +572,7 @@ class XtreamApiService { 'wf': 'Wallis y Futuna', 'wlf': 'Wallis y Futuna', 'wallisandfutuna': 'Wallis y Futuna', - + // GRUPOS ESPECIALES - Se mostrarĂĄn como estĂĄn '24/7': '24/7', '24/7 ar': '24/7 AR', @@ -580,7 +588,7 @@ class XtreamApiService { 'ar-kids': 'AR Kids', 'ar-sp': 'AR SP', 'ar_ns': 'AR NS', - + // Idiomas / Languages 'ar': 'Árabe', 'vip': 'VIP', @@ -614,7 +622,7 @@ class XtreamApiService { 'as': 'AS', 'ei': 'EI', 'su': 'SU', - + // Otros cĂłdigos especiales 'af': 'AfganistĂĄn', 'afg': 'AfganistĂĄn', @@ -662,12 +670,12 @@ class XtreamApiService { }; /// Normalize a country string to a standard full name - /// + /// /// This function relies on the smart extraction in extractCountryFromChannelName() /// which already handles context-aware disambiguation of codes like "AR" String normalizeCountry(String rawCountry) { final normalized = rawCountry.toLowerCase().trim(); - + // Direct lookup in the mapping return _countryMapping[normalized] ?? rawCountry; } @@ -677,6 +685,7 @@ class XtreamApiService { _username = username; _password = password; _baseUrl = server.startsWith('http') ? server : 'http://$server'; + _countryExtractionCache.clear(); } String? get server => _server; @@ -684,7 +693,7 @@ class XtreamApiService { Future> authenticate() async { final url = '$_baseUrl/player_api.php'; - final response = await http.get( + final response = await _httpClient.get( Uri.parse('$url?username=$_username&password=$_password'), ); @@ -701,8 +710,10 @@ class XtreamApiService { Future> getLiveCategories() async { final url = '$_baseUrl/player_api.php'; - final response = await http.get( - Uri.parse('$url?username=$_username&password=$_password&action=get_live_categories'), + final response = await _httpClient.get( + Uri.parse( + '$url?username=$_username&password=$_password&action=get_live_categories', + ), ); if (response.statusCode == 200) { @@ -714,8 +725,10 @@ class XtreamApiService { Future> getVodCategories() async { final url = '$_baseUrl/player_api.php'; - final response = await http.get( - Uri.parse('$url?username=$_username&password=$_password&action=get_vod_categories'), + final response = await _httpClient.get( + Uri.parse( + '$url?username=$_username&password=$_password&action=get_vod_categories', + ), ); if (response.statusCode == 200) { @@ -727,8 +740,10 @@ class XtreamApiService { Future> getSeriesCategories() async { final url = '$_baseUrl/player_api.php'; - final response = await http.get( - Uri.parse('$url?username=$_username&password=$_password&action=get_series_categories'), + final response = await _httpClient.get( + Uri.parse( + '$url?username=$_username&password=$_password&action=get_series_categories', + ), ); if (response.statusCode == 200) { @@ -740,18 +755,20 @@ class XtreamApiService { Future> getLiveStreams(String categoryId) async { final url = '$_baseUrl/player_api.php'; - String apiUrl = '$url?username=$_username&password=$_password&action=get_live_streams'; + String apiUrl = + '$url?username=$_username&password=$_password&action=get_live_streams'; if (categoryId.isNotEmpty) { apiUrl += '&category_id=$categoryId'; } - - final response = await http.get(Uri.parse(apiUrl)); + + final response = await _httpClient.get(Uri.parse(apiUrl)); if (response.statusCode == 200) { final List data = json.decode(response.body); return data.map((e) { final stream = XtreamStream.fromJson(e); - stream.url = '$_baseUrl/live/$_username/$_password/${stream.streamId}.ts'; + stream.url = + '$_baseUrl/live/$_username/$_password/${stream.streamId}.ts'; return stream; }).toList(); } @@ -760,19 +777,21 @@ class XtreamApiService { Future> getVodStreams(String categoryId) async { final url = '$_baseUrl/player_api.php'; - String apiUrl = '$url?username=$_username&password=$_password&action=get_vod_streams'; + String apiUrl = + '$url?username=$_username&password=$_password&action=get_vod_streams'; if (categoryId.isNotEmpty) { apiUrl += '&category_id=$categoryId'; } - - final response = await http.get(Uri.parse(apiUrl)); + + final response = await _httpClient.get(Uri.parse(apiUrl)); if (response.statusCode == 200) { final List data = json.decode(response.body); return data.map((e) { final stream = XtreamStream.fromJson(e); final ext = stream.containerExtension ?? 'm3u8'; - stream.url = '$_baseUrl/vod/$_username/$_password/${stream.streamId}.$ext'; + stream.url = + '$_baseUrl/vod/$_username/$_password/${stream.streamId}.$ext'; return stream; }).toList(); } @@ -781,8 +800,10 @@ class XtreamApiService { Future> getSeries() async { final url = '$_baseUrl/player_api.php'; - final response = await http.get( - Uri.parse('$url?username=$_username&password=$_password&action=get_series'), + final response = await _httpClient.get( + Uri.parse( + '$url?username=$_username&password=$_password&action=get_series', + ), ); if (response.statusCode == 200) { @@ -794,22 +815,24 @@ class XtreamApiService { Future> getSeriesEpisodes(int seriesId) async { final url = '$_baseUrl/player_api.php'; - final response = await http.get( - Uri.parse('$url?username=$_username&password=$_password&action=get_series_info&series_id=$seriesId'), + final response = await _httpClient.get( + Uri.parse( + '$url?username=$_username&password=$_password&action=get_series_info&series_id=$seriesId', + ), ); if (response.statusCode == 200) { final data = json.decode(response.body); final List episodesData = data['episodes'] ?? []; - + final List allEpisodes = []; for (final seasonData in episodesData) { - final season = seasonData['season_number'] ?? 0; final List episodes = seasonData['episodes'] ?? []; for (final ep in episodes) { final episode = XtreamEpisode.fromJson(ep); final ext = episode.containerExtension ?? 'm3u8'; - episode.url = '$_baseUrl/series/$_username/$_password/${episode.episodeId}.$ext'; + episode.url = + '$_baseUrl/series/$_username/$_password/${episode.episodeId}.$ext'; allEpisodes.add(episode); } } @@ -823,7 +846,9 @@ class XtreamApiService { return '$_baseUrl/$type/$_username/$_password/$streamId.$ext'; } - Future> getM3UStreams({void Function(int loaded, int total)? onProgress}) async { + Future> getM3UStreams({ + void Function(int loaded, int total)? onProgress, + }) async { // Try multiple M3U endpoints final endpoints = [ '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus&output=ts', @@ -836,48 +861,43 @@ class XtreamApiService { for (final url in endpoints) { try { - print('DEBUG: Trying M3U endpoint: $url'); - final response = await http.get(Uri.parse(url)).timeout( - const Duration(seconds: 60), // Increased timeout for large playlists (26k+ channels) - ); - - print('DEBUG: Response status: ${response.statusCode}, body length: ${response.body.length}'); + final response = await _httpClient + .get(Uri.parse(url)) + .timeout( + const Duration( + seconds: 60, + ), // Increased timeout for large playlists (26k+ channels) + ); if (response.statusCode == 200) { // Check if it's valid M3U content (may have BOM or whitespace before #EXTM3U) final bodyTrimmed = response.body.trim(); if (bodyTrimmed.startsWith('#EXTM3U')) { - print('DEBUG: Successfully loaded M3U from $url'); final streams = _parseM3U(response.body, onProgress: onProgress); - print('DEBUG: Parsed ${streams.length} streams from M3U'); if (streams.isEmpty) { - print('DEBUG: WARNING - M3U parsed but 0 streams found'); lastError = Exception('M3U loaded but no streams parsed'); continue; } return streams; } else { - print('DEBUG: Response does not start with #EXTM3U, first 100 chars: ${bodyTrimmed.substring(0, bodyTrimmed.length > 100 ? 100 : bodyTrimmed.length)}'); lastError = Exception('Invalid M3U format'); } } else { - print('DEBUG: HTTP ${response.statusCode} from $url'); lastError = Exception('HTTP ${response.statusCode}'); } - } on TimeoutException catch (e) { - print('DEBUG: Timeout loading M3U from $url: $e'); + } on TimeoutException { lastError = Exception('Timeout loading M3U'); continue; } catch (e) { - print('DEBUG: Error loading M3U from $url: $e'); lastError = Exception('Error: $e'); continue; // Try next endpoint } } - throw lastError ?? Exception('No se pudo cargar la lista M3U desde ningĂșn endpoint'); + throw lastError ?? + Exception('No se pudo cargar la lista M3U desde ningĂșn endpoint'); } /// Downloads M3U playlist and parses it into structured JSON format @@ -895,19 +915,16 @@ class XtreamApiService { for (final url in endpoints) { try { - print('DEBUG: Trying M3U endpoint: $url'); - final response = await http.get(Uri.parse(url)).timeout( - const Duration(seconds: 30), - ); + final response = await _httpClient + .get(Uri.parse(url)) + .timeout(const Duration(seconds: 30)); if (response.statusCode == 200 && response.body.startsWith('#EXTM3U')) { - print('DEBUG: Successfully loaded M3U from $url'); successfulUrl = url; m3uContent = response.body; break; } } catch (e) { - print('DEBUG: Failed to load from $url: $e'); continue; } } @@ -918,7 +935,7 @@ class XtreamApiService { // Parse M3U content final channels = _parseM3UToChannels(m3uContent); - + // Count groups final groupsCount = {}; for (final channel in channels) { @@ -939,38 +956,39 @@ class XtreamApiService { List _parseM3UToChannels(String m3uContent) { final List channels = []; final lines = m3uContent.split('\n'); - + final infoRegex = RegExp(r'#EXTINF:([^,]*),(.*)$'); + final attrRegex = RegExp(r'(\w+[-\w]*)="([^"]*)"'); + M3UChannel? currentChannel; Map currentMetadata = {}; - + for (final line in lines) { final trimmed = line.trim(); - + if (trimmed.startsWith('#EXTINF:')) { // Parse EXTINF line with all attributes currentMetadata = {}; - + // Extract duration and attributes - final infoMatch = RegExp(r'#EXTINF:([^,]*),(.*)$').firstMatch(trimmed); + final infoMatch = infoRegex.firstMatch(trimmed); if (infoMatch != null) { final attrsPart = infoMatch.group(1) ?? ''; final name = infoMatch.group(2)?.trim() ?? ''; - + // Parse all attributes (tvg-*, group-title, etc.) - final attrRegex = RegExp(r'(\w+[-\w]*)="([^"]*)"'); final matches = attrRegex.allMatches(attrsPart); - + String? tvgLogo; String? groupTitle; String? tvgId; String? tvgName; - + for (final match in matches) { final key = match.group(1); final value = match.group(2); if (key != null && value != null) { currentMetadata[key] = value; - + // Map common attributes switch (key.toLowerCase()) { case 'tvg-logo': @@ -988,7 +1006,7 @@ class XtreamApiService { } } } - + currentChannel = M3UChannel( name: name, url: '', // Will be set on next line @@ -1002,27 +1020,31 @@ class XtreamApiService { } else if (trimmed.isNotEmpty && !trimmed.startsWith('#')) { // This is the stream URL if (currentChannel != null) { - channels.add(M3UChannel( - name: currentChannel.name, - url: trimmed, - groupTitle: currentChannel.groupTitle, - tvgLogo: currentChannel.tvgLogo, - tvgId: currentChannel.tvgId, - tvgName: currentChannel.tvgName, - metadata: currentChannel.metadata, - )); + channels.add( + M3UChannel( + name: currentChannel.name, + url: trimmed, + groupTitle: currentChannel.groupTitle, + tvgLogo: currentChannel.tvgLogo, + tvgId: currentChannel.tvgId, + tvgName: currentChannel.tvgName, + metadata: currentChannel.metadata, + ), + ); currentChannel = null; currentMetadata = {}; } } } - - print('DEBUG: Parsed ${channels.length} channels from M3U'); + return channels; } /// Saves M3U data as JSON file to device storage - Future saveM3UAsJson(M3UDownloadResult result, {String? customFileName}) async { + Future saveM3UAsJson( + M3UDownloadResult result, { + String? customFileName, + }) async { try { // Request storage permission var status = await Permission.storage.request(); @@ -1036,7 +1058,7 @@ class XtreamApiService { // Get appropriate directory Directory? directory; - + // Try Downloads folder first (Android) if (Platform.isAndroid) { directory = Directory('/storage/emulated/0/Download'); @@ -1044,67 +1066,58 @@ class XtreamApiService { directory = null; } } - + // Fallback to app documents directory directory ??= await getApplicationDocumentsDirectory(); - + // Generate filename - final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').split('.').first; + final timestamp = DateTime.now() + .toIso8601String() + .replaceAll(':', '-') + .split('.') + .first; final fileName = customFileName ?? 'xstream_playlist_$timestamp.json'; final filePath = '${directory.path}/$fileName'; - + // Create JSON content - final jsonContent = const JsonEncoder.withIndent(' ').convert(result.toJson()); - + final jsonContent = const JsonEncoder.withIndent( + ' ', + ).convert(result.toJson()); + // Write file final file = File(filePath); await file.writeAsString(jsonContent); - - print('DEBUG: Saved JSON to: $filePath'); + return filePath; } catch (e) { - print('DEBUG: Error saving JSON: $e'); throw Exception('Error al guardar archivo JSON: $e'); } } /// Optimized version of getCountries - only processes a sample of channels for speed /// This dramatically improves load time from 3+ minutes to under 10 seconds - List getCountriesOptimized(List streams, {int maxChannelsToProcess = 2000}) { - print('DEBUG: getCountriesOptimized() called with ${streams.length} streams, processing max $maxChannelsToProcess'); - + List getCountriesOptimized( + List streams, { + int maxChannelsToProcess = 2000, + }) { final countries = {}; - + if (streams.isEmpty) { - print('DEBUG: WARNING - streams list is empty!'); return []; } - + // Sample channels from beginning, middle, and end for better country coverage - final sampleSize = streams.length > maxChannelsToProcess ? maxChannelsToProcess : streams.length; + final sampleSize = streams.length > maxChannelsToProcess + ? maxChannelsToProcess + : streams.length; final step = streams.length > sampleSize ? streams.length ~/ sampleSize : 1; - + int processed = 0; - int loggedSamples = 0; - final maxSamplesToLog = 10; - + for (int i = 0; i < streams.length && processed < sampleSize; i += step) { final stream = streams[i]; String? country; - - // Log sample channels (first few with AR or ARG) - if (loggedSamples < maxSamplesToLog) { - final name = stream.name; - if (name.toLowerCase().startsWith('ar|') || - name.toLowerCase().startsWith('arg|') || - name.toLowerCase().startsWith('24/7 ar|') || - name.toLowerCase().startsWith('24/7-ar|')) { - print('DEBUG SAMPLE CHANNEL: Processing "$name"'); - print('DEBUG SAMPLE CHANNEL: Group title: "${stream.plot}"'); - loggedSamples++; - } - } - + // Extract country from stream name final rawCountryFromName = extractCountryFromChannelName( stream.name, @@ -1113,61 +1126,32 @@ class XtreamApiService { if (rawCountryFromName.isNotEmpty) { country = normalizeCountry(rawCountryFromName); } - + if (country != null && country.isNotEmpty) { countries.add(country); } processed++; } - - print('DEBUG: Processed $processed channels, extracted ${countries.length} unique countries'); - + // Sort countries with custom priority order final sortedCountries = _sortCountriesByPriority(countries.toList()); - print('DEBUG: Returning ${sortedCountries.length} sorted countries'); return sortedCountries; } - List getCountries(List streams, {Map? categoryToCountryMap}) { - print('DEBUG: ========================================================='); - print('DEBUG: getCountries() called with ${streams.length} streams'); - if (categoryToCountryMap != null) { - print('DEBUG: Using category mapping with ${categoryToCountryMap.length} categories'); - } - print('DEBUG: ========================================================='); + List getCountries( + List streams, { + Map? categoryToCountryMap, + }) { final countries = {}; - + if (streams.isEmpty) { - print('DEBUG: WARNING - streams list is empty!'); return []; } - - // Sample first 10 stream names for debugging - print('DEBUG: First 10 stream names:'); - for (int i = 0; i < streams.length && i < 10; i++) { - final s = streams[i]; - print('DEBUG: [${i + 1}] "${s.name}" (category: ${s.categoryId})'); - } - - // Check how many have | separator - int withSeparator = 0; - int withoutSeparator = 0; - for (int i = 0; i < streams.length && i < 50; i++) { - if (streams[i].name.contains('|')) { - withSeparator++; - } else { - withoutSeparator++; - } - } - print('DEBUG: First 50 streams - with | separator: $withSeparator, without: $withoutSeparator'); - - // Track which patterns we're finding - final patternExamples = []; - + for (int i = 0; i < streams.length; i++) { final stream = streams[i]; String? country; - + // First, try to extract country from stream name (M3U format: "Country|XX - Channel Name") // Pass group title for context to help with ambiguous codes like "AR" final rawCountryFromName = extractCountryFromChannelName( @@ -1177,65 +1161,39 @@ class XtreamApiService { if (rawCountryFromName.isNotEmpty) { country = normalizeCountry(rawCountryFromName); } - + // If no country in name and we have category mapping, use category - if (country == null && categoryToCountryMap != null && stream.categoryId != null) { + if (country == null && + categoryToCountryMap != null && + stream.categoryId != null) { final categoryCountry = categoryToCountryMap[stream.categoryId]; if (categoryCountry != null && categoryCountry.isNotEmpty) { country = normalizeCountry(categoryCountry); - print('DEBUG: Using category for "${stream.name}" -> $country'); } } - + if (country != null && country.isNotEmpty) { countries.add(country); - - // Track examples of first 5 patterns found - if (patternExamples.length < 5 && !patternExamples.contains('${stream.name} -> $country')) { - patternExamples.add('${stream.name} -> $country'); - } } } - - print('DEBUG: Extracted ${countries.length} unique countries'); - print('DEBUG: Pattern examples (name -> country):'); - for (final example in patternExamples) { - print('DEBUG: $example'); - } - - // Show all extracted country codes/short names before normalization - final rawCountries = {}; - for (int i = 0; i < streams.length && rawCountries.length < 20; i++) { - final raw = extractCountryFromChannelName( - streams[i].name, - groupTitle: streams[i].plot, - ); - if (raw.isNotEmpty) { - rawCountries.add(raw); - } - } - print('DEBUG: First 20 unique raw country values: ${rawCountries.toList()}'); - + // Sort countries with custom priority order final sortedCountries = _sortCountriesByPriority(countries.toList()); - print('DEBUG: getCountries() returning ${sortedCountries.length} sorted countries'); - print('DEBUG: Countries list: $sortedCountries'); - print('DEBUG: ========================================================='); return sortedCountries; } - - /// Sort countries with custom priority: Argentina first, then Peru, then South America, + + /// Sort countries with custom priority: Argentina first, then Peru, then South America, /// then Europe, then Arabs at the end List _sortCountriesByPriority(List countries) { // Define priority order final priorityOrder = { // 1. Argentina (TOP priority) 'Argentina': 1, - + // 2. Peru (second priority) 'Peru': 2, 'PerĂș': 2, - + // 3. Other South American countries 'Bolivia': 3, 'Brasil': 3, @@ -1246,7 +1204,7 @@ class XtreamApiService { 'Paraguay': 3, 'Uruguay': 3, 'Venezuela': 3, - + // 4. Central America 'Costa Rica': 4, 'El Salvador': 4, @@ -1257,7 +1215,7 @@ class XtreamApiService { 'Panama': 4, 'Puerto Rico': 4, 'RepĂșblica Dominicana': 4, - + // 5. North America 'CanadĂĄ': 5, 'Canada': 5, @@ -1266,7 +1224,7 @@ class XtreamApiService { 'USA': 5, 'MĂ©xico': 5, 'Mexico': 5, - + // 6. Europe 'Alemania': 6, 'Germany': 6, @@ -1322,7 +1280,7 @@ class XtreamApiService { 'Reino Unido': 6, 'UKR': 6, 'Ukraine': 6, - + // 7. Asia (pure Asian countries - no Arab/Middle East) 'BD': 7, 'Bangladesh': 7, @@ -1352,7 +1310,7 @@ class XtreamApiService { 'Thailand': 7, 'VN': 7, 'Vietnam': 7, - + // 8. Africa 'ANGOLA': 8, 'BENIN': 8, @@ -1395,7 +1353,6 @@ class XtreamApiService { 'ZA': 8, 'South Africa': 8, 'ZAMBIA': 8, - // 9. Árabe / Arabic / Middle East (AFTER Africa) 'Árabe': 9, @@ -1444,7 +1401,7 @@ class XtreamApiService { 'Yemen': 9, 'IL': 9, 'Israel': 9, - + // 10. Special groups (24/7, VIP, PPV, etc.) - ABSOLUTE LAST '24/7 AR': 10, '24/7 IN': 10, @@ -1486,205 +1443,232 @@ class XtreamApiService { 'VIP - PK': 10, 'XMAS': 10, }; - + // Log priority for Argentina and Árabe - print('DEBUG SORT: Checking priority assignments...'); - if (countries.contains('Argentina')) { - print('DEBUG SORT: Argentina priority = ${priorityOrder["Argentina"] ?? 100}'); - } - if (countries.contains('Árabe')) { - print('DEBUG SORT: Árabe priority = ${priorityOrder["Árabe"] ?? 100}'); - } - + if (countries.contains('Argentina')) {} + if (countries.contains('Árabe')) {} + // Sort using custom comparator countries.sort((a, b) { final priorityA = priorityOrder[a] ?? 100; final priorityB = priorityOrder[b] ?? 100; - + if (priorityA != priorityB) { return priorityA.compareTo(priorityB); } - + // If same priority, sort alphabetically return a.compareTo(b); }); - + return countries; } /// Check if a channel name indicates it broadcasts Argentine or Spanish football - /// + /// /// IMPORTANT: Only returns true if the channel is from ARGENTINA (ARG|) or SPAIN (ES|) /// This prevents including ESPN/TNT from USA, Netherlands, etc. bool isArgentineFootballChannel(String channelName) { if (channelName.isEmpty) return false; - + final normalizedName = channelName.toLowerCase(); - + // STEP 1: Verify channel is from Argentina (ARG|) or Spain (ES|) // This is CRITICAL to avoid including channels from other countries final isArgentine = normalizedName.startsWith('arg|'); - final isSpanish = normalizedName.startsWith('es|') || normalizedName.contains('|es|'); - + final isSpanish = + normalizedName.startsWith('es|') || normalizedName.contains('|es|'); + if (!isArgentine && !isSpanish) { return false; // Skip channels from other countries (NL, US, UK, etc.) } - + // STEP 2: Check for football-related keywords // Only for Argentine and Spanish channels - + if (isArgentine) { // ARGENTINE CHANNELS - FĂștbol Argentino keywords final argentineKeywords = [ - 'tyc', 'tyc sports', - 'tntsports', 'tnt sports', - 'espn', 'espn premium', 'espn argentina', - 'deportv', 'depo tv', 'depo', - 'directv sports', 'directv', + 'tyc', + 'tyc sports', + 'tntsports', + 'tnt sports', + 'espn', + 'espn premium', + 'espn argentina', + 'deportv', + 'depo tv', + 'depo', + 'directv sports', + 'directv', 'fox sports', - 'futbol', 'fĂștbol', - 'primera', 'liga', - 'copa', 'superliga', + 'futbol', + 'fĂștbol', + 'primera', + 'liga', + 'copa', + 'superliga', 'lpf', - 'boca', 'river', 'racing', 'independiente', 'san lorenzo', - 'game', 'partido', 'cancha', 'gol', - 'sports', 'deportes', + 'boca', + 'river', + 'racing', + 'independiente', + 'san lorenzo', + 'game', + 'partido', + 'cancha', + 'gol', + 'sports', + 'deportes', ]; - + for (final keyword in argentineKeywords) { if (normalizedName.contains(keyword)) { return true; } } } - + if (isSpanish) { - // SPANISH CHANNELS - FĂștbol Español keywords + // SPANISH CHANNELS - FĂștbol Español keywords final spanishKeywords = [ - 'la liga', 'laliga', - 'movistar', 'movistar la liga', 'movistar futbol', - 'gol', 'gol tv', 'goltv', - 'champions', 'champions league', + 'la liga', + 'laliga', + 'movistar', + 'movistar la liga', + 'movistar futbol', + 'gol', + 'gol tv', + 'goltv', + 'champions', + 'champions league', 'europa league', - 'barca', 'barcelona', - 'madrid', 'real madrid', - 'atletico', 'atlĂ©tico', - 'sevilla', 'valencia', 'betis', - 'futbol', 'fĂștbol', + 'barca', + 'barcelona', + 'madrid', + 'real madrid', + 'atletico', + 'atlĂ©tico', + 'sevilla', + 'valencia', + 'betis', + 'futbol', + 'fĂștbol', 'liga', - 'partido', 'cancha', 'gol', + 'partido', + 'cancha', + 'gol', ]; - + for (final keyword in spanishKeywords) { if (normalizedName.contains(keyword)) { return true; } } } - + return false; } /// Smart country extraction with multiple strategies - /// + /// /// Strategy order: /// 1. Check for exact 3-letter matches (ARG, USA, etc.) /// 2. Check for 2-letter codes with context /// 3. Handle special formats (24/7-AR, AR-KIDS, etc.) /// 4. Use category/group information if available /// 5. Return proper full country names - String extractCountryFromChannelName(String channelName, {String? groupTitle}) { - print('DEBUG EXTRACTION: ============================================'); - print('DEBUG EXTRACTION: Processing channel: \"$channelName\"'); - print('DEBUG EXTRACTION: Group title: \"$groupTitle\"'); - + String extractCountryFromChannelName( + String channelName, { + String? groupTitle, + }) { + if (channelName.isEmpty) return ''; + + final normalizedName = channelName.toLowerCase(); + final normalizedGroup = groupTitle?.toLowerCase() ?? ''; + final cacheKey = '$normalizedName|$normalizedGroup'; + final cached = _countryExtractionCache[cacheKey]; + if (cached != null) { + return cached; + } + if (!channelName.contains('|')) { - print('DEBUG EXTRACTION: No | separator found, trying direct name extraction'); - final result = _extractCountryFromDirectName(channelName, groupTitle: groupTitle); - print('DEBUG EXTRACTION: Direct extraction result: \"$result\"'); - print('DEBUG EXTRACTION: ============================================'); - return result; + final result = _extractCountryFromDirectName( + channelName, + groupTitle: groupTitle, + ); + return _cacheCountryExtraction(cacheKey, result); } - + final parts = channelName.split('|'); - print('DEBUG EXTRACTION: Split into \${parts.length} parts: \$parts'); - if (parts.isEmpty) { - print('DEBUG EXTRACTION: No parts found, returning empty'); - print('DEBUG EXTRACTION: ============================================'); - return ''; + return _cacheCountryExtraction(cacheKey, ''); } - + final countryCode = parts.first.trim(); - print('DEBUG EXTRACTION: Extracted country code: \"$countryCode\" (length: \${countryCode.length})'); - + // Must be at least 2 characters (filter out single letters) if (countryCode.length < 2) { - print('DEBUG EXTRACTION: Code too short (< 2 chars), returning empty'); - print('DEBUG EXTRACTION: ============================================'); - return ''; + return _cacheCountryExtraction(cacheKey, ''); } - + // Strategy 1: Check if it's a known group title first if (_isGroupTitle(countryCode)) { - print('DEBUG EXTRACTION: \"$countryCode\" is a group title, extracting from group format'); final extractedFromGroup = _extractCountryFromGroupFormat(countryCode); - print('DEBUG EXTRACTION: Group extraction result: \"$extractedFromGroup\"'); - print('DEBUG EXTRACTION: ============================================'); if (extractedFromGroup.isNotEmpty) { - return extractedFromGroup; + return _cacheCountryExtraction(cacheKey, extractedFromGroup); } - return ''; + return _cacheCountryExtraction(cacheKey, ''); } - + // Strategy 2: Try exact match first (case insensitive) final normalizedCode = countryCode.toLowerCase().trim(); - print('DEBUG EXTRACTION: Normalized code: \"$normalizedCode\"'); - + // Check for exact 3-letter match first (highest priority) if (countryCode.length == 3) { - print('DEBUG EXTRACTION: Checking 3-letter code match...'); final exactMatch = _countryMapping[normalizedCode]; if (exactMatch != null) { - print('DEBUG EXTRACTION: FOUND 3-letter match: \"$countryCode\" -> \"$exactMatch\"'); - print('DEBUG EXTRACTION: ============================================'); - return exactMatch; + return _cacheCountryExtraction(cacheKey, exactMatch); } - print('DEBUG EXTRACTION: No 3-letter match found in mapping'); } - + // Strategy 3: Check for 2-letter match with context if (countryCode.length == 2) { - print('DEBUG EXTRACTION: Checking 2-letter code with context...'); - final result = _handleTwoLetterCode(normalizedCode, groupTitle: groupTitle); - print('DEBUG EXTRACTION: 2-letter code result: \"$result\"'); + final result = _handleTwoLetterCode( + normalizedCode, + groupTitle: groupTitle, + ); if (result.isNotEmpty) { - print('DEBUG EXTRACTION: ============================================'); - return result; + return _cacheCountryExtraction(cacheKey, result); } } - + // Strategy 4: Check the mapping - print('DEBUG EXTRACTION: Checking general mapping...'); final mappedCountry = _countryMapping[normalizedCode]; if (mappedCountry != null) { - print('DEBUG EXTRACTION: FOUND in general mapping: \"$countryCode\" -> \"$mappedCountry\"'); - print('DEBUG EXTRACTION: ============================================'); - return mappedCountry; + return _cacheCountryExtraction(cacheKey, mappedCountry); } - + // Return the raw code if no mapping found - print('DEBUG EXTRACTION: No mapping found, returning raw code: \"$countryCode\"'); - print('DEBUG EXTRACTION: ============================================'); - return countryCode; + return _cacheCountryExtraction(cacheKey, countryCode); } - + + String _cacheCountryExtraction(String cacheKey, String value) { + if (_countryExtractionCache.length > 60000) { + _countryExtractionCache.clear(); + } + _countryExtractionCache[cacheKey] = value; + return value; + } + /// Extract country from special group formats like "24/7-AR", "AR-KIDS", etc. String _extractCountryFromGroupFormat(String groupName) { final normalized = groupName.toLowerCase().trim(); - + // Pattern: 24/7-XX or 24/7 XX - final twentyFourSevenMatch = RegExp(r'24/7[-\s]*(\w{2,3})').firstMatch(normalized); + final twentyFourSevenMatch = RegExp( + r'24/7[-\s]*(\w{2,3})', + ).firstMatch(normalized); if (twentyFourSevenMatch != null) { final code = twentyFourSevenMatch.group(1)!.toLowerCase(); final mapped = _countryMapping[code]; @@ -1692,7 +1676,7 @@ class XtreamApiService { return mapped; } } - + // Pattern: AR-KIDS, AR-SP, etc. final arKidsMatch = RegExp(r'^(\w{2})[-_]').firstMatch(normalized); if (arKidsMatch != null) { @@ -1702,31 +1686,26 @@ class XtreamApiService { return _countryMapping[code]!; } } - + return ''; } - + /// Handle ambiguous 2-letter codes with context String _handleTwoLetterCode(String code, {String? groupTitle}) { - print('DEBUG 2LETTER: Handling 2-letter code: "$code" with groupTitle: "$groupTitle"'); - // AR is ambiguous: could be Argentina or Arabic // CRITICAL FIX: AR| always maps to Árabe (Arabic language/category) // ARG| (3-letter) maps to Argentina if (code == 'ar') { - print('DEBUG 2LETTER: Processing AR code...'); // Check group title for context if (groupTitle != null) { final normalizedGroup = groupTitle.toLowerCase(); - print('DEBUG 2LETTER: Checking group title context: "$normalizedGroup"'); // If group contains argentina-related terms explicitly, treat as Argentina - if (normalizedGroup.contains('argentina') || + if (normalizedGroup.contains('argentina') || normalizedGroup.contains('argentino')) { - print('DEBUG 2LETTER: AR matched Argentina context -> returning Argentina'); return 'Argentina'; } // If group contains arabic-related terms, treat as Arabic - if (normalizedGroup.contains('arab') || + if (normalizedGroup.contains('arab') || normalizedGroup.contains('islam') || normalizedGroup.contains('mbc') || normalizedGroup.contains('bein') || @@ -1735,90 +1714,134 @@ class XtreamApiService { normalizedGroup.contains('saudi') || normalizedGroup.contains('emirates') || normalizedGroup.contains('gulf')) { - print('DEBUG 2LETTER: AR matched Arabic context -> returning Árabe'); return 'Árabe'; } - print('DEBUG 2LETTER: Group title does not match Argentina or Arabic patterns'); - } else { - print('DEBUG 2LETTER: No group title provided for context'); } - + // DEFAULT: AR without context = Árabe (Arabic) // This is because AR| is the standard prefix for Arabic channels // Argentina uses ARG| (3-letter code) - print('DEBUG 2LETTER: AR returning default -> Árabe'); return 'Árabe'; } - + // US/USA -> Estados Unidos if (code == 'us' || code == 'usa') { return 'Estados Unidos'; } - + // UK/GB -> Reino Unido if (code == 'uk' || code == 'gb') { return 'Reino Unido'; } - + // Check if there's a direct mapping final mapped = _countryMapping[code]; if (mapped != null) { return mapped; } - + return ''; } - + /// Extract country from channel name when no | separator exists - String _extractCountryFromDirectName(String channelName, {String? groupTitle}) { + String _extractCountryFromDirectName( + String channelName, { + String? groupTitle, + }) { final normalized = channelName.toLowerCase().trim(); - + // Check for country codes at the beginning // Pattern: "XX - Channel Name" or "XX: Channel Name" - final leadingCodeMatch = RegExp(r'^([a-z]{2,3})\s*[-:]\s*').firstMatch(normalized); + final leadingCodeMatch = _leadingCodeRegex.firstMatch(normalized); if (leadingCodeMatch != null) { final code = leadingCodeMatch.group(1)!; - final country = _handleTwoLetterCode(code, groupTitle: groupTitle); + final country = code.length == 3 + ? (_countryMapping[code] ?? '') + : _handleTwoLetterCode(code, groupTitle: groupTitle); if (country.isNotEmpty) { return country; } } - + // Check for country codes in brackets // Pattern: "Channel Name [XX]" or "(XX)" - final bracketMatch = RegExp(r'[\[\(]([a-z]{2,3})[\]\)]').firstMatch(normalized); + final bracketMatch = _bracketCodeRegex.firstMatch(normalized); if (bracketMatch != null) { final code = bracketMatch.group(1)!; - final country = _handleTwoLetterCode(code, groupTitle: groupTitle); + final country = code.length == 3 + ? (_countryMapping[code] ?? '') + : _handleTwoLetterCode(code, groupTitle: groupTitle); if (country.isNotEmpty) { return country; } } - + return ''; } - + /// Check if a string is a group title (not a country) bool _isGroupTitle(String name) { final normalized = name.toLowerCase().trim(); - + // List of known group titles to exclude final groupTitles = { - '24/7', '24/7 ar', '24/7 in', '24/7-es', '24/7-de', '24/7-gr', - '24/7-my', '24/7-pt', '24/7-ro', '24/7-tr', '24/7-latino', - 'vip', 'vip - pk', 'ppv', 'movies', 'cine', 'cine sd', - 'cine y serie', 'latino', 'general', 'music', 'religious', - 'bein', 'mbc', 'tod', 'osn', 'myhd', 'dstv', 'art', - 'icc-ca', 'icc-car', 'icc-dstv', 'icc-in', 'icc-nz', - 'icc-pk', 'icc-uk', 'xmas', 'sin', 'ezd', 'exyu', 'rot', - 'ar-kids', 'ar-sp', 'islam', 'bab', 'as', 'ei' + '24/7', + '24/7 ar', + '24/7 in', + '24/7-es', + '24/7-de', + '24/7-gr', + '24/7-my', + '24/7-pt', + '24/7-ro', + '24/7-tr', + '24/7-latino', + 'vip', + 'vip - pk', + 'ppv', + 'movies', + 'cine', + 'cine sd', + 'cine y serie', + 'latino', + 'general', + 'music', + 'religious', + 'bein', + 'mbc', + 'tod', + 'osn', + 'myhd', + 'dstv', + 'art', + 'icc-ca', + 'icc-car', + 'icc-dstv', + 'icc-in', + 'icc-nz', + 'icc-pk', + 'icc-uk', + 'xmas', + 'sin', + 'ezd', + 'exyu', + 'rot', + 'ar-kids', + 'ar-sp', + 'islam', + 'bab', + 'as', + 'ei', }; - + return groupTitles.contains(normalized); } - List filterByCountry(List streams, String country, - {Map? categoryToCountryMap}) { + List filterByCountry( + List streams, + String country, { + Map? categoryToCountryMap, + }) { // Empty string or "Todos"/"All" means show all channels final normalizedCountry = country.trim(); if (normalizedCountry.isEmpty || @@ -1855,48 +1878,50 @@ class XtreamApiService { } /// Filter streams by special category - /// + /// /// Currently supports: /// - "FĂștbol Argentino" / "Futbol Argentino": Channels broadcasting Argentine football - List filterByCategory(List streams, String category) { + List filterByCategory( + List streams, + String category, + ) { final normalizedCategory = category.trim().toLowerCase(); - + if (normalizedCategory.isEmpty) { return streams; } - + // Special case: FĂștbol Argentino - if (normalizedCategory == 'fĂștbol argentino' || + if (normalizedCategory == 'fĂștbol argentino' || normalizedCategory == 'futbol argentino') { - print('DEBUG: Filtering for FĂștbol Argentino channels'); final filtered = streams.where((s) { return isArgentineFootballChannel(s.name); }).toList(); - + // Sort: Argentine channels (ARG|) first, then Spanish (ES|) filtered.sort((a, b) { final aIsArgentine = a.name.toLowerCase().startsWith('arg|'); final bIsArgentine = b.name.toLowerCase().startsWith('arg|'); - + if (aIsArgentine && !bIsArgentine) return -1; // a comes first - if (!aIsArgentine && bIsArgentine) return 1; // b comes first + if (!aIsArgentine && bIsArgentine) return 1; // b comes first return 0; // same priority, keep original order }); - - print('DEBUG: Found ${filtered.length} Argentine football channels (sorted: ARG first, then ES)'); + return filtered; } - + // Unknown category - return all streams return streams; } - List _parseM3U(String m3uContent, {void Function(int loaded, int total)? onProgress}) { - print('DEBUG: _parseM3U() START - content length: ${m3uContent.length} chars'); + List _parseM3U( + String m3uContent, { + void Function(int loaded, int total)? onProgress, + }) { final List streams = []; final lines = m3uContent.split('\n'); - print('DEBUG: _parseM3U() - ${lines.length} lines to parse'); - + // Count total EXTINF lines to estimate total channels int totalExtinfLines = 0; for (final line in lines) { @@ -1904,42 +1929,37 @@ class XtreamApiService { totalExtinfLines++; } } - print('DEBUG: _parseM3U() - Estimated total channels: $totalExtinfLines'); - + XtreamStream? currentStream; - int extinfCount = 0; - int urlCount = 0; int lastReportedProgress = 0; - + final logoRegex = RegExp(r'tvg-logo="([^"]*)"'); + final groupRegex = RegExp(r'group-title="([^"]*)"'); + for (final line in lines) { final trimmed = line.trim(); - + if (trimmed.startsWith('#EXTINF:')) { - extinfCount++; final info = trimmed.substring('#EXTINF:'.length); - final parts = info.split(','); - + final commaIndex = info.indexOf(','); + String? streamIcon; String? groupTitle; - String name = parts.length > 1 ? parts[1].trim() : ''; - + final name = commaIndex >= 0 + ? info.substring(commaIndex + 1).trim() + : ''; + // Parse attributes: tvg-logo, group-title, tvg-id - final attrs = parts[0]; - final logoMatch = RegExp(r'tvg-logo="([^"]*)"').firstMatch(attrs); - final groupMatch = RegExp(r'group-title="([^"]*)"').firstMatch(attrs); - + final attrs = commaIndex >= 0 ? info.substring(0, commaIndex) : info; + final logoMatch = logoRegex.firstMatch(attrs); + final groupMatch = groupRegex.firstMatch(attrs); + if (logoMatch != null) { streamIcon = logoMatch.group(1); } if (groupMatch != null) { groupTitle = groupMatch.group(1); } - - // Log first 5 and last 5 channels to see the format - if (streams.length < 5 || (streams.length > 25200 && streams.length < 25205)) { - print('DEBUG: _parseM3U() - Channel #${streams.length + 1}: name="$name", group="$groupTitle"'); - } - + currentStream = XtreamStream( streamId: streams.length + 1, name: name, @@ -1949,16 +1969,18 @@ class XtreamApiService { ); } else if (trimmed.isNotEmpty && !trimmed.startsWith('#')) { // This is the stream URL - urlCount++; if (currentStream != null) { currentStream.url = trimmed; streams.add(currentStream); currentStream = null; - + // Report progress every 100 channels or at the end - if (onProgress != null && (streams.length % 100 == 0 || streams.length == totalExtinfLines)) { + if (onProgress != null && + (streams.length % 100 == 0 || + streams.length == totalExtinfLines)) { // Only report if progress changed significantly - if (streams.length - lastReportedProgress >= 100 || streams.length == totalExtinfLines) { + if (streams.length - lastReportedProgress >= 100 || + streams.length == totalExtinfLines) { onProgress(streams.length, totalExtinfLines); lastReportedProgress = streams.length; } @@ -1966,13 +1988,12 @@ class XtreamApiService { } } } - + // Final progress report if (onProgress != null) { onProgress(streams.length, totalExtinfLines); } - - print('DEBUG: _parseM3U() END - Parsed ${streams.length} streams ($extinfCount EXTINF lines, $urlCount URLs)'); + return streams; } @@ -2008,10 +2029,8 @@ class XtreamApiService { // Write content to file await file.writeAsString(content, flush: true); - print('DEBUG: Text file saved to: $filePath'); return filePath; } catch (e) { - print('DEBUG: Error saving text file: $e'); throw Exception('Error al guardar archivo: $e'); } } diff --git a/lib/services/xtream_api.dart.backup b/lib/services/xtream_api.dart.backup index a7e7db0..94681af 100644 --- a/lib/services/xtream_api.dart.backup +++ b/lib/services/xtream_api.dart.backup @@ -107,7 +107,7 @@ class XtreamApiService { 've': 'Venezuela', 'ven': 'Venezuela', 'venezuela': 'Venezuela', - + // CentroamĂ©rica y Caribe 'cr': 'Costa Rica', 'cri': 'Costa Rica', @@ -141,7 +141,7 @@ class XtreamApiService { 'haitĂ­': 'HaitĂ­', 'jm': 'Jamaica', 'jamaica': 'Jamaica', - + // NorteamĂ©rica 'us': 'Estados Unidos', 'usa': 'Estados Unidos', @@ -154,7 +154,7 @@ class XtreamApiService { 'mex': 'MĂ©xico', 'mexico': 'MĂ©xico', 'mĂ©xico': 'MĂ©xico', - + // EUROPA 'es': 'España', 'españa': 'España', @@ -176,7 +176,7 @@ class XtreamApiService { 'pt': 'Portugal', 'prt': 'Portugal', 'portugal': 'Portugal', - + // Europa del Norte 'se': 'Suecia', 'sw': 'Suecia', @@ -195,7 +195,7 @@ class XtreamApiService { 'fin': 'Finlandia', 'finland': 'Finlandia', 'finlandia': 'Finlandia', - + // Europa del Este 'ru': 'Rusia', 'rus': 'Rusia', @@ -260,7 +260,7 @@ class XtreamApiService { 'by': 'Bielorrusia', 'blr': 'Bielorrusia', 'belarus': 'Bielorrusia', - + // Europa Occidental 'nl': 'PaĂ­ses Bajos', 'nld': 'PaĂ­ses Bajos', @@ -286,7 +286,7 @@ class XtreamApiService { 'grc': 'Grecia', 'greece': 'Grecia', 'grecia': 'Grecia', - + // ASIA 'in': 'India', 'ind': 'India', @@ -326,7 +326,7 @@ class XtreamApiService { 'sgp': 'Singapur', 'singapore': 'Singapur', 'singapur': 'Singapur', - + // Medio Oriente 'tr': 'TurquĂ­a', 'tur': 'TurquĂ­a', @@ -399,7 +399,7 @@ class XtreamApiService { 'lby': 'Libia', 'libya': 'Libia', 'libia': 'Libia', - + // ÁFRICA 'za': 'SudĂĄfrica', 'zaf': 'SudĂĄfrica', @@ -534,7 +534,7 @@ class XtreamApiService { 'so': 'Somalia', 'som': 'Somalia', 'somalia': 'Somalia', - + // OceanĂ­a 'au': 'Australia', 'aus': 'Australia', @@ -564,7 +564,7 @@ class XtreamApiService { 'wf': 'Wallis y Futuna', 'wlf': 'Wallis y Futuna', 'wallisandfutuna': 'Wallis y Futuna', - + // GRUPOS ESPECIALES - Se mostrarĂĄn como estĂĄn '24/7': '24/7', '24/7 ar': '24/7 AR', @@ -580,7 +580,7 @@ class XtreamApiService { 'ar-kids': 'AR Kids', 'ar-sp': 'AR SP', 'ar_ns': 'AR NS', - + // Idiomas / Languages 'ar': 'Árabe', 'vip': 'VIP', @@ -614,7 +614,7 @@ class XtreamApiService { 'as': 'AS', 'ei': 'EI', 'su': 'SU', - + // Otros cĂłdigos especiales 'af': 'AfganistĂĄn', 'afg': 'AfganistĂĄn', @@ -662,12 +662,12 @@ class XtreamApiService { }; /// Normalize a country string to a standard full name - /// + /// /// This function relies on the smart extraction in extractCountryFromChannelName() /// which already handles context-aware disambiguation of codes like "AR" String normalizeCountry(String rawCountry) { final normalized = rawCountry.toLowerCase().trim(); - + // Direct lookup in the mapping return _countryMapping[normalized] ?? rawCountry; } @@ -702,7 +702,9 @@ class XtreamApiService { Future> getLiveCategories() async { final url = '$_baseUrl/player_api.php'; final response = await http.get( - Uri.parse('$url?username=$_username&password=$_password&action=get_live_categories'), + Uri.parse( + '$url?username=$_username&password=$_password&action=get_live_categories', + ), ); if (response.statusCode == 200) { @@ -715,7 +717,9 @@ class XtreamApiService { Future> getVodCategories() async { final url = '$_baseUrl/player_api.php'; final response = await http.get( - Uri.parse('$url?username=$_username&password=$_password&action=get_vod_categories'), + Uri.parse( + '$url?username=$_username&password=$_password&action=get_vod_categories', + ), ); if (response.statusCode == 200) { @@ -728,7 +732,9 @@ class XtreamApiService { Future> getSeriesCategories() async { final url = '$_baseUrl/player_api.php'; final response = await http.get( - Uri.parse('$url?username=$_username&password=$_password&action=get_series_categories'), + Uri.parse( + '$url?username=$_username&password=$_password&action=get_series_categories', + ), ); if (response.statusCode == 200) { @@ -740,18 +746,20 @@ class XtreamApiService { Future> getLiveStreams(String categoryId) async { final url = '$_baseUrl/player_api.php'; - String apiUrl = '$url?username=$_username&password=$_password&action=get_live_streams'; + String apiUrl = + '$url?username=$_username&password=$_password&action=get_live_streams'; if (categoryId.isNotEmpty) { apiUrl += '&category_id=$categoryId'; } - + final response = await http.get(Uri.parse(apiUrl)); if (response.statusCode == 200) { final List data = json.decode(response.body); return data.map((e) { final stream = XtreamStream.fromJson(e); - stream.url = '$_baseUrl/live/$_username/$_password/${stream.streamId}.ts'; + stream.url = + '$_baseUrl/live/$_username/$_password/${stream.streamId}.ts'; return stream; }).toList(); } @@ -760,11 +768,12 @@ class XtreamApiService { Future> getVodStreams(String categoryId) async { final url = '$_baseUrl/player_api.php'; - String apiUrl = '$url?username=$_username&password=$_password&action=get_vod_streams'; + String apiUrl = + '$url?username=$_username&password=$_password&action=get_vod_streams'; if (categoryId.isNotEmpty) { apiUrl += '&category_id=$categoryId'; } - + final response = await http.get(Uri.parse(apiUrl)); if (response.statusCode == 200) { @@ -772,7 +781,8 @@ class XtreamApiService { return data.map((e) { final stream = XtreamStream.fromJson(e); final ext = stream.containerExtension ?? 'm3u8'; - stream.url = '$_baseUrl/vod/$_username/$_password/${stream.streamId}.$ext'; + stream.url = + '$_baseUrl/vod/$_username/$_password/${stream.streamId}.$ext'; return stream; }).toList(); } @@ -782,7 +792,9 @@ class XtreamApiService { Future> getSeries() async { final url = '$_baseUrl/player_api.php'; final response = await http.get( - Uri.parse('$url?username=$_username&password=$_password&action=get_series'), + Uri.parse( + '$url?username=$_username&password=$_password&action=get_series', + ), ); if (response.statusCode == 200) { @@ -795,13 +807,15 @@ class XtreamApiService { Future> getSeriesEpisodes(int seriesId) async { final url = '$_baseUrl/player_api.php'; final response = await http.get( - Uri.parse('$url?username=$_username&password=$_password&action=get_series_info&series_id=$seriesId'), + Uri.parse( + '$url?username=$_username&password=$_password&action=get_series_info&series_id=$seriesId', + ), ); if (response.statusCode == 200) { final data = json.decode(response.body); final List episodesData = data['episodes'] ?? []; - + final List allEpisodes = []; for (final seasonData in episodesData) { final season = seasonData['season_number'] ?? 0; @@ -809,7 +823,8 @@ class XtreamApiService { for (final ep in episodes) { final episode = XtreamEpisode.fromJson(ep); final ext = episode.containerExtension ?? 'm3u8'; - episode.url = '$_baseUrl/series/$_username/$_password/${episode.episodeId}.$ext'; + episode.url = + '$_baseUrl/series/$_username/$_password/${episode.episodeId}.$ext'; allEpisodes.add(episode); } } @@ -823,7 +838,9 @@ class XtreamApiService { return '$_baseUrl/$type/$_username/$_password/$streamId.$ext'; } - Future> getM3UStreams({void Function(int loaded, int total)? onProgress}) async { + Future> getM3UStreams({ + void Function(int loaded, int total)? onProgress, + }) async { // Try multiple M3U endpoints final endpoints = [ '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus&output=ts', @@ -836,48 +853,43 @@ class XtreamApiService { for (final url in endpoints) { try { - print('DEBUG: Trying M3U endpoint: $url'); - final response = await http.get(Uri.parse(url)).timeout( - const Duration(seconds: 60), // Increased timeout for large playlists (26k+ channels) - ); - - print('DEBUG: Response status: ${response.statusCode}, body length: ${response.body.length}'); + final response = await http + .get(Uri.parse(url)) + .timeout( + const Duration( + seconds: 60, + ), // Increased timeout for large playlists (26k+ channels) + ); if (response.statusCode == 200) { // Check if it's valid M3U content (may have BOM or whitespace before #EXTM3U) final bodyTrimmed = response.body.trim(); if (bodyTrimmed.startsWith('#EXTM3U')) { - print('DEBUG: Successfully loaded M3U from $url'); final streams = _parseM3U(response.body, onProgress: onProgress); - print('DEBUG: Parsed ${streams.length} streams from M3U'); if (streams.isEmpty) { - print('DEBUG: WARNING - M3U parsed but 0 streams found'); lastError = Exception('M3U loaded but no streams parsed'); continue; } return streams; } else { - print('DEBUG: Response does not start with #EXTM3U, first 100 chars: ${bodyTrimmed.substring(0, bodyTrimmed.length > 100 ? 100 : bodyTrimmed.length)}'); lastError = Exception('Invalid M3U format'); } } else { - print('DEBUG: HTTP ${response.statusCode} from $url'); lastError = Exception('HTTP ${response.statusCode}'); } } on TimeoutException catch (e) { - print('DEBUG: Timeout loading M3U from $url: $e'); lastError = Exception('Timeout loading M3U'); continue; } catch (e) { - print('DEBUG: Error loading M3U from $url: $e'); lastError = Exception('Error: $e'); continue; // Try next endpoint } } - throw lastError ?? Exception('No se pudo cargar la lista M3U desde ningĂșn endpoint'); + throw lastError ?? + Exception('No se pudo cargar la lista M3U desde ningĂșn endpoint'); } /// Downloads M3U playlist and parses it into structured JSON format @@ -895,19 +907,16 @@ class XtreamApiService { for (final url in endpoints) { try { - print('DEBUG: Trying M3U endpoint: $url'); - final response = await http.get(Uri.parse(url)).timeout( - const Duration(seconds: 30), - ); + final response = await http + .get(Uri.parse(url)) + .timeout(const Duration(seconds: 30)); if (response.statusCode == 200 && response.body.startsWith('#EXTM3U')) { - print('DEBUG: Successfully loaded M3U from $url'); successfulUrl = url; m3uContent = response.body; break; } } catch (e) { - print('DEBUG: Failed to load from $url: $e'); continue; } } @@ -918,7 +927,7 @@ class XtreamApiService { // Parse M3U content final channels = _parseM3UToChannels(m3uContent); - + // Count groups final groupsCount = {}; for (final channel in channels) { @@ -939,38 +948,38 @@ class XtreamApiService { List _parseM3UToChannels(String m3uContent) { final List channels = []; final lines = m3uContent.split('\n'); - + M3UChannel? currentChannel; Map currentMetadata = {}; - + for (final line in lines) { final trimmed = line.trim(); - + if (trimmed.startsWith('#EXTINF:')) { // Parse EXTINF line with all attributes currentMetadata = {}; - + // Extract duration and attributes final infoMatch = RegExp(r'#EXTINF:([^,]*),(.*)$').firstMatch(trimmed); if (infoMatch != null) { final attrsPart = infoMatch.group(1) ?? ''; final name = infoMatch.group(2)?.trim() ?? ''; - + // Parse all attributes (tvg-*, group-title, etc.) final attrRegex = RegExp(r'(\w+[-\w]*)="([^"]*)"'); final matches = attrRegex.allMatches(attrsPart); - + String? tvgLogo; String? groupTitle; String? tvgId; String? tvgName; - + for (final match in matches) { final key = match.group(1); final value = match.group(2); if (key != null && value != null) { currentMetadata[key] = value; - + // Map common attributes switch (key.toLowerCase()) { case 'tvg-logo': @@ -988,7 +997,7 @@ class XtreamApiService { } } } - + currentChannel = M3UChannel( name: name, url: '', // Will be set on next line @@ -1002,27 +1011,31 @@ class XtreamApiService { } else if (trimmed.isNotEmpty && !trimmed.startsWith('#')) { // This is the stream URL if (currentChannel != null) { - channels.add(M3UChannel( - name: currentChannel.name, - url: trimmed, - groupTitle: currentChannel.groupTitle, - tvgLogo: currentChannel.tvgLogo, - tvgId: currentChannel.tvgId, - tvgName: currentChannel.tvgName, - metadata: currentChannel.metadata, - )); + channels.add( + M3UChannel( + name: currentChannel.name, + url: trimmed, + groupTitle: currentChannel.groupTitle, + tvgLogo: currentChannel.tvgLogo, + tvgId: currentChannel.tvgId, + tvgName: currentChannel.tvgName, + metadata: currentChannel.metadata, + ), + ); currentChannel = null; currentMetadata = {}; } } } - - print('DEBUG: Parsed ${channels.length} channels from M3U'); + return channels; } /// Saves M3U data as JSON file to device storage - Future saveM3UAsJson(M3UDownloadResult result, {String? customFileName}) async { + Future saveM3UAsJson( + M3UDownloadResult result, { + String? customFileName, + }) async { try { // Request storage permission var status = await Permission.storage.request(); @@ -1036,7 +1049,7 @@ class XtreamApiService { // Get appropriate directory Directory? directory; - + // Try Downloads folder first (Android) if (Platform.isAndroid) { directory = Directory('/storage/emulated/0/Download'); @@ -1044,67 +1057,71 @@ class XtreamApiService { directory = null; } } - + // Fallback to app documents directory directory ??= await getApplicationDocumentsDirectory(); - + // Generate filename - final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').split('.').first; + final timestamp = DateTime.now() + .toIso8601String() + .replaceAll(':', '-') + .split('.') + .first; final fileName = customFileName ?? 'xstream_playlist_$timestamp.json'; final filePath = '${directory.path}/$fileName'; - + // Create JSON content - final jsonContent = const JsonEncoder.withIndent(' ').convert(result.toJson()); - + final jsonContent = const JsonEncoder.withIndent( + ' ', + ).convert(result.toJson()); + // Write file final file = File(filePath); await file.writeAsString(jsonContent); - - print('DEBUG: Saved JSON to: $filePath'); + return filePath; } catch (e) { - print('DEBUG: Error saving JSON: $e'); throw Exception('Error al guardar archivo JSON: $e'); } } /// Optimized version of getCountries - only processes a sample of channels for speed /// This dramatically improves load time from 3+ minutes to under 10 seconds - List getCountriesOptimized(List streams, {int maxChannelsToProcess = 2000}) { - print('DEBUG: getCountriesOptimized() called with ${streams.length} streams, processing max $maxChannelsToProcess'); - + List getCountriesOptimized( + List streams, { + int maxChannelsToProcess = 2000, + }) { final countries = {}; - + if (streams.isEmpty) { - print('DEBUG: WARNING - streams list is empty!'); return []; } - + // Sample channels from beginning, middle, and end for better country coverage - final sampleSize = streams.length > maxChannelsToProcess ? maxChannelsToProcess : streams.length; + final sampleSize = streams.length > maxChannelsToProcess + ? maxChannelsToProcess + : streams.length; final step = streams.length > sampleSize ? streams.length ~/ sampleSize : 1; - + int processed = 0; int loggedSamples = 0; final maxSamplesToLog = 10; - + for (int i = 0; i < streams.length && processed < sampleSize; i += step) { final stream = streams[i]; String? country; - + // Log sample channels (first few with AR or ARG) if (loggedSamples < maxSamplesToLog) { final name = stream.name; - if (name.toLowerCase().startsWith('ar|') || + if (name.toLowerCase().startsWith('ar|') || name.toLowerCase().startsWith('arg|') || name.toLowerCase().startsWith('24/7 ar|') || name.toLowerCase().startsWith('24/7-ar|')) { - print('DEBUG SAMPLE CHANNEL: Processing "$name"'); - print('DEBUG SAMPLE CHANNEL: Group title: "${stream.plot}"'); loggedSamples++; } } - + // Extract country from stream name final rawCountryFromName = extractCountryFromChannelName( stream.name, @@ -1113,42 +1130,34 @@ class XtreamApiService { if (rawCountryFromName.isNotEmpty) { country = normalizeCountry(rawCountryFromName); } - + if (country != null && country.isNotEmpty) { countries.add(country); } processed++; } - - print('DEBUG: Processed $processed channels, extracted ${countries.length} unique countries'); - + // Sort countries with custom priority order final sortedCountries = _sortCountriesByPriority(countries.toList()); - print('DEBUG: Returning ${sortedCountries.length} sorted countries'); return sortedCountries; } - List getCountries(List streams, {Map? categoryToCountryMap}) { - print('DEBUG: ========================================================='); - print('DEBUG: getCountries() called with ${streams.length} streams'); - if (categoryToCountryMap != null) { - print('DEBUG: Using category mapping with ${categoryToCountryMap.length} categories'); - } - print('DEBUG: ========================================================='); + List getCountries( + List streams, { + Map? categoryToCountryMap, + }) { + if (categoryToCountryMap != null) {} final countries = {}; - + if (streams.isEmpty) { - print('DEBUG: WARNING - streams list is empty!'); return []; } - + // Sample first 10 stream names for debugging - print('DEBUG: First 10 stream names:'); for (int i = 0; i < streams.length && i < 10; i++) { final s = streams[i]; - print('DEBUG: [${i + 1}] "${s.name}" (category: ${s.categoryId})'); } - + // Check how many have | separator int withSeparator = 0; int withoutSeparator = 0; @@ -1159,15 +1168,14 @@ class XtreamApiService { withoutSeparator++; } } - print('DEBUG: First 50 streams - with | separator: $withSeparator, without: $withoutSeparator'); - + // Track which patterns we're finding final patternExamples = []; - + for (int i = 0; i < streams.length; i++) { final stream = streams[i]; String? country; - + // First, try to extract country from stream name (M3U format: "Country|XX - Channel Name") // Pass group title for context to help with ambiguous codes like "AR" final rawCountryFromName = extractCountryFromChannelName( @@ -1177,32 +1185,30 @@ class XtreamApiService { if (rawCountryFromName.isNotEmpty) { country = normalizeCountry(rawCountryFromName); } - + // If no country in name and we have category mapping, use category - if (country == null && categoryToCountryMap != null && stream.categoryId != null) { + if (country == null && + categoryToCountryMap != null && + stream.categoryId != null) { final categoryCountry = categoryToCountryMap[stream.categoryId]; if (categoryCountry != null && categoryCountry.isNotEmpty) { country = normalizeCountry(categoryCountry); - print('DEBUG: Using category for "${stream.name}" -> $country'); } } - + if (country != null && country.isNotEmpty) { countries.add(country); - + // Track examples of first 5 patterns found - if (patternExamples.length < 5 && !patternExamples.contains('${stream.name} -> $country')) { + if (patternExamples.length < 5 && + !patternExamples.contains('${stream.name} -> $country')) { patternExamples.add('${stream.name} -> $country'); } } } - - print('DEBUG: Extracted ${countries.length} unique countries'); - print('DEBUG: Pattern examples (name -> country):'); - for (final example in patternExamples) { - print('DEBUG: $example'); - } - + + for (final example in patternExamples) {} + // Show all extracted country codes/short names before normalization final rawCountries = {}; for (int i = 0; i < streams.length && rawCountries.length < 20; i++) { @@ -1214,28 +1220,24 @@ class XtreamApiService { rawCountries.add(raw); } } - print('DEBUG: First 20 unique raw country values: ${rawCountries.toList()}'); - + // Sort countries with custom priority order final sortedCountries = _sortCountriesByPriority(countries.toList()); - print('DEBUG: getCountries() returning ${sortedCountries.length} sorted countries'); - print('DEBUG: Countries list: $sortedCountries'); - print('DEBUG: ========================================================='); return sortedCountries; } - - /// Sort countries with custom priority: Argentina first, then Peru, then South America, + + /// Sort countries with custom priority: Argentina first, then Peru, then South America, /// then Europe, then Arabs at the end List _sortCountriesByPriority(List countries) { // Define priority order final priorityOrder = { // 1. Argentina (TOP priority) 'Argentina': 1, - + // 2. Peru (second priority) 'Peru': 2, 'PerĂș': 2, - + // 3. Other South American countries 'Bolivia': 3, 'Brasil': 3, @@ -1246,7 +1248,7 @@ class XtreamApiService { 'Paraguay': 3, 'Uruguay': 3, 'Venezuela': 3, - + // 4. Central America 'Costa Rica': 4, 'El Salvador': 4, @@ -1257,7 +1259,7 @@ class XtreamApiService { 'Panama': 4, 'Puerto Rico': 4, 'RepĂșblica Dominicana': 4, - + // 5. North America 'CanadĂĄ': 5, 'Canada': 5, @@ -1266,7 +1268,7 @@ class XtreamApiService { 'USA': 5, 'MĂ©xico': 5, 'Mexico': 5, - + // 6. Europe 'Alemania': 6, 'Germany': 6, @@ -1322,7 +1324,7 @@ class XtreamApiService { 'Reino Unido': 6, 'UKR': 6, 'Ukraine': 6, - + // 7. Asia/Oceania 'AF': 7, 'AF': 7, @@ -1401,7 +1403,7 @@ class XtreamApiService { 'United Arab Emirates': 7, 'YEMEN': 7, 'Yemen': 7, - + // 8. Africa (second to last) 'ANGOLA': 8, 'BENIN': 8, @@ -1434,10 +1436,10 @@ class XtreamApiService { 'ZA': 8, 'South Africa': 8, 'ZAMBIA': 8, - + // 9. Árabe (Arabic channels) - NEAR THE END (after Africa) 'Árabe': 9, - + // 10. Special groups (priority 100 - absolute last) '24/7 AR': 100, '24/7 IN': 100, @@ -1479,132 +1481,109 @@ class XtreamApiService { 'VIP - PK': 100, 'XMAS': 100, }; - + // Log priority for Argentina and Árabe - print('DEBUG SORT: Checking priority assignments...'); - if (countries.contains('Argentina')) { - print('DEBUG SORT: Argentina priority = ${priorityOrder["Argentina"] ?? 100}'); - } - if (countries.contains('Árabe')) { - print('DEBUG SORT: Árabe priority = ${priorityOrder["Árabe"] ?? 100}'); - } - + if (countries.contains('Argentina')) {} + if (countries.contains('Árabe')) {} + // Sort using custom comparator countries.sort((a, b) { final priorityA = priorityOrder[a] ?? 100; final priorityB = priorityOrder[b] ?? 100; - + if (priorityA != priorityB) { return priorityA.compareTo(priorityB); } - + // If same priority, sort alphabetically return a.compareTo(b); }); - + return countries; } /// Smart country extraction with multiple strategies - /// + /// /// Strategy order: /// 1. Check for exact 3-letter matches (ARG, USA, etc.) /// 2. Check for 2-letter codes with context /// 3. Handle special formats (24/7-AR, AR-KIDS, etc.) /// 4. Use category/group information if available /// 5. Return proper full country names - String extractCountryFromChannelName(String channelName, {String? groupTitle}) { - print('DEBUG EXTRACTION: ============================================'); - print('DEBUG EXTRACTION: Processing channel: \"$channelName\"'); - print('DEBUG EXTRACTION: Group title: \"$groupTitle\"'); - + String extractCountryFromChannelName( + String channelName, { + String? groupTitle, + }) { if (!channelName.contains('|')) { - print('DEBUG EXTRACTION: No | separator found, trying direct name extraction'); - final result = _extractCountryFromDirectName(channelName, groupTitle: groupTitle); - print('DEBUG EXTRACTION: Direct extraction result: \"$result\"'); - print('DEBUG EXTRACTION: ============================================'); + final result = _extractCountryFromDirectName( + channelName, + groupTitle: groupTitle, + ); return result; } - + final parts = channelName.split('|'); - print('DEBUG EXTRACTION: Split into \${parts.length} parts: \$parts'); - + if (parts.isEmpty) { - print('DEBUG EXTRACTION: No parts found, returning empty'); - print('DEBUG EXTRACTION: ============================================'); return ''; } - + final countryCode = parts.first.trim(); - print('DEBUG EXTRACTION: Extracted country code: \"$countryCode\" (length: \${countryCode.length})'); - + // Must be at least 2 characters (filter out single letters) if (countryCode.length < 2) { - print('DEBUG EXTRACTION: Code too short (< 2 chars), returning empty'); - print('DEBUG EXTRACTION: ============================================'); return ''; } - + // Strategy 1: Check if it's a known group title first if (_isGroupTitle(countryCode)) { - print('DEBUG EXTRACTION: \"$countryCode\" is a group title, extracting from group format'); final extractedFromGroup = _extractCountryFromGroupFormat(countryCode); - print('DEBUG EXTRACTION: Group extraction result: \"$extractedFromGroup\"'); - print('DEBUG EXTRACTION: ============================================'); if (extractedFromGroup.isNotEmpty) { return extractedFromGroup; } return ''; } - + // Strategy 2: Try exact match first (case insensitive) final normalizedCode = countryCode.toLowerCase().trim(); - print('DEBUG EXTRACTION: Normalized code: \"$normalizedCode\"'); - + // Check for exact 3-letter match first (highest priority) if (countryCode.length == 3) { - print('DEBUG EXTRACTION: Checking 3-letter code match...'); final exactMatch = _countryMapping[normalizedCode]; if (exactMatch != null) { - print('DEBUG EXTRACTION: FOUND 3-letter match: \"$countryCode\" -> \"$exactMatch\"'); - print('DEBUG EXTRACTION: ============================================'); return exactMatch; } - print('DEBUG EXTRACTION: No 3-letter match found in mapping'); } - + // Strategy 3: Check for 2-letter match with context if (countryCode.length == 2) { - print('DEBUG EXTRACTION: Checking 2-letter code with context...'); - final result = _handleTwoLetterCode(normalizedCode, groupTitle: groupTitle); - print('DEBUG EXTRACTION: 2-letter code result: \"$result\"'); + final result = _handleTwoLetterCode( + normalizedCode, + groupTitle: groupTitle, + ); if (result.isNotEmpty) { - print('DEBUG EXTRACTION: ============================================'); return result; } } - + // Strategy 4: Check the mapping - print('DEBUG EXTRACTION: Checking general mapping...'); final mappedCountry = _countryMapping[normalizedCode]; if (mappedCountry != null) { - print('DEBUG EXTRACTION: FOUND in general mapping: \"$countryCode\" -> \"$mappedCountry\"'); - print('DEBUG EXTRACTION: ============================================'); return mappedCountry; } - + // Return the raw code if no mapping found - print('DEBUG EXTRACTION: No mapping found, returning raw code: \"$countryCode\"'); - print('DEBUG EXTRACTION: ============================================'); return countryCode; } - + /// Extract country from special group formats like "24/7-AR", "AR-KIDS", etc. String _extractCountryFromGroupFormat(String groupName) { final normalized = groupName.toLowerCase().trim(); - + // Pattern: 24/7-XX or 24/7 XX - final twentyFourSevenMatch = RegExp(r'24/7[-\s]*(\w{2,3})').firstMatch(normalized); + final twentyFourSevenMatch = RegExp( + r'24/7[-\s]*(\w{2,3})', + ).firstMatch(normalized); if (twentyFourSevenMatch != null) { final code = twentyFourSevenMatch.group(1)!.toLowerCase(); final mapped = _countryMapping[code]; @@ -1612,7 +1591,7 @@ class XtreamApiService { return mapped; } } - + // Pattern: AR-KIDS, AR-SP, etc. final arKidsMatch = RegExp(r'^(\w{2})[-_]').firstMatch(normalized); if (arKidsMatch != null) { @@ -1622,31 +1601,26 @@ class XtreamApiService { return _countryMapping[code]!; } } - + return ''; } - + /// Handle ambiguous 2-letter codes with context String _handleTwoLetterCode(String code, {String? groupTitle}) { - print('DEBUG 2LETTER: Handling 2-letter code: "$code" with groupTitle: "$groupTitle"'); - // AR is ambiguous: could be Argentina or Arabic // CRITICAL FIX: AR| always maps to Árabe (Arabic language/category) // ARG| (3-letter) maps to Argentina if (code == 'ar') { - print('DEBUG 2LETTER: Processing AR code...'); // Check group title for context if (groupTitle != null) { final normalizedGroup = groupTitle.toLowerCase(); - print('DEBUG 2LETTER: Checking group title context: "$normalizedGroup"'); // If group contains argentina-related terms explicitly, treat as Argentina - if (normalizedGroup.contains('argentina') || + if (normalizedGroup.contains('argentina') || normalizedGroup.contains('argentino')) { - print('DEBUG 2LETTER: AR matched Argentina context -> returning Argentina'); return 'Argentina'; } // If group contains arabic-related terms, treat as Arabic - if (normalizedGroup.contains('arab') || + if (normalizedGroup.contains('arab') || normalizedGroup.contains('islam') || normalizedGroup.contains('mbc') || normalizedGroup.contains('bein') || @@ -1655,47 +1629,47 @@ class XtreamApiService { normalizedGroup.contains('saudi') || normalizedGroup.contains('emirates') || normalizedGroup.contains('gulf')) { - print('DEBUG 2LETTER: AR matched Arabic context -> returning Árabe'); return 'Árabe'; } - print('DEBUG 2LETTER: Group title does not match Argentina or Arabic patterns'); - } else { - print('DEBUG 2LETTER: No group title provided for context'); - } - + } else {} + // DEFAULT: AR without context = Árabe (Arabic) // This is because AR| is the standard prefix for Arabic channels // Argentina uses ARG| (3-letter code) - print('DEBUG 2LETTER: AR returning default -> Árabe'); return 'Árabe'; } - + // US/USA -> Estados Unidos if (code == 'us' || code == 'usa') { return 'Estados Unidos'; } - + // UK/GB -> Reino Unido if (code == 'uk' || code == 'gb') { return 'Reino Unido'; } - + // Check if there's a direct mapping final mapped = _countryMapping[code]; if (mapped != null) { return mapped; } - + return ''; } - + /// Extract country from channel name when no | separator exists - String _extractCountryFromDirectName(String channelName, {String? groupTitle}) { + String _extractCountryFromDirectName( + String channelName, { + String? groupTitle, + }) { final normalized = channelName.toLowerCase().trim(); - + // Check for country codes at the beginning // Pattern: "XX - Channel Name" or "XX: Channel Name" - final leadingCodeMatch = RegExp(r'^([a-z]{2,3})\s*[-:]\s*').firstMatch(normalized); + final leadingCodeMatch = RegExp( + r'^([a-z]{2,3})\s*[-:]\s*', + ).firstMatch(normalized); if (leadingCodeMatch != null) { final code = leadingCodeMatch.group(1)!; final country = _handleTwoLetterCode(code, groupTitle: groupTitle); @@ -1703,10 +1677,12 @@ class XtreamApiService { return country; } } - + // Check for country codes in brackets // Pattern: "Channel Name [XX]" or "(XX)" - final bracketMatch = RegExp(r'[\[\(]([a-z]{2,3})[\]\)]').firstMatch(normalized); + final bracketMatch = RegExp( + r'[\[\(]([a-z]{2,3})[\]\)]', + ).firstMatch(normalized); if (bracketMatch != null) { final code = bracketMatch.group(1)!; final country = _handleTwoLetterCode(code, groupTitle: groupTitle); @@ -1714,31 +1690,73 @@ class XtreamApiService { return country; } } - + return ''; } - + /// Check if a string is a group title (not a country) bool _isGroupTitle(String name) { final normalized = name.toLowerCase().trim(); - + // List of known group titles to exclude final groupTitles = { - '24/7', '24/7 ar', '24/7 in', '24/7-es', '24/7-de', '24/7-gr', - '24/7-my', '24/7-pt', '24/7-ro', '24/7-tr', '24/7-latino', - 'vip', 'vip - pk', 'ppv', 'movies', 'cine', 'cine sd', - 'cine y serie', 'latino', 'general', 'music', 'religious', - 'bein', 'mbc', 'tod', 'osn', 'myhd', 'dstv', 'art', - 'icc-ca', 'icc-car', 'icc-dstv', 'icc-in', 'icc-nz', - 'icc-pk', 'icc-uk', 'xmas', 'sin', 'ezd', 'exyu', 'rot', - 'ar-kids', 'ar-sp', 'islam', 'bab', 'as', 'ei' + '24/7', + '24/7 ar', + '24/7 in', + '24/7-es', + '24/7-de', + '24/7-gr', + '24/7-my', + '24/7-pt', + '24/7-ro', + '24/7-tr', + '24/7-latino', + 'vip', + 'vip - pk', + 'ppv', + 'movies', + 'cine', + 'cine sd', + 'cine y serie', + 'latino', + 'general', + 'music', + 'religious', + 'bein', + 'mbc', + 'tod', + 'osn', + 'myhd', + 'dstv', + 'art', + 'icc-ca', + 'icc-car', + 'icc-dstv', + 'icc-in', + 'icc-nz', + 'icc-pk', + 'icc-uk', + 'xmas', + 'sin', + 'ezd', + 'exyu', + 'rot', + 'ar-kids', + 'ar-sp', + 'islam', + 'bab', + 'as', + 'ei', }; - + return groupTitles.contains(normalized); } - List filterByCountry(List streams, String country, - {Map? categoryToCountryMap}) { + List filterByCountry( + List streams, + String country, { + Map? categoryToCountryMap, + }) { // Empty string or "Todos"/"All" means show all channels final normalizedCountry = country.trim(); if (normalizedCountry.isEmpty || @@ -1774,12 +1792,13 @@ class XtreamApiService { }).toList(); } - List _parseM3U(String m3uContent, {void Function(int loaded, int total)? onProgress}) { - print('DEBUG: _parseM3U() START - content length: ${m3uContent.length} chars'); + List _parseM3U( + String m3uContent, { + void Function(int loaded, int total)? onProgress, + }) { final List streams = []; final lines = m3uContent.split('\n'); - print('DEBUG: _parseM3U() - ${lines.length} lines to parse'); - + // Count total EXTINF lines to estimate total channels int totalExtinfLines = 0; for (final line in lines) { @@ -1787,42 +1806,40 @@ class XtreamApiService { totalExtinfLines++; } } - print('DEBUG: _parseM3U() - Estimated total channels: $totalExtinfLines'); - + XtreamStream? currentStream; int extinfCount = 0; int urlCount = 0; int lastReportedProgress = 0; - + for (final line in lines) { final trimmed = line.trim(); - + if (trimmed.startsWith('#EXTINF:')) { extinfCount++; final info = trimmed.substring('#EXTINF:'.length); final parts = info.split(','); - + String? streamIcon; String? groupTitle; String name = parts.length > 1 ? parts[1].trim() : ''; - + // Parse attributes: tvg-logo, group-title, tvg-id final attrs = parts[0]; final logoMatch = RegExp(r'tvg-logo="([^"]*)"').firstMatch(attrs); final groupMatch = RegExp(r'group-title="([^"]*)"').firstMatch(attrs); - + if (logoMatch != null) { streamIcon = logoMatch.group(1); } if (groupMatch != null) { groupTitle = groupMatch.group(1); } - + // Log first 5 and last 5 channels to see the format - if (streams.length < 5 || (streams.length > 25200 && streams.length < 25205)) { - print('DEBUG: _parseM3U() - Channel #${streams.length + 1}: name="$name", group="$groupTitle"'); - } - + if (streams.length < 5 || + (streams.length > 25200 && streams.length < 25205)) {} + currentStream = XtreamStream( streamId: streams.length + 1, name: name, @@ -1837,11 +1854,14 @@ class XtreamApiService { currentStream.url = trimmed; streams.add(currentStream); currentStream = null; - + // Report progress every 100 channels or at the end - if (onProgress != null && (streams.length % 100 == 0 || streams.length == totalExtinfLines)) { + if (onProgress != null && + (streams.length % 100 == 0 || + streams.length == totalExtinfLines)) { // Only report if progress changed significantly - if (streams.length - lastReportedProgress >= 100 || streams.length == totalExtinfLines) { + if (streams.length - lastReportedProgress >= 100 || + streams.length == totalExtinfLines) { onProgress(streams.length, totalExtinfLines); lastReportedProgress = streams.length; } @@ -1849,13 +1869,12 @@ class XtreamApiService { } } } - + // Final progress report if (onProgress != null) { onProgress(streams.length, totalExtinfLines); } - - print('DEBUG: _parseM3U() END - Parsed ${streams.length} streams ($extinfCount EXTINF lines, $urlCount URLs)'); + return streams; } @@ -1891,10 +1910,8 @@ class XtreamApiService { // Write content to file await file.writeAsString(content, flush: true); - print('DEBUG: Text file saved to: $filePath'); return filePath; } catch (e) { - print('DEBUG: Error saving text file: $e'); throw Exception('Error al guardar archivo: $e'); } } diff --git a/lib/widgets/countries_sidebar.dart b/lib/widgets/countries_sidebar.dart index 0355e1a..8e420e8 100644 --- a/lib/widgets/countries_sidebar.dart +++ b/lib/widgets/countries_sidebar.dart @@ -18,10 +18,6 @@ class CountriesSidebar extends StatelessWidget { @override Widget build(BuildContext context) { - print('DEBUG: CountriesSidebar.build() called'); - print('DEBUG: CountriesSidebar received ${countries.length} countries: $countries'); - print('DEBUG: CountriesSidebar selectedCountry: "$selectedCountry"'); - final screenWidth = MediaQuery.of(context).size.width; final isLargeScreen = screenWidth > 900; final sidebarWidth = isLargeScreen ? 280.0 : 220.0; @@ -103,46 +99,48 @@ class CountriesSidebar extends StatelessWidget { ), ) : countries.isEmpty - ? Center( - child: Padding( - padding: EdgeInsets.all(horizontalPadding), - child: Text( - 'No hay paĂ­ses disponibles', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.5), - fontSize: fontSize, - ), - textAlign: TextAlign.center, - ), + ? Center( + child: Padding( + padding: EdgeInsets.all(horizontalPadding), + child: Text( + 'No hay paĂ­ses disponibles', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: fontSize, ), - ) - : ListView.builder( - padding: EdgeInsets.symmetric(vertical: isLargeScreen ? 12 : 8), - itemCount: countries.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return _CountryListItem( - name: 'Todos', - isSelected: selectedCountry.isEmpty, - onTap: () => onCountrySelected(''), - itemHeight: itemHeight, - fontSize: fontSize, - horizontalPadding: horizontalPadding, - icon: Icons.apps, - ); - } - - final country = countries[index - 1]; - return _CountryListItem( - name: country, - isSelected: selectedCountry == country, - onTap: () => onCountrySelected(country), - itemHeight: itemHeight, - fontSize: fontSize, - horizontalPadding: horizontalPadding, - ); - }, + textAlign: TextAlign.center, ), + ), + ) + : ListView.builder( + padding: EdgeInsets.symmetric( + vertical: isLargeScreen ? 12 : 8, + ), + itemCount: countries.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return _CountryListItem( + name: 'Todos', + isSelected: selectedCountry.isEmpty, + onTap: () => onCountrySelected(''), + itemHeight: itemHeight, + fontSize: fontSize, + horizontalPadding: horizontalPadding, + icon: Icons.apps, + ); + } + + final country = countries[index - 1]; + return _CountryListItem( + name: country, + isSelected: selectedCountry == country, + onTap: () => onCountrySelected(country), + itemHeight: itemHeight, + fontSize: fontSize, + horizontalPadding: horizontalPadding, + ); + }, + ), ), ], ), @@ -172,10 +170,7 @@ class _CountryListItem extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - vertical: 2, - ), + padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 2), child: Material( color: Colors.transparent, child: InkWell( @@ -211,7 +206,9 @@ class _CountryListItem extends StatelessWidget { if (icon != null) ...[ Icon( icon, - color: isSelected ? Colors.white : Colors.white.withValues(alpha: 0.6), + color: isSelected + ? Colors.white + : Colors.white.withValues(alpha: 0.6), size: fontSize + 2, ), SizedBox(width: 10), @@ -233,7 +230,9 @@ class _CountryListItem extends StatelessWidget { ? Colors.white : Colors.white.withValues(alpha: 0.7), fontSize: fontSize, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, letterSpacing: 0.3, ), maxLines: 1, @@ -241,11 +240,7 @@ class _CountryListItem extends StatelessWidget { ), ), if (isSelected) - Icon( - Icons.check, - color: Colors.white, - size: fontSize + 2, - ), + Icon(Icons.check, color: Colors.white, size: fontSize + 2), ], ), ), diff --git a/lib/widgets/simple_countries_sidebar.dart b/lib/widgets/simple_countries_sidebar.dart index fb58ebb..3bd8c57 100644 --- a/lib/widgets/simple_countries_sidebar.dart +++ b/lib/widgets/simple_countries_sidebar.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; class SimpleCountriesSidebar extends StatelessWidget { final List countries; @@ -25,21 +24,10 @@ class SimpleCountriesSidebar extends StatelessWidget { @override Widget build(BuildContext context) { - print('đŸ”„ SIDEBAR BUILD ============================================'); - print('đŸ”„ SIDEBAR BUILD - Countries count: ${countries.length}'); - print('đŸ”„ SIDEBAR BUILD - Is Loading: $isLoading'); - print('đŸ”„ SIDEBAR BUILD - Is Organizing: $isOrganizing'); - print('đŸ”„ SIDEBAR BUILD - Countries list: $countries'); - print('đŸ”„ SIDEBAR BUILD - Selected country: "$selectedCountry"'); - if (countries.isNotEmpty) { - print('đŸ”„ SIDEBAR BUILD - First 10 countries:'); - for (int i = 0; i < countries.length && i < 10; i++) { - print('đŸ”„ SIDEBAR BUILD [${i + 1}] "${countries[i]}"'); - } + for (int i = 0; i < countries.length && i < 10; i++) {} } - print('đŸ”„ SIDEBAR BUILD ============================================'); - + return Container( width: 250, color: Colors.grey[900], @@ -64,7 +52,7 @@ class SimpleCountriesSidebar extends StatelessWidget { ], ), ), - + // List Expanded( child: isOrganizing || (isLoading && countries.isEmpty) @@ -82,25 +70,25 @@ class SimpleCountriesSidebar extends StatelessWidget { ), ) : countries.isEmpty - ? const Center( - child: Text( - 'No hay paĂ­ses', - style: TextStyle(color: Colors.white54), - ), - ) - : ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _getItemCount(), - itemBuilder: (context, index) { - return _buildItemAtIndex(context, index); - }, - ), + ? const Center( + child: Text( + 'No hay paĂ­ses', + style: TextStyle(color: Colors.white54), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _getItemCount(), + itemBuilder: (context, index) { + return _buildItemAtIndex(context, index); + }, + ), ), ], ), ); } - + Widget _buildCountryItem(String name, bool isSelected, VoidCallback onTap) { return FocusableActionDetector( actions: >{ @@ -125,10 +113,16 @@ class SimpleCountriesSidebar extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: isSelected ? Colors.red : (hasFocus ? Colors.red.withValues(alpha: 0.5) : Colors.transparent), + color: isSelected + ? Colors.red + : (hasFocus + ? Colors.red.withValues(alpha: 0.5) + : Colors.transparent), border: Border( left: BorderSide( - color: isSelected ? Colors.white : (hasFocus ? Colors.white : Colors.transparent), + color: isSelected + ? Colors.white + : (hasFocus ? Colors.white : Colors.transparent), width: 4, ), ), @@ -167,7 +161,9 @@ class SimpleCountriesSidebar extends StatelessWidget { // Find insertion point for "FĂștbol Argentino" (after PerĂș) final peruIndex = countries.indexOf('PerĂș'); - final footballInsertIndex = peruIndex >= 0 ? peruIndex + 1 : countries.length; + final footballInsertIndex = peruIndex >= 0 + ? peruIndex + 1 + : countries.length; if (showFootballCategory) { // Adjust for "Todos" at index 0 and "FĂștbol Argentino" after PerĂș @@ -231,21 +227,23 @@ class SimpleCountriesSidebar extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( - color: isSelected ? Colors.green[700] : (hasFocus ? Colors.green[700]?.withOpacity(0.8) : Colors.green[900]?.withOpacity(0.3)), + color: isSelected + ? Colors.green[700] + : (hasFocus + ? Colors.green[700]?.withValues(alpha: 0.8) + : Colors.green[900]?.withValues(alpha: 0.3)), border: Border( left: BorderSide( - color: isSelected ? Colors.white : (hasFocus ? Colors.white : Colors.green[400]!), + color: isSelected + ? Colors.white + : (hasFocus ? Colors.white : Colors.green[400]!), width: 4, ), ), ), child: Row( children: [ - Icon( - Icons.sports_soccer, - color: Colors.white, - size: 20, - ), + Icon(Icons.sports_soccer, color: Colors.white, size: 20), const SizedBox(width: 12), Expanded( child: Text( @@ -253,7 +251,9 @@ class SimpleCountriesSidebar extends StatelessWidget { style: TextStyle( color: Colors.white, fontSize: 14, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, ), ), ), diff --git a/test/widget_test.dart b/test/widget_test.dart index 3893338..db8c3d5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,30 +1,21 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; -import 'package:xstream_tv/main.dart'; +import 'package:xstream_tv/screens/login_screen.dart'; +import 'package:xstream_tv/services/iptv_provider.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('renders login screen', (WidgetTester tester) async { + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => IPTVProvider(), + child: const MaterialApp(home: LoginScreen()), + ), + ); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + expect(find.text('XStream TV'), findsOneWidget); + expect(find.text('IPTV Player for Android TV'), findsOneWidget); + expect(find.text('Login'), findsOneWidget); }); }