feat: add mobile Xtream service, data providers, and UI widgets for Live TV and Movies.

This commit is contained in:
Michael committed 2026-03-27 15:15:44 +01:00
1 parent 527582e21b
commit a1b1363bc8
9 files changed
+875 -774

No files matched your search

+15 -14
View File
@@ -6,8 +6,8 @@ import 'package:flutter/material.dart';
class AppDecorations {
AppDecorations._();
static bool _isDark(BuildContext context) =>
Theme.of(context).brightness == Brightness.dark;
// Force Dark/Glossy mode for IPTV experience regardless of system theme
static bool _isDark(BuildContext context) => true;
// ─── Backgrounds ────────────────────────────────────────────────────────────
@@ -86,9 +86,9 @@ class AppDecorations {
border: Border.all(color: const Color(0x28FFFFFF), width: 1),
boxShadow: const [
BoxShadow(
color: Color(0xCC000000), // Increased opacity from 0xAA
color: Color(0xCC000000),
blurRadius: 16,
spreadRadius: 2, // Added spread to make it visible all around
spreadRadius: 2,
offset: Offset(0, 4),
),
],
@@ -119,11 +119,10 @@ class AppDecorations {
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x25FFFFFF), Color(0x00FFFFFF)], // Reduced from 0x40
stops: [0.0, 0.45], // Reduced stop
colors: [Color(0x45FFFFFF), Color(0x00FFFFFF)],
stops: [0.0, 0.5],
),
borderRadius:
BorderRadius.vertical(top: Radius.circular(radius)),
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
);
}
return BoxDecoration(
@@ -133,21 +132,23 @@ class AppDecorations {
colors: [Color(0x0A000000), Color(0x00000000)],
stops: [0.0, 0.55],
),
borderRadius:
BorderRadius.vertical(top: Radius.circular(radius)),
borderRadius: BorderRadius.vertical(top: Radius.circular(radius)),
);
}
/// Smaller channel card base (top area behind the logo).
static BoxDecoration channelCardBase(BuildContext context) {
if (_isDark(context)) {
return const BoxDecoration(
gradient: LinearGradient(
return BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF121214), Color(0xFF1C1C1E)], // Top darkened from 3A3A3C
colors: [Color(0xFF2C2C2E), Color(0xFF1C1C1E)],
),
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
border: Border(
top: BorderSide(color: Colors.white.withOpacity(0.12), width: 0.5),
),
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
);
}
return const BoxDecoration(
@@ -30,7 +30,7 @@ class _MobileSeriesDetailScreenState
model.SeriesInfo? _seriesInfo;
bool _isLoading = true;
String? _error;
int _selectedSeason = 1;
String _selectedSeason = "1";
@override
void initState() {
@@ -64,8 +64,7 @@ class _MobileSeriesDetailScreenState
_seriesInfo = info;
_isLoading = false;
if (_seriesInfo!.episodes.isNotEmpty) {
final firstKey = _seriesInfo!.episodes.keys.first;
_selectedSeason = int.tryParse(firstKey.toString()) ?? 1;
_selectedSeason = _seriesInfo!.episodes.keys.first.toString();
}
});
}
@@ -258,7 +257,7 @@ class _MobileSeriesDetailScreenState
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _selectedSeason = int.tryParse(seasonNum.toString()) ?? 1);
setState(() => _selectedSeason = seasonNum.toString());
}
},
backgroundColor: isDark
@@ -310,7 +309,7 @@ class _MobileSeriesDetailScreenState
final watchHistory = ref.watch(mobileWatchHistoryProvider);
final episodeKey = MobileWatchHistory.episodeKey(
widget.series.seriesId,
_selectedSeason,
int.tryParse(_selectedSeason) ?? 1,
episode.episodeNum,
);
final isWatched = watchHistory.isEpisodeWatched(episodeKey);
@@ -396,29 +396,77 @@ class XtreamServiceMobile {
'password': _password,
'action': 'get_series',
},
options: Options(receiveTimeout: const Duration(seconds: 45)),
);
if (response.statusCode == 200 && response.data is List) {
return (response.data as List)
.map((e) => model.Series.fromJson(e))
.toList();
}
if (kDebugMode) {
print('⚠️ get_series returned unexpected status or type: ${response.statusCode}');
}
return [];
} catch (e) {
return [];
}
}
/// Search series
Future<List<model.Series>> searchSeries(String query) async {
final all = await getSeries();
return all
.where((s) => s.name.toLowerCase().contains(query.toLowerCase()))
.toList();
/// Get series paginated / filtered by category
Future<List<model.Series>> getSeriesPaginated({String? categoryId}) async {
try {
final queryParams = {
'username': _username,
'password': _password,
'action': 'get_series',
};
if (categoryId != null && categoryId.isNotEmpty) {
queryParams['category_id'] = categoryId;
}
final response = await _dio.get(
'$_baseUrl/player_api.php',
queryParameters: queryParams,
options: Options(receiveTimeout: const Duration(seconds: 30)),
);
if (response.statusCode == 200 && response.data is List) {
return (response.data as List)
.map((e) => model.Series.fromJson(e))
.toList();
}
return [];
} catch (e) {
if (kDebugMode) print('❌ Error loading series by category: $e');
return [];
}
}
/// Get series paginated
Future<List<model.Series>> getSeriesPaginated({int? categoryId}) async {
return getSeries(); // Simplified for now, can add filtering by categoryId later
/// Get series categories
Future<List<model.Category>> getSeriesCategories() async {
try {
final response = await _dio.get(
'$_baseUrl/player_api.php',
queryParameters: {
'username': _username,
'password': _password,
'action': 'get_series_categories',
},
options: Options(receiveTimeout: const Duration(seconds: 15)),
);
if (response.statusCode == 200 && response.data is List) {
return (response.data as List)
.map((e) => model.Category.fromJson(e))
.toList();
}
return [];
} catch (e) {
if (kDebugMode) print('❌ Error loading series categories: $e');
return [];
}
}
/// Get series info
@@ -482,6 +530,20 @@ class XtreamServiceMobile {
return '$_baseUrl/series/$_username/$_password/$streamId.$extension';
}
/// Search series (local filter on full list)
Future<List<model.Series>> searchSeries(String query) async {
try {
final allSeries = await getSeries();
if (query.isEmpty) return allSeries;
final normalizedQuery = query.toLowerCase();
return allSeries.where((s) => s.name.toLowerCase().contains(normalizedQuery)).toList();
} catch (e) {
if (kDebugMode) print('❌ Error searching series: $e');
return [];
}
}
/// Dispose service resources
void dispose() {
clearCache();
+352 -375
View File
@@ -11,6 +11,7 @@ import 'package:xtremobile/core/models/iptv_models.dart';
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/mobile/widgets/mobile_category_card.dart';
import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
import 'package:flutter/services.dart';
import 'package:xtremobile/core/services/ip_service.dart';
@@ -85,7 +86,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
!_showFavoritesOnly;
return Container(
decoration: AppDecorations.background(context),
color: const Color(0xFF000000), // Pure black background
child: Scaffold(
backgroundColor: Colors.transparent,
body: PopScope(
@@ -192,176 +193,186 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
return SafeArea(
bottom: false,
child: Column(
children: [
// Header (Search + Title/Back)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Column(
children: [
// Search Bar - Wrapped in TVFocusable for remote access
TVFocusable(
scale: 1.0, // Disable scaling to prevent overflow
focusColor:
Colors.white, // Solid white selection frame
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
SystemChannels.textInput
.invokeMethod('TextInput.show');
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: AppDecorations.searchBar(context),
padding:
const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(
Icons.search,
color: AppDecorations.textSecondary(context),
),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(
color: AppDecorations.textPrimary(context),
),
decoration: const InputDecoration(
hintText: 'Rechercher une chaîne...',
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
),
onSubmitted: (_) => setState(
() => _isSearchEditing = false,
),
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () => _searchController.clear(),
child: Icon(
Icons.close,
child: CustomScrollView(
slivers: [
// Floating Header (Search + Navigation)
SliverAppBar(
floating: true,
pinned: false,
snap: true,
backgroundColor: Colors.transparent, // Minimal background
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
toolbarHeight: _searchQuery.isNotEmpty || _showFavoritesOnly || !uiState.isCategoryView
? 120.0
: 110.0,
flexibleSpace: FlexibleSpaceBar(
background: Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance
.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
SystemChannels.textInput
.invokeMethod('TextInput.show');
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: AppDecorations.searchBar(context),
padding:
const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(
Icons.search,
color: AppDecorations.textSecondary(context),
),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(
color:
AppDecorations.textPrimary(context),
),
decoration: const InputDecoration(
hintText: 'Rechercher une chaîne...',
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
),
onSubmitted: (_) => setState(
() => _isSearchEditing = false,
),
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () => _searchController.clear(),
child: Icon(
Icons.close,
color:
AppDecorations.textSecondary(context),
),
),
],
),
),
),
const SizedBox(height: 12),
// Title / Navigation Row
Row(
children: [
if (!showGrid ||
_showFavoritesOnly ||
_searchQuery.isNotEmpty) ...[
IconButton(
icon: Icon(
Icons.arrow_back,
color: AppDecorations.textPrimary(context),
),
onPressed: () {
if (_searchQuery.isNotEmpty) {
_searchController.clear();
} else if (_showFavoritesOnly) {
setState(
() => _showFavoritesOnly = false,
);
} else {
uiNotifier.state = uiState.copyWith(
isCategoryView: true,
);
}
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
_searchQuery.isNotEmpty
? 'Résultats de recherche'
: _showFavoritesOnly
? 'Favoris'
: showGrid
? 'Catégories'
: uiState.selectedCategory ??
'Chaînes',
style: TextStyle(
color: AppDecorations.textPrimary(context),
fontWeight: FontWeight.bold,
fontSize: 18,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
_showFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? const Color(0xFFFF453A)
: AppDecorations.textSecondary(context),
),
onPressed: () => setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
if (_showFavoritesOnly) {
uiNotifier.state = uiState.copyWith(
isCategoryView: false);
}
if (!_showFavoritesOnly) {
uiNotifier.state =
uiState.copyWith(isCategoryView: true);
}
}),
),
],
),
),
),
const SizedBox(height: 12),
// Title / Navigation Row
Row(
children: [
if (!showGrid ||
_showFavoritesOnly ||
_searchQuery.isNotEmpty) ...[
IconButton(
icon: Icon(
Icons.arrow_back,
color: AppDecorations.textPrimary(context),
),
onPressed: () {
if (_searchQuery.isNotEmpty) {
_searchController.clear();
} else if (_showFavoritesOnly) {
setState(
() => _showFavoritesOnly = false,
);
} else {
uiNotifier.state = uiState.copyWith(
isCategoryView: true,
);
}
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
_searchQuery.isNotEmpty
? 'Résultats de recherche'
: _showFavoritesOnly
? 'Favoris'
: showGrid
? 'Catégories'
: uiState.selectedCategory ??
'Chaînes',
style: TextStyle(
color: AppDecorations.textPrimary(context),
fontWeight: FontWeight.bold,
fontSize: 18,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
_showFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _showFavoritesOnly
? const Color(0xFFFF453A)
: AppDecorations.textSecondary(context),
),
onPressed: () => setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
// Reset category view if entering favorites
if (_showFavoritesOnly) {
uiNotifier.state =
uiState.copyWith(isCategoryView: false);
}
// If exiting favorites, default back to category grid
if (!_showFavoritesOnly) {
uiNotifier.state =
uiState.copyWith(isCategoryView: true);
}
}),
),
],
),
],
),
),
),
// Main Content
Expanded(
child: showGrid
? _buildCategoryGrid(categories, uiNotifier, uiState)
: _ChannelListView(
playlist: widget.playlist,
uiState: uiState,
searchQuery: _searchQuery,
showFavoritesOnly: _showFavoritesOnly,
onPlay: (channel, channels, index) =>
_playChannel(
context,
channel,
channels,
widget.playlist,
index,
),
),
),
// Main Content Sliver
if (showGrid)
_buildCategorySliver(categories, uiNotifier, uiState)
else
_ChannelListView(
playlist: widget.playlist,
uiState: uiState,
searchQuery: _searchQuery,
showFavoritesOnly: _showFavoritesOnly,
onPlay: (channel, channels, index) => _playChannel(
context,
channel,
channels,
widget.playlist,
index,
),
),
],
),
);
@@ -372,88 +383,50 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
);
}
Widget _buildCategoryGrid(
Widget _buildCategorySliver(
List<model.Category> categories,
StateController<LiveTvUiState> uiNotifier,
LiveTvUiState uiState,
) {
if (categories.isEmpty) {
return Center(
child: Text(
'Aucune catégorie trouvée',
style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)),
return SliverFillRemaining(
child: Center(
child: Text(
'Aucune catégorie trouvée',
style:
GoogleFonts.inter(color: AppDecorations.textSecondary(context)),
),
),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6, // Adjusted to 6 columns as requested
childAspectRatio: 1.0,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return TVFocusable(
onPressed: () {
uiNotifier.state = LiveTvUiState(
selectedCategory: category.categoryName,
selectedCategoryId: category.categoryId,
isCategoryView: false,
return SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 80),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
childAspectRatio: 1.0,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final category = categories[index];
return MobileCategoryCard(
title: category.categoryName,
icon: Icons.live_tv_rounded,
onTap: () {
uiNotifier.state = LiveTvUiState(
selectedCategory: category.categoryName,
selectedCategoryId: category.categoryId,
isCategoryView: false,
);
},
);
},
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
// Base gradient (theme-aware)
Container(decoration: AppDecorations.glossyCard(context)),
// Glossy highlight overlay (top shimmer)
Positioned(
top: 0,
left: 0,
right: 0,
height: 60,
child: Container(
decoration: AppDecorations.glossShimmer(context),
),
),
// Content (icon + text) — Positioned.fill ensures Column fills the card
Positioned.fill(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.live_tv_rounded,
color: Color(0xFF0A84FF),
size: 36,
),
const SizedBox(height: 10),
Text(
category.categoryName,
style: TextStyle(
color: AppDecorations.textPrimary(context),
fontWeight: FontWeight.w600,
fontSize: 13,
letterSpacing: 0.2,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
), // closes Positioned.fill
],
),
);
},
childCount: categories.length,
),
),
);
}
@@ -532,11 +505,12 @@ class _ChannelListView extends ConsumerWidget {
final categoryId = uiState.selectedCategoryId;
if (categoryId == null && !showFavoritesOnly && searchQuery.isEmpty) {
// No category selected and no filter → go back to categories
return Center(
child: Text(
'Aucune catégorie sélectionnée',
style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)),
return const SliverFillRemaining(
child: Center(
child: Text(
'Aucune catégorie sélectionnée',
style: TextStyle(color: Colors.white70),
),
),
);
}
@@ -548,32 +522,35 @@ class _ChannelListView extends ConsumerWidget {
: null;
if (channelsAsync == null) {
// Favorites or search without category: need channels — show loading
// For favorites/search without a category, we can't load without a categoryId.
// Show a message directing user to select a category first.
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
showFavoritesOnly
? 'Sélectionnez une catégorie pour voir vos favoris'
: 'Sélectionnez une catégorie pour rechercher',
style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)),
textAlign: TextAlign.center,
return SliverFillRemaining(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
showFavoritesOnly
? 'Sélectionnez une catégorie pour voir vos favoris'
: 'Sélectionnez une catégorie pour rechercher',
style: const TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
),
),
);
}
return channelsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Erreur: $e',
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
loading: () => const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
error: (e, s) => SliverFillRemaining(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Erreur: $e',
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
),
),
@@ -593,31 +570,37 @@ class _ChannelListView extends ConsumerWidget {
}
if (displayedChannels.isEmpty) {
return Center(
child: Text(
'Aucune chaîne trouvée',
style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)),
return const SliverFillRemaining(
child: Center(
child: Text(
'Aucune chaîne trouvée',
style: TextStyle(color: Colors.white70),
),
),
);
}
return GridView.builder(
return SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
childAspectRatio: 0.85,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
childAspectRatio: 1.0,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final channel = displayedChannels[index];
return _MobileChannelCard(
channel: channel,
playlist: playlist,
onTap: () => onPlay(channel, displayedChannels, index),
);
},
childCount: displayedChannels.length,
),
),
itemCount: displayedChannels.length,
itemBuilder: (context, index) {
final channel = displayedChannels[index];
return _MobileChannelCard(
channel: channel,
playlist: playlist,
onTap: () => onPlay(channel, displayedChannels, index),
);
},
);
},
);
@@ -680,131 +663,125 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> {
.read(mobileFavoritesProvider.notifier)
.toggle(widget.channel.streamId);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
decoration: AppDecorations.glossyCard(context, radius: 12),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Base card (theme-aware)
Container(
decoration: AppDecorations.glossyCard(context, radius: 12),
),
// Glossy highlight overlay (top shimmer)
Positioned(
top: 0,
left: 0,
right: 0,
height: 50,
height: 40,
child: Container(
decoration: AppDecorations.glossShimmer(context, radius: 12),
),
),
// Content
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Channel Logo
Expanded(
flex: 3,
child: Container(
width: double.infinity,
decoration: AppDecorations.channelCardBase(context),
child: Stack(
children: [
// Logo
// [P1-2 FIX] Optimize channel icon cache (40x40 display)
Center(
child: iconUrl != null
? CachedNetworkImage(
imageUrl: iconUrl,
fit: BoxFit.contain,
memCacheWidth: 50,
memCacheHeight: 50,
maxWidthDiskCache: 50,
maxHeightDiskCache: 50,
cacheManager: AppCacheManager.instance,
errorWidget: (_, __, ___) => Icon(
Icons.tv,
color: AppDecorations.iconMuted(context),
size: 40,
),
placeholder: (_, __) => Icon(
Icons.tv,
color: AppDecorations.iconMuted(context),
size: 40,
),
)
: Icon(
Icons.tv,
color: AppDecorations.iconMuted(context),
size: 40,
),
),
// Favorite badge
if (isFavorite)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.favorite,
color: Colors.white,
size: 12,
),
Positioned.fill(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Channel Logo
Expanded(
flex: 3,
child: Container(
decoration: AppDecorations.channelCardBase(context),
child: Stack(
children: [
// Logo
Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: iconUrl != null
? CachedNetworkImage(
imageUrl: iconUrl,
fit: BoxFit.contain,
memCacheHeight: 120,
cacheManager: AppCacheManager.instance,
errorWidget: (_, __, ___) => Icon(
Icons.tv,
color: AppDecorations.iconMuted(context),
size: 30,
),
placeholder: (_, __) => Icon(
Icons.tv,
color: AppDecorations.iconMuted(context),
size: 30,
),
)
: Icon(
Icons.tv,
color: AppDecorations.iconMuted(context),
size: 30,
),
),
),
],
// Favorite badge
if (isFavorite)
Positioned(
top: 4,
right: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.favorite,
color: Colors.white,
size: 10,
),
),
),
],
),
),
),
),
// Channel Name
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.channel.name,
style: TextStyle(
color: AppDecorations.textPrimary(context),
fontWeight: FontWeight.w500,
fontSize: 11,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (_epgTitle != null) ...[
const SizedBox(height: 2),
// Channel Info
Expanded(
flex: 2,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_epgTitle!,
widget.channel.name,
style: const TextStyle(
color: Colors.yellow,
fontSize: 9,
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 10,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (_epgTitle != null) ...[
const SizedBox(height: 1),
Text(
_epgTitle!,
style: const TextStyle(
color: Colors.yellow,
fontSize: 8,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
],
),
),
),
),
],
), // end channel card content
],
),
),
],
), // end Stack
), // end ClipRRect
); // end TVFocusable
),
),
);
}
}
+96 -117
View File
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/mobile/widgets/mobile_poster_card.dart';
import 'package:xtremobile/mobile/widgets/mobile_category_card.dart';
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
@@ -321,31 +322,97 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
color: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.surface,
child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Category Title (If inside a category)
if (showingMovies)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
// Pinned Header
SliverAppBar(
pinned: true,
floating: true,
backgroundColor: AppDecorations.background(context).gradient is LinearGradient
? (AppDecorations.background(context).gradient as LinearGradient).colors.first
: Theme.of(context).scaffoldBackgroundColor,
elevation: 4,
automaticallyImplyLeading: false,
expandedHeight: showingMovies ? 110 : 110,
collapsedHeight: showingMovies ? 110 : 110,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
children: [
IconButton(
icon: const Icon(
Icons.arrow_back,
color: Colors.white,
),
onPressed: _onBack,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedCategoryName ?? 'Films',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
if (showingMovies)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _onBack,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedCategoryName ?? 'Films',
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
),
),
],
),
)
else
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 40,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(Icons.search, size: 20, color: AppDecorations.textSecondary(context)),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: AppDecorations.textPrimary(context),
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(fontSize: 14, color: AppDecorations.textPrimary(context)),
decoration: InputDecoration(
hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher un film...',
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching) const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: Icon(Icons.close, size: 16, color: AppDecorations.textSecondary(context)),
),
],
),
),
),
),
@@ -353,88 +420,6 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
),
),
),
// Search Bar
SliverToBoxAdapter(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 40,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(
Icons.search,
size: 20,
color: AppDecorations.textSecondary(context),
),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: AppDecorations.textPrimary(context),
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(
fontSize: 14,
color: AppDecorations.textPrimary(context),
),
decoration: InputDecoration(
hintText: _selectedCategoryId != null
? 'Recherche Globale (Tout le catalogue)'
: 'Rechercher un film...',
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
contentPadding:
const EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
onSubmitted: (_) =>
setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching)
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: Icon(
Icons.close,
size: 16,
color: AppDecorations.textSecondary(context),
),
),
],
),
),
),
),
),
// Grid
@@ -442,26 +427,20 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: showingCategories
? 4
: 8, // Bigger cards for categories
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: showingCategories
? 2.5
: 0.7, // Wide cards for categories
crossAxisCount: showingCategories ? 3 : 4,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: showingCategories ? 2.0 : 0.67,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (showingCategories) {
if (index >= filteredCategories.length) return null;
final cat = filteredCategories[index];
return MobilePosterCard(
title: cat.value, // Name
imageUrl: null, // No image for category
placeholderIcon: Icons.folder, // Folder icon
return MobileCategoryCard(
title: cat.value,
icon: Icons.movie_filter_rounded,
onTap: () => _selectCategory(cat.key, cat.value),
isWatched: false,
);
} else if (showingMovies) {
if (index >= _categoryMovies.length) return null;
+234 -237
View File
@@ -4,9 +4,9 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
import 'package:xtremobile/mobile/widgets/mobile_poster_card.dart';
import 'package:xtremobile/mobile/widgets/mobile_category_card.dart';
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/core/widgets/components/hero_carousel.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/models/iptv_models.dart' as model;
@@ -25,20 +25,31 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
final List<model.Series> _series = [];
// Data
List<MapEntry<String, String>> _categories = [];
List<model.Series> _categorySeries = [];
String? _selectedCategoryId;
String? _selectedCategoryName;
// Search
List<model.Series>? _searchResults;
String _searchQuery = '';
bool _isSearchEditing = false;
bool _isLoading = false;
bool _isSearching = false;
bool _hasMore = true;
Timer? _searchDebounce;
// State
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadMoreSeries();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _categories.isEmpty) {
_loadCategories();
}
});
}
@override
@@ -50,16 +61,6 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
super.dispose();
}
void _onScroll() {
// Only trigger load when near bottom and not already loading
if (!_isLoading &&
_hasMore &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) {
_loadMoreSeries();
}
}
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
setState(() {
@@ -84,12 +85,11 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final results = await service.getSeries(); // Fallback to full list if searchSeries not available
final filteredResults = results.where((s) => s.name.toLowerCase().contains(query.toLowerCase())).toList();
final results = await service.searchSeries(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = filteredResults;
_searchResults = results;
_isSearching = false;
});
}
@@ -98,41 +98,72 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
}
}
Future<void> _loadMoreSeries() async {
// Prevent race conditions
if (_isLoading || !_hasMore || !mounted) return;
Future<void> _loadCategories() async {
if (_isLoading || !mounted) return;
setState(() => _isLoading = true);
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
// Add timeout to prevent infinite loading state
final newSeries = await service
.getSeriesPaginated()
.timeout(const Duration(seconds: 45));
final categories = await service.getSeriesCategories();
if (mounted) {
setState(() {
_series.addAll(newSeries);
_hasMore = false; // Disable pagination for now as it's not supported by simple getSeries
_categories = categories
.map((c) => MapEntry(c.categoryId, c.categoryName))
.toList()
..sort((a, b) => a.value.compareTo(b.value));
_isLoading = false;
});
}
} catch (e) {
debugPrint('Error loading series: $e');
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _selectCategory(String id, String name) async {
setState(() {
_selectedCategoryId = id;
_selectedCategoryName = name;
_categorySeries = [];
_isLoading = true;
});
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final series = await service.getSeriesPaginated(categoryId: id);
if (mounted) {
setState(() {
_categorySeries = series;
_isLoading = false;
// Do NOT set _hasMore to false on error
});
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
void _onBack() {
if (_searchQuery.isNotEmpty) {
_onSearchChanged('');
_searchController.clear();
setState(() => _isSearchEditing = false);
return;
}
if (_selectedCategoryId != null) {
setState(() {
_selectedCategoryId = null;
_selectedCategoryName = null;
_categorySeries = [];
});
return;
}
}
Future<void> _refresh() async {
// Clear service cache to force fresh data
try {
final service =
await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
@@ -140,14 +171,16 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
} catch (_) {}
setState(() {
_series.clear();
_hasMore = true;
_categories.clear();
_selectedCategoryId = null;
_selectedCategoryName = null;
_categorySeries.clear();
_searchResults = null;
_searchQuery = '';
_searchController.clear();
_isLoading = false;
});
await _loadMoreSeries();
await _loadCategories();
}
String? _formatRating(String? rating) {
@@ -159,7 +192,6 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
return rating;
}
/// On mobile, use direct URLs (no proxy needed)
String _getImageUrl(String? originalUrl) {
if (originalUrl == null || originalUrl.isEmpty) return '';
return originalUrl;
@@ -182,37 +214,36 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
@override
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
super.build(context);
final settings = ref.watch(mobileSettingsProvider);
List<model.Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!;
} else {
displaySeries = settings.seriesKeywords.isEmpty
? _series
: _series
.where((s) => settings.matchesSeriesFilter(s.categoryName))
.toList();
if (_categories.isEmpty && !_isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _categories.isEmpty) {
_loadCategories();
}
});
}
// Hero Items (use filtered series)
final heroItems = displaySeries
.take(3)
.map(
(s) {
final serie = s; // model.Series handle is enough
return HeroItem(
id: serie.streamId,
title: serie.name,
imageUrl: _getImageUrl(serie.cover),
subtitle: serie.rating.isNotEmpty ? '${_formatRating(serie.rating)} ★' : null,
onMoreInfo: () => _openSeries(serie),
);
},
)
final bool showingCategories =
_searchQuery.isEmpty && _selectedCategoryId == null;
final bool showingSeries =
_searchQuery.isEmpty && _selectedCategoryId != null;
final bool showingSearch = _searchQuery.isNotEmpty;
final filteredCategories = _categories
.where((entry) => settings.matchesSeriesFilter(entry.value))
.toList();
int itemCount = 0;
if (showingCategories) {
itemCount = filteredCategories.length;
} else if (showingSeries) {
itemCount = _categorySeries.length;
} else if (showingSearch) {
itemCount = _searchResults?.length ?? 0;
}
return Container(
decoration: AppDecorations.background(context),
child: PopScope(
@@ -221,40 +252,32 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
if (didPop) return;
if (_searchQuery.isNotEmpty || _isSearching) {
_onSearchChanged(''); // clear search
_onSearchChanged('');
_searchController.clear();
setState(() => _isSearchEditing = false);
return;
}
// Root View -> Show Exit Dialog
if (_selectedCategoryId != null) {
_onBack();
return;
}
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: Colors.grey[900],
title: const Text(
"Quitter l'application",
style: TextStyle(color: Colors.white),
),
content: const Text(
"Voulez-vous vraiment quitter l'application ?",
style: TextStyle(color: Colors.white70),
),
title: const Text("Quitter l'application", style: TextStyle(color: Colors.white)),
content: const Text("Voulez-vous vraiment quitter l'application ?", style: TextStyle(color: Colors.white70)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
autofocus: true,
child: const Text(
'Annuler',
style: TextStyle(color: Colors.blueAccent),
),
child: const Text('Annuler', style: TextStyle(color: Colors.blueAccent)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text(
'Quitter',
style: TextStyle(color: Colors.redAccent),
),
child: const Text('Quitter', style: TextStyle(color: Colors.redAccent)),
),
],
),
@@ -268,142 +291,150 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
bottom: false,
child: RefreshIndicator(
onRefresh: _refresh,
color: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.surface,
child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Hero Section (reduced height, moved above search)
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: SizedBox(
height: 180, // Reduced from 250
child: HeroCarousel(
items: heroItems,
onTap: (item) {
try {
final series = _series.firstWhere(
(s) => s.streamId.toString() == item.id,
);
_openSeries(series);
} catch (e) {
// Fallback logic not critical
}
},
),
),
),
),
// Pinned Header
SliverAppBar(
pinned: true,
floating: true,
backgroundColor: AppDecorations.background(context).gradient is LinearGradient
? (AppDecorations.background(context).gradient as LinearGradient).colors.first
: Theme.of(context).scaffoldBackgroundColor,
elevation: 4,
automaticallyImplyLeading: false,
expandedHeight: showingSeries ? 110 : 110,
collapsedHeight: showingSeries ? 110 : 110,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
children: [
if (showingSeries)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _onBack,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedCategoryName ?? 'Séries',
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
),
),
],
),
)
else
const SizedBox(height: 10),
// Search Bar (moved below carousel)
SliverToBoxAdapter(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TVFocusable(
scale: 1.0, // Disable scaling to prevent overflow
focusColor: Colors.white, // Solid white selection frame
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 40,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(
Icons.search,
size: 20,
color: AppDecorations.textSecondary(context),
),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: AppDecorations.textPrimary(context),
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(
fontSize: 14,
color: AppDecorations.textPrimary(context),
),
decoration: const InputDecoration(
hintText: 'Rechercher une série...',
border: InputBorder.none,
focusedBorder: InputBorder
.none, // Explicitly remove focus border
enabledBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
onSubmitted: (_) =>
setState(() => _isSearchEditing = false),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 40,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(Icons.search, size: 20, color: AppDecorations.textSecondary(context)),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: AppDecorations.textPrimary(context),
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(fontSize: 14, color: AppDecorations.textPrimary(context)),
decoration: InputDecoration(
hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher une série...',
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching) const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: Icon(Icons.close, size: 16, color: AppDecorations.textSecondary(context)),
),
],
),
),
),
if (_isSearching)
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: Icon(
Icons.close,
size: 16,
color: AppDecorations.textSecondary(context),
),
),
],
),
),
],
),
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8, // Reduced size further (was 4)
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.7,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: showingCategories ? 3 : 4,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: showingCategories ? 2.0 : 0.67,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= displaySeries.length) return null;
final series = displaySeries[index];
return MobilePosterCard(
title: series.name,
imageUrl: _getImageUrl(series.cover),
rating: _formatRating(series.rating),
placeholderIcon: Icons.tv,
onTap: () => _openSeries(series),
);
if (showingCategories) {
if (index >= filteredCategories.length) return null;
final cat = filteredCategories[index];
return MobileCategoryCard(
title: cat.value,
icon: Icons.video_library_rounded,
onTap: () => _selectCategory(cat.key, cat.value),
);
} else if (showingSeries) {
if (index >= _categorySeries.length) return null;
final series = _categorySeries[index];
return MobilePosterCard(
title: series.name,
imageUrl: _getImageUrl(series.cover),
rating: _formatRating(series.rating),
placeholderIcon: Icons.tv,
onTap: () => _openSeries(series),
);
} else if (showingSearch) {
if (_searchResults == null || index >= _searchResults!.length) return null;
final series = _searchResults![index];
return MobilePosterCard(
title: series.name,
imageUrl: _getImageUrl(series.cover),
rating: _formatRating(series.rating),
placeholderIcon: Icons.tv,
onTap: () => _openSeries(series),
);
}
return null;
},
childCount: displaySeries.length,
childCount: itemCount,
),
),
),
@@ -415,47 +446,13 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
child: Center(child: CircularProgressIndicator()),
),
),
if (!_isLoading && displaySeries.isEmpty)
if (!_isLoading && itemCount == 0 && (_selectedCategoryId != null || _searchQuery.isNotEmpty))
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.video_library_outlined,
size: 64,
color: AppDecorations.textSecondary(context),
),
const SizedBox(height: 16),
Text(
'Aucune série chargée',
style: TextStyle(
color: AppDecorations.textPrimary(context),
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
'Le chargement a pris trop de temps ou une erreur est survenue.',
textAlign: TextAlign.center,
style: TextStyle(
color: AppDecorations.textSecondary(context),
fontSize: 14,
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () {
setState(() => _hasMore = true);
_loadMoreSeries();
},
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
child: Center(
child: Padding(
padding: const EdgeInsets.all(48),
child: Text('Aucune série trouvée', style: TextStyle(color: AppDecorations.textSecondary(context))),
),
),
),
@@ -107,11 +107,20 @@ final mobileMoviesProvider =
return service.getMoviesByCategory(""); // Fetch all or default
});
/// Mobile-specific series pagination provider
final mobileSeriesProvider =
FutureProvider.family<List<model.Series>, PlaylistConfig>((ref, playlist) async {
/// Mobile-specific series categories provider
final mobileSeriesCategoriesProvider =
FutureProvider.family<List<model.Category>, PlaylistConfig>(
(ref, playlist) async {
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getSeriesPaginated();
return service.getSeriesCategories();
});
/// Mobile-specific series pagination provider
final mobileSeriesProvider = FutureProvider.family<List<model.Series>,
(PlaylistConfig, String?)>((ref, params) async {
final (playlist, categoryId) = params;
final service = await ref.watch(mobileXtreamServiceProvider(playlist).future);
return service.getSeriesPaginated(categoryId: categoryId);
});
/// Series info with playlist context
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:xtremobile/core/theme/app_decorations.dart';
import 'package:xtremobile/mobile/widgets/tv_focusable.dart';
class MobileCategoryCard extends StatelessWidget {
final String title;
final VoidCallback onTap;
final IconData icon;
final Color iconColor;
const MobileCategoryCard({
super.key,
required this.title,
required this.onTap,
this.icon = Icons.folder_rounded,
this.iconColor = const Color(0xFF0A84FF),
});
@override
Widget build(BuildContext context) {
return TVFocusable(
onPressed: onTap,
borderRadius: BorderRadius.circular(16),
child: Stack(
children: [
// Base glossy background
Container(
decoration: AppDecorations.glossyCard(context, radius: 16),
),
// Glossy highlight overlay (shimmer)
Positioned(
top: 0,
left: 0,
right: 0,
height: 60,
child: IgnorePointer(
child: Container(
decoration: AppDecorations.glossShimmer(context, radius: 16),
),
),
),
// Content
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
icon,
color: iconColor,
size: 28,
),
const SizedBox(height: 8),
Text(
title,
style: TextStyle(
color: AppDecorations.textPrimary(context),
fontWeight: FontWeight.w600,
fontSize: 12,
letterSpacing: 0.1,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
],
),
);
}
}
+14 -13
View File
@@ -26,7 +26,6 @@ class MobilePosterCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
@@ -34,18 +33,7 @@ class MobilePosterCard extends StatelessWidget {
// Shadow lives on this outer Container so ClipRRect doesn't swallow it
Expanded(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: isDark
? const Color(0x99000000)
: const Color(0x28000000),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
decoration: AppDecorations.glossyCard(context, radius: 12),
child: TVFocusable(
onPressed: onTap,
onLongPress: onLongPress,
@@ -144,6 +132,19 @@ class MobilePosterCard extends StatelessWidget {
),
),
),
// Glossy highlight overlay (top shimmer)
Positioned(
top: 0,
left: 0,
right: 0,
height: 80, // Taller for posters
child: IgnorePointer(
child: Container(
decoration: AppDecorations.glossShimmer(context, radius: 12),
),
),
),
],
),
),