4 Commits
v1.0.0 ... main

Author SHA1 Message Date
5351513619 v1.1.2: Channel name formatting and Live TV search optimization
## New Features

### lib/utils/channel_name_formatter.dart (NEW)
- Created new utility class for formatting channel names
- Removes quality tokens (SD, HD, FHD, UHD, 4K, 8K, HDR, HEVC, H264, H265, FULLHD)
- Strips prefixes before pipe character (e.g., "AR | Channel" → "Channel")
- Removes leading dashes, colons, and other separators
- Implements caching mechanism (max 50,000 entries) for performance
- Normalizes tokens by removing non-alphanumeric characters

## UI/UX Improvements

### lib/screens/home_screen.dart
- **Live TV Memory Optimization**: Live streams list now persists in memory while app is running
  - Prevents unnecessary reloads when navigating back to Live TV
  - Improves performance and reduces API calls
- **Search Bar Visibility**: Hidden search bar for Live TV content type
  - Search only shown for Movies and Series
  - Cleaner UI for Live TV browsing
- **Channel Name Display**: Applied ChannelNameFormatter to channel cards
  - Removes quality indicators from displayed names
  - Better text styling with centered alignment
  - Increased font weight (w500 → w600)
  - Improved line height (1.15) for better readability
  - Text alignment changed to center
  - Better overflow handling with ellipsis

### lib/screens/player_screen.dart
- Code cleanup and optimization
- Removed unused imports/statements (9 lines removed)

## Technical Details

### Performance
- Channel name caching reduces string processing overhead
- Live TV list persistence reduces API calls
- Memory-efficient cache with automatic cleanup

### Code Quality
- Separation of concerns with new utility class
- Consistent formatting across channel names
- Better memory management for large channel lists

## Statistics
- 3 files changed
- +141 insertions, -68 deletions
- Net: +73 lines
- New file: lib/utils/channel_name_formatter.dart

## Breaking Changes
None - all changes are additive or UI improvements
2026-02-26 00:28:03 -03:00
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
5d38b89a53 v1.0.2: Fix syntax error and complete Android TV remote navigation 2026-02-25 23:33:37 -03:00
19b45152f8 v1.0.1: Fix Android TV remote control navigation and focus indicators 2026-02-25 22:59:44 -03:00
13 changed files with 5606 additions and 463 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<application
android:label="XStream TV"

View File

