Files
xtremflow/lib/features/iptv/screens/series_detail_screen.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

386 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../core/models/playlist_config.dart';
import '../models/xtream_models.dart';
import '../providers/xtream_provider.dart';
import '../providers/watch_history_provider.dart';
import '../../../core/theme/app_colors.dart';
import 'player_screen.dart';
/// Series detail screen showing seasons and episodes
class SeriesDetailScreen extends ConsumerStatefulWidget {
final Series series;
final PlaylistConfig playlist;
const SeriesDetailScreen({
super.key,
required this.series,
required this.playlist,
});
@override
ConsumerState<SeriesDetailScreen> createState() => _SeriesDetailScreenState();
}
class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
SeriesInfo? _seriesInfo;
bool _isLoading = true;
String? _error;
int _selectedSeason = 1;
@override
void initState() {
super.initState();
_loadSeriesInfo();
}
Future<void> _loadSeriesInfo() async {
try {
setState(() {
_isLoading = true;
_error = null;
});
final service = ref.read(xtreamServiceProvider(widget.playlist));
final info = await service.getSeriesInfo(widget.series.seriesId);
setState(() {
_seriesInfo = info;
_isLoading = false;
// Select first available season
if (info.episodes.isNotEmpty) {
_selectedSeason = info.episodes.keys.first;
}
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
String? _formatRating(String? rating) {
if (rating == null || rating.isEmpty) return null;
final value = double.tryParse(rating);
if (value != null) {
return value.toStringAsFixed(1);
}
return rating;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.series.name),
backgroundColor: AppColors.background,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.onSurface),
onPressed: () => Navigator.pop(context),
),
),
backgroundColor: AppColors.surfaceContainerLow,
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text('Error: $_error', style: const TextStyle(color: AppColors.onSurfaceVariant)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadSeriesInfo,
child: const Text('Retry'),
),
],
),
)
: _buildContent(),
);
}
Widget _buildContent() {
if (_seriesInfo == null) return const SizedBox.shrink();
final currentEpisodes = _seriesInfo!.episodes[_selectedSeason] ?? [];
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left panel: Series info and season selector
SizedBox(
width: 280,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Cover image
if (_seriesInfo!.cover != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: _seriesInfo!.cover!,
width: 248,
height: 350,
fit: BoxFit.cover,
memCacheWidth: 500,
errorWidget: (_, __, ___) => Container(
width: 248,
height: 350,
color: AppColors.surfaceContainer,
child: const Icon(Icons.tv, size: 64, color: AppColors.onSurfaceVariant),
),
),
),
const SizedBox(height: 16),
// Title
Text(
_seriesInfo!.name,
style: GoogleFonts.karla(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.onSurface,
),
),
const SizedBox(height: 8),
// Rating
if (_seriesInfo!.rating != null)
Row(
children: [
Icon(Icons.star, size: 16, color: AppColors.ratingGold),
const SizedBox(width: 4),
Text(
_formatRating(_seriesInfo!.rating!)!,
style: GoogleFonts.karla(fontSize: 13, color: AppColors.onSurfaceVariant),
),
],
),
const SizedBox(height: 12),
// Plot
if (_seriesInfo!.plot != null && _seriesInfo!.plot!.isNotEmpty)
Text(
_seriesInfo!.plot!,
style: GoogleFonts.karla(fontSize: 12, color: AppColors.onSurfaceVariant),
maxLines: 5,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
// Season selector
Text(
'Seasons',
style: GoogleFonts.karla(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _seriesInfo!.episodes.keys.map((seasonNum) {
final isSelected = seasonNum == _selectedSeason;
return ChoiceChip(
label: Text('S$seasonNum'),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _selectedSeason = seasonNum);
}
},
selectedColor: AppColors.primaryContainer,
backgroundColor: AppColors.surfaceContainer,
labelStyle: GoogleFonts.karla(
color: isSelected ? AppColors.onPrimary : AppColors.onSurfaceVariant,
fontSize: 12,
),
);
}).toList(),
),
],
),
),
),
// Divider
Container(width: 1, color: AppColors.surfaceContainerHigh),
// Right panel: Episodes list
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Season $_selectedSeason - ${currentEpisodes.length} Episodes',
style: GoogleFonts.karla(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.onSurface,
),
),
),
Expanded(
child: currentEpisodes.isEmpty
? Center(
child: Text(
'No episodes found',
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: currentEpisodes.length,
itemBuilder: (context, index) {
final episode = currentEpisodes[index];
return _buildEpisodeTile(episode);
},
),
),
],
),
),
],
);
}
Widget _buildEpisodeTile(Episode episode) {
final watchHistory = ref.watch(watchHistoryProvider);
final episodeKey = WatchHistory.episodeKey(
widget.series.seriesId,
_selectedSeason,
episode.episodeNum,
);
final isWatched = watchHistory.isEpisodeWatched(episodeKey);
return Card(
color: AppColors.surfaceContainer,
margin: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: () async {
// Mark as watched when playing
ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey);
// Fetch actual duration from API if not available
Duration? episodeDuration;
if (episode.durationSecs != null && episode.durationSecs! > 0) {
episodeDuration = Duration(seconds: episode.durationSecs!);
} else {
// Try to fetch from API using getVodDuration (episodes are often queryable as VODs)
final service = ref.read(xtreamServiceProvider(widget.playlist));
final durationSecs = await service.getVodDuration(episode.id);
if (durationSecs != null && durationSecs > 0) {
episodeDuration = Duration(seconds: durationSecs);
}
}
if (!context.mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PlayerScreen(
streamId: episode.id,
title: '${widget.series.name} - ${episode.title}',
playlist: widget.playlist,
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mkv',
duration: episodeDuration,
),
),
);
},
onLongPress: () {
// Toggle watched on long press
ref.read(watchHistoryProvider.notifier).toggleEpisodeWatched(episodeKey);
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Episode number badge
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isWatched ? AppColors.success : AppColors.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: isWatched
? const Icon(Icons.check, color: AppColors.onSurface, size: 20)
: Text(
'${episode.episodeNum}',
style: GoogleFonts.karla(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.onSurface,
),
),
),
),
const SizedBox(width: 12),
// Episode info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
episode.title,
style: GoogleFonts.karla(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isWatched ? AppColors.onSurfaceVariant : AppColors.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (episode.durationSecs != null && episode.durationSecs! > 0)
Text(
_formatDuration(episode.durationSecs!),
style: GoogleFonts.karla(
fontSize: 12,
color: AppColors.onSurfaceVariant,
),
),
],
),
),
// Play icon
Icon(
isWatched ? Icons.replay : Icons.play_circle_outline,
color: AppColors.onSurfaceVariant,
size: 28,
),
],
),
),
),
);
}
String _formatDuration(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}
}