mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-16 11:48:42 +02:00
41 couleurs Material brutes traînaient encore dans l'interface, hors palette : rouge, vert, orange et surtout un violet omniprésent sur les Season Pass, qui n'appartient à aucune famille du thème. Correspondances retenues : - Colors.red / redAccent -> AppColors.live (#E5484D) pour les badges LIVE et les indicateurs d'enregistrement, AppColors.error pour les messages d'erreur - Colors.green / greenAccent -> AppColors.success - Colors.orangeAccent -> AppColors.warning - Colors.amber -> AppColors.ratingGold, pour aligner les étoiles de notation du mobile sur celles du bureau - Colors.purpleAccent -> famille secondary (terre cuite) - fonds de boutons passés sur les variantes *Container avec leur couleur de premier plan, le remplissage clair ne portant pas de texte lisible Le titre du programme EPG dans la grille Live TV utilisait ratingGold, un jeton réservé aux notes : il passe sur primary, l'accent ember du thème. Les fonds de SnackBar vert/rouge deviennent surfaceContainerHigh, le succès et l'échec restant portés par le pictogramme du message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
572 lines
22 KiB
Dart
572 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import '../providers/xtream_provider.dart';
|
|
import '../providers/settings_provider.dart';
|
|
import '../providers/favorites_provider.dart';
|
|
import '../screens/player_screen.dart';
|
|
import '../../../core/models/iptv_models.dart';
|
|
import '../../../core/models/playlist_config.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/utils/responsive_layout.dart';
|
|
import '../../../core/widgets/themed_loading_screen.dart';
|
|
import '../../../core/widgets/glass_container.dart';
|
|
import '../../../core/widgets/tv_focusable_card.dart';
|
|
import 'recording_modal.dart';
|
|
|
|
class LiveTVTab extends ConsumerStatefulWidget {
|
|
final PlaylistConfig playlist;
|
|
|
|
const LiveTVTab({super.key, required this.playlist});
|
|
|
|
@override
|
|
ConsumerState<LiveTVTab> createState() => _LiveTVTabState();
|
|
}
|
|
|
|
class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|
with AutomaticKeepAliveClientMixin {
|
|
String? _selectedCategory;
|
|
final TextEditingController _searchController = TextEditingController();
|
|
bool _showFavoritesOnly = false;
|
|
String _searchQuery = '';
|
|
final ScrollController _mainScrollController = ScrollController();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_searchController.addListener(() {
|
|
setState(() => _searchQuery = _searchController.text.toLowerCase());
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
_mainScrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
|
|
final channelsAsync =
|
|
ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
|
|
final favorites = ref.watch(favoritesProvider);
|
|
final settings = ref.watch(iptvSettingsProvider);
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
body: channelsAsync.when(
|
|
loading: () => const ThemedLoading(),
|
|
error: (e, s) => Center(
|
|
child: Text('Error: $e',
|
|
style: const TextStyle(color: AppColors.onSurface)),
|
|
),
|
|
data: (groupedChannels) {
|
|
var categories = groupedChannels.keys.toList();
|
|
if (settings.liveTvKeywords.isNotEmpty) {
|
|
categories = categories
|
|
.where((cat) => settings.matchesLiveTvFilter(cat))
|
|
.toList();
|
|
}
|
|
// REMOVED: categories.sort(); - Respect API/Settings order
|
|
|
|
List<Channel> displayedChannels = [];
|
|
bool showingCategoryGrid = false;
|
|
|
|
if (_searchQuery.isNotEmpty) {
|
|
displayedChannels = groupedChannels.values
|
|
.expand((l) => l)
|
|
.where((c) => c.name.toLowerCase().contains(_searchQuery))
|
|
.toList();
|
|
} else if (_showFavoritesOnly) {
|
|
displayedChannels = groupedChannels.values
|
|
.expand((l) => l)
|
|
.where((c) => favorites.contains(c.streamId))
|
|
.toList();
|
|
} else if (_selectedCategory != null) {
|
|
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
|
} else {
|
|
showingCategoryGrid = true;
|
|
}
|
|
|
|
return Column(
|
|
children: [
|
|
// HEADER (Glass)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
|
|
child: GlassContainer.glass(
|
|
borderRadius: 16,
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
child: Row(
|
|
children: [
|
|
if (_selectedCategory != null &&
|
|
_searchQuery.isEmpty &&
|
|
!_showFavoritesOnly) ...[
|
|
TvFocusableCard(
|
|
onTap: () {
|
|
setState(() {
|
|
_selectedCategory = null;
|
|
_mainScrollController.jumpTo(0);
|
|
});
|
|
},
|
|
borderRadius: 50,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.onSurface.withOpacity(0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.arrow_back,
|
|
color: AppColors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
],
|
|
|
|
Expanded(
|
|
child: Text(
|
|
_searchQuery.isNotEmpty
|
|
? 'Search Results'
|
|
: _showFavoritesOnly
|
|
? 'Favorites'
|
|
: _selectedCategory ?? 'Live TV',
|
|
style: GoogleFonts.karla(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.onSurface,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
|
|
// Search Bar
|
|
Container(
|
|
width: 250,
|
|
height: 44,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.onSurface.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
style: GoogleFonts.karla(color: AppColors.onSurface),
|
|
decoration: InputDecoration(
|
|
hintText: 'Search channels...',
|
|
hintStyle:
|
|
GoogleFonts.karla(color: AppColors.onSurface54),
|
|
prefixIcon: const Icon(Icons.search,
|
|
color: AppColors.onSurface54),
|
|
border: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 10,
|
|
),
|
|
suffixIcon: _searchQuery.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(
|
|
Icons.clear,
|
|
color: AppColors.onSurface,
|
|
),
|
|
onPressed: _searchController.clear,
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
|
|
// Favorites Toggle
|
|
TvFocusableCard(
|
|
onTap: () {
|
|
setState(() {
|
|
_showFavoritesOnly = !_showFavoritesOnly;
|
|
if (_showFavoritesOnly) _searchController.clear();
|
|
_selectedCategory = null;
|
|
});
|
|
},
|
|
borderRadius: 12,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: _showFavoritesOnly
|
|
? AppColors.live.withOpacity(0.2)
|
|
: AppColors.onSurface.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: _showFavoritesOnly
|
|
? Border.all(color: AppColors.live)
|
|
: null,
|
|
),
|
|
child: Icon(
|
|
_showFavoritesOnly
|
|
? Icons.favorite
|
|
: Icons.favorite_border,
|
|
color: _showFavoritesOnly
|
|
? AppColors.live
|
|
: AppColors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// GRID / LIST AREA
|
|
Expanded(
|
|
child: showingCategoryGrid
|
|
? _buildCategoryGrid(categories, groupedChannels)
|
|
: displayedChannels.isEmpty
|
|
? Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.tv_off,
|
|
size: 64,
|
|
color: AppColors.onSurface.withOpacity(0.3),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No channels found',
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurface54,
|
|
fontSize: 18,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
// Use raw grid for now, but wrapped in focusable cards
|
|
: _buildChannelGrid(displayedChannels),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCategoryGrid(
|
|
List<String> categories,
|
|
Map<String, List<Channel>> groupedChannels,
|
|
) {
|
|
final columns = ResponsiveLayout.value(
|
|
context,
|
|
mobile: 2,
|
|
tablet: 4,
|
|
desktop: 6,
|
|
);
|
|
|
|
return GridView.builder(
|
|
controller: _mainScrollController,
|
|
padding: const EdgeInsets.all(24),
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: columns,
|
|
mainAxisSpacing: 24,
|
|
crossAxisSpacing: 24,
|
|
childAspectRatio: 1.6,
|
|
),
|
|
itemCount: categories.length,
|
|
itemBuilder: (context, index) {
|
|
final category = categories[index];
|
|
final count = groupedChannels[category]?.length ?? 0;
|
|
|
|
return TvFocusableCard(
|
|
onTap: () {
|
|
setState(() {
|
|
_selectedCategory = category;
|
|
_mainScrollController.jumpTo(0);
|
|
});
|
|
},
|
|
borderRadius: 16,
|
|
child: GlassContainer.base(
|
|
borderRadius: 16,
|
|
padding: const EdgeInsets.all(0),
|
|
child: Stack(
|
|
children: [
|
|
// Background Gradient - Apple TV Style
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
AppColors.primary.withOpacity(0.15),
|
|
AppColors.primary.withOpacity(0.05),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.folder_copy_outlined,
|
|
color: AppColors.onSurface.withOpacity(0.9),
|
|
size: 32,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Text(
|
|
category,
|
|
textAlign: TextAlign.center,
|
|
style: GoogleFonts.karla(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.onSurface,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'$count Channels',
|
|
style: GoogleFonts.karla(
|
|
fontSize: 13,
|
|
color: AppColors.onSurface54,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildChannelGrid(List<Channel> channels) {
|
|
final columns = ResponsiveLayout.value(
|
|
context,
|
|
mobile: 2,
|
|
tablet: 5,
|
|
desktop: 8,
|
|
);
|
|
|
|
return GridView.builder(
|
|
controller: _mainScrollController,
|
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: columns,
|
|
mainAxisSpacing: 16,
|
|
crossAxisSpacing: 16,
|
|
childAspectRatio: 1.0, // Square-ish cards
|
|
),
|
|
itemCount: channels.length,
|
|
itemBuilder: (context, index) {
|
|
final channel = channels[index];
|
|
return TvFocusableCard(
|
|
onTap: () => _playChannel(channel, channels),
|
|
borderRadius: 12,
|
|
semanticLabel: 'Chaîne ${channel.name}',
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceVariant,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Icon Area
|
|
Expanded(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.onSurface.withOpacity(
|
|
0.05,
|
|
), // Subtle transparent background
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(12),
|
|
),
|
|
),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Center(
|
|
child: channel.streamIcon.isNotEmpty
|
|
? Image.network(
|
|
_getProxiedIconUrl(channel.streamIcon)!,
|
|
cacheWidth: 160,
|
|
filterQuality: FilterQuality.low,
|
|
errorBuilder: (_, __, ___) => Icon(
|
|
Icons.tv,
|
|
color: AppColors.onSurface.withOpacity(0.3),
|
|
size: 40,
|
|
),
|
|
)
|
|
: Text(
|
|
channel.name.characters.first.toUpperCase(),
|
|
style: GoogleFonts.fraunces(
|
|
fontSize: 40,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.onSurface24,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Footer
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
color: Colors.black54,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
channel.name,
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurface,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Consumer(
|
|
builder: (context, ref, _) {
|
|
final epgAsync = ref.watch(
|
|
epgByPlaylistProvider(
|
|
EpgRequestKey(
|
|
playlist: widget.playlist,
|
|
streamId: channel.streamId,
|
|
),
|
|
),
|
|
);
|
|
|
|
return epgAsync.when(
|
|
data: (epgList) {
|
|
if (epgList.isEmpty) {
|
|
return Text(
|
|
'No Info',
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurface54,
|
|
fontSize: 10,
|
|
),
|
|
);
|
|
}
|
|
final now = DateTime.now();
|
|
final currentProgram = epgList.firstWhere(
|
|
(entry) {
|
|
try {
|
|
final start =
|
|
EpgEntry.parseDateTime(entry.start);
|
|
final end =
|
|
EpgEntry.parseDateTime(entry.end);
|
|
if (start == null || end == null) {
|
|
return false;
|
|
}
|
|
return now.isAfter(start) &&
|
|
now.isBefore(end);
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
},
|
|
orElse: () => epgList.first,
|
|
);
|
|
|
|
return Text(
|
|
currentProgram.title,
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.primary,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
);
|
|
},
|
|
loading: () => Text(
|
|
'...',
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurface24,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
error: (_, __) => Text(
|
|
'Err',
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.error,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Record Button Icon Overlay (Positioned top right)
|
|
Positioned(
|
|
top: 8,
|
|
right: 8,
|
|
child: TvFocusableCard(
|
|
onTap: () {
|
|
RecordingModal.show(context, channel);
|
|
},
|
|
borderRadius: 20,
|
|
scaleFactor: 1.2,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withOpacity(0.5),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.fiber_manual_record,
|
|
color: AppColors.live,
|
|
size: 16,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
String? _getProxiedIconUrl(String originalUrl) {
|
|
if (originalUrl.isEmpty) return null;
|
|
if (originalUrl.startsWith('http://')) {
|
|
return '/api/xtream/$originalUrl';
|
|
}
|
|
return originalUrl;
|
|
}
|
|
|
|
void _playChannel(Channel channel, List<Channel> contextList) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => PlayerScreen(
|
|
streamId: channel.streamId,
|
|
title: channel.name,
|
|
playlist: widget.playlist,
|
|
streamType: StreamType.live,
|
|
channels: contextList,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|