Files
iptv-ren/lib/screens/home_screen.dart
renato97 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

1196 lines
38 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../services/iptv_provider.dart';
import '../models/xtream_models.dart';
import '../utils/channel_name_formatter.dart';
import 'player_screen.dart';
import '../widgets/simple_countries_sidebar.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
// No automatic data loading on startup
}
double get _screenWidth => MediaQuery.of(context).size.width;
bool get _isLargeScreen => _screenWidth > 900;
double get _headerPadding => _isLargeScreen ? 32 : 24;
void _showLiveCategories() {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ContentListScreen(type: ContentType.live),
),
);
}
Future<void> _refreshChannels() async {
final provider = context.read<IPTVProvider>();
await provider.reloadM3UStreams();
}
Future<void> _downloadPlaylistAsJson() async {
final provider = context.read<IPTVProvider>();
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<IPTVProvider>();
// Cargar películas bajo demanda
if (provider.vodStreams.isEmpty) {
await provider.loadVodStreams();
}
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ContentListScreen(type: ContentType.movies),
),
);
}
void _showSeries() async {
final provider = context.read<IPTVProvider>();
// Cargar series bajo demanda
if (provider.seriesList.isEmpty) {
await provider.loadSeries();
}
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ContentListScreen(type: ContentType.series),
),
);
}
String _formatExpiry(int? timestamp) {
if (timestamp == null) return 'Ilimitado';
try {
final date = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
return DateFormat('dd MMM yyyy').format(date);
} catch (_) {
return 'Ilimitado';
}
}
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final dateStr = DateFormat('EEEE, dd MMMM').format(now);
final timeStr = DateFormat('HH:mm').format(now);
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.5,
colors: [Color(0xFF1a1a2e), Color(0xFF0f0f1a)],
),
),
child: SafeArea(
child: Column(
children: [
_buildHeader(timeStr, dateStr),
Expanded(child: _buildDashboard()),
_buildFooter(),
],
),
),
),
);
}
Widget _buildHeader(String timeStr, String dateStr) {
final double titleSize = _isLargeScreen ? 28.0 : 24.0;
final double iconSize = _isLargeScreen ? 40.0 : 32.0;
return Padding(
padding: EdgeInsets.symmetric(
horizontal: _headerPadding,
vertical: _isLargeScreen ? 24 : 16,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.live_tv, color: Colors.red, size: iconSize),
const SizedBox(width: 12),
Text(
'XSTREAM TV',
style: TextStyle(
color: Colors.white,
fontSize: titleSize,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
],
),
Row(
children: [
Text(
timeStr,
style: TextStyle(
color: Colors.white70,
fontSize: _isLargeScreen ? 20 : 16,
),
),
const SizedBox(width: 16),
Text(
dateStr,
style: TextStyle(
color: Colors.white54,
fontSize: _isLargeScreen ? 16 : 14,
),
),
const SizedBox(width: 24),
Icon(
Icons.person,
color: Colors.white70,
size: _isLargeScreen ? 32 : 24,
),
const SizedBox(width: 16),
Consumer<IPTVProvider>(
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<IPTVProvider>().logout();
},
),
);
},
),
),
],
),
],
),
);
}
Widget _buildDashboard() {
final double cardSpacing = _isLargeScreen ? 24.0 : 16.0;
return Padding(
padding: EdgeInsets.symmetric(horizontal: _headerPadding),
child: Row(
children: [
Expanded(
flex: 2,
child: _DashboardCard(
title: 'LIVE TV',
icon: Icons.tv,
isLarge: _isLargeScreen,
gradient: const LinearGradient(
colors: [Color(0xFF00c853), Color(0xFF2979ff)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
onTap: _showLiveCategories,
),
),
SizedBox(width: cardSpacing),
Expanded(
child: Column(
children: [
Expanded(
child: _DashboardCard(
title: 'MOVIES',
icon: Icons.play_circle_fill,
isLarge: _isLargeScreen,
gradient: const LinearGradient(
colors: [Color(0xFFff5252), Color(0xFFff9800)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
onTap: _showMovies,
),
),
SizedBox(height: cardSpacing),
Expanded(
child: _DashboardCard(
title: 'SERIES',
icon: Icons.tv,
isLarge: _isLargeScreen,
gradient: const LinearGradient(
colors: [Color(0xFF9c27b0), Color(0xFF03a9f4)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
onTap: _showSeries,
),
),
],
),
),
],
),
);
}
Widget _buildFooter() {
final double footerPadding = _isLargeScreen ? 32.0 : 24.0;
final double fontSize = _isLargeScreen ? 16.0 : 12.0;
return Consumer<IPTVProvider>(
builder: (context, provider, _) {
final expDate = provider.userInfo?.expDate;
final username = provider.userInfo?.username ?? 'Usuario';
return Padding(
padding: EdgeInsets.all(footerPadding),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Expiración: ${_formatExpiry(expDate)}',
style: TextStyle(color: Colors.white38, fontSize: fontSize),
),
Text(
'Términos de Servicio',
style: TextStyle(color: Colors.white38, fontSize: fontSize),
),
Text(
'Usuario: $username',
style: TextStyle(color: Colors.white38, fontSize: fontSize),
),
],
),
);
},
);
}
}
class _DashboardCard extends StatefulWidget {
final String title;
final IconData icon;
final Gradient gradient;
final VoidCallback onTap;
final bool isLarge;
const _DashboardCard({
required this.title,
required this.icon,
required this.gradient,
required this.onTap,
this.isLarge = false,
});
@override
State<_DashboardCard> createState() => _DashboardCardState();
}
class _DashboardCardState extends State<_DashboardCard> {
bool _hasFocus = false;
void _handleTap() {
widget.onTap();
}
@override
Widget build(BuildContext context) {
final iconSize = widget.isLarge ? 80.0 : 60.0;
final titleSize = widget.isLarge ? 32.0 : 24.0;
final bgIconSize = widget.isLarge ? 200.0 : 150.0;
return FocusableActionDetector(
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (intent) {
_handleTap();
return null;
},
),
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(
onInvoke: (intent) {
_handleTap();
return null;
},
),
},
onFocusChange: (hasFocus) {
setState(() {
_hasFocus = hasFocus;
});
},
child: GestureDetector(
onTap: _handleTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
gradient: widget.gradient,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: _hasFocus
? Colors.white.withValues(alpha: 0.6)
: Colors.black.withValues(alpha: 0.3),
blurRadius: _hasFocus ? 35 : 15,
spreadRadius: _hasFocus ? 6 : 0,
offset: const Offset(0, 8),
),
],
border: _hasFocus
? Border.all(color: Colors.white, width: 5)
: Border.all(color: Colors.transparent, width: 5),
),
child: Stack(
children: [
Positioned(
right: -40,
bottom: -40,
child: Icon(
widget.icon,
size: bgIconSize,
color: Colors.white.withValues(alpha: 0.1),
),
),
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(widget.icon, size: iconSize, color: Colors.white),
const SizedBox(height: 16),
Text(
widget.title,
style: TextStyle(
color: Colors.white,
fontSize: titleSize,
fontWeight: FontWeight.bold,
letterSpacing: 2,
),
),
],
),
),
],
),
),
),
);
}
}
enum ContentType { live, movies, series }
class ContentListScreen extends StatefulWidget {
final ContentType type;
const ContentListScreen({super.key, required this.type});
@override
State<ContentListScreen> createState() => _ContentListScreenState();
}
class _ContentListScreenState extends State<ContentListScreen> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
String? _selectedCountry;
List<XtreamStream>? _lastSearchSource;
String _lastSearchQuery = '';
List<XtreamStream>? _lastSearchResults;
@override
void initState() {
super.initState();
_loadContent();
}
double get _screenWidth => MediaQuery.of(context).size.width;
bool get _isLargeScreen => _screenWidth > 900;
bool get _isMediumScreen => _screenWidth > 600 && _screenWidth <= 900;
int get _gridCrossAxisCount {
if (_screenWidth > 900) return 6;
if (_screenWidth > 600) return 4;
return 3;
}
double get _titleFontSize =>
_isLargeScreen ? 32 : (_isMediumScreen ? 28 : 24);
double get _headerPadding => _isLargeScreen ? 32 : 16;
void _loadContent() {
final provider = context.read<IPTVProvider>();
if (widget.type == ContentType.live) {
// Keep live list in memory while app is running.
if (provider.liveStreams.isEmpty) {
provider.loadLiveStreams(_selectedCountry ?? '');
}
} else if (widget.type == ContentType.movies) {
provider.loadVodStreams();
} else {
provider.loadSeries();
}
}
void _onFootballSelected() {
final provider = context.read<IPTVProvider>();
provider.filterByCategory(SpecialCategories.argentineFootball);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
String get _title {
switch (widget.type) {
case ContentType.live:
return 'TV en Vivo';
case ContentType.movies:
return 'Películas';
case ContentType.series:
return 'Series';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0f0f1a),
body: SafeArea(
child: Column(
children: [
_buildHeader(),
if (widget.type == ContentType.live)
Expanded(
child: Row(
children: [
_buildCountrySidebar(),
Expanded(child: _buildContentList()),
],
),
)
else
Expanded(child: _buildContentList()),
],
),
),
);
}
Widget _buildHeader() {
final searchWidth = _isLargeScreen
? 350.0
: (_isMediumScreen ? 300.0 : 250.0);
final searchHeight = _isLargeScreen ? 56.0 : 44.0;
final iconSize = _isLargeScreen ? 32.0 : 24.0;
final showSearch = widget.type != ContentType.live;
return Container(
padding: EdgeInsets.all(_headerPadding),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white, size: iconSize),
onPressed: () => Navigator.pop(context),
iconSize: 48,
padding: EdgeInsets.all(_isLargeScreen ? 12 : 8),
),
const SizedBox(width: 8),
Text(
_title,
style: TextStyle(
color: Colors.white,
fontSize: _titleFontSize,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (showSearch)
SizedBox(
width: searchWidth,
height: searchHeight,
child: TextField(
controller: _searchController,
style: TextStyle(
color: Colors.white,
fontSize: _isLargeScreen ? 18 : 14,
),
decoration: InputDecoration(
hintText: 'Buscar...',
hintStyle: TextStyle(
color: Colors.grey,
fontSize: _isLargeScreen ? 18 : 14,
),
prefixIcon: Icon(
Icons.search,
color: Colors.grey,
size: _isLargeScreen ? 28 : 20,
),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
color: Colors.grey,
size: _isLargeScreen ? 28 : 20,
),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
_lastSearchResults = null;
});
},
)
: null,
filled: true,
fillColor: Colors.grey[900],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: _isLargeScreen ? 16 : 12,
),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
],
),
);
}
List<XtreamStream> _buildStreamsForType(IPTVProvider provider) {
if (widget.type == ContentType.live) {
return provider.filteredLiveStreams;
}
if (widget.type == ContentType.movies) {
return provider.vodStreams;
}
return provider.seriesList
.map(
(s) => XtreamStream(
streamId: s.seriesId,
name: s.name,
streamIcon: s.cover,
plot: s.plot,
rating: s.rating,
),
)
.toList(growable: false);
}
List<XtreamStream> _applySearchFilter(List<XtreamStream> streams) {
if (_searchQuery.isEmpty) {
_lastSearchSource = streams;
_lastSearchQuery = '';
_lastSearchResults = streams;
return streams;
}
if (identical(streams, _lastSearchSource) &&
_searchQuery == _lastSearchQuery &&
_lastSearchResults != null) {
return _lastSearchResults!;
}
final query = _searchQuery.toLowerCase();
final filtered = streams
.where((stream) {
final name = stream.name.toLowerCase();
if (query == 'arg|') {
return name.contains('arg|');
}
return name.contains(query);
})
.toList(growable: false);
_lastSearchSource = streams;
_lastSearchQuery = _searchQuery;
_lastSearchResults = filtered;
return filtered;
}
Widget _buildCountrySidebar() {
return Consumer<IPTVProvider>(
builder: (context, provider, _) {
final countries = provider.countries;
return SimpleCountriesSidebar(
countries: countries,
selectedCountry: provider.selectedCategory.isNotEmpty
? provider.selectedCategory
: provider.selectedCountry,
onCountrySelected: (country) => provider.filterByCountry(country),
isLoading: provider.isLoading,
isOrganizing: provider.isOrganizingCountries,
showFootballCategory: true,
onFootballSelected: () => _onFootballSelected(),
);
},
);
}
Widget _buildContentList() {
final padding = _isLargeScreen ? 24.0 : 16.0;
final spacing = _isLargeScreen ? 16.0 : 12.0;
return Consumer<IPTVProvider>(
builder: (context, provider, _) {
if (provider.isLoading) {
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<Color>(
Colors.red,
),
),
),
],
),
);
}
final baseStreams = _buildStreamsForType(provider);
final streams = _applySearchFilter(baseStreams);
if (streams.isEmpty) {
return Center(
child: Text(
_searchQuery.isNotEmpty
? 'No se encontraron resultados'
: 'Sin contenido',
style: TextStyle(
color: Colors.grey,
fontSize: _isLargeScreen ? 20 : 16,
),
),
);
}
return GridView.builder(
padding: EdgeInsets.all(padding),
cacheExtent: _isLargeScreen ? 1600 : 1100,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _gridCrossAxisCount,
childAspectRatio: 16 / 9,
crossAxisSpacing: spacing,
mainAxisSpacing: spacing,
),
itemCount: streams.length,
itemBuilder: (context, index) {
final stream = streams[index];
return _ChannelCard(
stream: stream,
isSeries: widget.type == ContentType.series,
isLarge: _isLargeScreen,
onTap: () {
if (widget.type == ContentType.series) {
final series = provider.seriesList.firstWhere(
(s) => s.seriesId == stream.streamId,
);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => SeriesEpisodesScreen(series: series),
),
);
} else {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerScreen(
stream: stream,
isLive: widget.type == ContentType.live,
),
),
);
}
},
);
},
);
},
);
}
}
class _ChannelCard extends StatefulWidget {
final XtreamStream stream;
final bool isSeries;
final VoidCallback onTap;
final bool isLarge;
const _ChannelCard({
required this.stream,
required this.isSeries,
required this.onTap,
this.isLarge = false,
});
@override
State<_ChannelCard> createState() => _ChannelCardState();
}
class _ChannelCardState extends State<_ChannelCard> {
bool _hasFocus = false;
void _handleTap() {
widget.onTap();
}
@override
Widget build(BuildContext context) {
final textSize = widget.isLarge ? 16.0 : 12.0;
final ratingFontSize = widget.isLarge ? 14.0 : 10.0;
final placeholderIconSize = widget.isLarge ? 56.0 : 40.0;
final padding = widget.isLarge ? 12.0 : 8.0;
final ratingPaddingH = widget.isLarge ? 10.0 : 6.0;
final ratingPaddingV = widget.isLarge ? 4.0 : 2.0;
return FocusableActionDetector(
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (intent) {
_handleTap();
return null;
},
),
ButtonActivateIntent: CallbackAction<ButtonActivateIntent>(
onInvoke: (intent) {
_handleTap();
return null;
},
),
},
onFocusChange: (hasFocus) {
setState(() {
_hasFocus = hasFocus;
});
},
child: GestureDetector(
onTap: _handleTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _hasFocus ? Colors.red : Colors.red.withValues(alpha: 0.3),
width: _hasFocus ? 3 : 1,
),
boxShadow: _hasFocus
? [
BoxShadow(
color: Colors.red.withValues(alpha: 0.3),
blurRadius: 15,
spreadRadius: 2,
),
]
: null,
),
child: Stack(
children: [
if (widget.stream.streamIcon != null &&
widget.stream.streamIcon!.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
widget.stream.streamIcon!,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
cacheWidth: widget.isLarge ? 512 : 384,
cacheHeight: widget.isLarge ? 288 : 216,
filterQuality: FilterQuality.low,
gaplessPlayback: false,
errorBuilder: (context, error, stackTrace) =>
_buildPlaceholder(placeholderIconSize),
),
)
else
_buildPlaceholder(placeholderIconSize),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.8),
],
),
),
),
Positioned(
left: padding,
right: padding,
bottom: padding,
child: SizedBox(
height: textSize * 2.8,
child: Center(
child: Text(
ChannelNameFormatter.forDisplay(widget.stream.name),
style: TextStyle(
color: Colors.white,
fontSize: textSize,
fontWeight: FontWeight.w600,
height: 1.15,
),
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
),
),
),
if (widget.stream.rating != null)
Positioned(
top: padding,
right: padding,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: ratingPaddingH,
vertical: ratingPaddingV,
),
decoration: BoxDecoration(
color: Colors.amber,
borderRadius: BorderRadius.circular(6),
),
child: Text(
widget.stream.rating!,
style: TextStyle(
color: Colors.black,
fontSize: ratingFontSize,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
);
}
Widget _buildPlaceholder(double iconSize) {
return Container(
color: Colors.grey[800],
child: Center(
child: Icon(
widget.isSeries ? Icons.tv : Icons.play_circle_outline,
color: Colors.red,
size: iconSize,
),
),
);
}
}
class SeriesEpisodesScreen extends StatefulWidget {
final XtreamSeries series;
const SeriesEpisodesScreen({super.key, required this.series});
@override
State<SeriesEpisodesScreen> createState() => _SeriesEpisodesScreenState();
}
class _SeriesEpisodesScreenState extends State<SeriesEpisodesScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<IPTVProvider>().loadSeriesEpisodes(widget.series);
});
}
double get _screenWidth => MediaQuery.of(context).size.width;
bool get _isLargeScreen => _screenWidth > 900;
@override
Widget build(BuildContext context) {
final double fontSize = _isLargeScreen ? 24.0 : 20.0;
final double iconSize = _isLargeScreen ? 56.0 : 40.0;
final double padding = _isLargeScreen ? 24.0 : 16.0;
return Scaffold(
backgroundColor: const Color(0xFF0f0f1a),
body: SafeArea(
child: Column(
children: [
Container(
padding: EdgeInsets.all(padding),
child: Row(
children: [
IconButton(
icon: Icon(
Icons.arrow_back,
color: Colors.white,
size: iconSize,
),
onPressed: () => Navigator.pop(context),
iconSize: 48,
padding: EdgeInsets.all(_isLargeScreen ? 12 : 8),
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.series.name,
style: TextStyle(
color: Colors.white,
fontSize: fontSize,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
Expanded(
child: Consumer<IPTVProvider>(
builder: (context, provider, _) {
if (provider.isLoading) {
return Center(
child: CircularProgressIndicator(
color: Colors.red,
strokeWidth: _isLargeScreen ? 4 : 2,
),
);
}
final episodes = provider.seriesEpisodes;
if (episodes.isEmpty) {
return Center(
child: Text(
'No hay episodios',
style: TextStyle(
color: Colors.grey,
fontSize: _isLargeScreen ? 20 : 16,
),
),
);
}
return ListView.builder(
padding: EdgeInsets.all(padding),
itemCount: episodes.length,
itemBuilder: (context, index) {
final episode = episodes[index];
return Card(
color: Colors.grey[900],
margin: EdgeInsets.only(
bottom: _isLargeScreen ? 16 : 8,
),
child: ListTile(
contentPadding: EdgeInsets.all(
_isLargeScreen ? 16 : 12,
),
leading: Icon(
Icons.play_circle_fill,
color: Colors.red,
size: iconSize,
),
title: Text(
'S${episode.seasonNumber}E${episode.episodeNumber} - ${episode.title}',
style: TextStyle(
color: Colors.white,
fontSize: _isLargeScreen ? 20 : 16,
),
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerScreen(
stream: XtreamStream(
streamId: episode.episodeId,
name: episode.title,
containerExtension:
episode.containerExtension,
url: episode.url,
),
isLive: false,
),
),
);
},
),
);
},
);
},
),
),
],
),
),
);
}
}