mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-21 14:08: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>
395 lines
13 KiB
Dart
395 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 '../../../../features/iptv/models/xtream_models.dart';
|
|
import '../../../../features/iptv/providers/xtream_provider.dart';
|
|
import '../../../../features/iptv/providers/watch_history_provider.dart';
|
|
// Explicit import for StreamType
|
|
import 'mobile_player_screen.dart';
|
|
import '../../../../core/theme/app_colors.dart';
|
|
import '../../../../core/widgets/glass_container.dart';
|
|
|
|
class MobileSeriesDetailScreen extends ConsumerStatefulWidget {
|
|
final Series series;
|
|
final PlaylistConfig playlist;
|
|
|
|
const MobileSeriesDetailScreen({
|
|
super.key,
|
|
required this.series,
|
|
required this.playlist,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<MobileSeriesDetailScreen> createState() =>
|
|
_MobileSeriesDetailScreenState();
|
|
}
|
|
|
|
class _MobileSeriesDetailScreenState
|
|
extends ConsumerState<MobileSeriesDetailScreen> {
|
|
SeriesInfo? _seriesInfo;
|
|
bool _isLoading = true;
|
|
String? _error;
|
|
int _selectedSeason = 1;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadSeriesInfo();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_seriesInfo = info;
|
|
_isLoading = false;
|
|
if (info.episodes.isNotEmpty) {
|
|
_selectedSeason = info.episodes.keys.first;
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_error = e.toString();
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: AppColors.background,
|
|
body: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
// Global Background
|
|
Container(
|
|
decoration: const BoxDecoration(
|
|
gradient: RadialGradient(
|
|
center: Alignment.center,
|
|
radius: 1.5,
|
|
colors: [AppColors.surfaceContainerLow, AppColors.background],
|
|
),
|
|
),
|
|
),
|
|
|
|
if (_isLoading)
|
|
const Center(child: CircularProgressIndicator(color: AppColors.onSurface))
|
|
else if (_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: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
|
|
),
|
|
TextButton(
|
|
onPressed: _loadSeriesInfo,
|
|
child: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else
|
|
_buildMobileContent(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMobileContent() {
|
|
if (_seriesInfo == null) return const SizedBox.shrink();
|
|
|
|
final currentEpisodes = _seriesInfo!.episodes[_selectedSeason] ?? [];
|
|
|
|
return CustomScrollView(
|
|
slivers: [
|
|
// App Bar with Cover
|
|
SliverAppBar(
|
|
expandedHeight: 400,
|
|
pinned: true,
|
|
backgroundColor: Colors.transparent, // Let background show through
|
|
leading: Container(
|
|
margin: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.background.withOpacity(0.5),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: AppColors.onSurface),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
),
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
background: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
if (_seriesInfo!.cover != null)
|
|
CachedNetworkImage(
|
|
imageUrl: _seriesInfo!.cover!,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: 800,
|
|
errorWidget: (_, __, ___) =>
|
|
Container(color: AppColors.surfaceContainerLow),
|
|
),
|
|
// Gradient Overlay
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.transparent,
|
|
AppColors.background.withOpacity(0.8),
|
|
AppColors.background, // Merge into body background
|
|
],
|
|
stops: const [0.5, 0.8, 1.0],
|
|
),
|
|
),
|
|
),
|
|
// Info Overlay
|
|
Positioned(
|
|
bottom: 16,
|
|
left: 16,
|
|
right: 16,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
_seriesInfo!.name,
|
|
style: GoogleFonts.karla(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
if (_seriesInfo!.rating != null) ...[
|
|
const Icon(
|
|
Icons.star,
|
|
size: 16,
|
|
color: AppColors.ratingGold,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
_formatRating(_seriesInfo!.rating!)!,
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurfaceVariant,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
],
|
|
Text(
|
|
'${_seriesInfo!.episodes.keys.length} Seasons',
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurfaceVariant,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Plot
|
|
if (_seriesInfo!.plot != null && _seriesInfo!.plot!.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Text(
|
|
_seriesInfo!.plot!,
|
|
style: GoogleFonts.karla(color: AppColors.onSurfaceVariant, height: 1.4),
|
|
maxLines: 4,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Seasons Filter
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(
|
|
height: 50,
|
|
child: ListView.separated(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: _seriesInfo!.episodes.keys.length,
|
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
|
itemBuilder: (context, index) {
|
|
final seasonNum = _seriesInfo!.episodes.keys.elementAt(index);
|
|
final isSelected = seasonNum == _selectedSeason;
|
|
return ChoiceChip(
|
|
label: Text('Season $seasonNum'),
|
|
selected: isSelected,
|
|
onSelected: (selected) {
|
|
if (selected) setState(() => _selectedSeason = seasonNum);
|
|
},
|
|
backgroundColor: AppColors.surfaceContainer,
|
|
selectedColor: AppColors.primary,
|
|
labelStyle: GoogleFonts.karla(
|
|
color: isSelected ? AppColors.onPrimary : AppColors.onSurfaceVariant,
|
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
showCheckmark: false,
|
|
side: BorderSide.none,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
|
|
|
// Episodes List
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
|
|
sliver: SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) {
|
|
final episode = currentEpisodes[index];
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: _buildEpisodeTile(episode),
|
|
);
|
|
},
|
|
childCount: currentEpisodes.length,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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 GlassContainer.glass(
|
|
borderRadius: 12,
|
|
padding: EdgeInsets.zero,
|
|
child: InkWell(
|
|
onTap: () {
|
|
ref
|
|
.read(watchHistoryProvider.notifier)
|
|
.markEpisodeWatched(episodeKey);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => MobilePlayerScreen(
|
|
streamId: episode.id,
|
|
title: '${widget.series.name} - ${episode.title}',
|
|
playlist: widget.playlist,
|
|
streamType: StreamType.series,
|
|
containerExtension: episode.containerExtension ?? 'mkv',
|
|
),
|
|
),
|
|
);
|
|
},
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
child: Row(
|
|
children: [
|
|
// Play Button / Indicator
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: isWatched
|
|
? AppColors.primary
|
|
: AppColors.surfaceContainer,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
isWatched ? Icons.check : Icons.play_arrow_rounded,
|
|
color: isWatched ? AppColors.onPrimary : AppColors.primary,
|
|
size: 24,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'E${episode.episodeNum} - ${episode.title}',
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 14,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (episode.durationSecs != null &&
|
|
episode.durationSecs! > 0)
|
|
Text(
|
|
_formatDuration(episode.durationSecs!),
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurfaceVariant,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatDuration(int seconds) {
|
|
final hours = seconds ~/ 3600;
|
|
final minutes = (seconds % 3600) ~/ 60;
|
|
if (hours > 0) return '${hours}h ${minutes}m';
|
|
return '${minutes}m';
|
|
}
|
|
}
|