779 lines
23 KiB
Dart
779 lines
23 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 'player_screen.dart';
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_loadInitialData();
|
|
});
|
|
}
|
|
|
|
Future<void> _loadInitialData() async {
|
|
final provider = context.read<IPTVProvider>();
|
|
await provider.loadLiveStreams();
|
|
await provider.loadVodStreams();
|
|
await provider.loadSeries();
|
|
}
|
|
|
|
void _showLiveCategories() {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.live)),
|
|
);
|
|
}
|
|
|
|
void _showMovies() {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.movies)),
|
|
);
|
|
}
|
|
|
|
void _showSeries() {
|
|
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) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Row(
|
|
children: [
|
|
Icon(Icons.live_tv, color: Colors.red, size: 32),
|
|
SizedBox(width: 12),
|
|
Text(
|
|
'XSTREAM TV',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
letterSpacing: 2,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Row(
|
|
children: [
|
|
Text(timeStr, style: const TextStyle(color: Colors.white70, fontSize: 16)),
|
|
const SizedBox(width: 16),
|
|
Text(dateStr, style: const TextStyle(color: Colors.white54, fontSize: 14)),
|
|
const SizedBox(width: 24),
|
|
const Icon(Icons.person, color: Colors.white70, size: 24),
|
|
const SizedBox(width: 16),
|
|
IconButton(
|
|
icon: const Icon(Icons.settings, color: Colors.white70),
|
|
onPressed: () {
|
|
context.read<IPTVProvider>().logout();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDashboard() {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: _DashboardCard(
|
|
title: 'LIVE TV',
|
|
icon: Icons.tv,
|
|
gradient: const LinearGradient(
|
|
colors: [Color(0xFF00c853), Color(0xFF2979ff)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
onTap: _showLiveCategories,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: _DashboardCard(
|
|
title: 'MOVIES',
|
|
icon: Icons.play_circle_fill,
|
|
gradient: const LinearGradient(
|
|
colors: [Color(0xFFff5252), Color(0xFFff9800)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
onTap: _showMovies,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: _DashboardCard(
|
|
title: 'SERIES',
|
|
icon: Icons.movie,
|
|
gradient: const LinearGradient(
|
|
colors: [Color(0xFF9c27b0), Color(0xFF03a9f4)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
onTap: _showSeries,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFooter() {
|
|
return Consumer<IPTVProvider>(
|
|
builder: (context, provider, _) {
|
|
final expDate = provider.userInfo?.expDate;
|
|
final username = provider.userInfo?.username ?? 'Usuario';
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Expiración: ${_formatExpiry(expDate)}',
|
|
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
|
),
|
|
const Text(
|
|
'Términos de Servicio',
|
|
style: TextStyle(color: Colors.white38, fontSize: 12),
|
|
),
|
|
Text(
|
|
'Usuario: $username',
|
|
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DashboardCard extends StatelessWidget {
|
|
final String title;
|
|
final IconData icon;
|
|
final Gradient gradient;
|
|
final VoidCallback onTap;
|
|
|
|
const _DashboardCard({
|
|
required this.title,
|
|
required this.icon,
|
|
required this.gradient,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: gradient,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
blurRadius: 15,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
Positioned(
|
|
right: -20,
|
|
bottom: -20,
|
|
child: Icon(
|
|
icon,
|
|
size: 150,
|
|
color: Colors.white.withValues(alpha: 0.1),
|
|
),
|
|
),
|
|
Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(icon, size: 60, color: Colors.white),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 24,
|
|
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;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadContent();
|
|
}
|
|
|
|
void _loadContent() {
|
|
final provider = context.read<IPTVProvider>();
|
|
if (widget.type == ContentType.live) {
|
|
provider.loadLiveStreams(_selectedCountry ?? '');
|
|
} else if (widget.type == ContentType.movies) {
|
|
provider.loadVodStreams();
|
|
} else {
|
|
provider.loadSeries();
|
|
}
|
|
}
|
|
|
|
@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';
|
|
}
|
|
}
|
|
|
|
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) {
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFF0f0f1a),
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
_buildHeader(),
|
|
_buildCountryFilter(),
|
|
Expanded(child: _buildContentList()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
_title,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
SizedBox(
|
|
width: 250,
|
|
height: 44,
|
|
child: TextField(
|
|
controller: _searchController,
|
|
style: const TextStyle(color: Colors.white),
|
|
decoration: InputDecoration(
|
|
hintText: 'Buscar...',
|
|
hintStyle: const TextStyle(color: Colors.grey),
|
|
prefixIcon: const Icon(Icons.search, color: Colors.grey),
|
|
suffixIcon: _searchQuery.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear, color: Colors.grey),
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
setState(() => _searchQuery = '');
|
|
},
|
|
)
|
|
: null,
|
|
filled: true,
|
|
fillColor: Colors.grey[900],
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
|
),
|
|
onChanged: (value) {
|
|
setState(() => _searchQuery = value);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCountryFilter() {
|
|
if (widget.type != ContentType.live) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final categories = _categories;
|
|
if (categories.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final countries = categories.map((c) => c.name).toList();
|
|
|
|
return Container(
|
|
height: 50,
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
itemCount: countries.length + 1,
|
|
itemBuilder: (context, index) {
|
|
if (index == 0) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: FilterChip(
|
|
label: const Text('Todos', style: TextStyle(color: Colors.white)),
|
|
selected: _selectedCountry == null,
|
|
selectedColor: Colors.red,
|
|
backgroundColor: Colors.grey[800],
|
|
onSelected: (_) {
|
|
setState(() => _selectedCountry = null);
|
|
context.read<IPTVProvider>().loadLiveStreams('');
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
final country = countries[index - 1];
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: FilterChip(
|
|
label: Text(
|
|
country.length > 20 ? '${country.substring(0, 20)}...' : country,
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
selected: _selectedCountry == categories[index - 1].id,
|
|
selectedColor: Colors.red,
|
|
backgroundColor: Colors.grey[800],
|
|
onSelected: (_) {
|
|
setState(() => _selectedCountry = categories[index - 1].id);
|
|
context.read<IPTVProvider>().loadLiveStreams(categories[index - 1].id);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildContentList() {
|
|
return Consumer<IPTVProvider>(
|
|
builder: (context, provider, _) {
|
|
if (provider.isLoading) {
|
|
return const Center(child: CircularProgressIndicator(color: Colors.red));
|
|
}
|
|
|
|
List<XtreamStream> streams = [];
|
|
if (widget.type == ContentType.live) {
|
|
streams = provider.liveStreams;
|
|
} 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) {
|
|
streams = streams
|
|
.where((s) => s.name.toLowerCase().contains(_searchQuery.toLowerCase()))
|
|
.toList();
|
|
}
|
|
|
|
if (streams.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
_searchQuery.isNotEmpty ? 'No se encontraron resultados' : 'Sin contenido',
|
|
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
|
),
|
|
);
|
|
}
|
|
|
|
return GridView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 4,
|
|
childAspectRatio: 16 / 9,
|
|
crossAxisSpacing: 12,
|
|
mainAxisSpacing: 12,
|
|
),
|
|
itemCount: streams.length,
|
|
itemBuilder: (context, index) {
|
|
final stream = streams[index];
|
|
return _ChannelCard(
|
|
stream: stream,
|
|
isSeries: widget.type == ContentType.series,
|
|
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 StatelessWidget {
|
|
final XtreamStream stream;
|
|
final bool isSeries;
|
|
final VoidCallback onTap;
|
|
|
|
const _ChannelCard({
|
|
required this.stream,
|
|
required this.isSeries,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[900],
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
if (stream.streamIcon != null && stream.streamIcon!.isNotEmpty)
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Image.network(
|
|
stream.streamIcon!,
|
|
width: double.infinity,
|
|
height: double.infinity,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => _buildPlaceholder(),
|
|
),
|
|
)
|
|
else
|
|
_buildPlaceholder(),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.transparent,
|
|
Colors.black.withValues(alpha: 0.8),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 8,
|
|
left: 8,
|
|
right: 8,
|
|
child: Text(
|
|
stream.name,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (stream.rating != null)
|
|
Positioned(
|
|
top: 8,
|
|
right: 8,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.amber,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(
|
|
stream.rating!,
|
|
style: const TextStyle(
|
|
color: Colors.black,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPlaceholder() {
|
|
return Container(
|
|
color: Colors.grey[800],
|
|
child: Center(
|
|
child: Icon(
|
|
isSeries ? Icons.tv : Icons.play_circle_outline,
|
|
color: Colors.red,
|
|
size: 40,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFF0f0f1a),
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
widget.series.name,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Consumer<IPTVProvider>(
|
|
builder: (context, provider, _) {
|
|
if (provider.isLoading) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(color: Colors.red),
|
|
);
|
|
}
|
|
|
|
final episodes = provider.seriesEpisodes;
|
|
if (episodes.isEmpty) {
|
|
return const Center(
|
|
child: Text(
|
|
'No hay episodios',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: episodes.length,
|
|
itemBuilder: (context, index) {
|
|
final episode = episodes[index];
|
|
return Card(
|
|
color: Colors.grey[900],
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: ListTile(
|
|
leading: const Icon(
|
|
Icons.play_circle_fill,
|
|
color: Colors.red,
|
|
size: 40,
|
|
),
|
|
title: Text(
|
|
'S${episode.seasonNumber}E${episode.episodeNumber} - ${episode.title}',
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
onTap: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => PlayerScreen(
|
|
stream: XtreamStream(
|
|
streamId: episode.episodeId,
|
|
name: episode.title,
|
|
containerExtension: episode.containerExtension,
|
|
url: episode.url,
|
|
),
|
|
isLive: false,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|