mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-19 13:08:05 +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>
427 lines
15 KiB
Dart
427 lines
15 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/providers/watch_history_provider.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_player_screen.dart';
|
|
|
|
class MobileMoviesTab extends ConsumerStatefulWidget {
|
|
final PlaylistConfig playlist;
|
|
const MobileMoviesTab({super.key, required this.playlist});
|
|
|
|
@override
|
|
ConsumerState<MobileMoviesTab> createState() => _MobileMoviesTabState();
|
|
}
|
|
|
|
class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
|
|
final ScrollController _scrollController = ScrollController();
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final List<Movie> _movies = [];
|
|
List<Movie>? _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();
|
|
_loadMoreMovies();
|
|
_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) {
|
|
_loadMoreMovies();
|
|
}
|
|
}
|
|
|
|
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.searchMovies(query);
|
|
|
|
if (mounted && _searchQuery == query) {
|
|
setState(() {
|
|
_searchResults = results;
|
|
_isSearching = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
if (mounted) setState(() => _isSearching = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadMoreMovies() async {
|
|
if (_isLoading || !_hasMore || !mounted) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
|
final newMovies = await service.getMoviesPaginated(
|
|
offset: _currentOffset,
|
|
limit: _pageSize,
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_movies.addAll(newMovies);
|
|
_currentOffset += _pageSize;
|
|
_hasMore = newMovies.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));
|
|
// Re-use core _wrapWithProxy logic (via a new public method or direct construction)
|
|
return '${service.backendBaseUrl}/api/xtream/$originalUrl';
|
|
}
|
|
return originalUrl;
|
|
}
|
|
|
|
void _playMovie(Movie movie, List<Movie> movies) {
|
|
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
|
|
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => MobilePlayerScreen(
|
|
streamId: movie.streamId,
|
|
title: movie.name,
|
|
playlist: widget.playlist,
|
|
streamType: StreamType.vod,
|
|
containerExtension: movie.containerExtension ?? 'mp4',
|
|
channels: movies, // Pass list for zapping
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final settings = ref.watch(iptvSettingsProvider);
|
|
final watchHistory = ref.watch(watchHistoryProvider);
|
|
|
|
List<Movie> displayMovies;
|
|
if (_searchQuery.isNotEmpty && _searchResults != null) {
|
|
displayMovies = _searchResults!;
|
|
} else {
|
|
displayMovies = settings.moviesKeywords.isEmpty
|
|
? _movies
|
|
: _movies
|
|
.where((m) => settings.matchesMoviesFilter(m.categoryName))
|
|
.toList();
|
|
}
|
|
|
|
final heroItems = displayMovies
|
|
.take(3)
|
|
.map(
|
|
(m) => HeroItem(
|
|
id: m.streamId,
|
|
title: m.name,
|
|
imageUrl: _getProxiedImageUrl(m.streamIcon, ref),
|
|
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
|
|
onMoreInfo: () => _playMovie(m, displayMovies),
|
|
),
|
|
)
|
|
.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 Movies...',
|
|
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) {
|
|
final movie = _movies
|
|
.firstWhere((element) => element.streamId == item.id);
|
|
_playMovie(movie, _movies);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Grid
|
|
SliverPadding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
|
|
sliver: SliverGrid(
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 2,
|
|
crossAxisSpacing: 16,
|
|
mainAxisSpacing: 16,
|
|
childAspectRatio: 0.65,
|
|
),
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) {
|
|
if (index >= displayMovies.length) return null;
|
|
final movie = displayMovies[index];
|
|
final isWatched = watchHistory.isMovieWatched(movie.streamId);
|
|
|
|
return GestureDetector(
|
|
onTap: () => _playMovie(movie, displayMovies),
|
|
onLongPress: () {
|
|
ref
|
|
.read(watchHistoryProvider.notifier)
|
|
.toggleMovieWatched(movie.streamId);
|
|
},
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
// Poster
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: movie.streamIcon != null &&
|
|
movie.streamIcon!.isNotEmpty
|
|
? Image.network(
|
|
_getProxiedImageUrl(movie.streamIcon, ref),
|
|
fit: BoxFit.cover,
|
|
cacheWidth: 360,
|
|
filterQuality: FilterQuality.low,
|
|
errorBuilder: (_, __, ___) =>
|
|
Container(color: AppColors.surfaceContainerLow),
|
|
)
|
|
: Container(
|
|
color: AppColors.surfaceContainerLow,
|
|
child: const Icon(
|
|
Icons.movie,
|
|
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: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
movie.name,
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.onSurface,
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (isWatched)
|
|
Text(
|
|
'WATCHED',
|
|
style: GoogleFonts.karla(
|
|
color: AppColors.primary,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Rating
|
|
if (movie.rating != null && movie.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(movie.rating)!,
|
|
style: GoogleFonts.karla(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
childCount: displayMovies.length,
|
|
),
|
|
),
|
|
),
|
|
|
|
if (_isLoading)
|
|
const SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24),
|
|
child: Center(
|
|
child: CircularProgressIndicator(color: AppColors.onSurface),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|