Merge pull request #56 from R0m1k3/antigravity

Antigravity
This commit is contained in:
LogiFlow authored and GitHub committed 2025-12-06 23:29:15 +01:00
commit f456621b18
15 files changed
+2239 -75

No files matched your search

+19 -3
View File
@@ -7,6 +7,9 @@ import '../../features/iptv/screens/playlist_selection_screen.dart';
import '../../features/iptv/screens/dashboard_screen.dart';
import '../../features/admin/screens/admin_panel.dart';
import '../models/playlist_config.dart';
import '../../mobile/features/auth/screens/mobile_login_screen.dart';
import '../../mobile/features/iptv/screens/mobile_playlist_selection_screen.dart';
import '../../mobile/features/iptv/screens/mobile_dashboard_screen.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
@@ -43,18 +46,27 @@ final routerProvider = Provider<GoRouter>((ref) {
routes: [
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
builder: (context, state) {
if (MediaQuery.of(context).size.width < 768) {
return const MobileLoginScreen();
}
return const LoginScreen();
},
),
GoRoute(
path: '/playlists',
builder: (context, state) => const PlaylistSelectionScreen(),
builder: (context, state) {
if (MediaQuery.of(context).size.width < 768) {
return const MobilePlaylistSelectionScreen();
}
return const PlaylistSelectionScreen();
},
),
GoRoute(
path: '/dashboard',
builder: (context, state) {
final playlist = state.extra as PlaylistConfig?;
if (playlist == null) {
// Redirect back if no playlist provided
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/playlists');
});
@@ -62,6 +74,10 @@ final routerProvider = Provider<GoRouter>((ref) {
body: Center(child: CircularProgressIndicator()),
);
}
if (MediaQuery.of(context).size.width < 768) {
return MobileDashboardScreen(playlist: playlist);
}
return DashboardScreen(playlist: playlist);
},
),
+2 -2
View File
@@ -188,8 +188,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..allow = 'autoplay; fullscreen'
..allowFullscreen = true;
..style.height = '100%'
..allow = 'autoplay; fullscreen; picture-in-picture';
return iframe;
},
);
+146 -70
View File
@@ -81,6 +81,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
// Filter Logic
List<Channel> displayedChannels = [];
bool showingCategoryGrid = false;
if (_searchQuery.isNotEmpty) {
// Global Search
@@ -93,37 +94,47 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
.where((c) => favorites.contains(c.streamId))
.toList();
} else if (_selectedCategory != null) {
// Selected Category
// Selected Category View
displayedChannels = groupedChannels[_selectedCategory] ?? [];
} else {
// Default: Show first category or All if practical, but "All" is too big.
// Apple TV pattern: Auto-select first category if nothing selected.
if (categories.isNotEmpty) {
// We don't want to trigger setState during build, so we just use the first category functionality
// But visualized as "All" might be confusing with map keys.
// Let's default to the first category in the list.
_selectedCategory ??= categories.first;
displayedChannels = groupedChannels[_selectedCategory] ?? [];
}
// Category Grid View (Default)
showingCategoryGrid = true;
}
return Column(
children: [
// Minimalist Header
// Header
Container(
padding: const EdgeInsets.only(left: 24, right: 24, top: 16, bottom: 8),
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: Row(
children: [
// Back Button (if in category view and not searching)
if (_selectedCategory != null && _searchQuery.isEmpty && !_showFavoritesOnly) ...[
IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () {
setState(() {
_selectedCategory = null;
_mainScrollController.jumpTo(0);
});
},
tooltip: 'Back to Categories',
),
const SizedBox(width: 8),
],
Text(
'Live TV',
_searchQuery.isNotEmpty ? 'Search Results' :
_showFavoritesOnly ? 'Favorites' :
_selectedCategory ?? 'Live TV',
style: Theme.of(context).textTheme.headlineMedium,
),
const Spacer(),
// Search Pill
Container(
width: 250,
height: 36,
width: 300,
height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
@@ -132,17 +143,17 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const Icon(Icons.search, size: 18, 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: 13, color: AppColors.textPrimary),
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Search channels',
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(bottom: 12),
contentPadding: EdgeInsets.only(bottom: 11),
),
),
),
@@ -166,63 +177,41 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
if (_showFavoritesOnly) _searchController.clear();
_selectedCategory = null; // Reset selection when toggling favorites
});
},
tooltip: 'Favorites Only',
),
IconButton(
icon: Icon(
_isGridView ? Icons.view_list : Icons.grid_view,
color: AppColors.textSecondary,
// View Toggle (Only show when not in category grid)
if (!showingCategoryGrid)
IconButton(
icon: Icon(
_isGridView ? Icons.view_list : Icons.grid_view,
color: AppColors.textSecondary,
),
onPressed: () => setState(() => _isGridView = !_isGridView),
tooltip: 'Toggle View',
),
onPressed: () => setState(() => _isGridView = !_isGridView),
tooltip: 'Toggle View',
),
],
),
),
// Categories Horizontal List (Only if not searching/favorites)
if (_searchQuery.isEmpty && !_showFavoritesOnly)
Container(
height: 48,
padding: const EdgeInsets.symmetric(vertical: 4),
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 24),
scrollDirection: Axis.horizontal,
itemCount: categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final category = categories[index];
final isSelected = category == _selectedCategory;
return CategoryChip(
label: category,
isSelected: isSelected,
onTap: () {
setState(() {
_selectedCategory = category;
_mainScrollController.jumpTo(0); // Reset scroll
});
},
);
},
),
),
const SizedBox(height: 8),
// Channels Content
// Content
Expanded(
child: displayedChannels.isEmpty
? Center(
child: Text(
_showFavoritesOnly ? 'No favorites' : 'No channels found',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary),
),
)
: _isGridView
? _buildGridView(displayedChannels)
: _buildListView(displayedChannels),
child: showingCategoryGrid
? _buildCategoryGrid(categories, groupedChannels)
: 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),
),
)
: _isGridView
? _buildChannelGrid(displayedChannels)
: _buildChannelList(displayedChannels),
),
],
);
@@ -231,7 +220,94 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
);
}
Widget _buildGridView(List<Channel> channels) {
Widget _buildCategoryGrid(List<String> categories, Map<String, List<Channel>> groupedChannels) {
final columns = ResponsiveLayout.value(
context,
mobile: 2,
tablet: 3,
desktop: 5,
);
return GridView.builder(
controller: _mainScrollController,
padding: const EdgeInsets.all(24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.5,
),
itemCount: categories.length,
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)),
child: InkWell(
onTap: () {
setState(() {
_selectedCategory = category;
_mainScrollController.jumpTo(0);
});
},
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.surface,
AppColors.surface.withOpacity(0.8),
],
),
border: Border.all(color: AppColors.border.withOpacity(0.5)),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.folder_open, color: AppColors.primary, size: 32),
),
const SizedBox(height: 12),
Text(
category,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'$count Channels',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
),
),
);
},
);
}
Widget _buildChannelGrid(List<Channel> channels) {
final columns = ResponsiveLayout.value(
context,
mobile: 2,
@@ -241,12 +317,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
return GridView.builder(
controller: _mainScrollController,
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.3, // Card ratio 4:3ish
childAspectRatio: 1.3,
),
itemCount: channels.length,
itemBuilder: (context, index) {
@@ -262,10 +338,10 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
);
}
Widget _buildListView(List<Channel> channels) {
Widget _buildChannelList(List<Channel> channels) {
return ListView.separated(
controller: _mainScrollController,
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
itemCount: channels.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../features/auth/providers/auth_provider.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../theme/mobile_theme.dart';
import '../../../../core/widgets/components/ui_components.dart';
class MobileLoginScreen extends ConsumerStatefulWidget {
const MobileLoginScreen({super.key});
@override
ConsumerState<MobileLoginScreen> createState() => _MobileLoginScreenState();
}
class _MobileLoginScreenState extends ConsumerState<MobileLoginScreen> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (!_formKey.currentState!.validate()) return;
final auth = ref.read(authProvider.notifier);
final success = await auth.login(
_usernameController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
context.go('/playlists');
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return Theme(
data: MobileTheme.darkTheme,
child: Scaffold(
backgroundColor: AppColors.background,
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: const Icon(
Icons.live_tv,
color: AppColors.primary,
size: 36,
),
),
const SizedBox(height: 16),
const Text(
'XtremFlow',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
),
const SizedBox(height: 4),
const Text(
'Mobile Edition',
style: TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
letterSpacing: 1.5,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 48),
// Form
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _usernameController,
style: const TextStyle(color: AppColors.textPrimary),
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person_outline),
),
validator: (value) =>
(value == null || value.trim().isEmpty) ? 'Required' : null,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
style: const TextStyle(color: AppColors.textPrimary),
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
),
validator: (value) =>
(value == null || value.isEmpty) ? 'Required' : null,
onFieldSubmitted: (_) => _handleLogin(),
),
if (authState.errorMessage != null) ...[
const SizedBox(height: 16),
Text(
authState.errorMessage!,
style: const TextStyle(color: AppColors.error, fontSize: 12),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: authState.isLoading ? null : _handleLogin,
child: authState.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black),
)
: const Text('Sign In'),
),
),
],
),
),
const SizedBox(height: 24),
const Text(
'Default: admin / admin',
style: TextStyle(
fontSize: 12,
color: AppColors.textTertiary,
fontStyle: FontStyle.italic,
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/models/playlist_config.dart';
import '../../widgets/mobile_scaffold.dart';
import '../../theme/mobile_theme.dart';
import '../widgets/mobile_live_tv_tab.dart';
import '../widgets/mobile_movies_tab.dart';
import '../widgets/mobile_series_tab.dart';
import '../widgets/mobile_settings_tab.dart';
class MobileDashboardScreen extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const MobileDashboardScreen({
super.key,
required this.playlist,
});
@override
ConsumerState<MobileDashboardScreen> createState() => _MobileDashboardScreenState();
}
class _MobileDashboardScreenState extends ConsumerState<MobileDashboardScreen> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
// Determine which tab to show
Widget currentTab;
switch (_currentIndex) {
case 0:
currentTab = MobileLiveTVTab(playlist: widget.playlist);
break;
case 1:
currentTab = MobileMoviesTab(playlist: widget.playlist);
break;
case 2:
currentTab = MobileSeriesTab(playlist: widget.playlist);
break;
case 3:
currentTab = const MobileSettingsTab();
break;
default:
currentTab = MobileLiveTVTab(playlist: widget.playlist);
}
return Theme(
data: MobileTheme.darkTheme,
child: MobileScaffold(
currentIndex: _currentIndex,
onIndexChanged: (index) {
setState(() {
_currentIndex = index;
});
},
child: currentTab,
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import '../../../../features/iptv/screens/player_screen.dart';
import '../../../../core/models/playlist_config.dart';
/// Mobile wrapper for the PlayerScreen.
/// Currently reuses the desktop PlayerScreen as it adapts well to mobile (video player),
/// but allows for future mobile-specific overlays or gesture controls.
class MobilePlayerScreen extends StatelessWidget {
final String streamId;
final String title;
final StreamType streamType;
final String containerExtension;
final PlaylistConfig playlist;
const MobilePlayerScreen({
super.key,
required this.streamId,
required this.title,
required this.playlist,
this.streamType = StreamType.live,
this.containerExtension = 'mp4',
});
@override
Widget build(BuildContext context) {
// For now, we reuse the existing PlayerScreen which is responsive.
// In the future, we can add Mobile-specific gesture detectors here.
return PlayerScreen(
streamId: streamId,
title: title,
playlist: playlist,
streamType: streamType,
containerExtension: containerExtension,
);
}
}
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/services/playlist_api_service.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../features/auth/providers/auth_provider.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../theme/mobile_theme.dart';
final playlistsProvider = FutureProvider<List<PlaylistConfig>>((ref) async {
final service = PlaylistApiService();
return service.getPlaylists();
});
class MobilePlaylistSelectionScreen extends ConsumerWidget {
const MobilePlaylistSelectionScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentUser = ref.watch(authProvider).currentUser;
final playlistsAsync = ref.watch(playlistsProvider);
return Theme(
data: MobileTheme.darkTheme,
child: Scaffold(
appBar: AppBar(
title: const Text('Playlists'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await ref.read(authProvider.notifier).logout();
if (context.mounted) {
context.go('/login');
}
},
),
],
),
body: playlistsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text('Error loading playlists', style: GoogleFonts.inter()),
TextButton(
onPressed: () => ref.refresh(playlistsProvider),
child: const Text('Retry'),
),
],
),
),
data: (playlists) {
if (playlists.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.playlist_remove, size: 48, color: AppColors.textSecondary),
const SizedBox(height: 16),
Text('No playlists available', style: GoogleFonts.inter(color: AppColors.textSecondary)),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: playlists.length,
separatorBuilder: (context, index) => const SizedBox(height: 16),
itemBuilder: (context, index) {
final playlist = playlists[index];
return _MobilePlaylistCard(
playlist: playlist,
onTap: () {
// Mobile navigation to dashboard with playlist
context.go('/dashboard', extra: playlist);
},
);
},
);
},
),
),
);
}
}
class _MobilePlaylistCard extends StatelessWidget {
final PlaylistConfig playlist;
final VoidCallback onTap;
const _MobilePlaylistCard({
required this.playlist,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.playlist_play, color: AppColors.primary, size: 28),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playlist.name,
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
Uri.tryParse(playlist.dns)?.host ?? playlist.dns,
style: GoogleFonts.inter(
fontSize: 13,
color: AppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(Icons.chevron_right, color: AppColors.textTertiary),
],
),
),
),
),
);
}
}
@@ -0,0 +1,316 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../features/iptv/models/xtream_models.dart';
import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../features/iptv/providers/watch_history_provider.dart';
import 'mobile_player_screen.dart';
import '../../../../core/theme/app_colors.dart';
class MobileSeriesDetailScreen extends ConsumerStatefulWidget {
final Series series;
final PlaylistConfig playlist;
const MobileSeriesDetailScreen({
super.key,
required this.series,
required this.playlist,
});
@override
ConsumerState<MobileSeriesDetailScreen> createState() => _MobileSeriesDetailScreenState();
}
class _MobileSeriesDetailScreenState extends ConsumerState<MobileSeriesDetailScreen> {
SeriesInfo? _seriesInfo;
bool _isLoading = true;
String? _error;
int _selectedSeason = 1;
@override
void initState() {
super.initState();
_loadSeriesInfo();
}
Future<void> _loadSeriesInfo() async {
try {
setState(() {
_isLoading = true;
_error = null;
});
final service = ref.read(xtreamServiceProvider(widget.playlist));
final info = await service.getSeriesInfo(widget.series.seriesId);
if (mounted) {
setState(() {
_seriesInfo = info;
_isLoading = false;
if (info.episodes.isNotEmpty) {
_selectedSeason = info.episodes.keys.first;
}
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: 16),
Text('Error: $_error', style: const TextStyle(color: AppColors.textSecondary)),
TextButton(onPressed: _loadSeriesInfo, child: const Text('Retry')),
],
),
)
: _buildMobileContent(),
);
}
Widget _buildMobileContent() {
if (_seriesInfo == null) return const SizedBox.shrink();
final currentEpisodes = _seriesInfo!.episodes[_selectedSeason] ?? [];
return CustomScrollView(
slivers: [
// App Bar with Cover
SliverAppBar(
expandedHeight: 400,
pinned: true,
backgroundColor: AppColors.background,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (_seriesInfo!.cover != null)
CachedNetworkImage(
imageUrl: _seriesInfo!.cover!,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => Container(color: Colors.grey.shade900),
),
// Gradient Overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
AppColors.background.withOpacity(0.8),
AppColors.background,
],
stops: const [0.5, 0.8, 1.0],
),
),
),
// Info Overlay
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_seriesInfo!.name,
style: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
Row(
children: [
if (_seriesInfo!.rating != null) ...[
const Icon(Icons.star, size: 16, color: Colors.amber),
const SizedBox(width: 4),
Text(
_seriesInfo!.rating!,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
const SizedBox(width: 16),
],
Text(
'${_seriesInfo!.episodes.keys.length} Seasons',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
],
),
],
),
),
],
),
),
),
// Plot
if (_seriesInfo!.plot != null && _seriesInfo!.plot!.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
_seriesInfo!.plot!,
style: const TextStyle(color: AppColors.textSecondary, height: 1.4),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
),
),
// Seasons Filter
SliverToBoxAdapter(
child: SizedBox(
height: 50,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: _seriesInfo!.episodes.keys.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final seasonNum = _seriesInfo!.episodes.keys.elementAt(index);
final isSelected = seasonNum == _selectedSeason;
return ChoiceChip(
label: Text('Season $seasonNum'),
selected: isSelected,
onSelected: (selected) {
if (selected) setState(() => _selectedSeason = seasonNum);
},
backgroundColor: AppColors.surface,
selectedColor: AppColors.primary,
labelStyle: TextStyle(
color: isSelected ? Colors.white : AppColors.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
showCheckmark: false,
side: BorderSide.none,
);
},
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
// Episodes List
SliverPadding(
padding: const EdgeInsets.only(bottom: 32),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final episode = currentEpisodes[index];
return _buildEpisodeTile(episode);
},
childCount: currentEpisodes.length,
),
),
),
],
);
}
Widget _buildEpisodeTile(Episode episode) {
final watchHistory = ref.watch(watchHistoryProvider);
final episodeKey = WatchHistory.episodeKey(
widget.series.seriesId,
_selectedSeason,
episode.episodeNum,
);
final isWatched = watchHistory.isEpisodeWatched(episodeKey);
return InkWell(
onTap: () {
ref.read(watchHistoryProvider.notifier).markEpisodeWatched(episodeKey);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MobilePlayerScreen(
streamId: episode.id,
title: '${widget.series.name} - ${episode.title}',
playlist: widget.playlist,
streamType: StreamType.series,
containerExtension: episode.containerExtension ?? 'mkv',
),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.white10)),
),
child: Row(
children: [
// Play Button / Indicator
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isWatched ? AppColors.primary : Colors.white10,
shape: BoxShape.circle,
),
child: Icon(
isWatched ? Icons.check : Icons.play_arrow,
color: isWatched ? Colors.white : AppColors.primary,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'E${episode.episodeNum} - ${episode.title}',
style: const TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (episode.durationSecs != null && episode.durationSecs! > 0)
Text(
_formatDuration(episode.durationSecs!),
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12),
),
],
),
),
],
),
),
);
}
String _formatDuration(int seconds) {
final hours = seconds ~/ 3600;
final minutes = (seconds % 3600) ~/ 60;
if (hours > 0) return '${hours}h ${minutes}m';
return '${minutes}m';
}
}
@@ -0,0 +1,290 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../features/iptv/providers/settings_provider.dart';
import '../../../../features/iptv/providers/favorites_provider.dart';
import '../screens/mobile_player_screen.dart';
import '../../../../core/models/iptv_models.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
import '../../theme/mobile_theme.dart';
import '../../../../core/widgets/components/ui_components.dart'; // For images/CachedNetworkImage if needed
class MobileLiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const MobileLiveTVTab({super.key, required this.playlist});
@override
ConsumerState<MobileLiveTVTab> createState() => _MobileLiveTVTabState();
}
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab> {
String? _selectedCategory;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
bool _showFavoritesOnly = false;
@override
void initState() {
super.initState();
_searchController.addListener(() {
setState(() {
_searchQuery = _searchController.text.toLowerCase();
});
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider);
final settings = ref.watch(iptvSettingsProvider);
return Scaffold(
backgroundColor: AppColors.background,
body: channelsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center(child: Text('Error: $e')),
data: (groupedChannels) {
// Prepare categories
var categories = groupedChannels.keys.toList();
if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
}
categories.sort();
// Prepare channels
List<Channel> displayedChannels = [];
if (_searchQuery.isNotEmpty) {
displayedChannels = groupedChannels.values.expand((l) => l)
.where((c) => c.name.toLowerCase().contains(_searchQuery))
.toList();
} else if (_showFavoritesOnly) {
displayedChannels = groupedChannels.values.expand((l) => l)
.where((c) => favorites.contains(c.streamId))
.toList();
} else {
// Default to first category if none selected
if (_selectedCategory == null && categories.isNotEmpty) {
_selectedCategory = categories.first;
}
if (_selectedCategory != null) {
displayedChannels = groupedChannels[_selectedCategory] ?? [];
}
}
return SafeArea(
bottom: false, // For bottom nav
child: Column(
children: [
// Search Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container(
height: 48,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
const Icon(Icons.search, color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Search channels',
border: InputBorder.none,
isDense: true,
),
),
),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () => _searchController.clear(),
child: const Icon(Icons.close, color: AppColors.textSecondary),
),
],
),
),
),
// Categories (Chips)
if (_searchQuery.isEmpty && !_showFavoritesOnly)
SizedBox(
height: 40,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
itemCount: categories.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final category = categories[index];
final isSelected = category == _selectedCategory;
return ChoiceChip(
label: Text(category),
selected: isSelected,
onSelected: (bool selected) {
if (selected) {
setState(() {
_selectedCategory = category;
});
}
},
backgroundColor: AppColors.surface,
selectedColor: AppColors.primary,
labelStyle: TextStyle(
color: isSelected ? Colors.white : AppColors.textSecondary,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
fontSize: 12,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
side: BorderSide.none,
showCheckmark: false,
);
},
),
),
// Content Switcher (Favorites button etc)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Text(
_searchQuery.isNotEmpty ? 'Search Results' :
_showFavoritesOnly ? 'Favorites' :
_selectedCategory ?? 'All Channels',
style: const TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const Spacer(),
IconButton(
icon: Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary,
),
onPressed: () => setState(() => _showFavoritesOnly = !_showFavoritesOnly),
),
],
),
),
// Channel List
Expanded(
child: displayedChannels.isEmpty
? Center(
child: Text(
'No channels found',
style: GoogleFonts.inter(color: AppColors.textSecondary),
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), // Bottom padding for FAB/Nav
itemCount: displayedChannels.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final channel = displayedChannels[index];
return _MobileChannelTile(
channel: channel,
onTap: () => _playChannel(context, channel),
);
},
),
),
],
),
);
},
),
);
}
void _playChannel(BuildContext context, Channel channel) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MobilePlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: widget.playlist,
streamType: StreamType.live,
),
),
);
}
}
class _MobileChannelTile extends StatelessWidget {
final Channel channel;
final VoidCallback onTap;
const _MobileChannelTile({required this.channel, required this.onTap});
@override
Widget build(BuildContext context) {
final iconUrl = channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http')
? '/api/xtream/${channel.streamIcon}'
: null;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// Icon
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(8),
),
child: iconUrl != null
? Image.network(
iconUrl,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const Icon(Icons.tv, color: Colors.white24),
)
: const Icon(Icons.tv, color: Colors.white24),
),
const SizedBox(width: 12),
// Name
Expanded(
child: Text(
channel.name,
style: const TextStyle(
color: AppColors.textPrimary,
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// Play Icon
const Icon(Icons.play_circle_outline, color: AppColors.primary),
],
),
),
);
}
}
@@ -0,0 +1,271 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/components/hero_carousel.dart';
import '../../../../core/widgets/components/ui_components.dart';
import '../../../../features/iptv/providers/watch_history_provider.dart';
import '../../../../features/iptv/models/xtream_models.dart';
import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../features/iptv/providers/settings_provider.dart';
import '../screens/mobile_player_screen.dart';
class MobileMoviesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const MobileMoviesTab({super.key, required this.playlist});
@override
ConsumerState<MobileMoviesTab> createState() => _MobileMoviesTabState();
}
class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final List<Movie> _movies = [];
List<Movie>? _searchResults;
String _searchQuery = '';
bool _isLoading = false;
bool _isSearching = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 50; // Smaller page size for mobile
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_loadMoreMovies();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_searchDebounce?.cancel();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) {
_loadMoreMovies();
}
}
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
setState(() {
_searchQuery = query;
if (query.isEmpty) {
_searchResults = null;
_isSearching = false;
}
});
if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
}
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() => _isSearching = true);
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchMovies(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (e) {
if (mounted) setState(() => _isSearching = false);
}
}
Future<void> _loadMoreMovies() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final newMovies = await service.getMoviesPaginated(
offset: _currentOffset,
limit: _pageSize,
);
if (mounted) {
setState(() {
_movies.addAll(newMovies);
_currentOffset += _pageSize;
_hasMore = newMovies.length == _pageSize;
_isLoading = false;
});
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
void _playMovie(Movie movie) {
ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MobilePlayerScreen(
streamId: movie.streamId,
title: movie.name,
playlist: widget.playlist,
streamType: StreamType.vod,
containerExtension: movie.containerExtension ?? 'mp4',
),
),
);
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
final watchHistory = ref.watch(watchHistoryProvider);
List<Movie> displayMovies;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displayMovies = _searchResults!;
} else {
displayMovies = settings.moviesKeywords.isEmpty
? _movies
: _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList();
}
// Hero Items
final heroItems = _movies.take(3).map((m) => HeroItem(
id: m.streamId,
title: m.name,
imageUrl: m.streamIcon ?? '',
subtitle: m.rating != null ? '${m.rating} ★' : null,
onMoreInfo: () => _playMovie(m),
)).toList();
return SafeArea(
bottom: false,
child: CustomScrollView(
controller: _scrollController,
slivers: [
// Header & Search
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Container(
height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
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 SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Search Movies',
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
),
),
if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
),
],
),
),
),
),
// Hero Section (Only if not searching)
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: SizedBox(
height: 250, // Reduced height for mobile hero
child: HeroCarousel(
items: heroItems,
onTap: (item) {
final movie = _movies.firstWhere((element) => element.streamId == item.id);
_playMovie(movie);
},
),
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), // Bottom padding for nav
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 2 columns for mobile
crossAxisSpacing: 12,
mainAxisSpacing: 16,
childAspectRatio: 0.65, // Standard poster ratio
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= displayMovies.length) return null;
final movie = displayMovies[index];
final isWatched = watchHistory.isMovieWatched(movie.streamId);
return MediaCard(
title: movie.name,
imageUrl: movie.streamIcon,
subtitle: movie.rating != null ? '${movie.rating} ★' : null,
rating: movie.rating,
isWatched: isWatched,
onTap: () => _playMovie(movie),
onLongPress: () {
ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId);
},
);
},
childCount: displayMovies.length,
),
),
),
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
),
],
),
);
}
}
@@ -0,0 +1,268 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/components/hero_carousel.dart';
import '../../../../core/widgets/components/ui_components.dart';
import '../../../../features/iptv/models/xtream_models.dart';
import '../../../../features/iptv/providers/xtream_provider.dart';
import '../../../../features/iptv/providers/settings_provider.dart';
import '../screens/mobile_series_detail_screen.dart';
class MobileSeriesTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist;
const MobileSeriesTab({super.key, required this.playlist});
@override
ConsumerState<MobileSeriesTab> createState() => _MobileSeriesTabState();
}
class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final List<Series> _series = [];
List<Series>? _searchResults;
String _searchQuery = '';
bool _isLoading = false;
bool _isSearching = false;
bool _hasMore = true;
int _currentOffset = 0;
static const int _pageSize = 50;
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_loadMoreSeries();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_searchDebounce?.cancel();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent * 0.8) {
_loadMoreSeries();
}
}
void _onSearchChanged(String query) {
_searchDebounce?.cancel();
setState(() {
_searchQuery = query;
if (query.isEmpty) {
_searchResults = null;
_isSearching = false;
}
});
if (query.length >= 2) {
_searchDebounce = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
}
}
Future<void> _performSearch(String query) async {
if (query.isEmpty) return;
setState(() => _isSearching = true);
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final results = await service.searchSeries(query);
if (mounted && _searchQuery == query) {
setState(() {
_searchResults = results;
_isSearching = false;
});
}
} catch (e) {
if (mounted) setState(() => _isSearching = false);
}
}
Future<void> _loadMoreSeries() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
try {
final service = ref.read(xtreamServiceProvider(widget.playlist));
final newSeries = await service.getSeriesPaginated(
offset: _currentOffset,
limit: _pageSize,
);
if (mounted) {
setState(() {
_series.addAll(newSeries);
_currentOffset += _pageSize;
_hasMore = newSeries.length == _pageSize;
_isLoading = false;
});
}
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
void _openSeries(Series series) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MobileSeriesDetailScreen(
series: series,
playlist: widget.playlist,
),
),
);
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(iptvSettingsProvider);
List<Series> displaySeries;
if (_searchQuery.isNotEmpty && _searchResults != null) {
displaySeries = _searchResults!;
} else {
displaySeries = settings.seriesKeywords.isEmpty
? _series
: _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList();
}
// Hero Items
final heroItems = _series.take(3).map((s) => HeroItem(
id: s.seriesId.toString(),
title: s.name,
imageUrl: s.cover ?? '',
subtitle: s.rating != null ? '${s.rating} ★' : null,
onMoreInfo: () => _openSeries(s),
)).toList();
return SafeArea(
bottom: false,
child: CustomScrollView(
controller: _scrollController,
slivers: [
// Header & Search
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Container(
height: 40,
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
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 SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration(
hintText: 'Search Series',
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
),
),
if (_isSearching)
const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary),
),
],
),
),
),
),
// Hero Section
if (_searchQuery.isEmpty && heroItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: SizedBox(
height: 250,
child: HeroCarousel(
items: heroItems,
onTap: (item) {
// Find series by title fallback if ID logic differs (Series ID is int usually, Hero is Str)
// But here we rely on passing the correct string ID or using the closure directly if possible.
// HeroCarousel onTap returns HeroItem.
// We must map it back.
try {
final series = _series.firstWhere((s) => s.seriesId.toString() == item.id);
_openSeries(series);
} catch (e) {
// Fallback logic not critical for now, assume ID matches
}
},
),
),
),
),
// Grid
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
childAspectRatio: 0.65,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index >= displaySeries.length) return null;
final series = displaySeries[index];
return MediaCard(
title: series.name,
imageUrl: series.cover,
subtitle: series.rating != null ? '${series.rating} ★' : null,
rating: series.rating,
placeholderIcon: Icons.tv,
onTap: () => _openSeries(series),
);
},
childCount: displaySeries.length,
),
),
),
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
),
],
),
);
}
}
@@ -0,0 +1,318 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../features/auth/providers/auth_provider.dart';
import '../../../../features/iptv/providers/settings_provider.dart';
import '../../../../core/providers/theme_provider.dart';
import '../../../../core/theme/app_colors.dart';
class MobileSettingsTab extends ConsumerStatefulWidget {
const MobileSettingsTab({super.key});
@override
ConsumerState<MobileSettingsTab> createState() => _MobileSettingsTabState();
}
class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
late TextEditingController _liveTvController;
late TextEditingController _moviesController;
late TextEditingController _seriesController;
bool _initialized = false;
@override
void initState() {
super.initState();
_liveTvController = TextEditingController();
_moviesController = TextEditingController();
_seriesController = TextEditingController();
}
@override
void dispose() {
_liveTvController.dispose();
_moviesController.dispose();
_seriesController.dispose();
super.dispose();
}
void _syncControllers(IptvSettings settings) {
if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) {
_liveTvController.text = settings.liveTvCategoryFilter;
}
if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) {
_moviesController.text = settings.moviesCategoryFilter;
}
if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) {
_seriesController.text = settings.seriesCategoryFilter;
}
_initialized = true;
}
@override
Widget build(BuildContext context) {
final currentUser = ref.watch(authProvider).currentUser;
final settings = ref.watch(iptvSettingsProvider);
final themeState = ref.watch(themeProvider);
final themeNotifier = ref.read(themeProvider.notifier);
final isDark = Theme.of(context).brightness == Brightness.dark;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_initialized) _syncControllers(settings);
});
return SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Header
Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Text(
'Settings',
style: Theme.of(context).textTheme.headlineMedium,
),
),
// User Profile
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: AppColors.primary.withOpacity(0.2),
child: Text(
currentUser?.username.substring(0, 1).toUpperCase() ?? 'U',
style: const TextStyle(fontWeight: FontWeight.bold, color: AppColors.primary),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentUser?.username ?? 'Guest',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
if (currentUser?.isAdmin ?? false)
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
child: const Text('ADMIN', style: TextStyle(fontSize: 10, color: AppColors.primary, fontWeight: FontWeight.bold)),
),
],
),
),
],
),
),
const SizedBox(height: 24),
// Appearance
_SectionHeader(title: 'Appearance'),
Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
SwitchListTile(
title: const Text('Dark Mode', style: TextStyle(color: AppColors.textPrimary)),
secondary: Icon(isDark ? Icons.dark_mode : Icons.light_mode, color: AppColors.primary),
value: isDark,
onChanged: (_) => themeNotifier.toggleTheme(),
activeColor: AppColors.primary,
),
],
),
),
const SizedBox(height: 24),
// Content Filters
_SectionHeader(title: 'Content Filters'),
Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.all(16),
child: Column(
children: [
_FilterInput(
label: 'Live TV Keywords',
icon: Icons.live_tv,
controller: _liveTvController,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setLiveTvFilter(val),
),
const Divider(height: 24),
_FilterInput(
label: 'Movies Keywords',
icon: Icons.movie,
controller: _moviesController,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setMoviesFilter(val),
),
const Divider(height: 24),
_FilterInput(
label: 'Series Keywords',
icon: Icons.tv,
controller: _seriesController,
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setSeriesFilter(val),
),
],
),
),
const SizedBox(height: 24),
// Actions
if (currentUser?.isAdmin ?? false)
_SettingsButton(
icon: Icons.admin_panel_settings,
label: 'Admin Panel',
onTap: () => context.go('/admin'),
),
const SizedBox(height: 12),
_SettingsButton(
icon: Icons.playlist_play,
label: 'Change Playlist',
onTap: () => context.go('/playlists'),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () {
ref.read(authProvider.notifier).logout();
context.go('/login');
},
style: FilledButton.styleFrom(
backgroundColor: AppColors.error.withOpacity(0.1),
foregroundColor: AppColors.error,
minimumSize: const Size(double.infinity, 50),
),
icon: const Icon(Icons.logout),
label: const Text('Logout'),
),
const SizedBox(height: 100),
],
),
);
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader({required this.title});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text(
title,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
);
}
}
class _FilterInput extends StatelessWidget {
final String label;
final IconData icon;
final TextEditingController controller;
final ValueChanged<String> onChanged;
const _FilterInput({
required this.label,
required this.icon,
required this.controller,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 20, color: AppColors.textSecondary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
TextField(
controller: controller,
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 4),
border: InputBorder.none,
hintText: 'e.g. FR, HD',
),
onChanged: onChanged,
),
],
),
),
],
);
}
}
class _SettingsButton extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
const _SettingsButton({
required this.icon,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(icon, color: AppColors.textPrimary),
const SizedBox(width: 16),
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const Spacer(),
const Icon(Icons.chevron_right, color: AppColors.textSecondary),
],
),
),
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/theme/app_colors.dart';
/// XtremFlow Mobile Theme
///
/// Adapted from AppTheme for touch-first screens.
class MobileTheme {
MobileTheme._();
// ============ SPACING ============
static const double spacing4 = 4.0;
static const double spacing8 = 8.0;
static const double spacing12 = 12.0;
static const double spacing16 = 16.0;
static const double spacing24 = 24.0;
// ============ RADIUS ============
static const double radiusSm = 8.0;
static const double radiusMd = 12.0;
static const double radiusLg = 16.0;
// ============ DARK THEME (Mobile Main) ============
static ThemeData get darkTheme {
final baseTheme = ThemeData.dark(useMaterial3: true);
return baseTheme.copyWith(
colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background,
// Typography: Scaled down for mobile
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -1.0, color: AppColors.textPrimary),
displayMedium: GoogleFonts.inter(fontSize: 28, fontWeight: FontWeight.w700, letterSpacing: -0.5, color: AppColors.textPrimary),
displaySmall: GoogleFonts.inter(fontSize: 24, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
headlineMedium: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
titleLarge: GoogleFonts.inter(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
titleMedium: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
bodyLarge: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.textSecondary),
bodyMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w400, color: AppColors.textSecondary),
labelLarge: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
),
// AppBar: Transparent / Glass
appBarTheme: AppBarTheme(
backgroundColor: AppColors.surface.withOpacity(0.8), // Slight background for mobile
foregroundColor: AppColors.textPrimary,
elevation: 0,
centerTitle: true,
scrolledUnderElevation: 0,
titleTextStyle: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
// Bottom Navigation Bar
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: AppColors.surface.withOpacity(0.9),
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textSecondary,
selectedLabelStyle: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.w600),
unselectedLabelStyle: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.w500),
type: BottomNavigationBarType.fixed,
elevation: 0,
),
// Inputs
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: spacing16, vertical: spacing12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: AppColors.focusColor, width: 2),
),
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary, fontSize: 14),
),
// Buttons
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.focusColor,
foregroundColor: Colors.black,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusMd)),
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 14),
),
),
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_colors.dart';
class MobileScaffold extends ConsumerWidget {
final Widget child;
final int currentIndex;
final Function(int) onIndexChanged;
const MobileScaffold({
super.key,
required this.child,
required this.currentIndex,
required this.onIndexChanged,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
extendBody: true, // Allow content behind nav bar if transparent
backgroundColor: AppColors.background,
body: child,
bottomNavigationBar: Container(
decoration: BoxDecoration(
border: Border(top: BorderSide(color: AppColors.border.withOpacity(0.5), width: 0.5)),
),
child: BottomNavigationBar(
currentIndex: currentIndex,
onTap: onIndexChanged,
backgroundColor: AppColors.surface.withOpacity(0.95), // Highly opaque for readability
type: BottomNavigationBarType.fixed,
showSelectedLabels: true,
showUnselectedLabels: true,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.tv),
label: 'Live',
),
BottomNavigationBarItem(
icon: Icon(Icons.movie_outlined),
activeIcon: Icon(Icons.movie),
label: 'Movies',
),
BottomNavigationBarItem(
icon: Icon(Icons.video_library_outlined),
activeIcon: Icon(Icons.video_library),
label: 'Series',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
),
);
}
}
+9
View File
@@ -289,6 +289,11 @@
return false;
}
if (mpegts.getLoggingControl) {
mpegts.getLoggingControl().enableAll = false;
mpegts.getLoggingControl().enableError = true;
}
const player = mpegts.createPlayer({
type: 'mpegts',
isLive: true,
@@ -298,6 +303,10 @@
enableStashBuffer: true,
stashInitialSize: 384 * 1024,
autoCleanupSourceBuffer: true,
// Make the player more resilient to timestamp gaps without spamming
fixAudioTimestampGap: true,
lazyLoadMaxDuration: 3 * 60,
seekType: 'range',
});
player.attachMediaElement(video);