mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
feat: Implement a web-based IPTV player with HLS/MPEG-TS support and Flutter communication.
This commit is contained in:
1 parent
2ca92d8425
commit
55acbbc135
28 files changed
+2075
-1448
No files matched your search
@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/digital_clock.dart';
|
||||
import '../widgets/live_tv_tab.dart';
|
||||
import '../widgets/movies_tab.dart';
|
||||
@@ -11,7 +9,7 @@ import '../widgets/series_tab.dart';
|
||||
import '../widgets/settings_tab.dart';
|
||||
|
||||
/// Apple TV Style Dashboard
|
||||
///
|
||||
///
|
||||
/// Uses a floating Top Bar for navigation instead of a sidebar.
|
||||
/// Content flows underneath the top bar.
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
@@ -26,11 +24,17 @@ class DashboardScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTickerProviderStateMixin {
|
||||
class _DashboardScreenState extends ConsumerState<DashboardScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
|
||||
final List<String> _tabs = ['Live TV', 'Films', 'Séries', 'Réglages'];
|
||||
final List<IconData> _icons = [Icons.live_tv, Icons.movie, Icons.tv, Icons.settings];
|
||||
final List<IconData> _icons = [
|
||||
Icons.live_tv,
|
||||
Icons.movie,
|
||||
Icons.tv,
|
||||
Icons.settings
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -74,13 +78,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
|
||||
child: _buildLogo(),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// 2. Centered Navigation Tabs (Expanded to fill space, then Centered)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Container(
|
||||
height: 50,
|
||||
constraints: const BoxConstraints(maxWidth: 600), // Prevent taking too much space
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 600), // Prevent taking too much space
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
@@ -97,8 +102,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
indicatorPadding: const EdgeInsets.all(4),
|
||||
labelColor: Colors.black,
|
||||
unselectedLabelColor: Colors.white70, // Fixed: Improved readability
|
||||
labelStyle: GoogleFonts.inter(fontWeight: FontWeight.w700, fontSize: 13),
|
||||
unselectedLabelColor:
|
||||
Colors.white70, // Fixed: Improved readability
|
||||
labelStyle: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w700, fontSize: 13),
|
||||
overlayColor: WidgetStateProperty.all(Colors.transparent),
|
||||
tabs: List.generate(_tabs.length, (index) {
|
||||
return Tab(
|
||||
@@ -118,7 +125,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// 3. Search / Profile (Fixed Width for centering balance)
|
||||
SizedBox(
|
||||
width: 200,
|
||||
@@ -130,7 +137,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search, color: Colors.white),
|
||||
onPressed: () {
|
||||
// Global search trigger
|
||||
// Global search trigger
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -139,7 +146,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
|
||||
const CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: Colors.white24,
|
||||
child: Icon(Icons.person, size: 18, color: Colors.white),
|
||||
child:
|
||||
Icon(Icons.person, size: 18, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -165,17 +173,25 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Content
|
||||
TabBarView(
|
||||
controller: _tabController,
|
||||
physics: const NeverScrollableScrollPhysics(), // Disable swipe to avoid gesture conflicts
|
||||
physics:
|
||||
const NeverScrollableScrollPhysics(), // Disable swipe to avoid gesture conflicts
|
||||
children: [
|
||||
// Padding top to account for the transparent AppBar
|
||||
Padding(padding: const EdgeInsets.only(top: 80), child: LiveTVTab(playlist: widget.playlist)),
|
||||
Padding(padding: const EdgeInsets.only(top: 80), child: MoviesTab(playlist: widget.playlist)),
|
||||
Padding(padding: const EdgeInsets.only(top: 80), child: SeriesTab(playlist: widget.playlist)),
|
||||
const Padding(padding: EdgeInsets.only(top: 80), child: SettingsTab()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 80),
|
||||
child: LiveTVTab(playlist: widget.playlist)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 80),
|
||||
child: MoviesTab(playlist: widget.playlist)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 80),
|
||||
child: SeriesTab(playlist: widget.playlist)),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 80), child: SettingsTab()),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -190,7 +206,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [Color(0xFF43cea2), Color(0xFF185a9d)]), // Green to Blue
|
||||
gradient: const LinearGradient(colors: [
|
||||
Color(0xFF43cea2),
|
||||
Color(0xFF185a9d)
|
||||
]), // Green to Blue
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
|
||||
@@ -202,13 +221,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> with SingleTi
|
||||
children: [
|
||||
Text(
|
||||
'Xtrem',
|
||||
style: GoogleFonts.inter(fontWeight: FontWeight.w800, fontSize: 16, color: Colors.white),
|
||||
style: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 16,
|
||||
color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
'Flow',
|
||||
style: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 16,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
height: 0.8,
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -26,14 +26,16 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'Select Playlist',
|
||||
style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white),
|
||||
style: GoogleFonts.roboto(
|
||||
fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
if (currentUser?.isAdmin ?? false)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.admin_panel_settings, color: Colors.white70),
|
||||
icon:
|
||||
const Icon(Icons.admin_panel_settings, color: Colors.white70),
|
||||
onPressed: () => context.go('/admin'),
|
||||
tooltip: 'Admin Panel',
|
||||
),
|
||||
@@ -69,7 +71,9 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: Colors.red.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text('Error loading playlists', style: GoogleFonts.roboto(fontSize: 18, color: Colors.white70)),
|
||||
Text('Error loading playlists',
|
||||
style: GoogleFonts.roboto(
|
||||
fontSize: 18, color: Colors.white70)),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () => ref.refresh(playlistsProvider),
|
||||
@@ -84,7 +88,7 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.playlist_remove,
|
||||
size: 64,
|
||||
color: Colors.white24,
|
||||
@@ -113,7 +117,8 @@ class PlaylistSelectionScreen extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(24, 100, 24, 24), // Top padding for transparent AppBar
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
24, 100, 24, 24), // Top padding for transparent AppBar
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: 24,
|
||||
@@ -165,23 +170,25 @@ class _PlaylistCardState extends State<_PlaylistCard> {
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? Colors.white.withOpacity(0.15)
|
||||
color: _isHovered
|
||||
? Colors.white.withOpacity(0.15)
|
||||
: Colors.white.withOpacity(0.05), // Glass effect
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: _isHovered
|
||||
? Colors.white.withOpacity(0.5)
|
||||
color: _isHovered
|
||||
? Colors.white.withOpacity(0.5)
|
||||
: Colors.white.withOpacity(0.1),
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: _isHovered ? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
] : [],
|
||||
boxShadow: _isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
@@ -204,7 +211,8 @@ class _PlaylistCardState extends State<_PlaylistCard> {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.playlist.name,
|
||||
style: GoogleFonts.outfit( // Using Outfit for modern look
|
||||
style: GoogleFonts.outfit(
|
||||
// Using Outfit for modern look
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
@@ -229,4 +237,3 @@ class _PlaylistCardState extends State<_PlaylistCard> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
/// Horizontal carousel for displaying channels by category
|
||||
///
|
||||
///
|
||||
/// Netflix-style horizontal scrolling list with:
|
||||
/// - Category title with count
|
||||
/// - Smooth horizontal scrolling
|
||||
@@ -36,7 +36,7 @@ class CategoryCarousel<T> extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
|
||||
if (items.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -110,15 +110,15 @@ class CategoryCarousel<T> extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
|
||||
// Horizontal scrolling list with fade edges
|
||||
SizedBox(
|
||||
height: itemHeight,
|
||||
child: ShaderMask(
|
||||
shaderCallback: (Rect bounds) {
|
||||
return LinearGradient(
|
||||
return const LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
@@ -127,7 +127,7 @@ class CategoryCarousel<T> extends StatelessWidget {
|
||||
Colors.white,
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.02, 0.98, 1.0],
|
||||
stops: [0.0, 0.02, 0.98, 1.0],
|
||||
).createShader(bounds);
|
||||
},
|
||||
blendMode: BlendMode.dstIn,
|
||||
@@ -173,7 +173,7 @@ class CategoryChipButton extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
@@ -181,14 +181,10 @@ class CategoryChipButton extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: isSelected ? AppColors.primaryGradient : null,
|
||||
color: isSelected
|
||||
? null
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
color: isSelected ? null : colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Colors.transparent
|
||||
: colorScheme.outlineVariant,
|
||||
color: isSelected ? Colors.transparent : colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -209,9 +205,8 @@ class CategoryChipButton extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimary
|
||||
: colorScheme.onSurface,
|
||||
color:
|
||||
isSelected ? colorScheme.onPrimary : colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/playlist_config.dart';
|
||||
import '../providers/xtream_provider.dart';
|
||||
|
||||
|
||||
/// Minimal EPG overlay - shows program title and info
|
||||
class EpgOverlay extends ConsumerWidget {
|
||||
final String streamId;
|
||||
@@ -18,9 +16,11 @@ class EpgOverlay extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final epgAsync = ref.watch(epgByPlaylistProvider(
|
||||
EpgRequestKey(playlist: playlist, streamId: streamId),
|
||||
),);
|
||||
final epgAsync = ref.watch(
|
||||
epgByPlaylistProvider(
|
||||
EpgRequestKey(playlist: playlist, streamId: streamId),
|
||||
),
|
||||
);
|
||||
|
||||
return epgAsync.when(
|
||||
data: (epgEntries) {
|
||||
@@ -41,67 +41,69 @@ class EpgOverlay extends ConsumerWidget {
|
||||
|
||||
// Enhanced info display
|
||||
return Align(
|
||||
alignment: Alignment.bottomLeft, // Align to bottom left near controls
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5), // Semi-transparent backing for readability
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'LIVE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12, // Larger
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
alignment: Alignment.bottomLeft, // Align to bottom left near controls
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black
|
||||
.withOpacity(0.5), // Semi-transparent backing for readability
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'LIVE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12, // Larger
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500), // Wider
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 500), // Wider
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentProgram.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18, // Much Larger
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [Shadow(color: Colors.black, blurRadius: 4)],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (currentProgram.description.isNotEmpty)
|
||||
Text(
|
||||
currentProgram.title,
|
||||
currentProgram.description,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18, // Much Larger
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [Shadow(color: Colors.black, blurRadius: 4)],
|
||||
color: Colors.white70,
|
||||
fontSize: 13, // Larger desc
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (currentProgram.description.isNotEmpty)
|
||||
Text(
|
||||
currentProgram.description,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 13, // Larger desc
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../providers/xtream_provider.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../providers/favorites_provider.dart';
|
||||
@@ -10,7 +9,6 @@ import '../../../core/models/playlist_config.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/responsive_layout.dart';
|
||||
import '../../../core/widgets/components/ui_components.dart';
|
||||
import '../../../core/widgets/themed_loading_screen.dart';
|
||||
import 'channel_card.dart';
|
||||
|
||||
@@ -26,16 +24,15 @@ class LiveTVTab extends ConsumerStatefulWidget {
|
||||
|
||||
class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
|
||||
// State
|
||||
String? _selectedCategory;
|
||||
|
||||
|
||||
// UI State
|
||||
bool _isGridView = true;
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
bool _showFavoritesOnly = false;
|
||||
String _searchQuery = '';
|
||||
|
||||
|
||||
// Scroll Controllers
|
||||
final ScrollController _mainScrollController = ScrollController();
|
||||
|
||||
@@ -63,7 +60,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
||||
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
|
||||
final channelsAsync =
|
||||
ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
|
||||
final favorites = ref.watch(favoritesProvider);
|
||||
final settings = ref.watch(iptvSettingsProvider);
|
||||
|
||||
@@ -73,34 +71,38 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
loading: () => const ThemedLoading(),
|
||||
error: (e, s) => Center(child: Text('Error: $e')),
|
||||
data: (groupedChannels) {
|
||||
// Groups
|
||||
var categories = groupedChannels.keys.toList();
|
||||
if (settings.liveTvKeywords.isNotEmpty) {
|
||||
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
|
||||
}
|
||||
categories.sort();
|
||||
|
||||
// Filter Logic
|
||||
List<Channel> displayedChannels = [];
|
||||
bool showingCategoryGrid = false;
|
||||
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
// Global Search
|
||||
displayedChannels = groupedChannels.values.expand((l) => l)
|
||||
.where((c) => c.name.toLowerCase().contains(_searchQuery))
|
||||
.toList();
|
||||
} else if (_showFavoritesOnly) {
|
||||
// Favorites
|
||||
displayedChannels = groupedChannels.values.expand((l) => l)
|
||||
.where((c) => favorites.contains(c.streamId))
|
||||
.toList();
|
||||
} else if (_selectedCategory != null) {
|
||||
// Selected Category View
|
||||
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
||||
} else {
|
||||
// Category Grid View (Default)
|
||||
showingCategoryGrid = true;
|
||||
}
|
||||
// Groups
|
||||
var categories = groupedChannels.keys.toList();
|
||||
if (settings.liveTvKeywords.isNotEmpty) {
|
||||
categories = categories
|
||||
.where((cat) => settings.matchesLiveTvFilter(cat))
|
||||
.toList();
|
||||
}
|
||||
categories.sort();
|
||||
|
||||
// Filter Logic
|
||||
List<Channel> displayedChannels = [];
|
||||
bool showingCategoryGrid = false;
|
||||
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
// Global Search
|
||||
displayedChannels = groupedChannels.values
|
||||
.expand((l) => l)
|
||||
.where((c) => c.name.toLowerCase().contains(_searchQuery))
|
||||
.toList();
|
||||
} else if (_showFavoritesOnly) {
|
||||
// Favorites
|
||||
displayedChannels = groupedChannels.values
|
||||
.expand((l) => l)
|
||||
.where((c) => favorites.contains(c.streamId))
|
||||
.toList();
|
||||
} else if (_selectedCategory != null) {
|
||||
// Selected Category View
|
||||
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
||||
} else {
|
||||
// Category Grid View (Default)
|
||||
showingCategoryGrid = true;
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -110,9 +112,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
child: Row(
|
||||
children: [
|
||||
// Back Button (if in category view and not searching)
|
||||
if (_selectedCategory != null && _searchQuery.isEmpty && !_showFavoritesOnly) ...[
|
||||
if (_selectedCategory != null &&
|
||||
_searchQuery.isEmpty &&
|
||||
!_showFavoritesOnly) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
icon: const Icon(Icons.arrow_back,
|
||||
color: AppColors.textPrimary),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedCategory = null;
|
||||
@@ -125,34 +130,40 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
],
|
||||
|
||||
Text(
|
||||
_searchQuery.isNotEmpty ? 'Search Results' :
|
||||
_showFavoritesOnly ? 'Favorites' :
|
||||
_selectedCategory ?? 'Live TV',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
_searchQuery.isNotEmpty
|
||||
? 'Search Results'
|
||||
: _showFavoritesOnly
|
||||
? 'Favorites'
|
||||
: _selectedCategory ?? 'Live TV',
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
||||
|
||||
// Search Pill
|
||||
Container(
|
||||
width: 300,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.radiusFull),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
|
||||
const Icon(Icons.search,
|
||||
size: 20, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.white),
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Rechercher...',
|
||||
hintStyle: TextStyle(color: Colors.white54),
|
||||
@@ -163,31 +174,37 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
),
|
||||
),
|
||||
if (_searchQuery.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: () => _searchController.clear(),
|
||||
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => _searchController.clear(),
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
|
||||
// Favorites Toggle
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
|
||||
color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary,
|
||||
_showFavoritesOnly
|
||||
? Icons.favorite
|
||||
: Icons.favorite_border,
|
||||
color: _showFavoritesOnly
|
||||
? AppColors.error
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showFavoritesOnly = !_showFavoritesOnly;
|
||||
if (_showFavoritesOnly) _searchController.clear();
|
||||
_selectedCategory = null; // Reset selection when toggling favorites
|
||||
});
|
||||
setState(() {
|
||||
_showFavoritesOnly = !_showFavoritesOnly;
|
||||
if (_showFavoritesOnly) _searchController.clear();
|
||||
_selectedCategory =
|
||||
null; // Reset selection when toggling favorites
|
||||
});
|
||||
},
|
||||
tooltip: 'Favorites Only',
|
||||
),
|
||||
|
||||
|
||||
// View Toggle (Only show when not in category grid)
|
||||
if (!showingCategoryGrid)
|
||||
IconButton(
|
||||
@@ -195,7 +212,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
_isGridView ? Icons.view_list : Icons.grid_view,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
onPressed: () => setState(() => _isGridView = !_isGridView),
|
||||
onPressed: () =>
|
||||
setState(() => _isGridView = !_isGridView),
|
||||
tooltip: 'Toggle View',
|
||||
),
|
||||
],
|
||||
@@ -209,9 +227,15 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
: displayedChannels.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_searchQuery.isNotEmpty ? 'No channels found' :
|
||||
_showFavoritesOnly ? 'No favorites' : 'No channels in this group',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary),
|
||||
_searchQuery.isNotEmpty
|
||||
? 'No channels found'
|
||||
: _showFavoritesOnly
|
||||
? 'No favorites'
|
||||
: 'No channels in this group',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(color: AppColors.textSecondary),
|
||||
),
|
||||
)
|
||||
: _isGridView
|
||||
@@ -225,7 +249,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryGrid(List<String> categories, Map<String, List<Channel>> groupedChannels) {
|
||||
Widget _buildCategoryGrid(
|
||||
List<String> categories, Map<String, List<Channel>> groupedChannels) {
|
||||
final columns = ResponsiveLayout.value(
|
||||
context,
|
||||
mobile: 2,
|
||||
@@ -246,11 +271,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
final count = groupedChannels[category]?.length ?? 0;
|
||||
|
||||
|
||||
return Card(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape:
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -281,7 +307,8 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
||||
color: AppColors.primary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.folder_open, color: AppColors.primary, size: 32),
|
||||
child: const Icon(Icons.folder_open,
|
||||
color: AppColors.primary, size: 32),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
|
||||
@@ -61,7 +61,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
_searchDebounce?.cancel();
|
||||
|
||||
|
||||
setState(() {
|
||||
_searchQuery = query;
|
||||
if (query.isEmpty) {
|
||||
@@ -69,7 +69,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (query.length >= 2) {
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
|
||||
_performSearch(query);
|
||||
@@ -79,13 +79,13 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
if (query.isEmpty) return;
|
||||
|
||||
|
||||
setState(() => _isSearching = true);
|
||||
|
||||
|
||||
try {
|
||||
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
||||
final results = await service.searchMovies(query);
|
||||
|
||||
|
||||
if (mounted && _searchQuery == query) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
@@ -151,7 +151,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
|
||||
void _playMovie(Movie movie) {
|
||||
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
|
||||
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -170,32 +170,39 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
Widget build(BuildContext context) {
|
||||
final settings = ref.watch(iptvSettingsProvider);
|
||||
final watchHistory = ref.watch(watchHistoryProvider);
|
||||
|
||||
|
||||
List<Movie> displayMovies;
|
||||
if (_searchQuery.isNotEmpty && _searchResults != null) {
|
||||
displayMovies = _searchResults!;
|
||||
} else {
|
||||
displayMovies = settings.moviesKeywords.isEmpty
|
||||
? _movies
|
||||
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList();
|
||||
: _movies
|
||||
.where((m) => settings.matchesMoviesFilter(m.categoryName))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Hero Items (Take 5 random or first 5 from filtered list)
|
||||
final heroItems = displayMovies.take(5).map((m) => HeroItem(
|
||||
id: m.streamId,
|
||||
title: m.name,
|
||||
imageUrl: _getProxiedImageUrl(m.streamIcon),
|
||||
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
|
||||
onMoreInfo: () {
|
||||
_playMovie(m);
|
||||
},
|
||||
)).toList();
|
||||
final heroItems = displayMovies
|
||||
.take(5)
|
||||
.map(
|
||||
(m) => HeroItem(
|
||||
id: m.streamId,
|
||||
title: m.name,
|
||||
imageUrl: _getProxiedImageUrl(m.streamIcon),
|
||||
subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null,
|
||||
onMoreInfo: () {
|
||||
_playMovie(m);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (_movies.isEmpty && !_isLoading) {
|
||||
return const Center(child: Text('No movies available'));
|
||||
}
|
||||
|
||||
final double gridItemRatio = 0.65;
|
||||
const double gridItemRatio = 0.65;
|
||||
final int crossAxisCount = ResponsiveLayout.value(
|
||||
context,
|
||||
mobile: 3,
|
||||
@@ -215,9 +222,9 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
Text(
|
||||
'Movies',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
@@ -231,12 +238,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
|
||||
const Icon(Icons.search,
|
||||
size: 20, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.white),
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Rechercher...',
|
||||
hintStyle: TextStyle(color: Colors.white54),
|
||||
@@ -250,14 +259,18 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
),
|
||||
),
|
||||
if (_isSearching)
|
||||
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
if (_searchQuery.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
},
|
||||
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -275,13 +288,14 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
child: HeroCarousel(
|
||||
items: heroItems,
|
||||
onTap: (item) {
|
||||
final movie = _movies.firstWhere((element) => element.streamId == item.id);
|
||||
_playMovie(movie);
|
||||
final movie = _movies
|
||||
.firstWhere((element) => element.streamId == item.id);
|
||||
_playMovie(movie);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Grid
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
@@ -297,17 +311,20 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
if (index >= displayMovies.length) return null;
|
||||
final movie = displayMovies[index];
|
||||
final isWatched = watchHistory.isMovieWatched(movie.streamId);
|
||||
|
||||
|
||||
return MediaCard(
|
||||
title: movie.name,
|
||||
imageUrl: _getProxiedImageUrl(movie.streamIcon),
|
||||
|
||||
subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null,
|
||||
subtitle: movie.rating != null
|
||||
? '${_formatRating(movie.rating)} ★'
|
||||
: null,
|
||||
rating: _formatRating(movie.rating),
|
||||
isWatched: isWatched,
|
||||
onTap: () => _playMovie(movie),
|
||||
onLongPress: () {
|
||||
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
|
||||
ref
|
||||
.read(watchHistoryProvider.notifier)
|
||||
.toggleMovieWatched(movie.streamId);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -315,7 +332,7 @@ class _MoviesTabState extends ConsumerState<MoviesTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Loader
|
||||
if (_isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
|
||||
@@ -61,7 +61,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
|
||||
void _onSearchChanged(String query) {
|
||||
_searchDebounce?.cancel();
|
||||
|
||||
|
||||
setState(() {
|
||||
_searchQuery = query;
|
||||
if (query.isEmpty) {
|
||||
@@ -69,7 +69,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
_isSearching = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (query.length >= 2) {
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
|
||||
_performSearch(query);
|
||||
@@ -79,13 +79,13 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
if (query.isEmpty) return;
|
||||
|
||||
|
||||
setState(() => _isSearching = true);
|
||||
|
||||
|
||||
try {
|
||||
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
||||
final results = await service.searchSeries(query);
|
||||
|
||||
|
||||
if (mounted && _searchQuery == query) {
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
@@ -164,14 +164,16 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = ref.watch(iptvSettingsProvider);
|
||||
|
||||
|
||||
List<Series> displaySeries;
|
||||
if (_searchQuery.isNotEmpty && _searchResults != null) {
|
||||
displaySeries = _searchResults!;
|
||||
} else {
|
||||
displaySeries = settings.seriesKeywords.isEmpty
|
||||
? _series
|
||||
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList();
|
||||
: _series
|
||||
.where((s) => settings.matchesSeriesFilter(s.categoryName))
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (_series.isEmpty && !_isLoading) {
|
||||
@@ -179,15 +181,20 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
}
|
||||
|
||||
// Hero Items
|
||||
final heroItems = displaySeries.take(5).map((s) => HeroItem(
|
||||
id: s.seriesId.toString(), // ID logic might differ for Series
|
||||
title: s.name,
|
||||
imageUrl: _getProxiedImageUrl(s.cover),
|
||||
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
|
||||
onMoreInfo: () => _openSeries(s),
|
||||
)).toList();
|
||||
final heroItems = displaySeries
|
||||
.take(5)
|
||||
.map(
|
||||
(s) => HeroItem(
|
||||
id: s.seriesId.toString(), // ID logic might differ for Series
|
||||
title: s.name,
|
||||
imageUrl: _getProxiedImageUrl(s.cover),
|
||||
subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null,
|
||||
onMoreInfo: () => _openSeries(s),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final double gridItemRatio = 0.65;
|
||||
const double gridItemRatio = 0.65;
|
||||
final int crossAxisCount = ResponsiveLayout.value(
|
||||
context,
|
||||
mobile: 3,
|
||||
@@ -207,9 +214,9 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
Text(
|
||||
'TV Series',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
@@ -223,12 +230,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
|
||||
const Icon(Icons.search,
|
||||
size: 20, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
style: const TextStyle(fontSize: 14, color: Colors.white),
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: Colors.white),
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Rechercher...',
|
||||
hintStyle: TextStyle(color: Colors.white54),
|
||||
@@ -242,14 +251,18 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
),
|
||||
),
|
||||
if (_isSearching)
|
||||
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
if (_searchQuery.isNotEmpty)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
_searchController.clear();
|
||||
_onSearchChanged('');
|
||||
},
|
||||
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
|
||||
child: const Icon(Icons.close,
|
||||
size: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -267,13 +280,14 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
child: HeroCarousel(
|
||||
items: heroItems,
|
||||
onTap: (item) {
|
||||
final series = _series.firstWhere((s) => s.name == item.title); // Fallback by title if ID mismatch
|
||||
_openSeries(series);
|
||||
final series = _series.firstWhere((s) =>
|
||||
s.name == item.title); // Fallback by title if ID mismatch
|
||||
_openSeries(series);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Grid
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
@@ -291,8 +305,9 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
return MediaCard(
|
||||
title: serie.name,
|
||||
imageUrl: _getProxiedImageUrl(serie.cover),
|
||||
|
||||
subtitle: serie.rating != null ? '${_formatRating(serie.rating)} ★' : null,
|
||||
subtitle: serie.rating != null
|
||||
? '${_formatRating(serie.rating)} ★'
|
||||
: null,
|
||||
rating: _formatRating(serie.rating),
|
||||
placeholderIcon: Icons.tv,
|
||||
onTap: () => _openSeries(serie),
|
||||
@@ -302,7 +317,7 @@ class _SeriesTabState extends ConsumerState<SeriesTab> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// Loader
|
||||
if (_isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
|
||||
@@ -18,7 +18,8 @@ class SettingsTab extends ConsumerStatefulWidget {
|
||||
ConsumerState<SettingsTab> createState() => _SettingsTabState();
|
||||
}
|
||||
|
||||
class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProviderStateMixin {
|
||||
class _SettingsTabState extends ConsumerState<SettingsTab>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late TextEditingController _liveTvController;
|
||||
late TextEditingController _moviesController;
|
||||
@@ -45,13 +46,16 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
}
|
||||
|
||||
void _syncControllers(IptvSettings settings) {
|
||||
if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) {
|
||||
if (!_initialized ||
|
||||
_liveTvController.text != settings.liveTvCategoryFilter) {
|
||||
_liveTvController.text = settings.liveTvCategoryFilter;
|
||||
}
|
||||
if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) {
|
||||
if (!_initialized ||
|
||||
_moviesController.text != settings.moviesCategoryFilter) {
|
||||
_moviesController.text = settings.moviesCategoryFilter;
|
||||
}
|
||||
if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) {
|
||||
if (!_initialized ||
|
||||
_seriesController.text != settings.seriesCategoryFilter) {
|
||||
_seriesController.text = settings.seriesCategoryFilter;
|
||||
}
|
||||
_initialized = true;
|
||||
@@ -61,7 +65,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
Widget build(BuildContext context) {
|
||||
final currentUser = ref.watch(authProvider).currentUser;
|
||||
final settings = ref.watch(iptvSettingsProvider);
|
||||
|
||||
|
||||
// Sync controllers with persisted state (only on first load)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_initialized) _syncControllers(settings);
|
||||
@@ -85,17 +89,19 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: Colors.white54,
|
||||
dividerColor: Colors.transparent,
|
||||
overlayColor: MaterialStateProperty.all(Colors.transparent),
|
||||
overlayColor: WidgetStateProperty.all(Colors.transparent),
|
||||
tabs: [
|
||||
const Tab(icon: Icon(Icons.filter_list), text: 'Filtres'),
|
||||
const Tab(icon: Icon(Icons.stream), text: 'Streaming'),
|
||||
const Tab(icon: Icon(Icons.palette), text: 'Apparence'),
|
||||
if (currentUser?.isAdmin ?? false)
|
||||
const Tab(icon: Icon(Icons.admin_panel_settings), text: 'Administration'),
|
||||
const Tab(
|
||||
icon: Icon(Icons.admin_panel_settings),
|
||||
text: 'Administration'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
// TabBarView
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
@@ -103,16 +109,15 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
children: [
|
||||
// Filters Tab
|
||||
_buildFiltersTab(context, currentUser, settings),
|
||||
|
||||
|
||||
// Streaming Tab
|
||||
const StreamingSettingsTab(),
|
||||
|
||||
|
||||
// Appearance Tab
|
||||
_buildAppearanceTab(context),
|
||||
|
||||
// Admin Tab
|
||||
if (currentUser?.isAdmin ?? false)
|
||||
const AdminContent(),
|
||||
if (currentUser?.isAdmin ?? false) const AdminContent(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -125,7 +130,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
final themeState = ref.watch(themeProvider);
|
||||
final themeNotifier = ref.read(themeProvider.notifier);
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@@ -142,7 +147,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.dark_mode,
|
||||
color: Colors.white,
|
||||
),
|
||||
@@ -158,7 +163,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// Theme options
|
||||
_buildThemeOption(
|
||||
context: context,
|
||||
@@ -189,9 +194,9 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// Quick toggle card
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -206,20 +211,21 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
),
|
||||
title: Text(
|
||||
'Mode actuel',
|
||||
style: GoogleFonts.inter(fontWeight: FontWeight.w500, color: Colors.white),
|
||||
style: GoogleFonts.inter(
|
||||
fontWeight: FontWeight.w500, color: Colors.white),
|
||||
),
|
||||
subtitle: Text(
|
||||
isDark ? 'Thème sombre actif' : 'Thème clair actif',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
trailing: Switch(
|
||||
value: isDark,
|
||||
onChanged: (_) => themeNotifier.toggleTheme(),
|
||||
activeColor: AppColors.primary,
|
||||
trackColor: MaterialStateProperty.resolveWith(
|
||||
(states) => states.contains(MaterialState.selected)
|
||||
? AppColors.primary.withOpacity(0.5)
|
||||
: Colors.grey.shade800,
|
||||
activeThumbColor: AppColors.primary,
|
||||
trackColor: WidgetStateProperty.resolveWith(
|
||||
(states) => states.contains(WidgetState.selected)
|
||||
? AppColors.primary.withOpacity(0.5)
|
||||
: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -238,7 +244,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -298,8 +304,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) {
|
||||
Widget _buildFiltersTab(
|
||||
BuildContext context, dynamic currentUser, IptvSettings settings) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@@ -319,18 +325,21 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
),
|
||||
subtitle: Text(
|
||||
currentUser?.username ?? 'Unknown',
|
||||
style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white),
|
||||
style: GoogleFonts.roboto(
|
||||
fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
trailing: currentUser?.isAdmin ?? false
|
||||
? Chip(
|
||||
label: Text('Admin', style: GoogleFonts.roboto(fontSize: 11, color: Colors.black)),
|
||||
label: Text('Admin',
|
||||
style: GoogleFonts.roboto(
|
||||
fontSize: 11, color: Colors.black)),
|
||||
backgroundColor: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// Category filters section
|
||||
Text(
|
||||
'Filtres de Catégories',
|
||||
@@ -349,7 +358,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
|
||||
// Live TV Filter
|
||||
_buildFilterCard(
|
||||
title: 'Filtre TV Live',
|
||||
@@ -365,7 +374,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
|
||||
// Movies Filter
|
||||
_buildFilterCard(
|
||||
title: 'Filtre Films',
|
||||
@@ -381,7 +390,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
|
||||
// Series Filter
|
||||
_buildFilterCard(
|
||||
title: 'Filtre Séries',
|
||||
@@ -397,9 +406,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
|
||||
// Change playlist
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -409,13 +416,14 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.playlist_play, color: Colors.white),
|
||||
title: Text('Changer de Playlist', style: GoogleFonts.roboto(color: Colors.white)),
|
||||
title: Text('Changer de Playlist',
|
||||
style: GoogleFonts.roboto(color: Colors.white)),
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.white54),
|
||||
onTap: () => context.go('/playlists'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
|
||||
// About
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -425,7 +433,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.info_outline, color: Colors.white),
|
||||
title: Text('À propos', style: GoogleFonts.roboto(color: Colors.white)),
|
||||
title: Text('À propos',
|
||||
style: GoogleFonts.roboto(color: Colors.white)),
|
||||
subtitle: Text(
|
||||
'XtremFlow IPTV v1.0.0',
|
||||
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
|
||||
@@ -433,7 +442,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
// Maintenance Section
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -443,50 +452,53 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.cleaning_services, color: Colors.orange),
|
||||
title: Text('Maintenance', style: GoogleFonts.roboto(color: Colors.white)),
|
||||
title: Text('Maintenance',
|
||||
style: GoogleFonts.roboto(color: Colors.white)),
|
||||
subtitle: Text(
|
||||
'En cas de problèmes d\'affichage ou de mise à jour',
|
||||
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
|
||||
),
|
||||
trailing: FilledButton.icon(
|
||||
onPressed: () {
|
||||
// Show confirmation dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
title: const Text('Vider le cache ?', style: TextStyle(color: Colors.white)),
|
||||
content: const Text(
|
||||
'Cette action va recharger l\'application et effacer les données temporaires.\n'
|
||||
'Vos réglages et favoris sont sauvegardés sur le serveur.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.orange),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
|
||||
// Clear Flutter Image Cache
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||
|
||||
// Clear Browser Local Storage (SharedPreferences backend for Web)
|
||||
html.window.localStorage.clear();
|
||||
html.window.sessionStorage.clear();
|
||||
|
||||
// Reload application
|
||||
html.window.location.reload();
|
||||
},
|
||||
child: const Text('Vider et Recharger'),
|
||||
),
|
||||
],
|
||||
)
|
||||
);
|
||||
// Show confirmation dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1E1E1E),
|
||||
title: const Text('Vider le cache ?',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
content: const Text(
|
||||
'Cette action va recharger l\'application et effacer les données temporaires.\n'
|
||||
'Vos réglages et favoris sont sauvegardés sur le serveur.',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.orange),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
|
||||
// Clear Flutter Image Cache
|
||||
PaintingBinding.instance.imageCache.clear();
|
||||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||
|
||||
// Clear Browser Local Storage (SharedPreferences backend for Web)
|
||||
html.window.localStorage.clear();
|
||||
html.window.sessionStorage.clear();
|
||||
|
||||
// Reload application
|
||||
html.window.location.reload();
|
||||
},
|
||||
child: const Text('Vider et Recharger'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
label: const Text('Vider le Cache'),
|
||||
@@ -498,7 +510,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
|
||||
// Logout button
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
@@ -553,7 +565,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Ex: FR,FRANCE,HD,SPORT',
|
||||
hintStyle: GoogleFonts.roboto(fontSize: 12, color: Colors.white30),
|
||||
hintStyle:
|
||||
GoogleFonts.roboto(fontSize: 12, color: Colors.white30),
|
||||
filled: true,
|
||||
fillColor: Colors.black26,
|
||||
border: OutlineInputBorder(
|
||||
@@ -567,7 +580,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
),
|
||||
suffixIcon: controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16, color: Colors.white54),
|
||||
icon: const Icon(Icons.clear,
|
||||
size: 16, color: Colors.white54),
|
||||
onPressed: onClear,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
@@ -584,15 +598,18 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
|
||||
runSpacing: 6,
|
||||
children: keywords.map((keyword) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueAccent.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: Border.all(color: Colors.blueAccent.withOpacity(0.5)),
|
||||
border:
|
||||
Border.all(color: Colors.blueAccent.withOpacity(0.5)),
|
||||
),
|
||||
child: Text(
|
||||
keyword,
|
||||
style: GoogleFonts.roboto(fontSize: 11, color: Colors.white),
|
||||
style:
|
||||
GoogleFonts.roboto(fontSize: 11, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
@@ -107,7 +107,7 @@ class StreamingSettingsTab extends ConsumerWidget {
|
||||
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
|
||||
),
|
||||
value: settings.autoReconnect,
|
||||
activeColor: Colors.blueAccent,
|
||||
activeThumbColor: Colors.blueAccent,
|
||||
onChanged: (v) => notifier.setAutoReconnect(v),
|
||||
),
|
||||
],
|
||||
@@ -143,7 +143,7 @@ class StreamingSettingsTab extends ConsumerWidget {
|
||||
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54),
|
||||
),
|
||||
value: settings.preferDirectPlay,
|
||||
activeColor: Colors.blueAccent,
|
||||
activeThumbColor: Colors.blueAccent,
|
||||
onChanged: (v) => notifier.setPreferDirectPlay(v),
|
||||
),
|
||||
],
|
||||
@@ -248,8 +248,10 @@ class StreamingSettingsTab extends ConsumerWidget {
|
||||
required String Function(T) labelBuilder,
|
||||
}) {
|
||||
return ListTile(
|
||||
title: Text(title, style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)),
|
||||
subtitle: Text(subtitle, style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)),
|
||||
title: Text(title,
|
||||
style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)),
|
||||
subtitle: Text(subtitle,
|
||||
style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)),
|
||||
trailing: DropdownButton<T>(
|
||||
value: value,
|
||||
underline: const SizedBox(),
|
||||
|
||||
Reference in new issue
Block a user