mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-14 10:47:31 +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>
402 lines
14 KiB
Dart
402 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import '../../../../core/models/playlist_config.dart';
|
|
import '../../../../core/theme/app_colors.dart';
|
|
import '../../../../core/widgets/components/hero_carousel.dart';
|
|
import '../../../../core/widgets/glass_container.dart';
|
|
import '../../../../features/iptv/models/xtream_models.dart';
|
|
import '../../../../features/iptv/providers/xtream_provider.dart';
|
|
import '../../../../features/iptv/providers/settings_provider.dart';
|
|
import '../screens/mobile_series_detail_screen.dart';
|
|
|
|
class MobileSeriesTab extends ConsumerStatefulWidget {
|
|
final PlaylistConfig playlist;
|
|
const MobileSeriesTab({super.key, required this.playlist});
|
|
|
|
@override
|
|
ConsumerState<MobileSeriesTab> createState() => _MobileSeriesTabState();
|
|
}
|
|
|
|
class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
|
|
final ScrollController _scrollController = ScrollController();
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final List<Series> _series = [];
|
|
List<Series>? _searchResults;
|
|
String _searchQuery = '';
|
|
bool _isLoading = false;
|
|
bool _isSearching = false;
|
|
bool _hasMore = true;
|
|
int _currentOffset = 0;
|
|
static const int _pageSize = 50;
|
|
Timer? _searchDebounce;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadMoreSeries();
|
|
_scrollController.addListener(_onScroll);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.dispose();
|
|
_searchController.dispose();
|
|
_searchDebounce?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_isLoading &&
|
|
_hasMore &&
|
|
_scrollController.position.pixels >=
|
|
_scrollController.position.maxScrollExtent * 0.8) {
|
|
_loadMoreSeries();
|
|
}
|
|
}
|
|
|
|
void _onSearchChanged(String query) {
|
|
_searchDebounce?.cancel();
|
|
setState(() {
|
|
_searchQuery = query;
|
|
if (query.isEmpty) {
|
|
_searchResults = null;
|
|
_isSearching = false;
|
|
}
|
|
});
|
|
|
|
if (query.length >= 2) {
|
|
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
|
|
_performSearch(query);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _performSearch(String query) async {
|
|
if (query.isEmpty) return;
|
|
setState(() => _isSearching = true);
|
|
|
|
try {
|
|
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
|
final results = await service.searchSeries(query);
|
|
|
|
if (mounted && _searchQuery == query) {
|
|
setState(() {
|
|
_searchResults = results;
|
|
_isSearching = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) setState(() => _isSearching = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadMoreSeries() async {
|
|
if (_isLoading || !_hasMore || !mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
|
final newSeries = await service.getSeriesPaginated(
|
|
offset: _currentOffset,
|
|
limit: _pageSize,
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_series.addAll(newSeries);
|
|
_currentOffset += _pageSize;
|
|
_hasMore = newSeries.length == _pageSize;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() => _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;
|
|
}
|
|
|
|
String _getProxiedImageUrl(String? originalUrl, WidgetRef ref) {
|
|
if (originalUrl == null || originalUrl.isEmpty) return '';
|
|
if (originalUrl.startsWith('http')) {
|
|
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
|
return '${service.backendBaseUrl}/api/xtream/$originalUrl';
|
|
}
|
|
return originalUrl;
|
|
}
|
|
|
|
void _openSeries(Series series) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => MobileSeriesDetailScreen(
|
|
series: series,
|
|
playlist: widget.playlist,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final settings = ref.watch(iptvSettingsProvider);
|
|
|
|
List<Series> displaySeries;
|
|
if (_searchQuery.isNotEmpty && _searchResults != null) {
|
|
displaySeries = _searchResults!;
|
|
} else {
|
|
displaySeries = settings.seriesKeywords.isEmpty
|
|
? _series
|
|
: _series
|
|
.where((s) => settings.matchesSeriesFilter(s.categoryName))
|
|
.toList();
|
|
}
|
|
|
|
final heroItems = displaySeries
|
|
.take(3)
|
|
.map(
|
|
(s) => HeroItem(
|
|
id: s.seriesId.toString(),
|
|
title: s.name,
|
|
imageUrl: _getProxiedImageUrl(s.cover, ref),
|
|
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
|
|
onMoreInfo: () => _openSeries(s),
|
|
),
|
|
)
|
|
.toList();
|
|
|
|
return SafeArea(
|
|
bottom: false,
|
|
child: CustomScrollView(
|
|
controller: _scrollController,
|
|
slivers: [
|
|
// Glass Header & Search
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: GlassContainer.glass(
|
|
borderRadius: 100,
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.search, size: 20, color: AppColors.onSurfaceVariant),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _searchController,
|
|
style: GoogleFonts.karla(
|
|
fontSize: 14,
|
|
color: AppColors.onSurface,
|
|
),
|
|
decoration: InputDecoration(
|
|
hintText: 'Search Series...',
|
|
hintStyle: GoogleFonts.karla(color: AppColors.onSurfaceVariant),
|
|
border: InputBorder.none,
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.only(bottom: 11),
|
|
),
|
|
onChanged: _onSearchChanged,
|
|
),
|
|
),
|
|
if (_isSearching)
|
|
const SizedBox(
|
|
width: 12,
|
|
height: 12,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: AppColors.onSurface,
|
|
),
|
|
),
|
|
if (_searchQuery.isNotEmpty)
|
|
GestureDetector(
|
|
onTap: () {
|
|
_searchController.clear();
|
|
_onSearchChanged('');
|
|
},
|
|
child: const Icon(
|
|
Icons.close,
|
|
size: 16,
|
|
color: AppColors.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Hero Section
|
|
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 24),
|
|
child: SizedBox(
|
|
height: 250,
|
|
child: HeroCarousel(
|
|
items: heroItems,
|
|
onTap: (item) {
|
|
try {
|
|
final series = _series.firstWhere(
|
|
(s) => s.seriesId.toString() == item.id,
|
|
);
|
|
_openSeries(series);
|
|
} catch (e) {
|
|
// Fallback
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Grid
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
|
|
sliver: SliverGrid(
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
crossAxisSpacing: 12,
|
|
mainAxisSpacing: 16,
|
|
childAspectRatio: 0.65,
|
|
),
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) {
|
|
if (index >= displaySeries.length) return null;
|
|
final series = displaySeries[index];
|
|
|
|
return GestureDetector(
|
|
onTap: () => _openSeries(series),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
// Poster
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child:
|
|
series.cover != null && series.cover!.isNotEmpty
|
|
? Image.network(
|
|
_getProxiedImageUrl(series.cover, ref),
|
|
fit: BoxFit.cover,
|
|
cacheWidth: 360,
|
|
filterQuality: FilterQuality.low,
|
|
errorBuilder: (_, __, ___) =>
|
|
Container(color: AppColors.surfaceContainerLow),
|
|
)
|
|
: Container(
|
|
color: AppColors.surfaceContainerLow,
|
|
child: const Icon(
|
|
Icons.tv,
|
|
color: AppColors.outlineVariant,
|
|
),
|
|
),
|
|
),
|
|
|
|
// Gradient Overlay
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 80,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.transparent,
|
|
AppColors.background.withOpacity(0.9),
|
|
],
|
|
),
|
|
borderRadius: const BorderRadius.vertical(
|
|
bottom: Radius.circular(16),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Title
|
|
Positioned(
|
|
bottom: 12,
|
|
left: 12,
|
|
right: 12,
|
|
child: Text(
|
|
series.name,
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurface,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
|
|
// Rating
|
|
if (series.rating != null && series.rating!.isNotEmpty)
|
|
Positioned(
|
|
top: 8,
|
|
right: 8,
|
|
child: GlassContainer.glass(
|
|
borderRadius: 4,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 6,
|
|
vertical: 2,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.star,
|
|
size: 10,
|
|
color: AppColors.ratingGold,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
_formatRating(series.rating)!,
|
|
style: GoogleFonts.karla(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
childCount: displaySeries.length,
|
|
),
|
|
),
|
|
),
|
|
|
|
if (_isLoading)
|
|
const SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24),
|
|
child: Center(
|
|
child: CircularProgressIndicator(color: AppColors.onSurface),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|