@@ -26,6 +26,7 @@ class XtreamStream {
final String? plot;
final String? rating;
final String? containerExtension;
final String? categoryId;
String? url;
XtreamStream({
@@ -35,6 +36,7 @@ class XtreamStream {
this.plot,
this.rating,
this.containerExtension,
this.categoryId,
this.url,
});
@@ -46,6 +48,7 @@ class XtreamStream {
plot: json['plot']?.toString(),
rating: json['rating']?.toString(),
containerExtension: json['container_extension']?.toString(),
categoryId: json['category_id']?.toString(),
url: null,
);
}
@@ -58,6 +61,7 @@ class XtreamStream {
'plot': plot,
'rating': rating,
'container_extension': containerExtension,
'category_id': categoryId,
'url': url,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
import '../models/xtream_models.dart';
@@ -8,18 +7,14 @@ class PlayerScreen extends StatefulWidget {
final XtreamStream stream;
final bool isLive;
const PlayerScreen({
super.key,
required this.stream,
this.isLive = true,
});
const PlayerScreen({super.key, required this.stream, this.isLive = true});
@override
State<PlayerScreen> createState() => _PlayerScreenState();
}
class _PlayerScreenState extends State<PlayerScreen> {
late VideoPlayerController _videoController;
VideoPlayerController? _videoController;
ChewieController? _chewieController;
bool _isLoading = true;
String? _error;
@@ -32,20 +27,32 @@ class _PlayerScreenState extends State<PlayerScreen> {
Future<void> _initPlayer() async {
try {
_chewieController?.dispose();
_chewieController = null;
await _videoController?.dispose();
_videoController = null;
final url = widget.stream.url;
if (url == null || url.isEmpty) {
throw Exception('No stream URL available');
}
_videoController = VideoPlayerController.networkUrl(Uri.parse(url));
await _videoController.initialize();
final videoController = VideoPlayerController.networkUrl(
Uri.parse(url),
videoPlayerOptions: VideoPlayerOptions(
allowBackgroundPlayback: false,
mixWithOthers: false,
),
);
await videoController.initialize();
_videoController = videoController;
_chewieController = ChewieController(
videoPlayerController: _videoController,
videoPlayerController: videoController,
autoPlay: true,
looping: widget.isLive,
aspectRatio: _videoController.value.aspectRatio,
aspectRatio: videoController.value.aspectRatio,
allowFullScreen: true,
allowMuting: true,
showControls: true,
@@ -76,10 +83,6 @@ class _PlayerScreenState extends State<PlayerScreen> {
setState(() {
_isLoading = false;
});
_videoController.addListener(() {
setState(() {});
});
} catch (e) {
setState(() {
_error = e.toString();
@@ -90,7 +93,7 @@ class _PlayerScreenState extends State<PlayerScreen> {
@override
void dispose() {
_videoController.dispose();
_videoController?.dispose();
_chewieController?.dispose();
super.dispose();
}
@@ -99,26 +102,17 @@ class _PlayerScreenState extends State<PlayerScreen> {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
widget.stream.name,
style: const TextStyle(color: Colors.white),
),
iconTheme: const IconThemeData(color: Colors.white),
),
body: Center(
child: _isLoading
? const CircularProgressIndicator(color: Colors.red)
: _error != null
? _buildError()
: _chewieController != null
? Chewie(controller: _chewieController!)
: const Text(
'No video available',
style: TextStyle(color: Colors.white),
),
? _buildError()
: _chewieController != null
? Chewie(controller: _chewieController!)
: const Text(
'No video available',
style: TextStyle(color: Colors.white),
),
),
);
}

View File

@@ -5,44 +5,156 @@ 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<XtreamCategory> _liveCategories = [];
List<XtreamCategory> _vodCategories = [];
List<XtreamCategory> _seriesCategories = [];
List<XtreamStream> _liveStreams = [];
List<XtreamStream> _vodStreams = [];
List<XtreamSeries> _seriesList = [];
List<XtreamEpisode> _seriesEpisodes = [];
String _selectedLiveCategory = '';
String _selectedVodCategory = '';
String _selectedCountry = '';
String _selectedCategory =
''; // For special categories like "Fútbol Argentino"
List<String> _countries = [];
bool _isOrganizingCountries = false;
XtreamSeries? _selectedSeries;
Map<String, String>? _categoryToCountryMapCache;
List<XtreamStream>? _filteredLiveStreamsCache;
String _filteredCountryCacheKey = '';
String _filteredCategoryCacheKey = '';
int _liveStreamsVersion = 0;
int _filteredCacheVersion = -1;
int _lastProgressUiUpdateMs = 0;
bool get isLoading => _isLoading;
String? get error => _error;
XtreamUserInfo? get userInfo => _userInfo;
XtreamApiService get api => _api;
int get loadedChannels => _loadedChannels;
int get totalChannels => _totalChannels;
double get loadingProgress =>
_totalChannels > 0 ? _loadedChannels / _totalChannels : 0.0;
bool get isOrganizingCountries => _isOrganizingCountries;
List<XtreamCategory> get liveCategories => _liveCategories;
List<XtreamCategory> get vodCategories => _vodCategories;
List<XtreamCategory> get seriesCategories => _seriesCategories;
List<XtreamStream> get liveStreams => _liveStreams;
List<XtreamStream> get vodStreams => _vodStreams;
List<XtreamSeries> get seriesList => _seriesList;
List<XtreamEpisode> get seriesEpisodes => _seriesEpisodes;
String get selectedLiveCategory => _selectedLiveCategory;
String get selectedVodCategory => _selectedVodCategory;
String get selectedCountry => _selectedCountry;
String get selectedCategory => _selectedCategory;
List<String> get countries => _countries;
/// Get display items for sidebar including special categories
/// Returns a list of maps with 'name', 'type', and 'priority' for proper ordering
List<Map<String, dynamic>> get sidebarItems {
final items = <Map<String, dynamic>>[];
// 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;
void _invalidateLiveDerivedCaches() {
_categoryToCountryMapCache = null;
_filteredLiveStreamsCache = null;
_filteredCacheVersion = -1;
}
void _setLiveStreams(List<XtreamStream> streams, String categoryId) {
_liveStreams = streams;
_selectedLiveCategory = categoryId;
_liveStreamsVersion++;
_invalidateLiveDerivedCaches();
}
void _notifyProgressUpdate() {
final nowMs = DateTime.now().millisecondsSinceEpoch;
final shouldUpdate =
(nowMs - _lastProgressUiUpdateMs) >= 120 ||
(_totalChannels > 0 && _loadedChannels >= _totalChannels);
if (shouldUpdate) {
_lastProgressUiUpdateMs = nowMs;
notifyListeners();
}
}
Future<void> login(String server, String username, String password) async {
_isLoading = true;
_error = null;
@@ -51,8 +163,9 @@ class IPTVProvider extends ChangeNotifier {
try {
_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();
@@ -62,23 +175,51 @@ class IPTVProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> _loadCategories() async {
try {
_liveCategories = await _api.getLiveCategories();
_vodCategories = await _api.getVodCategories();
_seriesCategories = await _api.getSeriesCategories();
} catch (e) {
_error = e.toString();
}
}
Future<void> loadLiveStreams([String categoryId = '']) async {
_isLoading = true;
_isOrganizingCountries = false;
_loadedChannels = 0;
_totalChannels = 0;
_countries = [];
_lastProgressUiUpdateMs = 0;
_invalidateLiveDerivedCaches();
notifyListeners();
try {
_liveStreams = await _api.getLiveStreams(categoryId);
_selectedLiveCategory = categoryId;
// STEP 1: Load from API first (much faster than M3U)
try {
_setLiveStreams(await _api.getLiveStreams(categoryId), categoryId);
_totalChannels = _liveStreams.length;
_loadedChannels = _liveStreams.length;
if (_liveStreams.isEmpty) {
throw Exception('API returned 0 streams');
}
} catch (apiError) {
// Fallback to M3U only if API fails
_setLiveStreams(
await _api.getM3UStreams(
onProgress: (loaded, total) {
_loadedChannels = loaded;
_totalChannels = total;
_notifyProgressUpdate();
},
),
categoryId,
);
if (_liveStreams.isEmpty) {
throw Exception('No channels available from API or M3U');
}
}
// STEP 2: Mark loading complete - channels ready to display
_isLoading = false;
notifyListeners();
// STEP 3: Extract countries in background (using optimized method)
_extractCountriesInBackground();
} catch (e) {
_error = e.toString();
}
@@ -87,13 +228,169 @@ class IPTVProvider extends ChangeNotifier {
notifyListeners();
}
/// Extract countries from streams in the background to avoid UI freezing
void _extractCountriesInBackground() {
if (_liveStreams.isEmpty) return;
_isOrganizingCountries = true;
notifyListeners();
// 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,
);
} catch (e) {
_countries = [];
} finally {
_isOrganizingCountries = false;
notifyListeners();
}
});
}
/// 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<String, String> _buildCategoryToCountryMap() {
if (_categoryToCountryMapCache != null) {
return _categoryToCountryMapCache!;
}
final map = <String, String>{};
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;
}
}
_categoryToCountryMapCache = map;
return map;
}
void filterByCountry(String country) {
final normalizedCountry = country.trim();
if (_selectedCountry == normalizedCountry && _selectedCategory.isEmpty) {
return;
}
_selectedCountry = normalizedCountry;
_selectedCategory = ''; // Clear special category when country is selected
_filteredLiveStreamsCache = null;
notifyListeners();
}
void filterByCategory(String category) {
final normalizedCategory = category.trim();
if (_selectedCategory == normalizedCategory && _selectedCountry.isEmpty) {
return;
}
_selectedCategory = normalizedCategory;
_selectedCountry = ''; // Clear country when special category is selected
_filteredLiveStreamsCache = null;
notifyListeners();
}
List<XtreamStream> get filteredLiveStreams {
final selectedCountry = _selectedCountry.trim();
final selectedCategory = _selectedCategory.trim();
if (_filteredLiveStreamsCache != null &&
_filteredCacheVersion == _liveStreamsVersion &&
_filteredCountryCacheKey == selectedCountry &&
_filteredCategoryCacheKey == selectedCategory) {
return _filteredLiveStreamsCache!;
}
late final List<XtreamStream> result;
// If a special category is selected, filter by that
if (selectedCategory.isNotEmpty) {
result = _api.filterByCategory(_liveStreams, selectedCategory);
} else if (selectedCountry.isEmpty ||
selectedCountry.toLowerCase() == 'todos' ||
selectedCountry.toLowerCase() == 'all') {
result = _liveStreams;
} else {
// Build category map for API streams that don't have country in name
final categoryMap = _buildCategoryToCountryMap();
result = _api.filterByCountry(
_liveStreams,
selectedCountry,
categoryToCountryMap: categoryMap,
);
}
_filteredCountryCacheKey = selectedCountry;
_filteredCategoryCacheKey = selectedCategory;
_filteredCacheVersion = _liveStreamsVersion;
_filteredLiveStreamsCache = identical(result, _liveStreams)
? _liveStreams
: List.unmodifiable(result);
return _filteredLiveStreamsCache!;
}
Future<void> loadVodStreams([String categoryId = '']) async {
_isLoading = true;
notifyListeners();
try {
_vodStreams = await _api.getVodStreams(categoryId);
_selectedVodCategory = categoryId;
} catch (e) {
_error = e.toString();
}
@@ -131,12 +428,193 @@ class IPTVProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> reloadM3UStreams() async {
_isLoading = true;
_isOrganizingCountries = false;
_error = null;
_loadedChannels = 0;
_totalChannels = 0;
_countries = [];
_lastProgressUiUpdateMs = 0;
_invalidateLiveDerivedCaches();
notifyListeners();
try {
// Try API first, then M3U fallback
try {
_setLiveStreams(await _api.getLiveStreams(''), '');
_totalChannels = _liveStreams.length;
_loadedChannels = _liveStreams.length;
} catch (apiError) {
_setLiveStreams(
await _api.getM3UStreams(
onProgress: (loaded, total) {
_loadedChannels = loaded;
_totalChannels = total;
_notifyProgressUpdate();
},
),
'',
);
}
// Mark loading as complete - channels are ready to display
_isLoading = false;
notifyListeners();
// Extract countries in background (optimized)
_extractCountriesInBackground();
} catch (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<String> downloadAndSaveM3UAsJson() async {
_isLoading = true;
_error = null;
notifyListeners();
try {
// If we already have streams loaded, save those instead of downloading again
if (_liveStreams.isNotEmpty) {
// 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);
_isLoading = false;
notifyListeners();
return filePath;
}
// If no streams loaded, try to download
final result = await _api.downloadM3UAsJson();
// Save as JSON file
final filePath = await _api.saveM3UAsJson(result);
_isLoading = false;
notifyListeners();
return filePath;
} catch (e) {
_error = 'Error al descargar playlist: $e';
_isLoading = false;
notifyListeners();
throw Exception(_error);
}
}
Map<String, int> _groupChannelsByCountry(List<M3UChannel> channels) {
final groups = <String, int>{};
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<String> saveChannelsAsText() async {
try {
// 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 = <String, List<XtreamStream>>{};
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());
return filePath;
} catch (e) {
throw Exception('Error al guardar lista: $e');
}
}
void clearError() {
_error = null;
notifyListeners();
}
Future<void> _saveCredentials(String server, String username, String password) async {
Future<void> _saveCredentials(
String server,
String username,
String password,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('server', server);
await prefs.setString('username', username);
@@ -161,7 +639,7 @@ class IPTVProvider extends ChangeNotifier {
await prefs.remove('server');
await prefs.remove('username');
await prefs.remove('password');
_userInfo = null;
_liveCategories = [];
_vodCategories = [];
@@ -169,6 +647,13 @@ class IPTVProvider extends ChangeNotifier {
_liveStreams = [];
_vodStreams = [];
_seriesList = [];
_countries = [];
_selectedLiveCategory = '';
_selectedCountry = '';
_selectedCategory = '';
_isOrganizingCountries = false;
_liveStreamsVersion = 0;
_invalidateLiveDerivedCaches();
notifyListeners();
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
class ChannelNameFormatter {
static final Map<String, String> _cache = <String, String>{};
static const Set<String> _qualityTokens = <String>{
'SD',
'HD',
'FHD',
'UHD',
'4K',
'8K',
'HDR',
'HEVC',
'H264',
'H265',
'FULLHD',
};
static String forDisplay(String rawName) {
if (rawName.isEmpty) return rawName;
final cached = _cache[rawName];
if (cached != null) return cached;
var display = rawName.trim();
final pipeIndex = display.indexOf('|');
if (pipeIndex >= 0 && pipeIndex < display.length - 1) {
display = display.substring(pipeIndex + 1).trim();
}
display = display.replaceFirst(RegExp(r'^[\-\\—:]+'), '').trim();
if (display.isNotEmpty) {
final parts = display.split(RegExp(r'\s+')).toList(growable: true);
while (parts.isNotEmpty && _isQualityToken(parts.last)) {
parts.removeLast();
}
display = parts.join(' ').trim();
}
if (display.isEmpty) {
display = rawName.trim();
}
if (_cache.length > 50000) {
_cache.clear();
}
_cache[rawName] = display;
return display;
}
static bool _isQualityToken(String token) {
final normalized = token.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
return _qualityTokens.contains(normalized);
}
}

View File

@@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
class CountriesSidebar extends StatelessWidget {
final List<String> countries;
final String selectedCountry;
final String title;
final ValueChanged<String> 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) {
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),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
class SimpleCountriesSidebar extends StatelessWidget {
final List<String> countries;
final String selectedCountry;
final ValueChanged<String> 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) {
if (countries.isNotEmpty) {
for (int i = 0; i < countries.length && i < 10; i++) {}
}
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 FocusableActionDetector(
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (intent) {
onTap();
return null;
},
),
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(
onInvoke: (intent) {
onTap();
return null;
},
),
},
child: Builder(
builder: (context) {
final hasFocus = Focus.of(context).hasFocus;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? Colors.red
: (hasFocus
? Colors.red.withValues(alpha: 0.5)
: Colors.transparent),
border: Border(
left: BorderSide(
color: isSelected
? Colors.white
: (hasFocus ? 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 FocusableActionDetector(
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (intent) {
onFootballSelected?.call();
return null;
},
),
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(
onInvoke: (intent) {
onFootballSelected?.call();
return null;
},
),
},
child: Builder(
builder: (context) {
final hasFocus = Focus.of(context).hasFocus;
return GestureDetector(
onTap: onFootballSelected,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? Colors.green[700]
: (hasFocus
? Colors.green[700]?.withValues(alpha: 0.8)
: Colors.green[900]?.withValues(alpha: 0.3)),
border: Border(
left: BorderSide(
color: isSelected
? Colors.white
: (hasFocus ? Colors.white : Colors.green[400]!),
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,
),
),
),
],
),
),
);
},
),
);
}
}

View File

@@ -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:

View File

@@ -1,30 +1,21 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:xstream_tv/main.dart';
import 'package:xstream_tv/screens/login_screen.dart';
import 'package:xstream_tv/services/iptv_provider.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
testWidgets('renders login screen', (WidgetTester tester) async {
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => IPTVProvider(),
child: const MaterialApp(home: LoginScreen()),
),
);
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
expect(find.text('XStream TV'), findsOneWidget);
expect(find.text('IPTV Player for Android TV'), findsOneWidget);
expect(find.text('Login'), findsOneWidget);
});
}