mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-22 06:28:04 +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>
386 lines
13 KiB
Dart
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';
|
|
}
|
|
}
|