mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-17 04:08:29 +02:00
feat: add mobile Xtream service, data providers, and UI widgets for Live TV and Movies.
This commit is contained in:
1 parent
527582e21b
commit
a1b1363bc8
9 files changed
+875
-774
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user