Files
iptv-ren/lib/services/xtream_api.dart.backup
renato97 8c7bbc5f2d v1.1.0: Major refactoring and Android TV optimizations
## Screens

### home_screen.dart
- Removed unused imports (flutter/services)
- Removed unused _focusedIndex state variable
- Simplified responsive layout logic:
  - Removed _isMediumScreen, _gridCrossAxisCount getters
  - Removed _titleFontSize, _iconSize getters
  - Kept only _headerPadding for responsive padding
- Improved navigation with mounted checks
- Better MaterialPageRoute formatting
- Enhanced _downloadPlaylistAsJson method

## Services

### xtream_api.dart
- Added http.Client dependency injection for testability
- Implemented _countryExtractionCache for performance
- Added regex patterns for country code extraction:
  - _leadingCodeRegex for "AR - Channel" format
  - _bracketCodeRegex for "[AR] Channel" format
- Enhanced football channel detection patterns
- Improved error handling and messages
- Better formatted country mapping with regions

### iptv_provider.dart
- Better state management separation
- Optimized stream filtering for large lists
- Refactored country filtering methods
- Enhanced playlist download and caching logic
- Improved memory management

## Widgets

### countries_sidebar.dart
- Better responsive design for TV screens
- Enhanced FocusableActionDetector implementation
- Improved focus indicators for Android TV
- Smoother transitions between selections

### simple_countries_sidebar.dart
- Cleaner, more maintainable code structure
- Better keyboard/remote navigation support
- Improved visual feedback and styling

## Player

### player_screen.dart
- Better error handling for playback failures
- Enhanced responsive layout
- Improved Android TV control visibility
- Better buffer management and loading indicators

## Tests

### widget_test.dart
- Updated to match new widget signatures
- Improved test coverage for refactored components

## Technical Improvements

- Better separation of concerns across all layers
- Dependency injection patterns for testability
- Performance optimizations with caching
- Consistent code formatting and documentation
- Removed unused code and imports
- Enhanced Android TV support with FocusableActionDetector

## Statistics
- 8 files changed
- +1300 insertions
- -1139 deletions
- Net: +161 lines of cleaner code

## Breaking Changes
None - all internal refactorings with no API changes
2026-02-26 00:02:41 -03:00

