v1.0.1: Fix Android TV remote control navigation and focus indicators
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
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';
|
||||
import '../models/xtream_models.dart';
|
||||
import 'player_screen.dart';
|
||||
import '../widgets/simple_countries_sidebar.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
@@ -18,9 +20,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadInitialData();
|
||||
});
|
||||
// No automatic data loading on startup
|
||||
}
|
||||
|
||||
double get _screenWidth => MediaQuery.of(context).size.width;
|
||||
@@ -37,13 +37,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
double get _iconSize => _isLargeScreen ? 80 : 60;
|
||||
double get _headerPadding => _isLargeScreen ? 32 : 24;
|
||||
|
||||
Future<void> _loadInitialData() async {
|
||||
final provider = context.read<IPTVProvider>();
|
||||
await provider.loadLiveStreams();
|
||||
await provider.loadVodStreams();
|
||||
await provider.loadSeries();
|
||||
}
|
||||
|
||||
void _showLiveCategories() {
|
||||
Navigator.push(
|
||||
context,
|
||||
@@ -51,14 +44,60 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showMovies() {
|
||||
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();
|
||||
}
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.movies)),
|
||||
);
|
||||
}
|
||||
|
||||
void _showSeries() {
|
||||
void _showSeries() async {
|
||||
final provider = context.read<IPTVProvider>();
|
||||
// Cargar series bajo demanda
|
||||
if (provider.seriesList.isEmpty) {
|
||||
await provider.loadSeries();
|
||||
}
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ContentListScreen(type: ContentType.series)),
|
||||
@@ -137,12 +176,105 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
const SizedBox(width: 24),
|
||||
Icon(Icons.person, color: Colors.white70, size: _isLargeScreen ? 32 : 24),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: Icon(Icons.settings, color: Colors.white70, size: _isLargeScreen ? 32 : 24),
|
||||
onPressed: () {
|
||||
context.read<IPTVProvider>().logout();
|
||||
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();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -272,11 +404,13 @@ class _DashboardCard extends StatelessWidget {
|
||||
final titleSize = isLarge ? 32.0 : 24.0;
|
||||
final bgIconSize = isLarge ? 200.0 : 150.0;
|
||||
return Focus(
|
||||
canRequestFocus: true,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final hasFocus = Focus.of(context).hasFocus;
|
||||
return GestureDetector(
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
@@ -284,14 +418,15 @@ class _DashboardCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: hasFocus ? 0.5 : 0.3),
|
||||
blurRadius: hasFocus ? 25 : 15,
|
||||
color: hasFocus ? Colors.white.withValues(alpha: 0.4) : Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: hasFocus ? 30 : 15,
|
||||
spreadRadius: hasFocus ? 4 : 0,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
border: hasFocus
|
||||
? Border.all(color: Colors.white.withValues(alpha: 0.5), width: 3)
|
||||
: null,
|
||||
? Border.all(color: Colors.white, width: 4)
|
||||
: Border.all(color: Colors.transparent, width: 4),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
@@ -352,6 +487,7 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
print('DEBUG: ContentListScreen.initState() - type: ${widget.type}');
|
||||
_loadContent();
|
||||
}
|
||||
|
||||
@@ -370,8 +506,10 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
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();
|
||||
@@ -380,6 +518,11 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onFootballSelected() {
|
||||
final provider = context.read<IPTVProvider>();
|
||||
provider.filterByCategory(SpecialCategories.argentineFootball);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@@ -412,14 +555,24 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
|
||||
@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(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(),
|
||||
_buildCountryFilter(),
|
||||
Expanded(child: _buildContentList()),
|
||||
if (widget.type == ContentType.live)
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
_buildCountrySidebar(),
|
||||
Expanded(child: _buildContentList()),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(child: _buildContentList()),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -494,66 +647,20 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
return categoryName.trim();
|
||||
}
|
||||
|
||||
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) => _getCountryName(c.name)).toList();
|
||||
final chipHeight = _isLargeScreen ? 56.0 : 50.0;
|
||||
final chipFontSize = _isLargeScreen ? 16.0 : 14.0;
|
||||
|
||||
return Container(
|
||||
height: chipHeight,
|
||||
margin: EdgeInsets.only(bottom: _isLargeScreen ? 16 : 8),
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.symmetric(horizontal: _headerPadding),
|
||||
itemCount: countries.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: _isLargeScreen ? 12 : 8),
|
||||
child: FilterChip(
|
||||
label: Text('Todos', style: TextStyle(color: Colors.white, fontSize: chipFontSize)),
|
||||
selected: _selectedCountry == null,
|
||||
selectedColor: Colors.red,
|
||||
backgroundColor: Colors.grey[800],
|
||||
padding: EdgeInsets.symmetric(horizontal: _isLargeScreen ? 12 : 8),
|
||||
onSelected: (_) {
|
||||
setState(() => _selectedCountry = null);
|
||||
context.read<IPTVProvider>().loadLiveStreams('');
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final countryName = countries[index - 1];
|
||||
final category = categories[index - 1];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: _isLargeScreen ? 12 : 8),
|
||||
child: FilterChip(
|
||||
label: Text(
|
||||
countryName.length > 20 ? '${countryName.substring(0, 20)}...' : countryName,
|
||||
style: TextStyle(color: Colors.white, fontSize: chipFontSize),
|
||||
),
|
||||
selected: _selectedCountry == category.id,
|
||||
selectedColor: Colors.red,
|
||||
backgroundColor: Colors.grey[800],
|
||||
padding: EdgeInsets.symmetric(horizontal: _isLargeScreen ? 12 : 8),
|
||||
onSelected: (_) {
|
||||
setState(() => _selectedCountry = category.id);
|
||||
context.read<IPTVProvider>().loadLiveStreams(category.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Widget _buildCountrySidebar() {
|
||||
return Consumer<IPTVProvider>(
|
||||
builder: (context, provider, _) {
|
||||
print('🔥 BUILDING SIDEBAR - countries: ${provider.countries.length}, loading: ${provider.isLoading}, organizing: ${provider.isOrganizingCountries}');
|
||||
return SimpleCountriesSidebar(
|
||||
countries: provider.countries,
|
||||
selectedCountry: provider.selectedCategory.isNotEmpty ? provider.selectedCategory : provider.selectedCountry,
|
||||
onCountrySelected: (country) => provider.filterByCountry(country),
|
||||
isLoading: provider.isLoading,
|
||||
isOrganizing: provider.isOrganizingCountries,
|
||||
showFootballCategory: true,
|
||||
onFootballSelected: () => _onFootballSelected(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,12 +670,46 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
return Consumer<IPTVProvider>(
|
||||
builder: (context, provider, _) {
|
||||
if (provider.isLoading) {
|
||||
return Center(child: CircularProgressIndicator(color: Colors.red, strokeWidth: _isLargeScreen ? 4 : 2));
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<XtreamStream> streams = [];
|
||||
if (widget.type == ContentType.live) {
|
||||
streams = provider.liveStreams;
|
||||
streams = provider.filteredLiveStreams;
|
||||
} else if (widget.type == ContentType.movies) {
|
||||
streams = provider.vodStreams;
|
||||
} else {
|
||||
@@ -582,9 +723,17 @@ class _ContentListScreenState extends State<ContentListScreen> {
|
||||
}
|
||||
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
streams = streams
|
||||
.where((s) => s.name.toLowerCase().contains(_searchQuery.toLowerCase()))
|
||||
.toList();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
if (streams.isEmpty) {
|
||||
|
||||
Reference in New Issue
Block a user