v1.1.0: Major refactoring and improvements
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../services/iptv_provider.dart';
|
||||
@@ -15,8 +14,6 @@ class HomeScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
int _focusedIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -25,22 +22,14 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
double get _screenWidth => MediaQuery.of(context).size.width;
|
||||
bool get _isLargeScreen => _screenWidth > 900;
|
||||
bool get _isMediumScreen => _screenWidth > 600 && _screenWidth <= 900;
|
||||
|
||||
int get _gridCrossAxisCount {
|
||||
if (_screenWidth > 900) return 6;
|
||||
if (_screenWidth > 600) return 4;
|
||||
return 3;
|
||||
}
|
||||
|
||||
double get _titleFontSize => _isLargeScreen ? 32 : (_isMediumScreen ? 28 : 24);
|
||||
double get _iconSize => _isLargeScreen ? 80 : 60;
|
||||
double get _headerPadding => _isLargeScreen ? 32 : 24;
|
||||
|
||||
void _showLiveCategories() {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.live)),
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ContentListScreen(type: ContentType.live),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,19 +40,16 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
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: () {},
|
||||
),
|
||||
action: SnackBarAction(label: 'OK', onPressed: () {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -86,9 +72,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
if (provider.vodStreams.isEmpty) {
|
||||
await provider.loadVodStreams();
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.movies)),
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ContentListScreen(type: ContentType.movies),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,9 +87,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
if (provider.seriesList.isEmpty) {
|
||||
await provider.loadSeries();
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.series)),
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const ContentListScreen(type: ContentType.series),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,10 +118,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
gradient: RadialGradient(
|
||||
center: Alignment.center,
|
||||
radius: 1.5,
|
||||
colors: [
|
||||
Color(0xFF1a1a2e),
|
||||
Color(0xFF0f0f1a),
|
||||
],
|
||||
colors: [Color(0xFF1a1a2e), Color(0xFF0f0f1a)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
@@ -149,7 +138,10 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final double titleSize = _isLargeScreen ? 28.0 : 24.0;
|
||||
final double iconSize = _isLargeScreen ? 40.0 : 32.0;
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: _headerPadding, vertical: _isLargeScreen ? 24 : 16),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: _headerPadding,
|
||||
vertical: _isLargeScreen ? 24 : 16,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -170,11 +162,27 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(timeStr, style: TextStyle(color: Colors.white70, fontSize: _isLargeScreen ? 20 : 16)),
|
||||
Text(
|
||||
timeStr,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: _isLargeScreen ? 20 : 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(dateStr, style: TextStyle(color: Colors.white54, fontSize: _isLargeScreen ? 16 : 14)),
|
||||
Text(
|
||||
dateStr,
|
||||
style: TextStyle(
|
||||
color: Colors.white54,
|
||||
fontSize: _isLargeScreen ? 16 : 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
Icon(Icons.person, color: Colors.white70, size: _isLargeScreen ? 32 : 24),
|
||||
Icon(
|
||||
Icons.person,
|
||||
color: Colors.white70,
|
||||
size: _isLargeScreen ? 32 : 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Consumer<IPTVProvider>(
|
||||
builder: (context, provider, _) {
|
||||
@@ -218,11 +226,18 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
decoration: hasFocus
|
||||
? BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.refresh, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24),
|
||||
icon: Icon(
|
||||
Icons.refresh,
|
||||
color: hasFocus ? Colors.white : Colors.white70,
|
||||
size: _isLargeScreen ? 32 : 24,
|
||||
),
|
||||
onPressed: _refreshChannels,
|
||||
tooltip: 'Actualizar canales',
|
||||
),
|
||||
@@ -245,7 +260,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
)
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.download, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24),
|
||||
icon: Icon(
|
||||
Icons.download,
|
||||
color: hasFocus ? Colors.white : Colors.white70,
|
||||
size: _isLargeScreen ? 32 : 24,
|
||||
),
|
||||
onPressed: () => _downloadPlaylistAsJson(),
|
||||
tooltip: 'Descargar playlist como JSON',
|
||||
),
|
||||
@@ -266,7 +285,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
)
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.settings, color: hasFocus ? Colors.white : Colors.white70, size: _isLargeScreen ? 32 : 24),
|
||||
icon: Icon(
|
||||
Icons.settings,
|
||||
color: hasFocus ? Colors.white : Colors.white70,
|
||||
size: _isLargeScreen ? 32 : 24,
|
||||
),
|
||||
onPressed: () {
|
||||
context.read<IPTVProvider>().logout();
|
||||
},
|
||||
@@ -348,7 +371,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
builder: (context, provider, _) {
|
||||
final expDate = provider.userInfo?.expDate;
|
||||
final username = provider.userInfo?.username ?? 'Usuario';
|
||||
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(footerPadding),
|
||||
child: Row(
|
||||
@@ -400,22 +423,12 @@ class _DashboardCardState extends State<_DashboardCard> {
|
||||
widget.onTap();
|
||||
}
|
||||
|
||||
void _handleKeyEvent(KeyEvent event) {
|
||||
if (event is KeyDownEvent) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter ||
|
||||
event.logicalKey == LogicalKeyboardKey.select ||
|
||||
event.logicalKey == LogicalKeyboardKey.space) {
|
||||
_handleTap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconSize = widget.isLarge ? 80.0 : 60.0;
|
||||
final titleSize = widget.isLarge ? 32.0 : 24.0;
|
||||
final bgIconSize = widget.isLarge ? 200.0 : 150.0;
|
||||
|
||||
|
||||
return FocusableActionDetector(
|
||||
actions: <Type, Action<Intent>>{
|
||||
ActivateIntent: CallbackAction<ActivateIntent>(
|
||||
@@ -445,7 +458,9 @@ class _DashboardCardState extends State<_DashboardCard> {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _hasFocus ? Colors.white.withValues(alpha: 0.6) : Colors.black.withValues(alpha: 0.3),
|
||||
color: _hasFocus
|
||||
? Colors.white.withValues(alpha: 0.6)
|
||||
: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: _hasFocus ? 35 : 15,
|
||||
spreadRadius: _hasFocus ? 6 : 0,
|
||||
offset: const Offset(0, 8),
|
||||
@@ -507,12 +522,14 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
String? _selectedCountry;
|
||||
final FocusNode _gridFocusNode = FocusNode();
|
||||
|
||||
List<XtreamStream>? _lastSearchSource;
|
||||
String _lastSearchQuery = '';
|
||||
List<XtreamStream>? _lastSearchResults;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print('DEBUG: ContentListScreen.initState() - type: ${widget.type}');
|
||||
_loadContent();
|
||||
}
|
||||
|
||||
@@ -526,15 +543,13 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
return 3;
|
||||
}
|
||||
|
||||
double get _titleFontSize => _isLargeScreen ? 32 : (_isMediumScreen ? 28 : 24);
|
||||
double get _cardTextSize => _isLargeScreen ? 16 : 12;
|
||||
double get _titleFontSize =>
|
||||
_isLargeScreen ? 32 : (_isMediumScreen ? 28 : 24);
|
||||
double get _headerPadding => _isLargeScreen ? 32 : 16;
|
||||
|
||||
void _loadContent() {
|
||||
print('DEBUG: _loadContent() called for type: ${widget.type}');
|
||||
final provider = context.read<IPTVProvider>();
|
||||
if (widget.type == ContentType.live) {
|
||||
print('DEBUG: Loading live streams with country filter: "${_selectedCountry ?? ''}"');
|
||||
provider.loadLiveStreams(_selectedCountry ?? '');
|
||||
} else if (widget.type == ContentType.movies) {
|
||||
provider.loadVodStreams();
|
||||
@@ -551,7 +566,6 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_gridFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -566,21 +580,8 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
List<XtreamCategory> get _categories {
|
||||
final provider = context.read<IPTVProvider>();
|
||||
switch (widget.type) {
|
||||
case ContentType.live:
|
||||
return provider.liveCategories;
|
||||
case ContentType.movies:
|
||||
return provider.vodCategories;
|
||||
case ContentType.series:
|
||||
return provider.seriesCategories;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
print('DEBUG: ContentListScreen.build() - type: ${widget.type}, isLive: ${widget.type == ContentType.live}');
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0f0f1a),
|
||||
body: SafeArea(
|
||||
@@ -605,7 +606,9 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final searchWidth = _isLargeScreen ? 350.0 : (_isMediumScreen ? 300.0 : 250.0);
|
||||
final searchWidth = _isLargeScreen
|
||||
? 350.0
|
||||
: (_isMediumScreen ? 300.0 : 250.0);
|
||||
final searchHeight = _isLargeScreen ? 56.0 : 44.0;
|
||||
final iconSize = _isLargeScreen ? 32.0 : 24.0;
|
||||
return Container(
|
||||
@@ -633,17 +636,34 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
height: searchHeight,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
style: TextStyle(color: Colors.white, fontSize: _isLargeScreen ? 18 : 14),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: _isLargeScreen ? 18 : 14,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Buscar...',
|
||||
hintStyle: TextStyle(color: Colors.grey, fontSize: _isLargeScreen ? 18 : 14),
|
||||
prefixIcon: Icon(Icons.search, color: Colors.grey, size: _isLargeScreen ? 28 : 20),
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: _isLargeScreen ? 18 : 14,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: Colors.grey,
|
||||
size: _isLargeScreen ? 28 : 20,
|
||||
),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(Icons.clear, color: Colors.grey, size: _isLargeScreen ? 28 : 20),
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
color: Colors.grey,
|
||||
size: _isLargeScreen ? 28 : 20,
|
||||
),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _searchQuery = '');
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
_lastSearchResults = null;
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
@@ -653,10 +673,15 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: _isLargeScreen ? 16 : 12),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: _isLargeScreen ? 16 : 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => _searchQuery = value);
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -665,20 +690,66 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _getCountryName(String categoryName) {
|
||||
if (categoryName.contains('|')) {
|
||||
return categoryName.split('|').first.trim();
|
||||
List<XtreamStream> _buildStreamsForType(IPTVProvider provider) {
|
||||
if (widget.type == ContentType.live) {
|
||||
return provider.filteredLiveStreams;
|
||||
}
|
||||
return categoryName.trim();
|
||||
if (widget.type == ContentType.movies) {
|
||||
return provider.vodStreams;
|
||||
}
|
||||
return provider.seriesList
|
||||
.map(
|
||||
(s) => XtreamStream(
|
||||
streamId: s.seriesId,
|
||||
name: s.name,
|
||||
streamIcon: s.cover,
|
||||
plot: s.plot,
|
||||
rating: s.rating,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<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, _) {
|
||||
print('🔥 BUILDING SIDEBAR - countries: ${provider.countries.length}, loading: ${provider.isLoading}, organizing: ${provider.isOrganizingCountries}');
|
||||
final countries = provider.countries;
|
||||
return SimpleCountriesSidebar(
|
||||
countries: provider.countries,
|
||||
selectedCountry: provider.selectedCategory.isNotEmpty ? provider.selectedCategory : provider.selectedCountry,
|
||||
countries: countries,
|
||||
selectedCountry: provider.selectedCategory.isNotEmpty
|
||||
? provider.selectedCategory
|
||||
: provider.selectedCountry,
|
||||
onCountrySelected: (country) => provider.filterByCountry(country),
|
||||
isLoading: provider.isLoading,
|
||||
isOrganizing: provider.isOrganizingCountries,
|
||||
@@ -699,7 +770,10 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2),
|
||||
CircularProgressIndicator(
|
||||
color: Colors.red,
|
||||
strokeWidth: _isLargeScreen ? 4 : 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (provider.totalChannels > 0)
|
||||
Text(
|
||||
@@ -724,7 +798,9 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
child: LinearProgressIndicator(
|
||||
value: provider.loadingProgress,
|
||||
backgroundColor: Colors.grey[800],
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Colors.red),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -732,46 +808,26 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
List<XtreamStream> streams = [];
|
||||
if (widget.type == ContentType.live) {
|
||||
streams = provider.filteredLiveStreams;
|
||||
} else if (widget.type == ContentType.movies) {
|
||||
streams = provider.vodStreams;
|
||||
} else {
|
||||
streams = provider.seriesList.map((s) => XtreamStream(
|
||||
streamId: s.seriesId,
|
||||
name: s.name,
|
||||
streamIcon: s.cover,
|
||||
plot: s.plot,
|
||||
rating: s.rating,
|
||||
)).toList();
|
||||
}
|
||||
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
// Special case: "arg|" prefix search - exact pattern match for "arg|" in channel name
|
||||
if (_searchQuery.toLowerCase() == 'arg|') {
|
||||
streams = streams
|
||||
.where((s) => s.name.toLowerCase().contains('arg|'))
|
||||
.toList();
|
||||
} else {
|
||||
// Normal search - contains query anywhere in name
|
||||
streams = streams
|
||||
.where((s) => s.name.toLowerCase().contains(_searchQuery.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
final baseStreams = _buildStreamsForType(provider);
|
||||
final streams = _applySearchFilter(baseStreams);
|
||||
|
||||
if (streams.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
_searchQuery.isNotEmpty ? 'No se encontraron resultados' : 'Sin contenido',
|
||||
style: TextStyle(color: Colors.grey, fontSize: _isLargeScreen ? 20 : 16),
|
||||
_searchQuery.isNotEmpty
|
||||
? 'No se encontraron resultados'
|
||||
: 'Sin contenido',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: _isLargeScreen ? 20 : 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: EdgeInsets.all(padding),
|
||||
cacheExtent: _isLargeScreen ? 1600 : 1100,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _gridCrossAxisCount,
|
||||
childAspectRatio: 16 / 9,
|
||||
@@ -890,73 +946,82 @@ class _ChannelCardState extends State<_ChannelCard> {
|
||||
: null,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (widget.stream.streamIcon != null && widget.stream.streamIcon!.isNotEmpty)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.network(
|
||||
widget.stream.streamIcon!,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _buildPlaceholder(placeholderIconSize),
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildPlaceholder(placeholderIconSize),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.8),
|
||||
],
|
||||
),
|
||||
),
|
||||
children: [
|
||||
if (widget.stream.streamIcon != null &&
|
||||
widget.stream.streamIcon!.isNotEmpty)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.network(
|
||||
widget.stream.streamIcon!,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: widget.isLarge ? 512 : 384,
|
||||
cacheHeight: widget.isLarge ? 288 : 216,
|
||||
filterQuality: FilterQuality.low,
|
||||
gaplessPlayback: false,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildPlaceholder(placeholderIconSize),
|
||||
),
|
||||
Positioned(
|
||||
bottom: padding,
|
||||
left: padding,
|
||||
right: padding,
|
||||
child: Text(
|
||||
widget.stream.name,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: textSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildPlaceholder(placeholderIconSize),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.8),
|
||||
],
|
||||
),
|
||||
if (widget.stream.rating != null)
|
||||
Positioned(
|
||||
top: padding,
|
||||
right: padding,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: ratingPaddingH, vertical: ratingPaddingV),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
widget.stream.rating!,
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: ratingFontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: padding,
|
||||
left: padding,
|
||||
right: padding,
|
||||
child: Text(
|
||||
widget.stream.name,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: textSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.stream.rating != null)
|
||||
Positioned(
|
||||
top: padding,
|
||||
right: padding,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: ratingPaddingH,
|
||||
vertical: ratingPaddingV,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.amber,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
widget.stream.rating!,
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: ratingFontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(double iconSize) {
|
||||
@@ -1009,7 +1074,11 @@ class _SeriesEpisodesScreenState extends State<SeriesEpisodesScreen> {
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: Colors.white, size: iconSize),
|
||||
icon: Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: iconSize,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
iconSize: 48,
|
||||
padding: EdgeInsets.all(_isLargeScreen ? 12 : 8),
|
||||
@@ -1035,7 +1104,10 @@ class _SeriesEpisodesScreenState extends State<SeriesEpisodesScreen> {
|
||||
builder: (context, provider, _) {
|
||||
if (provider.isLoading) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2),
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.red,
|
||||
strokeWidth: _isLargeScreen ? 4 : 2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1044,7 +1116,10 @@ class _SeriesEpisodesScreenState extends State<SeriesEpisodesScreen> {
|
||||
return Center(
|
||||
child: Text(
|
||||
'No hay episodios',
|
||||
style: TextStyle(color: Colors.grey, fontSize: _isLargeScreen ? 20 : 16),
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: _isLargeScreen ? 20 : 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1056,9 +1131,13 @@ class _SeriesEpisodesScreenState extends State<SeriesEpisodesScreen> {
|
||||
final episode = episodes[index];
|
||||
return Card(
|
||||
color: Colors.grey[900],
|
||||
margin: EdgeInsets.only(bottom: _isLargeScreen ? 16 : 8),
|
||||
margin: EdgeInsets.only(
|
||||
bottom: _isLargeScreen ? 16 : 8,
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.all(_isLargeScreen ? 16 : 12),
|
||||
contentPadding: EdgeInsets.all(
|
||||
_isLargeScreen ? 16 : 12,
|
||||
),
|
||||
leading: Icon(
|
||||
Icons.play_circle_fill,
|
||||
color: Colors.red,
|
||||
@@ -1066,7 +1145,10 @@ class _SeriesEpisodesScreenState extends State<SeriesEpisodesScreen> {
|
||||
),
|
||||
title: Text(
|
||||
'S${episode.seasonNumber}E${episode.episodeNumber} - ${episode.title}',
|
||||
style: TextStyle(color: Colors.white, fontSize: _isLargeScreen ? 20 : 16),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: _isLargeScreen ? 20 : 16,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
@@ -1076,7 +1158,8 @@ class _SeriesEpisodesScreenState extends State<SeriesEpisodesScreen> {
|
||||
stream: XtreamStream(
|
||||
streamId: episode.episodeId,
|
||||
name: episode.title,
|
||||
containerExtension: episode.containerExtension,
|
||||
containerExtension:
|
||||
episode.containerExtension,
|
||||
url: episode.url,
|
||||
),
|
||||
isLive: false,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -112,13 +115,13 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user