1919 lines
51 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, String> metadata;
M3UChannel({
required this.name,
required this.url,
this.groupTitle,
this.tvgLogo,
this.tvgId,
this.tvgName,
this.metadata = const {},
});
Map<String, dynamic> 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<M3UChannel> channels;
final String sourceUrl;
final DateTime downloadTime;
final int totalChannels;
final Map<String, int> groupsCount;
M3UDownloadResult({
required this.channels,
required this.sourceUrl,
required this.downloadTime,
required this.totalChannels,
required this.groupsCount,
});
Map<String, dynamic> 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<String, String> _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<Map<String, dynamic>> 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<XtreamUserInfo> getUserInfo() async {
final data = await authenticate();
return XtreamUserInfo.fromJson(data);
}
Future<List<XtreamCategory>> 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<dynamic> data = json.decode(response.body);
return data.map((e) => XtreamCategory.fromJson(e)).toList();
}
throw Exception('Failed to load categories');
}
Future<List<XtreamCategory>> 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<dynamic> data = json.decode(response.body);
return data.map((e) => XtreamCategory.fromJson(e)).toList();
}
throw Exception('Failed to load VOD categories');
}
Future<List<XtreamCategory>> 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<dynamic> data = json.decode(response.body);
return data.map((e) => XtreamCategory.fromJson(e)).toList();
}
throw Exception('Failed to load series categories');
}
Future<List<XtreamStream>> 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<dynamic> 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<List<XtreamStream>> 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<dynamic> 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<List<XtreamSeries>> 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<dynamic> data = json.decode(response.body);
return data.map((e) => XtreamSeries.fromJson(e)).toList();
}
throw Exception('Failed to load series');
}
Future<List<XtreamEpisode>> 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<dynamic> episodesData = data['episodes'] ?? [];
final List<XtreamEpisode> allEpisodes = [];
for (final seasonData in episodesData) {
final season = seasonData['season_number'] ?? 0;
final List<dynamic> 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<List<XtreamStream>> 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 {
final response = await http
.get(Uri.parse(url))
.timeout(
const Duration(
seconds: 60,
), // Increased timeout for large playlists (26k+ channels)
);
if (response.statusCode == 200) {
// Check if it's valid M3U content (may have BOM or whitespace before #EXTM3U)
final bodyTrimmed = response.body.trim();
if (bodyTrimmed.startsWith('#EXTM3U')) {
final streams = _parseM3U(response.body, onProgress: onProgress);
if (streams.isEmpty) {
lastError = Exception('M3U loaded but no streams parsed');
continue;
}
return streams;
} else {
lastError = Exception('Invalid M3U format');
}
} else {
lastError = Exception('HTTP ${response.statusCode}');
}
} on TimeoutException catch (e) {
lastError = Exception('Timeout loading M3U');
continue;
} catch (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<M3UDownloadResult> 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 {
final response = await http
.get(Uri.parse(url))
.timeout(const Duration(seconds: 30));
if (response.statusCode == 200 && response.body.startsWith('#EXTM3U')) {
successfulUrl = url;
m3uContent = response.body;
break;
}
} catch (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 = <String, int>{};
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<M3UChannel> _parseM3UToChannels(String m3uContent) {
final List<M3UChannel> channels = [];
final lines = m3uContent.split('\n');
M3UChannel? currentChannel;
Map<String, String> 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 = {};
}
}
}
return channels;
}
/// Saves M3U data as JSON file to device storage
Future<String> 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);
return filePath;
} catch (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<String> getCountriesOptimized(
List<XtreamStream> streams, {
int maxChannelsToProcess = 2000,
}) {
final countries = <String>{};
if (streams.isEmpty) {
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|')) {
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++;
}
// Sort countries with custom priority order
final sortedCountries = _sortCountriesByPriority(countries.toList());
return sortedCountries;
}
List<String> getCountries(
List<XtreamStream> streams, {
Map<String, String>? categoryToCountryMap,
}) {
if (categoryToCountryMap != null) {}
final countries = <String>{};
if (streams.isEmpty) {
return [];
}
// Sample first 10 stream names for debugging
for (int i = 0; i < streams.length && i < 10; i++) {
final s = streams[i];
}
// 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++;
}
}
// Track which patterns we're finding
final patternExamples = <String>[];
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);
}
}
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');
}
}
}
for (final example in patternExamples) {}
// Show all extracted country codes/short names before normalization
final rawCountries = <String>{};
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);
}
}
// Sort countries with custom priority order
final sortedCountries = _sortCountriesByPriority(countries.toList());
return sortedCountries;
}
/// Sort countries with custom priority: Argentina first, then Peru, then South America,
/// then Europe, then Arabs at the end
List<String> _sortCountriesByPriority(List<String> countries) {
// Define priority order
final priorityOrder = <String, int>{
// 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
if (countries.contains('Argentina')) {}
if (countries.contains('Árabe')) {}
// Sort using custom comparator
countries.sort((a, b) {
final priorityA = priorityOrder[a] ?? 100;
final priorityB = priorityOrder[b] ?? 100;
if (priorityA != priorityB) {
return priorityA.compareTo(priorityB);
}
// If same priority, sort alphabetically
return a.compareTo(b);
});
return countries;
}
/// Smart country extraction with multiple strategies
///
/// Strategy order:
/// 1. Check for exact 3-letter matches (ARG, USA, etc.)
/// 2. Check for 2-letter codes with context
/// 3. Handle special formats (24/7-AR, AR-KIDS, etc.)
/// 4. Use category/group information if available
/// 5. Return proper full country names
String extractCountryFromChannelName(
String channelName, {
String? groupTitle,
}) {
if (!channelName.contains('|')) {
final result = _extractCountryFromDirectName(
channelName,
groupTitle: groupTitle,
);
return result;
}
final parts = channelName.split('|');
if (parts.isEmpty) {
return '';
}
final countryCode = parts.first.trim();
// Must be at least 2 characters (filter out single letters)
if (countryCode.length < 2) {
return '';
}
// Strategy 1: Check if it's a known group title first
if (_isGroupTitle(countryCode)) {
final extractedFromGroup = _extractCountryFromGroupFormat(countryCode);
if (extractedFromGroup.isNotEmpty) {
return extractedFromGroup;
}
return '';
}
// Strategy 2: Try exact match first (case insensitive)
final normalizedCode = countryCode.toLowerCase().trim();
// Check for exact 3-letter match first (highest priority)
if (countryCode.length == 3) {
final exactMatch = _countryMapping[normalizedCode];
if (exactMatch != null) {
return exactMatch;
}
}
// Strategy 3: Check for 2-letter match with context
if (countryCode.length == 2) {
final result = _handleTwoLetterCode(
normalizedCode,
groupTitle: groupTitle,
);
if (result.isNotEmpty) {
return result;
}
}
// Strategy 4: Check the mapping
final mappedCountry = _countryMapping[normalizedCode];
if (mappedCountry != null) {
return mappedCountry;
}
// Return the raw code if no mapping found
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}) {
// 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') {
// Check group title for context
if (groupTitle != null) {
final normalizedGroup = groupTitle.toLowerCase();
// If group contains argentina-related terms explicitly, treat as Argentina
if (normalizedGroup.contains('argentina') ||
normalizedGroup.contains('argentino')) {
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')) {
return 'Árabe';
}
} else {}
// DEFAULT: AR without context = Árabe (Arabic)
// This is because AR| is the standard prefix for Arabic channels
// Argentina uses ARG| (3-letter code)
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<XtreamStream> filterByCountry(
List<XtreamStream> streams,
String country, {
Map<String, String>? 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<XtreamStream> _parseM3U(
String m3uContent, {
void Function(int loaded, int total)? onProgress,
}) {
final List<XtreamStream> streams = [];
final lines = m3uContent.split('\n');
// Count total EXTINF lines to estimate total channels
int totalExtinfLines = 0;
for (final line in lines) {
if (line.trim().startsWith('#EXTINF:')) {
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)) {}
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);
}
return streams;
}
/// Saves text content to a file in the Downloads directory
Future<String> 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);
return filePath;
} catch (e) {
throw Exception('Error al guardar archivo: $e');
}
}
}