From 19b45152f882bdb959afece0dbcffa6f0d5ebbdf Mon Sep 17 00:00:00 2001 From: renato97 Date: Wed, 25 Feb 2026 22:59:44 -0300 Subject: [PATCH] v1.0.1: Fix Android TV remote control navigation and focus indicators --- .flutter-plugins-dependencies | 2 +- android/app/src/main/AndroidManifest.xml | 3 + lib/models/xtream_models.dart | 4 + lib/screens/home_screen.dart | 325 +++- lib/services/iptv_provider.dart | 450 ++++- lib/services/xtream_api.dart | 1857 ++++++++++++++++++++ lib/services/xtream_api.dart.backup | 1901 +++++++++++++++++++++ lib/widgets/countries_sidebar.dart | 256 +++ lib/widgets/simple_countries_sidebar.dart | 225 +++ pubspec.yaml | 3 + 10 files changed, 4928 insertions(+), 98 deletions(-) create mode 100644 lib/services/xtream_api.dart.backup create mode 100644 lib/widgets/countries_sidebar.dart create mode 100644 lib/widgets/simple_countries_sidebar.dart diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 7f80b09..c05e6db 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"video_player_avfoundation","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_avfoundation-2.9.3/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false}],"android":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.20/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"video_player_android","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_android-2.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false}],"macos":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"video_player_avfoundation","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_avfoundation-2.9.3/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false}],"linux":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/ren/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":false,"dependencies":["package_info_plus"],"dev_dependency":false}],"windows":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/ren/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":false,"dependencies":["package_info_plus"],"dev_dependency":false}],"web":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"video_player_web","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_web-2.4.0/","dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","dependencies":["package_info_plus"],"dev_dependency":false}]},"dependencyGraph":[{"name":"package_info_plus","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"video_player","dependencies":["video_player_android","video_player_avfoundation","video_player_web"]},{"name":"video_player_android","dependencies":[]},{"name":"video_player_avfoundation","dependencies":[]},{"name":"video_player_web","dependencies":[]},{"name":"wakelock_plus","dependencies":["package_info_plus"]}],"date_created":"2026-02-25 12:49:06.890896","version":"3.41.2","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/ren/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_apple","path":"/home/ren/.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.7/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"video_player_avfoundation","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_avfoundation-2.9.3/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false}],"android":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"/home/ren/.pub-cache/hosted/pub.dev/path_provider_android-2.2.22/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_android","path":"/home/ren/.pub-cache/hosted/pub.dev/permission_handler_android-12.1.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.20/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"video_player_android","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_android-2.9.4/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false}],"macos":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/ren/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"video_player_avfoundation","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_avfoundation-2.9.3/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":true,"dependencies":["package_info_plus"],"dev_dependency":false}],"linux":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/ren/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":false,"dependencies":["package_info_plus"],"dev_dependency":false}],"windows":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/ren/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_windows","path":"/home/ren/.pub-cache/hosted/pub.dev/permission_handler_windows-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","native_build":false,"dependencies":["package_info_plus"],"dev_dependency":false}],"web":[{"name":"package_info_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/","dependencies":[],"dev_dependency":false},{"name":"permission_handler_html","path":"/home/ren/.pub-cache/hosted/pub.dev/permission_handler_html-0.1.3+5/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/ren/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"video_player_web","path":"/home/ren/.pub-cache/hosted/pub.dev/video_player_web-2.4.0/","dependencies":[],"dev_dependency":false},{"name":"wakelock_plus","path":"/home/ren/.pub-cache/hosted/pub.dev/wakelock_plus-1.4.0/","dependencies":["package_info_plus"],"dev_dependency":false}]},"dependencyGraph":[{"name":"package_info_plus","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"permission_handler","dependencies":["permission_handler_android","permission_handler_apple","permission_handler_html","permission_handler_windows"]},{"name":"permission_handler_android","dependencies":[]},{"name":"permission_handler_apple","dependencies":[]},{"name":"permission_handler_html","dependencies":[]},{"name":"permission_handler_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"video_player","dependencies":["video_player_android","video_player_avfoundation","video_player_web"]},{"name":"video_player_android","dependencies":[]},{"name":"video_player_avfoundation","dependencies":[]},{"name":"video_player_web","dependencies":[]},{"name":"wakelock_plus","dependencies":["package_info_plus"]}],"date_created":"2026-02-25 21:25:28.289165","version":"3.41.2","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 55dcf45..2a9eb50 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _loadInitialData(); - }); + // No automatic data loading on startup } double get _screenWidth => MediaQuery.of(context).size.width; @@ -37,13 +37,6 @@ class _HomeScreenState extends State { double get _iconSize => _isLargeScreen ? 80 : 60; double get _headerPadding => _isLargeScreen ? 32 : 24; - Future _loadInitialData() async { - final provider = context.read(); - await provider.loadLiveStreams(); - await provider.loadVodStreams(); - await provider.loadSeries(); - } - void _showLiveCategories() { Navigator.push( context, @@ -51,14 +44,60 @@ class _HomeScreenState extends State { ); } - void _showMovies() { + Future _refreshChannels() async { + final provider = context.read(); + await provider.reloadM3UStreams(); + } + + 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: () {}, + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: Colors.red, + duration: const Duration(seconds: 5), + ), + ); + } + } + } + + void _showMovies() async { + final provider = context.read(); + // Cargar pelĂ­culas bajo demanda + if (provider.vodStreams.isEmpty) { + await provider.loadVodStreams(); + } Navigator.push( context, MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.movies)), ); } - void _showSeries() { + void _showSeries() async { + final provider = context.read(); + // Cargar series bajo demanda + if (provider.seriesList.isEmpty) { + await provider.loadSeries(); + } Navigator.push( context, MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.series)), @@ -137,12 +176,105 @@ class _HomeScreenState extends State { const SizedBox(width: 24), Icon(Icons.person, color: Colors.white70, size: _isLargeScreen ? 32 : 24), const SizedBox(width: 16), - IconButton( - icon: Icon(Icons.settings, color: Colors.white70, size: _isLargeScreen ? 32 : 24), - onPressed: () { - context.read().logout(); + Consumer( + builder: (context, provider, _) { + if (provider.isLoading) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: _isLargeScreen ? 32 : 24, + height: _isLargeScreen ? 32 : 24, + child: CircularProgressIndicator( + color: Colors.white70, + strokeWidth: 2, + ), + ), + const SizedBox(width: 12), + if (provider.totalChannels > 0) + Text( + 'Cargando canales... ${provider.loadedChannels} de ${provider.totalChannels}', + style: TextStyle( + color: Colors.white70, + fontSize: _isLargeScreen ? 14 : 12, + ), + ) + else + Text( + 'Cargando canales...', + style: TextStyle( + color: Colors.white70, + fontSize: _isLargeScreen ? 14 : 12, + ), + ), + ], + ); + } + return Focus( + child: Builder( + builder: (context) { + final hasFocus = Focus.of(context).hasFocus; + return Container( + decoration: hasFocus + ? BoxDecoration( + shape: BoxShape.circle, + 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), + onPressed: _refreshChannels, + tooltip: 'Actualizar canales', + ), + ); + }, + ), + ); }, ), + const SizedBox(width: 8), + Focus( + child: Builder( + builder: (context) { + final hasFocus = Focus.of(context).hasFocus; + return Container( + decoration: hasFocus + ? BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ) + : null, + child: IconButton( + icon: Icon(Icons.download, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24), + onPressed: () => _downloadPlaylistAsJson(), + tooltip: 'Descargar playlist como JSON', + ), + ); + }, + ), + ), + const SizedBox(width: 8), + Focus( + child: Builder( + builder: (context) { + final hasFocus = Focus.of(context).hasFocus; + return Container( + decoration: hasFocus + ? BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ) + : null, + child: IconButton( + icon: Icon(Icons.settings, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24), + onPressed: () { + context.read().logout(); + }, + ), + ); + }, + ), + ), ], ), ], @@ -272,11 +404,13 @@ class _DashboardCard extends StatelessWidget { final titleSize = isLarge ? 32.0 : 24.0; final bgIconSize = isLarge ? 200.0 : 150.0; return Focus( + canRequestFocus: true, child: Builder( builder: (context) { final hasFocus = Focus.of(context).hasFocus; - return GestureDetector( + return InkWell( onTap: onTap, + borderRadius: BorderRadius.circular(20), child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( @@ -284,14 +418,15 @@ class _DashboardCard extends StatelessWidget { borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: hasFocus ? 0.5 : 0.3), - blurRadius: hasFocus ? 25 : 15, + color: hasFocus ? Colors.white.withValues(alpha: 0.4) : Colors.black.withValues(alpha: 0.3), + blurRadius: hasFocus ? 30 : 15, + spreadRadius: hasFocus ? 4 : 0, offset: const Offset(0, 8), ), ], border: hasFocus - ? Border.all(color: Colors.white.withValues(alpha: 0.5), width: 3) - : null, + ? Border.all(color: Colors.white, width: 4) + : Border.all(color: Colors.transparent, width: 4), ), child: Stack( children: [ @@ -352,6 +487,7 @@ class _ContentListScreenState extends State { @override void initState() { super.initState(); + print('DEBUG: ContentListScreen.initState() - type: ${widget.type}'); _loadContent(); } @@ -370,8 +506,10 @@ class _ContentListScreenState extends State { 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(); @@ -380,6 +518,11 @@ class _ContentListScreenState extends State { } } + void _onFootballSelected() { + final provider = context.read(); + provider.filterByCategory(SpecialCategories.argentineFootball); + } + @override void dispose() { _searchController.dispose(); @@ -412,14 +555,24 @@ class _ContentListScreenState extends State { @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( child: Column( children: [ _buildHeader(), - _buildCountryFilter(), - Expanded(child: _buildContentList()), + if (widget.type == ContentType.live) + Expanded( + child: Row( + children: [ + _buildCountrySidebar(), + Expanded(child: _buildContentList()), + ], + ), + ) + else + Expanded(child: _buildContentList()), ], ), ), @@ -494,66 +647,20 @@ class _ContentListScreenState extends State { return categoryName.trim(); } - Widget _buildCountryFilter() { - if (widget.type != ContentType.live) { - return const SizedBox.shrink(); - } - - final categories = _categories; - if (categories.isEmpty) { - return const SizedBox.shrink(); - } - - final countries = categories.map((c) => _getCountryName(c.name)).toList(); - final chipHeight = _isLargeScreen ? 56.0 : 50.0; - final chipFontSize = _isLargeScreen ? 16.0 : 14.0; - - return Container( - height: chipHeight, - margin: EdgeInsets.only(bottom: _isLargeScreen ? 16 : 8), - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: EdgeInsets.symmetric(horizontal: _headerPadding), - itemCount: countries.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return Padding( - padding: EdgeInsets.only(right: _isLargeScreen ? 12 : 8), - child: FilterChip( - label: Text('Todos', style: TextStyle(color: Colors.white, fontSize: chipFontSize)), - selected: _selectedCountry == null, - selectedColor: Colors.red, - backgroundColor: Colors.grey[800], - padding: EdgeInsets.symmetric(horizontal: _isLargeScreen ? 12 : 8), - onSelected: (_) { - setState(() => _selectedCountry = null); - context.read().loadLiveStreams(''); - }, - ), - ); - } - - final countryName = countries[index - 1]; - final category = categories[index - 1]; - return Padding( - padding: EdgeInsets.only(right: _isLargeScreen ? 12 : 8), - child: FilterChip( - label: Text( - countryName.length > 20 ? '${countryName.substring(0, 20)}...' : countryName, - style: TextStyle(color: Colors.white, fontSize: chipFontSize), - ), - selected: _selectedCountry == category.id, - selectedColor: Colors.red, - backgroundColor: Colors.grey[800], - padding: EdgeInsets.symmetric(horizontal: _isLargeScreen ? 12 : 8), - onSelected: (_) { - setState(() => _selectedCountry = category.id); - context.read().loadLiveStreams(category.id); - }, - ), - ); - }, - ), + Widget _buildCountrySidebar() { + return Consumer( + builder: (context, provider, _) { + print('đŸ”„ BUILDING SIDEBAR - countries: ${provider.countries.length}, loading: ${provider.isLoading}, organizing: ${provider.isOrganizingCountries}'); + return SimpleCountriesSidebar( + countries: provider.countries, + selectedCountry: provider.selectedCategory.isNotEmpty ? provider.selectedCategory : provider.selectedCountry, + onCountrySelected: (country) => provider.filterByCountry(country), + isLoading: provider.isLoading, + isOrganizing: provider.isOrganizingCountries, + showFootballCategory: true, + onFootballSelected: () => _onFootballSelected(), + ); + }, ); } @@ -563,12 +670,46 @@ class _ContentListScreenState extends State { return Consumer( builder: (context, provider, _) { if (provider.isLoading) { - return Center(child: CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2)); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2), + const SizedBox(height: 16), + if (provider.totalChannels > 0) + Text( + 'Cargando canales... ${provider.loadedChannels} de ${provider.totalChannels}', + style: TextStyle( + color: Colors.white70, + fontSize: _isLargeScreen ? 18 : 14, + ), + ) + else + Text( + 'Cargando canales...', + style: TextStyle( + color: Colors.white70, + fontSize: _isLargeScreen ? 18 : 14, + ), + ), + const SizedBox(height: 8), + if (provider.totalChannels > 0) + SizedBox( + width: 200, + child: LinearProgressIndicator( + value: provider.loadingProgress, + backgroundColor: Colors.grey[800], + valueColor: const AlwaysStoppedAnimation(Colors.red), + ), + ), + ], + ), + ); } List streams = []; if (widget.type == ContentType.live) { - streams = provider.liveStreams; + streams = provider.filteredLiveStreams; } else if (widget.type == ContentType.movies) { streams = provider.vodStreams; } else { @@ -582,9 +723,17 @@ class _ContentListScreenState extends State { } if (_searchQuery.isNotEmpty) { - streams = streams - .where((s) => s.name.toLowerCase().contains(_searchQuery.toLowerCase())) - .toList(); + // 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(); + } } if (streams.isEmpty) { diff --git a/lib/services/iptv_provider.dart b/lib/services/iptv_provider.dart index 4e80428..e4605f9 100644 --- a/lib/services/iptv_provider.dart +++ b/lib/services/iptv_provider.dart @@ -5,30 +5,46 @@ import '../models/xtream_models.dart'; enum ContentType { live, movies, series } +// Special category constants +class SpecialCategories { + static const String argentineFootball = 'FĂștbol Argentino'; +} + class IPTVProvider extends ChangeNotifier { final XtreamApiService _api = XtreamApiService(); - + bool _isLoading = false; String? _error; XtreamUserInfo? _userInfo; - + + int _loadedChannels = 0; + int _totalChannels = 0; + List _liveCategories = []; List _vodCategories = []; List _seriesCategories = []; - + List _liveStreams = []; List _vodStreams = []; List _seriesList = []; List _seriesEpisodes = []; - + String _selectedLiveCategory = ''; String _selectedVodCategory = ''; + String _selectedCountry = ''; + String _selectedCategory = ''; // For special categories like "FĂștbol Argentino" + List _countries = []; + bool _isOrganizingCountries = false; XtreamSeries? _selectedSeries; 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; + bool get isOrganizingCountries => _isOrganizingCountries; List get liveCategories => _liveCategories; List get vodCategories => _vodCategories; @@ -40,7 +56,76 @@ class IPTVProvider extends ChangeNotifier { List get seriesEpisodes => _seriesEpisodes; String get selectedLiveCategory => _selectedLiveCategory; - String get selectedVodCategory => _selectedVodCategory; + 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; + } + + /// Get display items for sidebar including special categories + /// Returns a list of maps with 'name', 'type', and 'priority' for proper ordering + List> get sidebarItems { + final items = >[]; + + // Add all countries with their priority + for (final country in _countries) { + int priority = _getCountryPriority(country); + 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)); + if (hasArgentineFootball) { + items.add({ + 'name': SpecialCategories.argentineFootball, + 'type': 'category', + '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); + if (priorityCompare != 0) return priorityCompare; + return (a['name'] as String).compareTo(b['name'] as String); + }); + + return items; + } + + /// Get priority for a country (lower number = higher priority) + int _getCountryPriority(String country) { + switch (country) { + case 'Argentina': + return 1; + case 'PerĂș': + case 'Peru': + return 2; + case 'Bolivia': + case 'Brasil': + case 'Brazil': + case 'Chile': + case 'Colombia': + case 'Ecuador': + case 'Paraguay': + case 'Uruguay': + case 'Venezuela': + return 3; + default: + return 100; // Low priority for other countries + } + } XtreamSeries? get selectedSeries => _selectedSeries; Future login(String server, String username, String password) async { @@ -52,7 +137,8 @@ class IPTVProvider extends ChangeNotifier { _api.setCredentials(server, username, password); _userInfo = await _api.getUserInfo(); - await _loadCategories(); + // No automatic data loading on startup - data loads on demand only + await _saveCredentials(server, username, password); } catch (e) { _error = e.toString(); @@ -73,19 +159,173 @@ class IPTVProvider extends ChangeNotifier { } 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 = []; notifyListeners(); try { - _liveStreams = await _api.getLiveStreams(categoryId); - _selectedLiveCategory = categoryId; + // 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; + _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(); + }, + ); + _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'); + } 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' + }; + return groupTitles.contains(normalized); + } + + // Build a map from category ID to country name for API streams + Map _buildCategoryToCountryMap() { + final map = {}; + 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)) { + map[category.id] = countryName; + } + } + print('DEBUG: Built category map with ${map.length} entries'); + return map; + } + + void filterByCountry(String country) { + _selectedCountry = country.trim(); + _selectedCategory = ''; // Clear special category when country is selected + print('DEBUG: Filter by country: "$_selectedCountry"'); + notifyListeners(); + } + + void filterByCategory(String category) { + _selectedCategory = category.trim(); + _selectedCountry = ''; // Clear country when special category is selected + print('DEBUG: Filter by category: "$_selectedCategory"'); + 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); + } + + // Show all if empty or "Todos"/"All" selected + final normalizedCountry = _selectedCountry.trim(); + if (normalizedCountry.isEmpty || + normalizedCountry.toLowerCase() == 'todos' || + normalizedCountry.toLowerCase() == 'all') { + return _liveStreams; + } + // Build category map for API streams that don't have country in name + final categoryMap = _buildCategoryToCountryMap(); + return _api.filterByCountry(_liveStreams, _selectedCountry, categoryToCountryMap: categoryMap); + } Future loadVodStreams([String categoryId = '']) async { _isLoading = true; @@ -131,6 +371,195 @@ class IPTVProvider extends ChangeNotifier { notifyListeners(); } + Future reloadM3UStreams() async { + _isLoading = true; + _isOrganizingCountries = false; + _error = null; + _loadedChannels = 0; + _totalChannels = 0; + _countries = []; + notifyListeners(); + + try { + // Try API first, then M3U fallback + try { + print('DEBUG: Attempting to reload from API...'); + _liveStreams = 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(); + }, + ); + 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; + notifyListeners(); + } + } + + /// Downloads M3U playlist and saves it as JSON file + /// Returns the file path where the JSON was saved + Future downloadAndSaveM3UAsJson() async { + _isLoading = true; + _error = null; + 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 result = M3UDownloadResult( + sourceUrl: '${_api.server}/get.php', + downloadTime: DateTime.now(), + totalChannels: channels.length, + 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) { + final country = channel.groupTitle ?? 'Unknown'; + groups[country] = (groups[country] ?? 0) + 1; + } + return groups; + } + + /// 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 ==='); + buffer.writeln('Fecha: ${DateTime.now()}'); + buffer.writeln('Total de canales: ${_liveStreams.length}'); + buffer.writeln(''); + + // Build category map for API streams + final categoryMap = _buildCategoryToCountryMap(); + + // Group by country + final groupedChannels = >{}; + for (final stream in _liveStreams) { + String? country; + + // First try to extract from name (M3U format) + final countryFromName = _api.extractCountryFromChannelName(stream.name); + if (countryFromName.isNotEmpty) { + country = countryFromName; + } + // If not found, try category mapping (API format) + else if (stream.categoryId != null && categoryMap.containsKey(stream.categoryId)) { + country = categoryMap[stream.categoryId]; + } + + final normalizedCountry = country != null && country.isNotEmpty + ? _api.normalizeCountry(country) + : 'Sin PaĂ­s'; + + if (!groupedChannels.containsKey(normalizedCountry)) { + groupedChannels[normalizedCountry] = []; + } + groupedChannels[normalizedCountry]!.add(stream); + } + + // Write grouped channels + final sortedCountries = groupedChannels.keys.toList()..sort(); + for (final country in sortedCountries) { + final channels = groupedChannels[country]!; + buffer.writeln(''); + buffer.writeln('=== $country (${channels.length} canales) ==='); + buffer.writeln(''); + + for (int i = 0; i < channels.length; i++) { + final stream = channels[i]; + buffer.writeln('${i + 1}. ${stream.name}'); + if (stream.url != null && stream.url!.isNotEmpty) { + buffer.writeln(' URL: ${stream.url}'); + } + } + } + + // Save to file + 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'); + } + } + void clearError() { _error = null; notifyListeners(); @@ -161,7 +590,7 @@ class IPTVProvider extends ChangeNotifier { await prefs.remove('server'); await prefs.remove('username'); await prefs.remove('password'); - + _userInfo = null; _liveCategories = []; _vodCategories = []; @@ -169,6 +598,9 @@ class IPTVProvider extends ChangeNotifier { _liveStreams = []; _vodStreams = []; _seriesList = []; + _countries = []; + _selectedCategory = ''; + _isOrganizingCountries = false; notifyListeners(); } } diff --git a/lib/services/xtream_api.dart b/lib/services/xtream_api.dart index c7fca0c..4898dfb 100644 --- a/lib/services/xtream_api.dart +++ b/lib/services/xtream_api.dart @@ -1,13 +1,677 @@ +import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; import '../models/xtream_models.dart'; +/// Represents a channel parsed from M3U playlist with all metadata +class M3UChannel { + final String name; + final String url; + final String? groupTitle; + final String? tvgLogo; + final String? tvgId; + final String? tvgName; + final Map metadata; + + M3UChannel({ + required this.name, + required this.url, + this.groupTitle, + this.tvgLogo, + this.tvgId, + this.tvgName, + this.metadata = const {}, + }); + + Map toJson() { + return { + 'name': name, + 'url': url, + 'group-title': groupTitle, + 'tvg-logo': tvgLogo, + 'tvg-id': tvgId, + 'tvg-name': tvgName, + 'metadata': metadata, + }; + } +} + +/// Result of downloading and parsing M3U playlist +class M3UDownloadResult { + final List channels; + final String sourceUrl; + final DateTime downloadTime; + final int totalChannels; + final Map groupsCount; + + M3UDownloadResult({ + required this.channels, + required this.sourceUrl, + required this.downloadTime, + required this.totalChannels, + required this.groupsCount, + }); + + Map toJson() { + return { + 'download_info': { + 'source_url': sourceUrl, + 'download_time': downloadTime.toIso8601String(), + 'total_channels': totalChannels, + 'groups_count': groupsCount, + }, + 'channels': channels.map((c) => c.toJson()).toList(), + }; + } +} + class XtreamApiService { String? _server; String? _username; String? _password; String? _baseUrl; + // Country normalization mapping: code/variation -> standard name + static const Map _countryMapping = { + // AMÉRICA - Prioridad alta + 'arg': 'Argentina', + 'argentina': 'Argentina', + 'pe': 'PerĂș', + 'per': 'PerĂș', + 'peru': 'PerĂș', + 'perĂș': 'PerĂș', + 'bo': 'Bolivia', + 'bol': 'Bolivia', + 'bolivia': 'Bolivia', + 'br': 'Brasil', + 'bra': 'Brasil', + 'brazil': 'Brasil', + 'cl': 'Chile', + 'chl': 'Chile', + 'chile': 'Chile', + 'co': 'Colombia', + 'col': 'Colombia', + 'colombia': 'Colombia', + 'ec': 'Ecuador', + 'ecu': 'Ecuador', + 'ecuador': 'Ecuador', + 'py': 'Paraguay', + 'par': 'Paraguay', + 'paraguay': 'Paraguay', + 'uy': 'Uruguay', + 'uru': 'Uruguay', + 'uruguay': 'Uruguay', + 've': 'Venezuela', + 'ven': 'Venezuela', + 'venezuela': 'Venezuela', + + // CentroamĂ©rica y Caribe + 'cr': 'Costa Rica', + 'cri': 'Costa Rica', + 'costarica': 'Costa Rica', + 'sv': 'El Salvador', + 'sal': 'El Salvador', + 'elsalvador': 'El Salvador', + 'gt': 'Guatemala', + 'guatemala': 'Guatemala', + 'hn': 'Honduras', + 'hon': 'Honduras', + 'honduras': 'Honduras', + 'ni': 'Nicaragua', + 'nic': 'Nicaragua', + 'nicaragua': 'Nicaragua', + 'pa': 'PanamĂĄ', + 'pan': 'PanamĂĄ', + 'panama': 'PanamĂĄ', + 'panamĂĄ': 'PanamĂĄ', + 'do': 'RepĂșblica Dominicana', + 'dom': 'RepĂșblica Dominicana', + 'republicadominicana': 'RepĂșblica Dominicana', + 'rd': 'RepĂșblica Dominicana', + 'pr': 'Puerto Rico', + 'pri': 'Puerto Rico', + 'puertorico': 'Puerto Rico', + 'cu': 'Cuba', + 'cuba': 'Cuba', + 'ht': 'HaitĂ­', + 'haiti': 'HaitĂ­', + 'haitĂ­': 'HaitĂ­', + 'jm': 'Jamaica', + 'jamaica': 'Jamaica', + + // NorteamĂ©rica + 'us': 'Estados Unidos', + 'usa': 'Estados Unidos', + 'unitedstates': 'Estados Unidos', + 'ca': 'CanadĂĄ', + 'can': 'CanadĂĄ', + 'canada': 'CanadĂĄ', + 'canadĂĄ': 'CanadĂĄ', + 'mx': 'MĂ©xico', + 'mex': 'MĂ©xico', + 'mexico': 'MĂ©xico', + 'mĂ©xico': 'MĂ©xico', + + // EUROPA + 'es': 'España', + 'españa': 'España', + 'spain': 'España', + 'uk': 'Reino Unido', + 'gb': 'Reino Unido', + 'unitedkingdom': 'Reino Unido', + 'fr': 'Francia', + 'fra': 'Francia', + 'france': 'Francia', + 'de': 'Alemania', + 'ger': 'Alemania', + 'germany': 'Alemania', + 'alemania': 'Alemania', + 'it': 'Italia', + 'ita': 'Italia', + 'italy': 'Italia', + 'italia': 'Italia', + 'pt': 'Portugal', + 'prt': 'Portugal', + 'portugal': 'Portugal', + + // Europa del Norte + 'se': 'Suecia', + 'sw': 'Suecia', + 'swe': 'Suecia', + 'sweden': 'Suecia', + 'suecia': 'Suecia', + 'no': 'Noruega', + 'nor': 'Noruega', + 'norway': 'Noruega', + 'noruega': 'Noruega', + 'dk': 'Dinamarca', + 'din': 'Dinamarca', + 'denmark': 'Dinamarca', + 'dinamarca': 'Dinamarca', + 'fi': 'Finlandia', + 'fin': 'Finlandia', + 'finland': 'Finlandia', + 'finlandia': 'Finlandia', + + // Europa del Este + 'ru': 'Rusia', + 'rus': 'Rusia', + 'russia': 'Rusia', + 'pl': 'Polonia', + 'pol': 'Polonia', + 'poland': 'Polonia', + 'polonia': 'Polonia', + 'ua': 'Ucrania', + 'ukr': 'Ucrania', + 'ukraine': 'Ucrania', + 'ucrania': 'Ucrania', + 'cz': 'RepĂșblica Checa', + 'cze': 'RepĂșblica Checa', + 'czechrepublic': 'RepĂșblica Checa', + 'sk': 'Eslovaquia', + 'svk': 'Eslovaquia', + 'slovakia': 'Eslovaquia', + 'hu': 'HungrĂ­a', + 'hun': 'HungrĂ­a', + 'hungary': 'HungrĂ­a', + 'hungria': 'HungrĂ­a', + 'ro': 'Rumania', + 'rou': 'Rumania', + 'romania': 'Rumania', + 'bg': 'Bulgaria', + 'bgr': 'Bulgaria', + 'bulgaria': 'Bulgaria', + 'al': 'Albania', + 'alb': 'Albania', + 'albania': 'Albania', + 'hr': 'Croacia', + 'hrv': 'Croacia', + 'croatia': 'Croacia', + 'croacia': 'Croacia', + 'rs': 'Serbia', + 'srb': 'Serbia', + 'serbia': 'Serbia', + 'ba': 'Bosnia y Herzegovina', + 'bih': 'Bosnia y Herzegovina', + 'bosnia': 'Bosnia y Herzegovina', + 'mk': 'Macedonia del Norte', + 'mkd': 'Macedonia del Norte', + 'macedonia': 'Macedonia del Norte', + 'si': 'Eslovenia', + 'svn': 'Eslovenia', + 'slovenia': 'Eslovenia', + 'md': 'Moldavia', + 'mda': 'Moldavia', + 'moldova': 'Moldavia', + 'lt': 'Lituania', + 'ltu': 'Lituania', + 'lithuania': 'Lituania', + 'lituania': 'Lituania', + 'lv': 'Letonia', + 'lva': 'Letonia', + 'latvia': 'Letonia', + 'letonia': 'Letonia', + 'ee': 'Estonia', + 'est': 'Estonia', + 'estonia': 'Estonia', + 'by': 'Bielorrusia', + 'blr': 'Bielorrusia', + 'belarus': 'Bielorrusia', + + // Europa Occidental + 'nl': 'PaĂ­ses Bajos', + 'nld': 'PaĂ­ses Bajos', + 'netherlands': 'PaĂ­ses Bajos', + 'paisesbajos': 'PaĂ­ses Bajos', + 'holanda': 'PaĂ­ses Bajos', + 'be': 'BĂ©lgica', + 'bel': 'BĂ©lgica', + 'belgium': 'BĂ©lgica', + 'belgica': 'BĂ©lgica', + 'at': 'Austria', + 'aut': 'Austria', + 'austria': 'Austria', + 'ch': 'Suiza', + 'che': 'Suiza', + 'switzerland': 'Suiza', + 'suiza': 'Suiza', + 'ie': 'Irlanda', + 'irl': 'Irlanda', + 'ireland': 'Irlanda', + 'irlanda': 'Irlanda', + 'gr': 'Grecia', + 'grc': 'Grecia', + 'greece': 'Grecia', + 'grecia': 'Grecia', + + // ASIA + 'in': 'India', + 'ind': 'India', + 'india': 'India', + 'cn': 'China', + 'chn': 'China', + 'china': 'China', + 'jp': 'JapĂłn', + 'jpn': 'JapĂłn', + 'japan': 'JapĂłn', + 'japon': 'JapĂłn', + 'kr': 'Corea del Sur', + 'kor': 'Corea del Sur', + 'southkorea': 'Corea del Sur', + 'kors': 'Corea del Sur', + 'kp': 'Corea del Norte', + 'prk': 'Corea del Norte', + 'northkorea': 'Corea del Norte', + 'korn': 'Corea del Norte', + 'th': 'Tailandia', + 'tha': 'Tailandia', + 'thailand': 'Tailandia', + 'tailandia': 'Tailandia', + 'vn': 'Vietnam', + 'vietnam': 'Vietnam', + 'my': 'Malasia', + 'mys': 'Malasia', + 'malaysia': 'Malasia', + 'malasia': 'Malasia', + 'id': 'Indonesia', + 'idn': 'Indonesia', + 'indonesia': 'Indonesia', + 'ph': 'Filipinas', + 'philippines': 'Filipinas', + 'filipinas': 'Filipinas', + 'sg': 'Singapur', + 'sgp': 'Singapur', + 'singapore': 'Singapur', + 'singapur': 'Singapur', + + // Medio Oriente + 'tr': 'TurquĂ­a', + 'tur': 'TurquĂ­a', + 'turkey': 'TurquĂ­a', + 'turquia': 'TurquĂ­a', + 'ir': 'IrĂĄn', + 'irn': 'IrĂĄn', + 'iran': 'IrĂĄn', + 'iq': 'Irak', + 'irq': 'Irak', + 'irak': 'Irak', + 'iraq': 'Irak', + 'sa': 'Arabia Saudita', + 'ksa': 'Arabia Saudita', + 'saudiarabia': 'Arabia Saudita', + 'arabiasaudita': 'Arabia Saudita', + 'ae': 'Emiratos Árabes Unidos', + 'uae': 'Emiratos Árabes Unidos', + 'unitedarabemirates': 'Emiratos Árabes Unidos', + 'emiratos': 'Emiratos Árabes Unidos', + 'eg': 'Egipto', + 'egy': 'Egipto', + 'egypt': 'Egipto', + 'egipto': 'Egipto', + 'jo': 'Jordania', + 'jor': 'Jordania', + 'jordan': 'Jordania', + 'jordania': 'Jordania', + 'sy': 'Siria', + 'syr': 'Siria', + 'syria': 'Siria', + 'lb': 'LĂ­bano', + 'lbn': 'LĂ­bano', + 'lebanon': 'LĂ­bano', + 'libano': 'LĂ­bano', + 'il': 'Israel', + 'isr': 'Israel', + 'israel': 'Israel', + 'ps': 'Palestina', + 'pse': 'Palestina', + 'palestine': 'Palestina', + 'palestina': 'Palestina', + 'qa': 'Qatar', + 'qat': 'Qatar', + 'qatar': 'Qatar', + 'kw': 'Kuwait', + 'kwt': 'Kuwait', + 'kuwait': 'Kuwait', + 'ku': 'Kuwait', + 'bh': 'BarĂ©in', + 'bhr': 'BarĂ©in', + 'bahrain': 'BarĂ©in', + 'barein': 'BarĂ©in', + 'om': 'OmĂĄn', + 'omn': 'OmĂĄn', + 'oman': 'OmĂĄn', + 'dz': 'Argelia', + 'dza': 'Argelia', + 'algeria': 'Argelia', + 'argelia': 'Argelia', + 'ma': 'Marruecos', + 'mar': 'Marruecos', + 'morocco': 'Marruecos', + 'marruecos': 'Marruecos', + 'tn': 'TĂșnez', + 'tun': 'TĂșnez', + 'tunisia': 'TĂșnez', + 'tunez': 'TĂșnez', + 'ly': 'Libia', + 'lby': 'Libia', + 'libya': 'Libia', + 'libia': 'Libia', + + // ÁFRICA + 'za': 'SudĂĄfrica', + 'zaf': 'SudĂĄfrica', + 'southafrica': 'SudĂĄfrica', + 'sudafrica': 'SudĂĄfrica', + 'ng': 'Nigeria', + 'nga': 'Nigeria', + 'nigeria': 'Nigeria', + 'et': 'EtiopĂ­a', + 'eth': 'EtiopĂ­a', + 'ethiopia': 'EtiopĂ­a', + 'etiopia': 'EtiopĂ­a', + 'ke': 'Kenia', + 'ken': 'Kenia', + 'kenya': 'Kenia', + 'tz': 'Tanzania', + 'tza': 'Tanzania', + 'tanzania': 'Tanzania', + 'ug': 'Uganda', + 'uga': 'Uganda', + 'uganda': 'Uganda', + 'gh': 'Ghana', + 'gha': 'Ghana', + 'ghana': 'Ghana', + 'sd': 'SudĂĄn', + 'sdn': 'SudĂĄn', + 'sudan': 'SudĂĄn', + 'sn': 'Senegal', + 'sen': 'Senegal', + 'senegal': 'Senegal', + 'ml': 'Mali', + 'mali': 'Mali', + 'bf': 'Burkina Faso', + 'bfa': 'Burkina Faso', + 'burkinafaso': 'Burkina Faso', + 'ci': 'Costa de Marfil', + 'civ': 'Costa de Marfil', + 'cotedivoire': 'Costa de Marfil', + 'ne': 'NĂ­ger', + 'ner': 'NĂ­ger', + 'niger': 'NĂ­ger', + 'td': 'Chad', + 'tcd': 'Chad', + 'chad': 'Chad', + 'cm': 'CamerĂșn', + 'cmr': 'CamerĂșn', + 'cameroon': 'CamerĂșn', + 'camerun': 'CamerĂșn', + 'cf': 'RepĂșblica Centroafricana', + 'caf': 'RepĂșblica Centroafricana', + 'gab': 'GabĂłn', + 'gabon': 'GabĂłn', + 'cg': 'Congo', + 'cog': 'Congo', + 'cd': 'RepĂșblica DemocrĂĄtica del Congo', + 'cod': 'RepĂșblica DemocrĂĄtica del Congo', + 'ao': 'Angola', + 'ago': 'Angola', + 'angola': 'Angola', + 'zm': 'Zambia', + 'zmb': 'Zambia', + 'zambia': 'Zambia', + 'zw': 'Zimbabue', + 'zwe': 'Zimbabue', + 'zimbabwe': 'Zimbabue', + 'mz': 'Mozambique', + 'moz': 'Mozambique', + 'mozambique': 'Mozambique', + 'bw': 'Botsuana', + 'bwa': 'Botsuana', + 'botswana': 'Botsuana', + 'na': 'Namibia', + 'nam': 'Namibia', + 'namibia': 'Namibia', + 'mw': 'Malaui', + 'mwi': 'Malaui', + 'malawi': 'Malaui', + 'mg': 'Madagascar', + 'mdg': 'Madagascar', + 'madagascar': 'Madagascar', + 'mu': 'Mauricio', + 'mus': 'Mauricio', + 'mauritius': 'Mauricio', + 'sc': 'Seychelles', + 'syc': 'Seychelles', + 'seychelles': 'Seychelles', + 'km': 'Comoras', + 'com': 'Comoras', + 'comoros': 'Comoras', + 'cv': 'Cabo Verde', + 'cpv': 'Cabo Verde', + 'capeverde': 'Cabo Verde', + 'gw': 'Guinea-BisĂĄu', + 'gnb': 'Guinea-BisĂĄu', + 'guineabissau': 'Guinea-BisĂĄu', + 'gm': 'Gambia', + 'gmb': 'Gambia', + 'gambia': 'Gambia', + 'sl': 'Sierra Leona', + 'sle': 'Sierra Leona', + 'sierraleone': 'Sierra Leona', + 'lr': 'Liberia', + 'lbr': 'Liberia', + 'liberia': 'Liberia', + 'gn': 'Guinea', + 'gin': 'Guinea', + 'guinea': 'Guinea', + 'gq': 'Guinea Ecuatorial', + 'gnq': 'Guinea Ecuatorial', + 'equatorialguinea': 'Guinea Ecuatorial', + 'st': 'Santo TomĂ© y PrĂ­ncipe', + 'stp': 'Santo TomĂ© y PrĂ­ncipe', + 'saotomeandprincipe': 'Santo TomĂ© y PrĂ­ncipe', + 'bj': 'BenĂ­n', + 'ben': 'BenĂ­n', + 'benin': 'BenĂ­n', + 'tg': 'Togo', + 'tgo': 'Togo', + 'togo': 'Togo', + 'rw': 'Ruanda', + 'rwa': 'Ruanda', + 'rwanda': 'Ruanda', + 'bi': 'Burundi', + 'bdi': 'Burundi', + 'burundi': 'Burundi', + 'dj': 'Yibuti', + 'dji': 'Yibuti', + 'djibouti': 'Yibuti', + 'er': 'Eritrea', + 'eri': 'Eritrea', + 'eritrea': 'Eritrea', + 'so': 'Somalia', + 'som': 'Somalia', + 'somalia': 'Somalia', + + // OceanĂ­a + 'au': 'Australia', + 'aus': 'Australia', + 'australia': 'Australia', + 'nz': 'Nueva Zelanda', + 'nzl': 'Nueva Zelanda', + 'newzealand': 'Nueva Zelanda', + 'nuevazelanda': 'Nueva Zelanda', + 'pg': 'PapĂșa Nueva Guinea', + 'png': 'PapĂșa Nueva Guinea', + 'papuanewguinea': 'PapĂșa Nueva Guinea', + 'fj': 'Fiyi', + 'fji': 'Fiyi', + 'fiji': 'Fiyi', + 'sb': 'Islas SalomĂłn', + 'slb': 'Islas SalomĂłn', + 'solomonislands': 'Islas SalomĂłn', + 'vu': 'Vanuatu', + 'vut': 'Vanuatu', + 'vanuatu': 'Vanuatu', + 'nc': 'Nueva Caledonia', + 'ncl': 'Nueva Caledonia', + 'newcaledonia': 'Nueva Caledonia', + 'pf': 'Polinesia Francesa', + 'pyf': 'Polinesia Francesa', + 'frenchpolynesia': 'Polinesia Francesa', + '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', + '24/7-ar': '24/7 AR', + '24/7-es': '24/7 ES', + '24/7-de': '24/7 DE', + '24/7-tr': '24/7 TR', + '24/7-ro': '24/7 RO', + '24/7-gr': '24/7 GR', + '24/7-my': '24/7 MY', + '24/7-pt': '24/7 PT', + '24/7-in': '24/7 IN', + 'ar-kids': 'AR Kids', + 'ar-sp': 'AR SP', + 'ar_ns': 'AR NS', + + // Idiomas / Languages + 'ar': 'Árabe', + 'vip': 'VIP', + 'vip - pk': 'VIP PK', + 'ppv': 'PPV', + 'exyu': 'EX-YU', + 'dstv': 'DSTV', + 'car': 'CAR', + 'bein': 'BeIN', + 'mbc': 'MBC', + 'osn': 'OSN', + 'myhd': 'MyHD', + 'art': 'ART', + 'tod': 'TOD', + 'islam': 'Islam', + 'latino': 'Latino', + 'general': 'General', + 'music': 'Music', + 'movies': 'Movies', + 'cine': 'Cine', + 'cine sd': 'Cine SD', + 'cine y serie': 'Cine y Serie', + 'xmas': 'Xmas', + 'sin': 'Sin categorĂ­a', + 'sin paĂ­s': 'Sin categorĂ­a', + 'ezd': 'EZD', + 'rot': 'ROT', + 'ic': 'IC', + 'sh': 'SH', + 'bab': 'BAB', + 'as': 'AS', + 'ei': 'EI', + 'su': 'SU', + + // Otros cĂłdigos especiales + 'af': 'AfganistĂĄn', + 'afg': 'AfganistĂĄn', + 'afghanistan': 'AfganistĂĄn', + 'arm': 'Armenia', + 'armenia': 'Armenia', + 'aze': 'AzerbaiyĂĄn', + 'azerbaijan': 'AzerbaiyĂĄn', + 'ge': 'Georgia', + 'geo': 'Georgia', + 'georgia': 'Georgia', + 'kz': 'KazajistĂĄn', + 'kaz': 'KazajistĂĄn', + 'kazakhstan': 'KazajistĂĄn', + 'kg': 'KirguistĂĄn', + 'kgz': 'KirguistĂĄn', + 'kyrgyzstan': 'KirguistĂĄn', + 'tj': 'TayikistĂĄn', + 'tjk': 'TayikistĂĄn', + 'tajikistan': 'TayikistĂĄn', + 'tm': 'TurkmenistĂĄn', + 'tkm': 'TurkmenistĂĄn', + 'turkmenistan': 'TurkmenistĂĄn', + 'uz': 'UzbekistĂĄn', + 'uzb': 'UzbekistĂĄn', + 'uzbekistan': 'UzbekistĂĄn', + 'bd': 'Bangladesh', + 'bgd': 'Bangladesh', + 'bangladesh': 'Bangladesh', + 'lk': 'Sri Lanka', + 'lka': 'Sri Lanka', + 'srilanka': 'Sri Lanka', + 'np': 'Nepal', + 'npl': 'Nepal', + 'nepal': 'Nepal', + 'bt': 'ButĂĄn', + 'btn': 'ButĂĄn', + 'bhutan': 'ButĂĄn', + 'mv': 'Maldivas', + 'mdv': 'Maldivas', + 'maldives': 'Maldivas', + 'pk': 'PakistĂĄn', + 'pak': 'PakistĂĄn', + 'pakistan': 'PakistĂĄn', + }; + + /// 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; + } + void setCredentials(String server, String username, String password) { _server = server; _username = username; @@ -158,4 +822,1197 @@ class XtreamApiService { final ext = type == 'live' ? 'ts' : 'm3u8'; return '$_baseUrl/$type/$_username/$_password/$streamId.$ext'; } + + 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', + '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus', + '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u', + '$_baseUrl/playlist?username=$_username&password=$_password', + ]; + + Exception? lastError; + + 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}'); + + 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'); + } + + /// Downloads M3U playlist and parses it into structured JSON format + Future downloadM3UAsJson() async { + // Try multiple M3U endpoints + final endpoints = [ + '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus&output=ts', + '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus', + '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u', + '$_baseUrl/playlist?username=$_username&password=$_password', + ]; + + String? successfulUrl; + String? m3uContent; + + 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), + ); + + 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; + } + } + + if (m3uContent == null || successfulUrl == null) { + throw Exception('No se pudo descargar la lista M3U de ningĂșn endpoint'); + } + + // Parse M3U content + final channels = _parseM3UToChannels(m3uContent); + + // Count groups + final groupsCount = {}; + for (final channel in channels) { + final group = channel.groupTitle ?? 'Sin categorĂ­a'; + groupsCount[group] = (groupsCount[group] ?? 0) + 1; + } + + return M3UDownloadResult( + channels: channels, + sourceUrl: successfulUrl, + downloadTime: DateTime.now(), + totalChannels: channels.length, + groupsCount: groupsCount, + ); + } + + /// Parses M3U content into a list of M3UChannel objects with full metadata + 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': + tvgLogo = value; + break; + case 'group-title': + groupTitle = value; + break; + case 'tvg-id': + tvgId = value; + break; + case 'tvg-name': + tvgName = value; + break; + } + } + } + + currentChannel = M3UChannel( + name: name, + url: '', // Will be set on next line + groupTitle: groupTitle, + tvgLogo: tvgLogo, + tvgId: tvgId, + tvgName: tvgName, + metadata: Map.from(currentMetadata), + ); + } + } 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, + )); + 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 { + try { + // Request storage permission + var status = await Permission.storage.request(); + if (!status.isGranted) { + // Try manage external storage for Android 11+ + status = await Permission.manageExternalStorage.request(); + if (!status.isGranted) { + throw Exception('Permiso de almacenamiento denegado'); + } + } + + // Get appropriate directory + Directory? directory; + + // Try Downloads folder first (Android) + if (Platform.isAndroid) { + directory = Directory('/storage/emulated/0/Download'); + if (!await directory.exists()) { + directory = null; + } + } + + // Fallback to app documents directory + directory ??= await getApplicationDocumentsDirectory(); + + // Generate filename + 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()); + + // 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'); + + 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 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, + groupTitle: stream.plot, + ); + 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: ========================================================='); + 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( + stream.name, + groupTitle: stream.plot, + ); + 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) { + 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, + /// 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, + 'Brazil': 3, + 'Chile': 3, + 'Colombia': 3, + 'Ecuador': 3, + 'Paraguay': 3, + 'Uruguay': 3, + 'Venezuela': 3, + + // 4. Central America + 'Costa Rica': 4, + 'El Salvador': 4, + 'Guatemala': 4, + 'Honduras': 4, + 'Nicaragua': 4, + 'PanamĂĄ': 4, + 'Panama': 4, + 'Puerto Rico': 4, + 'RepĂșblica Dominicana': 4, + + // 5. North America + 'CanadĂĄ': 5, + 'Canada': 5, + 'Estados Unidos': 5, + 'United States': 5, + 'USA': 5, + 'MĂ©xico': 5, + 'Mexico': 5, + + // 6. Europe + 'Alemania': 6, + 'Germany': 6, + 'AT': 6, + 'Austria': 6, + 'BE': 6, + 'Belgium': 6, + 'BG': 6, + 'Bulgaria': 6, + 'CZ': 6, + 'Czech Republic': 6, + 'DK': 6, + 'Denmark': 6, + 'EE': 6, + 'Estonia': 6, + 'España': 6, + 'Spain': 6, + 'FI': 6, + 'Finland': 6, + 'FR': 6, + 'France': 6, + 'GR': 6, + 'Greece': 6, + 'HR': 6, + 'Croatia': 6, + 'HU': 6, + 'Hungary': 6, + 'IE': 6, + 'Ireland': 6, + 'Italia': 6, + 'Italy': 6, + 'LT': 6, + 'Lithuania': 6, + 'LV': 6, + 'Latvia': 6, + 'NL': 6, + 'Netherlands': 6, + 'NO': 6, + 'Norway': 6, + 'PL': 6, + 'Poland': 6, + 'Portugal': 6, + 'RO': 6, + 'Romania': 6, + 'RU': 6, + 'Russia': 6, + 'SK': 6, + 'Slovakia': 6, + 'SW': 6, + 'Sweden': 6, + 'UK': 6, + 'United Kingdom': 6, + 'Reino Unido': 6, + 'UKR': 6, + 'Ukraine': 6, + + // 7. Asia (pure Asian countries - no Arab/Middle East) + 'BD': 7, + 'Bangladesh': 7, + 'CN': 7, + 'China': 7, + 'ID': 7, + 'Indonesia': 7, + 'IN': 7, + 'India': 7, + 'JP': 7, + 'Japan': 7, + 'KH': 7, + 'Cambodia': 7, + 'KR': 7, + 'Korea': 7, + 'KZ': 7, + 'Kazakhstan': 7, + 'MY': 7, + 'Malaysia': 7, + 'PH': 7, + 'Philippines': 7, + 'PK': 7, + 'Pakistan': 7, + 'SG': 7, + 'Singapore': 7, + 'TH': 7, + 'Thailand': 7, + 'VN': 7, + 'Vietnam': 7, + + // 8. Africa + 'ANGOLA': 8, + 'BENIN': 8, + 'BURKINAFASO': 8, + 'CAMEROON': 8, + 'CAPEVERDE': 8, + 'CONGO': 8, + 'COTEDIVOIRE': 8, + 'DJIBOUTI': 8, + 'DZ': 8, + 'Algeria': 8, + 'EGY': 8, + 'Egypt': 8, + 'ERITREA': 8, + 'ETHIOPIA': 8, + 'GABON': 8, + 'GAMBIA': 8, + 'GHANA': 8, + 'GUINEE': 8, + 'KENYA': 8, + 'LBY': 8, + 'Libya': 8, + 'MA': 8, + 'Morocco': 8, + 'MALAWI': 8, + 'MALI': 8, + 'MOZAMBIQUE': 8, + 'NIGERIA': 8, + 'ROT': 8, + 'ROWANDA': 8, + 'SENEGAL': 8, + 'SOMAL': 8, + 'SUDAN': 8, + 'TN': 8, + 'Tunisia': 8, + 'TANZANIA': 8, + 'TCHAD': 8, + 'TOGO': 8, + 'UGANDA': 8, + 'ZA': 8, + 'South Africa': 8, + 'ZAMBIA': 8, + + + // 9. Árabe / Arabic / Middle East (AFTER Africa) + 'Árabe': 9, + 'AF': 9, + 'AFG': 9, + 'AL': 9, + 'Albania': 9, + 'AR-KIDS': 9, + 'AR-SP': 9, + 'ARM': 9, + 'Armenia': 9, + 'AZE': 9, + 'Azerbaijan': 9, + 'BAB': 9, + 'BAHR': 9, + 'Bahrain': 9, + 'BH': 9, + 'IQ': 9, + 'Iraq': 9, + 'IR': 9, + 'Iran': 9, + 'ISLAM': 9, + 'JOR': 9, + 'Jordan': 9, + 'KSA': 9, + 'Saudi Arabia': 9, + 'KU': 9, + 'KUW': 9, + 'Kuwait': 9, + 'LEB': 9, + 'Lebanon': 9, + 'MYHD': 9, + 'OMAN': 9, + 'OSN': 9, + 'PALES': 9, + 'Palestine': 9, + 'QA': 9, + 'Qatar': 9, + 'SYR': 9, + 'Syria': 9, + 'TR': 9, + 'Turkey': 9, + 'UAE': 9, + 'United Arab Emirates': 9, + 'YEMEN': 9, + 'Yemen': 9, + 'IL': 9, + 'Israel': 9, + + // 10. Special groups (24/7, VIP, PPV, etc.) - ABSOLUTE LAST + '24/7 AR': 10, + '24/7 IN': 10, + '24/7-AR': 10, + '24/7-DE': 10, + '24/7-ES': 10, + '24/7-GR': 10, + '24/7-IN': 10, + '24/7-MY': 10, + '24/7-PT': 10, + '24/7-RO': 10, + '24/7-TR': 10, + 'ART': 10, + 'BEIN': 10, + 'CINE': 10, + 'CINE SD': 10, + 'CINE Y SERIE': 10, + 'DSTV': 10, + 'EXYU': 10, + 'EZD': 10, + 'GENERAL': 10, + 'ICC-CA': 10, + 'ICC-CAR': 10, + 'ICC-DSTV': 10, + 'ICC-IN': 10, + 'ICC-NZ': 10, + 'ICC-PK': 10, + 'ICC-UK': 10, + 'LATINO': 10, + 'MBC': 10, + 'MOVIES': 10, + 'MUSIC': 10, + 'PPV': 10, + 'RELIGIOUS': 10, + 'SIN': 10, + 'Sin PaĂ­s': 10, + 'TOD': 10, + 'VIP': 10, + '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}'); + } + + // 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|'); + + 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', + 'fox sports', + 'futbol', 'fĂștbol', + 'primera', 'liga', + 'copa', 'superliga', + 'lpf', + '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 + final spanishKeywords = [ + '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', + 'liga', + '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\"'); + + 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 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\"'); + 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); + if (twentyFourSevenMatch != null) { + final code = twentyFourSevenMatch.group(1)!.toLowerCase(); + final mapped = _countryMapping[code]; + if (mapped != null) { + return mapped; + } + } + + // Pattern: AR-KIDS, AR-SP, etc. + final arKidsMatch = RegExp(r'^(\w{2})[-_]').firstMatch(normalized); + if (arKidsMatch != null) { + final code = arKidsMatch.group(1)!.toLowerCase(); + // Only treat as country code if it maps to a known country + if (_countryMapping.containsKey(code)) { + 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') || + 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') || + normalizedGroup.contains('islam') || + normalizedGroup.contains('mbc') || + normalizedGroup.contains('bein') || + normalizedGroup.contains('osn') || + normalizedGroup.contains('myhd') || + 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}) { + 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); + if (leadingCodeMatch != null) { + final code = leadingCodeMatch.group(1)!; + final country = _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); + if (bracketMatch != null) { + final code = bracketMatch.group(1)!; + final country = _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' + }; + + return groupTitles.contains(normalized); + } + + List filterByCountry(List streams, String country, + {Map? categoryToCountryMap}) { + // Empty string or "Todos"/"All" means show all channels + final normalizedCountry = country.trim(); + if (normalizedCountry.isEmpty || + normalizedCountry.toLowerCase() == 'todos' || + normalizedCountry.toLowerCase() == 'all') { + return streams; + } + + return streams.where((s) { + String? streamCountry; + + // First, try to extract country from stream name (M3U format) + // Pass group title for context to help with ambiguous codes + final rawCountryFromName = extractCountryFromChannelName( + s.name, + groupTitle: s.plot, + ); + if (rawCountryFromName.isNotEmpty) { + streamCountry = normalizeCountry(rawCountryFromName); + } + + // If no country in name and we have category mapping, use category + if (streamCountry == null && + categoryToCountryMap != null && + s.categoryId != null) { + final categoryCountry = categoryToCountryMap[s.categoryId]; + if (categoryCountry != null && categoryCountry.isNotEmpty) { + streamCountry = normalizeCountry(categoryCountry); + } + } + + return streamCountry == normalizedCountry; + }).toList(); + } + + /// Filter streams by special category + /// + /// Currently supports: + /// - "FĂștbol Argentino" / "Futbol Argentino": Channels broadcasting Argentine football + 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' || + 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 + 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'); + 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) { + if (line.trim().startsWith('#EXTINF:')) { + 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"'); + } + + currentStream = XtreamStream( + streamId: streams.length + 1, + name: name, + streamIcon: streamIcon, + containerExtension: 'ts', + plot: groupTitle, // Store group as plot for now + ); + } 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)) { + // Only report if progress changed significantly + if (streams.length - lastReportedProgress >= 100 || streams.length == totalExtinfLines) { + onProgress(streams.length, totalExtinfLines); + lastReportedProgress = streams.length; + } + } + } + } + } + + // 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; + } + + /// Saves text content to a file in the Downloads directory + Future saveTextFile(String fileName, String content) async { + try { + // Request storage permissions + var status = await Permission.storage.request(); + if (!status.isGranted) { + // Try manage external storage for Android 11+ + status = await Permission.manageExternalStorage.request(); + if (!status.isGranted) { + throw Exception('Permiso de almacenamiento denegado'); + } + } + + // Get Downloads directory + Directory? directory; + if (Platform.isAndroid) { + directory = Directory('/storage/emulated/0/Download'); + } else { + directory = await getDownloadsDirectory(); + } + + if (directory == null || !directory.existsSync()) { + // Fallback to app documents directory + directory = await getApplicationDocumentsDirectory(); + } + + final filePath = '${directory.path}/$fileName'; + final file = File(filePath); + + // 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 new file mode 100644 index 0000000..a7e7db0 --- /dev/null +++ b/lib/services/xtream_api.dart.backup @@ -0,0 +1,1901 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import '../models/xtream_models.dart'; + +/// Represents a channel parsed from M3U playlist with all metadata +class M3UChannel { + final String name; + final String url; + final String? groupTitle; + final String? tvgLogo; + final String? tvgId; + final String? tvgName; + final Map metadata; + + M3UChannel({ + required this.name, + required this.url, + this.groupTitle, + this.tvgLogo, + this.tvgId, + this.tvgName, + this.metadata = const {}, + }); + + Map toJson() { + return { + 'name': name, + 'url': url, + 'group-title': groupTitle, + 'tvg-logo': tvgLogo, + 'tvg-id': tvgId, + 'tvg-name': tvgName, + 'metadata': metadata, + }; + } +} + +/// Result of downloading and parsing M3U playlist +class M3UDownloadResult { + final List channels; + final String sourceUrl; + final DateTime downloadTime; + final int totalChannels; + final Map groupsCount; + + M3UDownloadResult({ + required this.channels, + required this.sourceUrl, + required this.downloadTime, + required this.totalChannels, + required this.groupsCount, + }); + + Map toJson() { + return { + 'download_info': { + 'source_url': sourceUrl, + 'download_time': downloadTime.toIso8601String(), + 'total_channels': totalChannels, + 'groups_count': groupsCount, + }, + 'channels': channels.map((c) => c.toJson()).toList(), + }; + } +} + +class XtreamApiService { + String? _server; + String? _username; + String? _password; + String? _baseUrl; + + // Country normalization mapping: code/variation -> standard name + static const Map _countryMapping = { + // AMÉRICA - Prioridad alta + 'arg': 'Argentina', + 'argentina': 'Argentina', + 'pe': 'PerĂș', + 'per': 'PerĂș', + 'peru': 'PerĂș', + 'perĂș': 'PerĂș', + 'bo': 'Bolivia', + 'bol': 'Bolivia', + 'bolivia': 'Bolivia', + 'br': 'Brasil', + 'bra': 'Brasil', + 'brazil': 'Brasil', + 'cl': 'Chile', + 'chl': 'Chile', + 'chile': 'Chile', + 'co': 'Colombia', + 'col': 'Colombia', + 'colombia': 'Colombia', + 'ec': 'Ecuador', + 'ecu': 'Ecuador', + 'ecuador': 'Ecuador', + 'py': 'Paraguay', + 'par': 'Paraguay', + 'paraguay': 'Paraguay', + 'uy': 'Uruguay', + 'uru': 'Uruguay', + 'uruguay': 'Uruguay', + 've': 'Venezuela', + 'ven': 'Venezuela', + 'venezuela': 'Venezuela', + + // CentroamĂ©rica y Caribe + 'cr': 'Costa Rica', + 'cri': 'Costa Rica', + 'costarica': 'Costa Rica', + 'sv': 'El Salvador', + 'sal': 'El Salvador', + 'elsalvador': 'El Salvador', + 'gt': 'Guatemala', + 'guatemala': 'Guatemala', + 'hn': 'Honduras', + 'hon': 'Honduras', + 'honduras': 'Honduras', + 'ni': 'Nicaragua', + 'nic': 'Nicaragua', + 'nicaragua': 'Nicaragua', + 'pa': 'PanamĂĄ', + 'pan': 'PanamĂĄ', + 'panama': 'PanamĂĄ', + 'panamĂĄ': 'PanamĂĄ', + 'do': 'RepĂșblica Dominicana', + 'dom': 'RepĂșblica Dominicana', + 'republicadominicana': 'RepĂșblica Dominicana', + 'rd': 'RepĂșblica Dominicana', + 'pr': 'Puerto Rico', + 'pri': 'Puerto Rico', + 'puertorico': 'Puerto Rico', + 'cu': 'Cuba', + 'cuba': 'Cuba', + 'ht': 'HaitĂ­', + 'haiti': 'HaitĂ­', + 'haitĂ­': 'HaitĂ­', + 'jm': 'Jamaica', + 'jamaica': 'Jamaica', + + // NorteamĂ©rica + 'us': 'Estados Unidos', + 'usa': 'Estados Unidos', + 'unitedstates': 'Estados Unidos', + 'ca': 'CanadĂĄ', + 'can': 'CanadĂĄ', + 'canada': 'CanadĂĄ', + 'canadĂĄ': 'CanadĂĄ', + 'mx': 'MĂ©xico', + 'mex': 'MĂ©xico', + 'mexico': 'MĂ©xico', + 'mĂ©xico': 'MĂ©xico', + + // EUROPA + 'es': 'España', + 'españa': 'España', + 'spain': 'España', + 'uk': 'Reino Unido', + 'gb': 'Reino Unido', + 'unitedkingdom': 'Reino Unido', + 'fr': 'Francia', + 'fra': 'Francia', + 'france': 'Francia', + 'de': 'Alemania', + 'ger': 'Alemania', + 'germany': 'Alemania', + 'alemania': 'Alemania', + 'it': 'Italia', + 'ita': 'Italia', + 'italy': 'Italia', + 'italia': 'Italia', + 'pt': 'Portugal', + 'prt': 'Portugal', + 'portugal': 'Portugal', + + // Europa del Norte + 'se': 'Suecia', + 'sw': 'Suecia', + 'swe': 'Suecia', + 'sweden': 'Suecia', + 'suecia': 'Suecia', + 'no': 'Noruega', + 'nor': 'Noruega', + 'norway': 'Noruega', + 'noruega': 'Noruega', + 'dk': 'Dinamarca', + 'din': 'Dinamarca', + 'denmark': 'Dinamarca', + 'dinamarca': 'Dinamarca', + 'fi': 'Finlandia', + 'fin': 'Finlandia', + 'finland': 'Finlandia', + 'finlandia': 'Finlandia', + + // Europa del Este + 'ru': 'Rusia', + 'rus': 'Rusia', + 'russia': 'Rusia', + 'pl': 'Polonia', + 'pol': 'Polonia', + 'poland': 'Polonia', + 'polonia': 'Polonia', + 'ua': 'Ucrania', + 'ukr': 'Ucrania', + 'ukraine': 'Ucrania', + 'ucrania': 'Ucrania', + 'cz': 'RepĂșblica Checa', + 'cze': 'RepĂșblica Checa', + 'czechrepublic': 'RepĂșblica Checa', + 'sk': 'Eslovaquia', + 'svk': 'Eslovaquia', + 'slovakia': 'Eslovaquia', + 'hu': 'HungrĂ­a', + 'hun': 'HungrĂ­a', + 'hungary': 'HungrĂ­a', + 'hungria': 'HungrĂ­a', + 'ro': 'Rumania', + 'rou': 'Rumania', + 'romania': 'Rumania', + 'bg': 'Bulgaria', + 'bgr': 'Bulgaria', + 'bulgaria': 'Bulgaria', + 'al': 'Albania', + 'alb': 'Albania', + 'albania': 'Albania', + 'hr': 'Croacia', + 'hrv': 'Croacia', + 'croatia': 'Croacia', + 'croacia': 'Croacia', + 'rs': 'Serbia', + 'srb': 'Serbia', + 'serbia': 'Serbia', + 'ba': 'Bosnia y Herzegovina', + 'bih': 'Bosnia y Herzegovina', + 'bosnia': 'Bosnia y Herzegovina', + 'mk': 'Macedonia del Norte', + 'mkd': 'Macedonia del Norte', + 'macedonia': 'Macedonia del Norte', + 'si': 'Eslovenia', + 'svn': 'Eslovenia', + 'slovenia': 'Eslovenia', + 'md': 'Moldavia', + 'mda': 'Moldavia', + 'moldova': 'Moldavia', + 'lt': 'Lituania', + 'ltu': 'Lituania', + 'lithuania': 'Lituania', + 'lituania': 'Lituania', + 'lv': 'Letonia', + 'lva': 'Letonia', + 'latvia': 'Letonia', + 'letonia': 'Letonia', + 'ee': 'Estonia', + 'est': 'Estonia', + 'estonia': 'Estonia', + 'by': 'Bielorrusia', + 'blr': 'Bielorrusia', + 'belarus': 'Bielorrusia', + + // Europa Occidental + 'nl': 'PaĂ­ses Bajos', + 'nld': 'PaĂ­ses Bajos', + 'netherlands': 'PaĂ­ses Bajos', + 'paisesbajos': 'PaĂ­ses Bajos', + 'holanda': 'PaĂ­ses Bajos', + 'be': 'BĂ©lgica', + 'bel': 'BĂ©lgica', + 'belgium': 'BĂ©lgica', + 'belgica': 'BĂ©lgica', + 'at': 'Austria', + 'aut': 'Austria', + 'austria': 'Austria', + 'ch': 'Suiza', + 'che': 'Suiza', + 'switzerland': 'Suiza', + 'suiza': 'Suiza', + 'ie': 'Irlanda', + 'irl': 'Irlanda', + 'ireland': 'Irlanda', + 'irlanda': 'Irlanda', + 'gr': 'Grecia', + 'grc': 'Grecia', + 'greece': 'Grecia', + 'grecia': 'Grecia', + + // ASIA + 'in': 'India', + 'ind': 'India', + 'india': 'India', + 'cn': 'China', + 'chn': 'China', + 'china': 'China', + 'jp': 'JapĂłn', + 'jpn': 'JapĂłn', + 'japan': 'JapĂłn', + 'japon': 'JapĂłn', + 'kr': 'Corea del Sur', + 'kor': 'Corea del Sur', + 'southkorea': 'Corea del Sur', + 'kors': 'Corea del Sur', + 'kp': 'Corea del Norte', + 'prk': 'Corea del Norte', + 'northkorea': 'Corea del Norte', + 'korn': 'Corea del Norte', + 'th': 'Tailandia', + 'tha': 'Tailandia', + 'thailand': 'Tailandia', + 'tailandia': 'Tailandia', + 'vn': 'Vietnam', + 'vietnam': 'Vietnam', + 'my': 'Malasia', + 'mys': 'Malasia', + 'malaysia': 'Malasia', + 'malasia': 'Malasia', + 'id': 'Indonesia', + 'idn': 'Indonesia', + 'indonesia': 'Indonesia', + 'ph': 'Filipinas', + 'philippines': 'Filipinas', + 'filipinas': 'Filipinas', + 'sg': 'Singapur', + 'sgp': 'Singapur', + 'singapore': 'Singapur', + 'singapur': 'Singapur', + + // Medio Oriente + 'tr': 'TurquĂ­a', + 'tur': 'TurquĂ­a', + 'turkey': 'TurquĂ­a', + 'turquia': 'TurquĂ­a', + 'ir': 'IrĂĄn', + 'irn': 'IrĂĄn', + 'iran': 'IrĂĄn', + 'iq': 'Irak', + 'irq': 'Irak', + 'irak': 'Irak', + 'iraq': 'Irak', + 'sa': 'Arabia Saudita', + 'ksa': 'Arabia Saudita', + 'saudiarabia': 'Arabia Saudita', + 'arabiasaudita': 'Arabia Saudita', + 'ae': 'Emiratos Árabes Unidos', + 'uae': 'Emiratos Árabes Unidos', + 'unitedarabemirates': 'Emiratos Árabes Unidos', + 'emiratos': 'Emiratos Árabes Unidos', + 'eg': 'Egipto', + 'egy': 'Egipto', + 'egypt': 'Egipto', + 'egipto': 'Egipto', + 'jo': 'Jordania', + 'jor': 'Jordania', + 'jordan': 'Jordania', + 'jordania': 'Jordania', + 'sy': 'Siria', + 'syr': 'Siria', + 'syria': 'Siria', + 'lb': 'LĂ­bano', + 'lbn': 'LĂ­bano', + 'lebanon': 'LĂ­bano', + 'libano': 'LĂ­bano', + 'il': 'Israel', + 'isr': 'Israel', + 'israel': 'Israel', + 'ps': 'Palestina', + 'pse': 'Palestina', + 'palestine': 'Palestina', + 'palestina': 'Palestina', + 'qa': 'Qatar', + 'qat': 'Qatar', + 'qatar': 'Qatar', + 'kw': 'Kuwait', + 'kwt': 'Kuwait', + 'kuwait': 'Kuwait', + 'ku': 'Kuwait', + 'bh': 'BarĂ©in', + 'bhr': 'BarĂ©in', + 'bahrain': 'BarĂ©in', + 'barein': 'BarĂ©in', + 'om': 'OmĂĄn', + 'omn': 'OmĂĄn', + 'oman': 'OmĂĄn', + 'dz': 'Argelia', + 'dza': 'Argelia', + 'algeria': 'Argelia', + 'argelia': 'Argelia', + 'ma': 'Marruecos', + 'mar': 'Marruecos', + 'morocco': 'Marruecos', + 'marruecos': 'Marruecos', + 'tn': 'TĂșnez', + 'tun': 'TĂșnez', + 'tunisia': 'TĂșnez', + 'tunez': 'TĂșnez', + 'ly': 'Libia', + 'lby': 'Libia', + 'libya': 'Libia', + 'libia': 'Libia', + + // ÁFRICA + 'za': 'SudĂĄfrica', + 'zaf': 'SudĂĄfrica', + 'southafrica': 'SudĂĄfrica', + 'sudafrica': 'SudĂĄfrica', + 'ng': 'Nigeria', + 'nga': 'Nigeria', + 'nigeria': 'Nigeria', + 'et': 'EtiopĂ­a', + 'eth': 'EtiopĂ­a', + 'ethiopia': 'EtiopĂ­a', + 'etiopia': 'EtiopĂ­a', + 'ke': 'Kenia', + 'ken': 'Kenia', + 'kenya': 'Kenia', + 'tz': 'Tanzania', + 'tza': 'Tanzania', + 'tanzania': 'Tanzania', + 'ug': 'Uganda', + 'uga': 'Uganda', + 'uganda': 'Uganda', + 'gh': 'Ghana', + 'gha': 'Ghana', + 'ghana': 'Ghana', + 'sd': 'SudĂĄn', + 'sdn': 'SudĂĄn', + 'sudan': 'SudĂĄn', + 'sn': 'Senegal', + 'sen': 'Senegal', + 'senegal': 'Senegal', + 'ml': 'Mali', + 'mali': 'Mali', + 'bf': 'Burkina Faso', + 'bfa': 'Burkina Faso', + 'burkinafaso': 'Burkina Faso', + 'ci': 'Costa de Marfil', + 'civ': 'Costa de Marfil', + 'cotedivoire': 'Costa de Marfil', + 'ne': 'NĂ­ger', + 'ner': 'NĂ­ger', + 'niger': 'NĂ­ger', + 'td': 'Chad', + 'tcd': 'Chad', + 'chad': 'Chad', + 'cm': 'CamerĂșn', + 'cmr': 'CamerĂșn', + 'cameroon': 'CamerĂșn', + 'camerun': 'CamerĂșn', + 'cf': 'RepĂșblica Centroafricana', + 'caf': 'RepĂșblica Centroafricana', + 'gab': 'GabĂłn', + 'gabon': 'GabĂłn', + 'cg': 'Congo', + 'cog': 'Congo', + 'cd': 'RepĂșblica DemocrĂĄtica del Congo', + 'cod': 'RepĂșblica DemocrĂĄtica del Congo', + 'ao': 'Angola', + 'ago': 'Angola', + 'angola': 'Angola', + 'zm': 'Zambia', + 'zmb': 'Zambia', + 'zambia': 'Zambia', + 'zw': 'Zimbabue', + 'zwe': 'Zimbabue', + 'zimbabwe': 'Zimbabue', + 'mz': 'Mozambique', + 'moz': 'Mozambique', + 'mozambique': 'Mozambique', + 'bw': 'Botsuana', + 'bwa': 'Botsuana', + 'botswana': 'Botsuana', + 'na': 'Namibia', + 'nam': 'Namibia', + 'namibia': 'Namibia', + 'mw': 'Malaui', + 'mwi': 'Malaui', + 'malawi': 'Malaui', + 'mg': 'Madagascar', + 'mdg': 'Madagascar', + 'madagascar': 'Madagascar', + 'mu': 'Mauricio', + 'mus': 'Mauricio', + 'mauritius': 'Mauricio', + 'sc': 'Seychelles', + 'syc': 'Seychelles', + 'seychelles': 'Seychelles', + 'km': 'Comoras', + 'com': 'Comoras', + 'comoros': 'Comoras', + 'cv': 'Cabo Verde', + 'cpv': 'Cabo Verde', + 'capeverde': 'Cabo Verde', + 'gw': 'Guinea-BisĂĄu', + 'gnb': 'Guinea-BisĂĄu', + 'guineabissau': 'Guinea-BisĂĄu', + 'gm': 'Gambia', + 'gmb': 'Gambia', + 'gambia': 'Gambia', + 'sl': 'Sierra Leona', + 'sle': 'Sierra Leona', + 'sierraleone': 'Sierra Leona', + 'lr': 'Liberia', + 'lbr': 'Liberia', + 'liberia': 'Liberia', + 'gn': 'Guinea', + 'gin': 'Guinea', + 'guinea': 'Guinea', + 'gq': 'Guinea Ecuatorial', + 'gnq': 'Guinea Ecuatorial', + 'equatorialguinea': 'Guinea Ecuatorial', + 'st': 'Santo TomĂ© y PrĂ­ncipe', + 'stp': 'Santo TomĂ© y PrĂ­ncipe', + 'saotomeandprincipe': 'Santo TomĂ© y PrĂ­ncipe', + 'bj': 'BenĂ­n', + 'ben': 'BenĂ­n', + 'benin': 'BenĂ­n', + 'tg': 'Togo', + 'tgo': 'Togo', + 'togo': 'Togo', + 'rw': 'Ruanda', + 'rwa': 'Ruanda', + 'rwanda': 'Ruanda', + 'bi': 'Burundi', + 'bdi': 'Burundi', + 'burundi': 'Burundi', + 'dj': 'Yibuti', + 'dji': 'Yibuti', + 'djibouti': 'Yibuti', + 'er': 'Eritrea', + 'eri': 'Eritrea', + 'eritrea': 'Eritrea', + 'so': 'Somalia', + 'som': 'Somalia', + 'somalia': 'Somalia', + + // OceanĂ­a + 'au': 'Australia', + 'aus': 'Australia', + 'australia': 'Australia', + 'nz': 'Nueva Zelanda', + 'nzl': 'Nueva Zelanda', + 'newzealand': 'Nueva Zelanda', + 'nuevazelanda': 'Nueva Zelanda', + 'pg': 'PapĂșa Nueva Guinea', + 'png': 'PapĂșa Nueva Guinea', + 'papuanewguinea': 'PapĂșa Nueva Guinea', + 'fj': 'Fiyi', + 'fji': 'Fiyi', + 'fiji': 'Fiyi', + 'sb': 'Islas SalomĂłn', + 'slb': 'Islas SalomĂłn', + 'solomonislands': 'Islas SalomĂłn', + 'vu': 'Vanuatu', + 'vut': 'Vanuatu', + 'vanuatu': 'Vanuatu', + 'nc': 'Nueva Caledonia', + 'ncl': 'Nueva Caledonia', + 'newcaledonia': 'Nueva Caledonia', + 'pf': 'Polinesia Francesa', + 'pyf': 'Polinesia Francesa', + 'frenchpolynesia': 'Polinesia Francesa', + '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', + '24/7-ar': '24/7 AR', + '24/7-es': '24/7 ES', + '24/7-de': '24/7 DE', + '24/7-tr': '24/7 TR', + '24/7-ro': '24/7 RO', + '24/7-gr': '24/7 GR', + '24/7-my': '24/7 MY', + '24/7-pt': '24/7 PT', + '24/7-in': '24/7 IN', + 'ar-kids': 'AR Kids', + 'ar-sp': 'AR SP', + 'ar_ns': 'AR NS', + + // Idiomas / Languages + 'ar': 'Árabe', + 'vip': 'VIP', + 'vip - pk': 'VIP PK', + 'ppv': 'PPV', + 'exyu': 'EX-YU', + 'dstv': 'DSTV', + 'car': 'CAR', + 'bein': 'BeIN', + 'mbc': 'MBC', + 'osn': 'OSN', + 'myhd': 'MyHD', + 'art': 'ART', + 'tod': 'TOD', + 'islam': 'Islam', + 'latino': 'Latino', + 'general': 'General', + 'music': 'Music', + 'movies': 'Movies', + 'cine': 'Cine', + 'cine sd': 'Cine SD', + 'cine y serie': 'Cine y Serie', + 'xmas': 'Xmas', + 'sin': 'Sin categorĂ­a', + 'sin paĂ­s': 'Sin categorĂ­a', + 'ezd': 'EZD', + 'rot': 'ROT', + 'ic': 'IC', + 'sh': 'SH', + 'bab': 'BAB', + 'as': 'AS', + 'ei': 'EI', + 'su': 'SU', + + // Otros cĂłdigos especiales + 'af': 'AfganistĂĄn', + 'afg': 'AfganistĂĄn', + 'afghanistan': 'AfganistĂĄn', + 'arm': 'Armenia', + 'armenia': 'Armenia', + 'aze': 'AzerbaiyĂĄn', + 'azerbaijan': 'AzerbaiyĂĄn', + 'ge': 'Georgia', + 'geo': 'Georgia', + 'georgia': 'Georgia', + 'kz': 'KazajistĂĄn', + 'kaz': 'KazajistĂĄn', + 'kazakhstan': 'KazajistĂĄn', + 'kg': 'KirguistĂĄn', + 'kgz': 'KirguistĂĄn', + 'kyrgyzstan': 'KirguistĂĄn', + 'tj': 'TayikistĂĄn', + 'tjk': 'TayikistĂĄn', + 'tajikistan': 'TayikistĂĄn', + 'tm': 'TurkmenistĂĄn', + 'tkm': 'TurkmenistĂĄn', + 'turkmenistan': 'TurkmenistĂĄn', + 'uz': 'UzbekistĂĄn', + 'uzb': 'UzbekistĂĄn', + 'uzbekistan': 'UzbekistĂĄn', + 'bd': 'Bangladesh', + 'bgd': 'Bangladesh', + 'bangladesh': 'Bangladesh', + 'lk': 'Sri Lanka', + 'lka': 'Sri Lanka', + 'srilanka': 'Sri Lanka', + 'np': 'Nepal', + 'npl': 'Nepal', + 'nepal': 'Nepal', + 'bt': 'ButĂĄn', + 'btn': 'ButĂĄn', + 'bhutan': 'ButĂĄn', + 'mv': 'Maldivas', + 'mdv': 'Maldivas', + 'maldives': 'Maldivas', + 'pk': 'PakistĂĄn', + 'pak': 'PakistĂĄn', + 'pakistan': 'PakistĂĄn', + }; + + /// 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; + } + + void setCredentials(String server, String username, String password) { + _server = server; + _username = username; + _password = password; + _baseUrl = server.startsWith('http') ? server : 'http://$server'; + } + + String? get server => _server; + String? get username => _username; + + Future> authenticate() async { + final url = '$_baseUrl/player_api.php'; + final response = await http.get( + Uri.parse('$url?username=$_username&password=$_password'), + ); + + if (response.statusCode == 200) { + return json.decode(response.body); + } + throw Exception('Authentication failed: ${response.statusCode}'); + } + + Future getUserInfo() async { + final data = await authenticate(); + return XtreamUserInfo.fromJson(data); + } + + 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'), + ); + + if (response.statusCode == 200) { + final List data = json.decode(response.body); + return data.map((e) => XtreamCategory.fromJson(e)).toList(); + } + throw Exception('Failed to load categories'); + } + + 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'), + ); + + if (response.statusCode == 200) { + final List data = json.decode(response.body); + return data.map((e) => XtreamCategory.fromJson(e)).toList(); + } + throw Exception('Failed to load VOD categories'); + } + + 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'), + ); + + if (response.statusCode == 200) { + final List data = json.decode(response.body); + return data.map((e) => XtreamCategory.fromJson(e)).toList(); + } + throw Exception('Failed to load series categories'); + } + + Future> getLiveStreams(String categoryId) async { + final url = '$_baseUrl/player_api.php'; + 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'; + return stream; + }).toList(); + } + throw Exception('Failed to load live streams'); + } + + Future> getVodStreams(String categoryId) async { + final url = '$_baseUrl/player_api.php'; + 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) { + 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'; + return stream; + }).toList(); + } + throw Exception('Failed to load VOD streams'); + } + + Future> getSeries() async { + final url = '$_baseUrl/player_api.php'; + final response = await http.get( + Uri.parse('$url?username=$_username&password=$_password&action=get_series'), + ); + + if (response.statusCode == 200) { + final List data = json.decode(response.body); + return data.map((e) => XtreamSeries.fromJson(e)).toList(); + } + throw Exception('Failed to load series'); + } + + 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'), + ); + + 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'; + allEpisodes.add(episode); + } + } + return allEpisodes; + } + throw Exception('Failed to load series episodes'); + } + + String getStreamUrl(int streamId, {String type = 'live'}) { + final ext = type == 'live' ? 'ts' : 'm3u8'; + return '$_baseUrl/$type/$_username/$_password/$streamId.$ext'; + } + + 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', + '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus', + '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u', + '$_baseUrl/playlist?username=$_username&password=$_password', + ]; + + Exception? lastError; + + 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}'); + + 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'); + } + + /// Downloads M3U playlist and parses it into structured JSON format + Future downloadM3UAsJson() async { + // Try multiple M3U endpoints + final endpoints = [ + '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus&output=ts', + '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u_plus', + '$_baseUrl/get.php?username=$_username&password=$_password&type=m3u', + '$_baseUrl/playlist?username=$_username&password=$_password', + ]; + + String? successfulUrl; + String? m3uContent; + + 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), + ); + + 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; + } + } + + if (m3uContent == null || successfulUrl == null) { + throw Exception('No se pudo descargar la lista M3U de ningĂșn endpoint'); + } + + // Parse M3U content + final channels = _parseM3UToChannels(m3uContent); + + // Count groups + final groupsCount = {}; + for (final channel in channels) { + final group = channel.groupTitle ?? 'Sin categorĂ­a'; + groupsCount[group] = (groupsCount[group] ?? 0) + 1; + } + + return M3UDownloadResult( + channels: channels, + sourceUrl: successfulUrl, + downloadTime: DateTime.now(), + totalChannels: channels.length, + groupsCount: groupsCount, + ); + } + + /// Parses M3U content into a list of M3UChannel objects with full metadata + 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': + tvgLogo = value; + break; + case 'group-title': + groupTitle = value; + break; + case 'tvg-id': + tvgId = value; + break; + case 'tvg-name': + tvgName = value; + break; + } + } + } + + currentChannel = M3UChannel( + name: name, + url: '', // Will be set on next line + groupTitle: groupTitle, + tvgLogo: tvgLogo, + tvgId: tvgId, + tvgName: tvgName, + metadata: Map.from(currentMetadata), + ); + } + } 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, + )); + 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 { + try { + // Request storage permission + var status = await Permission.storage.request(); + if (!status.isGranted) { + // Try manage external storage for Android 11+ + status = await Permission.manageExternalStorage.request(); + if (!status.isGranted) { + throw Exception('Permiso de almacenamiento denegado'); + } + } + + // Get appropriate directory + Directory? directory; + + // Try Downloads folder first (Android) + if (Platform.isAndroid) { + directory = Directory('/storage/emulated/0/Download'); + if (!await directory.exists()) { + directory = null; + } + } + + // Fallback to app documents directory + directory ??= await getApplicationDocumentsDirectory(); + + // Generate filename + 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()); + + // 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'); + + 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 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, + groupTitle: stream.plot, + ); + 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: ========================================================='); + 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( + stream.name, + groupTitle: stream.plot, + ); + 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) { + 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, + /// 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, + 'Brazil': 3, + 'Chile': 3, + 'Colombia': 3, + 'Ecuador': 3, + 'Paraguay': 3, + 'Uruguay': 3, + 'Venezuela': 3, + + // 4. Central America + 'Costa Rica': 4, + 'El Salvador': 4, + 'Guatemala': 4, + 'Honduras': 4, + 'Nicaragua': 4, + 'PanamĂĄ': 4, + 'Panama': 4, + 'Puerto Rico': 4, + 'RepĂșblica Dominicana': 4, + + // 5. North America + 'CanadĂĄ': 5, + 'Canada': 5, + 'Estados Unidos': 5, + 'United States': 5, + 'USA': 5, + 'MĂ©xico': 5, + 'Mexico': 5, + + // 6. Europe + 'Alemania': 6, + 'Germany': 6, + 'AT': 6, + 'Austria': 6, + 'BE': 6, + 'Belgium': 6, + 'BG': 6, + 'Bulgaria': 6, + 'CZ': 6, + 'Czech Republic': 6, + 'DK': 6, + 'Denmark': 6, + 'EE': 6, + 'Estonia': 6, + 'España': 6, + 'Spain': 6, + 'FI': 6, + 'Finland': 6, + 'FR': 6, + 'France': 6, + 'GR': 6, + 'Greece': 6, + 'HR': 6, + 'Croatia': 6, + 'HU': 6, + 'Hungary': 6, + 'IE': 6, + 'Ireland': 6, + 'Italia': 6, + 'Italy': 6, + 'LT': 6, + 'Lithuania': 6, + 'LV': 6, + 'Latvia': 6, + 'NL': 6, + 'Netherlands': 6, + 'NO': 6, + 'Norway': 6, + 'PL': 6, + 'Poland': 6, + 'Portugal': 6, + 'RO': 6, + 'Romania': 6, + 'RU': 6, + 'Russia': 6, + 'SK': 6, + 'Slovakia': 6, + 'SW': 6, + 'Sweden': 6, + 'UK': 6, + 'United Kingdom': 6, + 'Reino Unido': 6, + 'UKR': 6, + 'Ukraine': 6, + + // 7. Asia/Oceania + 'AF': 7, + 'AF': 7, + 'AFG': 7, + 'Albania': 7, + 'AR-KIDS': 7, + 'AR-SP': 7, + 'ARM': 7, + 'Armenia': 7, + 'AZE': 7, + 'Azerbaijan': 7, + 'BAB': 7, + 'BAHR': 7, + 'Bahrain': 7, + 'BD': 7, + 'Bangladesh': 7, + 'BH': 7, + 'CN': 7, + 'China': 7, + 'DZ': 7, + 'Algeria': 7, + 'EGY': 7, + 'Egypt': 7, + 'ID': 7, + 'Indonesia': 7, + 'IL': 7, + 'Israel': 7, + 'IN': 7, + 'India': 7, + 'IQ': 7, + 'Iraq': 7, + 'IR': 7, + 'Iran': 7, + 'ISLAM': 7, + 'JP': 7, + 'Japan': 7, + 'JOR': 7, + 'Jordan': 7, + 'KH': 7, + 'Cambodia': 7, + 'KSA': 7, + 'Saudi Arabia': 7, + 'KU': 7, + 'KUW': 7, + 'Kuwait': 7, + 'KZ': 7, + 'Kazakhstan': 7, + 'LBY': 7, + 'Libya': 7, + 'LEB': 7, + 'Lebanon': 7, + 'MA': 7, + 'Morocco': 7, + 'MY': 7, + 'Malaysia': 7, + 'MYHD': 7, + 'OMAN': 7, + 'OSN': 7, + 'PALES': 7, + 'Palestine': 7, + 'PH': 7, + 'Philippines': 7, + 'PK': 7, + 'Pakistan': 7, + 'QA': 7, + 'Qatar': 7, + 'SYR': 7, + 'Syria': 7, + 'TH': 7, + 'Thailand': 7, + 'TN': 7, + 'Tunisia': 7, + 'TR': 7, + 'Turkey': 7, + 'UAE': 7, + 'United Arab Emirates': 7, + 'YEMEN': 7, + 'Yemen': 7, + + // 8. Africa (second to last) + 'ANGOLA': 8, + 'BENIN': 8, + 'BURKINAFASO': 8, + 'CAMEROON': 8, + 'CAPEVERDE': 8, + 'CONGO': 8, + 'COTEDIVOIRE': 8, + 'DJIBOUTI': 8, + 'ERITREA': 8, + 'ETHIOPIA': 8, + 'GABON': 8, + 'GAMBIA': 8, + 'GHANA': 8, + 'GUINEE': 8, + 'KENYA': 8, + 'MALAWI': 8, + 'MALI': 8, + 'MOZAMBIQUE': 8, + 'NIGERIA': 8, + 'ROT': 8, + 'ROWANDA': 8, + 'SENEGAL': 8, + 'SOMAL': 8, + 'SUDAN': 8, + 'TANZANIA': 8, + 'TCHAD': 8, + 'TOGO': 8, + 'UGANDA': 8, + '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, + '24/7-AR': 100, + '24/7-DE': 100, + '24/7-ES': 100, + '24/7-GR': 100, + '24/7-IN': 100, + '24/7-MY': 100, + '24/7-PT': 100, + '24/7-RO': 100, + '24/7-TR': 100, + 'ART': 100, + 'BEIN': 100, + 'CINE': 100, + 'CINE SD': 100, + 'CINE Y SERIE': 100, + 'DSTV': 100, + 'EXYU': 100, + 'EZD': 100, + 'GENERAL': 100, + 'ICC-CA': 100, + 'ICC-CAR': 100, + 'ICC-DSTV': 100, + 'ICC-IN': 100, + 'ICC-NZ': 100, + 'ICC-PK': 100, + 'ICC-UK': 100, + 'LATINO': 100, + 'MBC': 100, + 'MOVIES': 100, + 'MUSIC': 100, + 'PPV': 100, + 'RELIGIOUS': 100, + 'SIN': 100, + 'Sin PaĂ­s': 100, + 'TOD': 100, + 'VIP': 100, + '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}'); + } + + // 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\"'); + + 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 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\"'); + 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); + if (twentyFourSevenMatch != null) { + final code = twentyFourSevenMatch.group(1)!.toLowerCase(); + final mapped = _countryMapping[code]; + if (mapped != null) { + return mapped; + } + } + + // Pattern: AR-KIDS, AR-SP, etc. + final arKidsMatch = RegExp(r'^(\w{2})[-_]').firstMatch(normalized); + if (arKidsMatch != null) { + final code = arKidsMatch.group(1)!.toLowerCase(); + // Only treat as country code if it maps to a known country + if (_countryMapping.containsKey(code)) { + 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') || + 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') || + normalizedGroup.contains('islam') || + normalizedGroup.contains('mbc') || + normalizedGroup.contains('bein') || + normalizedGroup.contains('osn') || + normalizedGroup.contains('myhd') || + 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}) { + 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); + if (leadingCodeMatch != null) { + final code = leadingCodeMatch.group(1)!; + final country = _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); + if (bracketMatch != null) { + final code = bracketMatch.group(1)!; + final country = _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' + }; + + return groupTitles.contains(normalized); + } + + List filterByCountry(List streams, String country, + {Map? categoryToCountryMap}) { + // Empty string or "Todos"/"All" means show all channels + final normalizedCountry = country.trim(); + if (normalizedCountry.isEmpty || + normalizedCountry.toLowerCase() == 'todos' || + normalizedCountry.toLowerCase() == 'all') { + return streams; + } + + return streams.where((s) { + String? streamCountry; + + // First, try to extract country from stream name (M3U format) + // Pass group title for context to help with ambiguous codes + final rawCountryFromName = extractCountryFromChannelName( + s.name, + groupTitle: s.plot, + ); + if (rawCountryFromName.isNotEmpty) { + streamCountry = normalizeCountry(rawCountryFromName); + } + + // If no country in name and we have category mapping, use category + if (streamCountry == null && + categoryToCountryMap != null && + s.categoryId != null) { + final categoryCountry = categoryToCountryMap[s.categoryId]; + if (categoryCountry != null && categoryCountry.isNotEmpty) { + streamCountry = normalizeCountry(categoryCountry); + } + } + + return streamCountry == normalizedCountry; + }).toList(); + } + + List _parseM3U(String m3uContent, {void Function(int loaded, int total)? onProgress}) { + print('DEBUG: _parseM3U() START - content length: ${m3uContent.length} chars'); + 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) { + if (line.trim().startsWith('#EXTINF:')) { + 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"'); + } + + currentStream = XtreamStream( + streamId: streams.length + 1, + name: name, + streamIcon: streamIcon, + containerExtension: 'ts', + plot: groupTitle, // Store group as plot for now + ); + } 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)) { + // Only report if progress changed significantly + if (streams.length - lastReportedProgress >= 100 || streams.length == totalExtinfLines) { + onProgress(streams.length, totalExtinfLines); + lastReportedProgress = streams.length; + } + } + } + } + } + + // 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; + } + + /// Saves text content to a file in the Downloads directory + Future saveTextFile(String fileName, String content) async { + try { + // Request storage permissions + var status = await Permission.storage.request(); + if (!status.isGranted) { + // Try manage external storage for Android 11+ + status = await Permission.manageExternalStorage.request(); + if (!status.isGranted) { + throw Exception('Permiso de almacenamiento denegado'); + } + } + + // Get Downloads directory + Directory? directory; + if (Platform.isAndroid) { + directory = Directory('/storage/emulated/0/Download'); + } else { + directory = await getDownloadsDirectory(); + } + + if (directory == null || !directory.existsSync()) { + // Fallback to app documents directory + directory = await getApplicationDocumentsDirectory(); + } + + final filePath = '${directory.path}/$fileName'; + final file = File(filePath); + + // 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 new file mode 100644 index 0000000..0355e1a --- /dev/null +++ b/lib/widgets/countries_sidebar.dart @@ -0,0 +1,256 @@ +import 'package:flutter/material.dart'; + +class CountriesSidebar extends StatelessWidget { + final List countries; + final String selectedCountry; + final String title; + final ValueChanged onCountrySelected; + final bool isLoading; + + const CountriesSidebar({ + super.key, + required this.countries, + required this.selectedCountry, + required this.onCountrySelected, + this.title = 'PaĂ­ses', + this.isLoading = false, + }); + + @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; + final headerHeight = isLargeScreen ? 70.0 : 60.0; + final itemHeight = isLargeScreen ? 52.0 : 44.0; + final fontSize = isLargeScreen ? 16.0 : 14.0; + final headerFontSize = isLargeScreen ? 18.0 : 16.0; + final horizontalPadding = isLargeScreen ? 20.0 : 16.0; + + return Container( + width: sidebarWidth, + decoration: BoxDecoration( + color: const Color(0xFF14141f), + border: Border( + right: BorderSide( + color: Colors.white.withValues(alpha: 0.08), + width: 1, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: headerHeight, + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + decoration: BoxDecoration( + color: const Color(0xFF1a1a2e), + border: Border( + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.08), + width: 1, + ), + ), + ), + child: Row( + children: [ + Icon( + Icons.flag, + color: Colors.red.shade400, + size: isLargeScreen ? 24 : 20, + ), + SizedBox(width: isLargeScreen ? 12 : 10), + Text( + title, + style: TextStyle( + color: Colors.white, + fontSize: headerFontSize, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + Expanded( + child: isLoading && countries.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: isLargeScreen ? 40 : 32, + height: isLargeScreen ? 40 : 32, + child: CircularProgressIndicator( + color: Colors.red.shade400, + strokeWidth: isLargeScreen ? 3 : 2, + ), + ), + SizedBox(height: isLargeScreen ? 16 : 12), + Text( + 'Cargando paĂ­ses...', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.6), + fontSize: fontSize, + ), + ), + ], + ), + ) + : 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, + ), + ), + ) + : 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, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _CountryListItem extends StatelessWidget { + final String name; + final bool isSelected; + final VoidCallback onTap; + final double itemHeight; + final double fontSize; + final double horizontalPadding; + final IconData? icon; + + const _CountryListItem({ + required this.name, + required this.isSelected, + required this.onTap, + required this.itemHeight, + required this.fontSize, + required this.horizontalPadding, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: 2, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + height: itemHeight, + padding: EdgeInsets.symmetric(horizontal: horizontalPadding * 0.8), + decoration: BoxDecoration( + color: isSelected ? Colors.red.shade600 : Colors.transparent, + borderRadius: BorderRadius.circular(8), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.red.withValues(alpha: 0.25), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + children: [ + Container( + width: 3, + height: isSelected ? 20 : 0, + decoration: BoxDecoration( + color: isSelected ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(2), + ), + ), + SizedBox(width: isSelected ? 12 : 15), + if (icon != null) ...[ + Icon( + icon, + color: isSelected ? Colors.white : Colors.white.withValues(alpha: 0.6), + size: fontSize + 2, + ), + SizedBox(width: 10), + ] else ...[ + Icon( + Icons.circle, + color: isSelected + ? Colors.white + : Colors.white.withValues(alpha: 0.3), + size: 6, + ), + SizedBox(width: 12), + ], + Expanded( + child: Text( + name, + style: TextStyle( + color: isSelected + ? Colors.white + : Colors.white.withValues(alpha: 0.7), + fontSize: fontSize, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + letterSpacing: 0.3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected) + 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 new file mode 100644 index 0000000..de1e2fb --- /dev/null +++ b/lib/widgets/simple_countries_sidebar.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; + +class SimpleCountriesSidebar extends StatelessWidget { + final List countries; + final String selectedCountry; + final ValueChanged onCountrySelected; + final bool isLoading; + final bool isOrganizing; + final bool showFootballCategory; + final VoidCallback? onFootballSelected; + + const SimpleCountriesSidebar({ + super.key, + required this.countries, + required this.selectedCountry, + required this.onCountrySelected, + this.isLoading = false, + this.isOrganizing = false, + this.showFootballCategory = false, + this.onFootballSelected, + }); + + static const String _footballCategoryName = 'FĂștbol Argentino'; + + @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]}"'); + } + } + print('đŸ”„ SIDEBAR BUILD ============================================'); + + return Container( + width: 250, + color: Colors.grey[900], + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + color: Colors.red, + child: const Row( + children: [ + Icon(Icons.public, color: Colors.white), + SizedBox(width: 8), + Text( + 'PAÍSES', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + + // List + Expanded( + child: isOrganizing || (isLoading && countries.isEmpty) + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: Colors.red), + SizedBox(height: 16), + Text( + 'Organizando paĂ­ses...', + style: TextStyle(color: Colors.white), + ), + ], + ), + ) + : 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); + }, + ), + ), + ], + ), + ); + } + + Widget _buildCountryItem(String name, bool isSelected, VoidCallback onTap) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected ? Colors.red : Colors.transparent, + border: Border( + left: BorderSide( + color: isSelected ? Colors.white : Colors.transparent, + width: 4, + ), + ), + ), + child: Text( + name, + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } + + int _getItemCount() { + int count = countries.length + 1; // +1 for "Todos" + if (showFootballCategory) { + count += 1; // +1 for "FĂștbol Argentino" + } + return count; + } + + Widget _buildItemAtIndex(BuildContext context, int index) { + if (index == 0) { + return _buildCountryItem( + 'Todos', + selectedCountry.isEmpty, + () => onCountrySelected(''), + ); + } + + // Find insertion point for "FĂștbol Argentino" (after PerĂș) + final peruIndex = countries.indexOf('PerĂș'); + final footballInsertIndex = peruIndex >= 0 ? peruIndex + 1 : countries.length; + + if (showFootballCategory) { + // Adjust for "Todos" at index 0 and "FĂștbol Argentino" after PerĂș + if (index == footballInsertIndex + 1) { + return _buildFootballItem(); + } + + // Map the adjusted index to the actual country list + int countryIndex; + if (index <= footballInsertIndex) { + // Before football item + countryIndex = index - 1; + } else { + // After football item + countryIndex = index - 2; + } + + if (countryIndex >= 0 && countryIndex < countries.length) { + final country = countries[countryIndex]; + return _buildCountryItem( + country, + selectedCountry == country, + () => onCountrySelected(country), + ); + } + } else { + // Normal behavior without football category + final country = countries[index - 1]; + return _buildCountryItem( + country, + selectedCountry == country, + () => onCountrySelected(country), + ); + } + + return const SizedBox.shrink(); + } + + Widget _buildFootballItem() { + final isSelected = selectedCountry == _footballCategoryName; + return InkWell( + onTap: onFootballSelected, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected ? Colors.green[700] : Colors.green[900]?.withOpacity(0.3), + border: Border( + left: BorderSide( + color: isSelected ? Colors.white : Colors.green[400]!, + width: 4, + ), + ), + ), + child: Row( + children: [ + Icon( + Icons.sports_soccer, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _footballCategoryName, + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1ab3847..584f728 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,9 @@ dependencies: shared_preferences: ^2.3.5 provider: ^6.1.2 intl: ^0.19.0 + path_provider: ^2.1.5 + permission_handler: ^11.4.0 + path: ^1.9.0 dev_dependencies: flutter_test: