Files
xtremflow/lib/features/iptv/widgets/live_tv_tab.dart
T
MichaelandClaude Opus 5 8b82b101aa fix(ui): remplacer les couleurs Material codées en dur par les jetons Warm Cinema
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>
2026-08-11 10:16:38 +02:00

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,
),
),
);
}
}