mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
commit
6d2008af5e
15 files changed
+2239
-75
No files matched your search
@@ -7,6 +7,9 @@ import '../../features/iptv/screens/playlist_selection_screen.dart';
|
|||||||
import '../../features/iptv/screens/dashboard_screen.dart';
|
import '../../features/iptv/screens/dashboard_screen.dart';
|
||||||
import '../../features/admin/screens/admin_panel.dart';
|
import '../../features/admin/screens/admin_panel.dart';
|
||||||
import '../models/playlist_config.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 routerProvider = Provider<GoRouter>((ref) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
@@ -43,18 +46,27 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: '/login',
|
||||||
builder: (context, state) => const LoginScreen(),
|
builder: (context, state) {
|
||||||
|
if (MediaQuery.of(context).size.width < 768) {
|
||||||
|
return const MobileLoginScreen();
|
||||||
|
}
|
||||||
|
return const LoginScreen();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/playlists',
|
path: '/playlists',
|
||||||
builder: (context, state) => const PlaylistSelectionScreen(),
|
builder: (context, state) {
|
||||||
|
if (MediaQuery.of(context).size.width < 768) {
|
||||||
|
return const MobilePlaylistSelectionScreen();
|
||||||
|
}
|
||||||
|
return const PlaylistSelectionScreen();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final playlist = state.extra as PlaylistConfig?;
|
final playlist = state.extra as PlaylistConfig?;
|
||||||
if (playlist == null) {
|
if (playlist == null) {
|
||||||
// Redirect back if no playlist provided
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.go('/playlists');
|
context.go('/playlists');
|
||||||
});
|
});
|
||||||
@@ -62,6 +74,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (MediaQuery.of(context).size.width < 768) {
|
||||||
|
return MobileDashboardScreen(playlist: playlist);
|
||||||
|
}
|
||||||
return DashboardScreen(playlist: playlist);
|
return DashboardScreen(playlist: playlist);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
..style.border = 'none'
|
..style.border = 'none'
|
||||||
..style.width = '100%'
|
..style.width = '100%'
|
||||||
..style.height = '100%'
|
..style.height = '100%'
|
||||||
..allow = 'autoplay; fullscreen'
|
..style.height = '100%'
|
||||||
..allowFullscreen = true;
|
..allow = 'autoplay; fullscreen; picture-in-picture';
|
||||||
return iframe;
|
return iframe;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
|
|
||||||
// Filter Logic
|
// Filter Logic
|
||||||
List<Channel> displayedChannels = [];
|
List<Channel> displayedChannels = [];
|
||||||
|
bool showingCategoryGrid = false;
|
||||||
|
|
||||||
if (_searchQuery.isNotEmpty) {
|
if (_searchQuery.isNotEmpty) {
|
||||||
// Global Search
|
// Global Search
|
||||||
@@ -93,37 +94,47 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
.where((c) => favorites.contains(c.streamId))
|
.where((c) => favorites.contains(c.streamId))
|
||||||
.toList();
|
.toList();
|
||||||
} else if (_selectedCategory != null) {
|
} else if (_selectedCategory != null) {
|
||||||
// Selected Category
|
// Selected Category View
|
||||||
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
displayedChannels = groupedChannels[_selectedCategory] ?? [];
|
||||||
} else {
|
} else {
|
||||||
// Default: Show first category or All if practical, but "All" is too big.
|
// Category Grid View (Default)
|
||||||
// Apple TV pattern: Auto-select first category if nothing selected.
|
showingCategoryGrid = true;
|
||||||
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] ?? [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Minimalist Header
|
// Header
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(left: 24, right: 24, top: 16, bottom: 8),
|
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
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(
|
Text(
|
||||||
'Live TV',
|
_searchQuery.isNotEmpty ? 'Search Results' :
|
||||||
|
_showFavoritesOnly ? 'Favorites' :
|
||||||
|
_selectedCategory ?? 'Live TV',
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
|
||||||
// Search Pill
|
// Search Pill
|
||||||
Container(
|
Container(
|
||||||
width: 250,
|
width: 300,
|
||||||
height: 36,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.surface,
|
color: AppColors.surface,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
borderRadius: BorderRadius.circular(AppTheme.radiusFull),
|
||||||
@@ -132,17 +143,17 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.search, size: 18, color: AppColors.textSecondary),
|
const Icon(Icons.search, size: 20, color: AppColors.textSecondary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
style: const TextStyle(fontSize: 13, color: AppColors.textPrimary),
|
style: const TextStyle(fontSize: 14, color: AppColors.textPrimary),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Search channels',
|
hintText: 'Search channels',
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: EdgeInsets.only(bottom: 12),
|
contentPadding: EdgeInsets.only(bottom: 11),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -166,63 +177,41 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_showFavoritesOnly = !_showFavoritesOnly;
|
_showFavoritesOnly = !_showFavoritesOnly;
|
||||||
if (_showFavoritesOnly) _searchController.clear();
|
if (_showFavoritesOnly) _searchController.clear();
|
||||||
|
_selectedCategory = null; // Reset selection when toggling favorites
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
tooltip: 'Favorites Only',
|
tooltip: 'Favorites Only',
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
// View Toggle (Only show when not in category grid)
|
||||||
_isGridView ? Icons.view_list : Icons.grid_view,
|
if (!showingCategoryGrid)
|
||||||
color: AppColors.textSecondary,
|
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)
|
// Content
|
||||||
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
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: displayedChannels.isEmpty
|
child: showingCategoryGrid
|
||||||
? Center(
|
? _buildCategoryGrid(categories, groupedChannels)
|
||||||
child: Text(
|
: displayedChannels.isEmpty
|
||||||
_showFavoritesOnly ? 'No favorites' : 'No channels found',
|
? Center(
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary),
|
child: Text(
|
||||||
),
|
_searchQuery.isNotEmpty ? 'No channels found' :
|
||||||
)
|
_showFavoritesOnly ? 'No favorites' : 'No channels in this group',
|
||||||
: _isGridView
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary),
|
||||||
? _buildGridView(displayedChannels)
|
),
|
||||||
: _buildListView(displayedChannels),
|
)
|
||||||
|
: _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(
|
final columns = ResponsiveLayout.value(
|
||||||
context,
|
context,
|
||||||
mobile: 2,
|
mobile: 2,
|
||||||
@@ -241,12 +317,12 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
|
|||||||
|
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
controller: _mainScrollController,
|
controller: _mainScrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: columns,
|
crossAxisCount: columns,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
childAspectRatio: 1.3, // Card ratio 4:3ish
|
childAspectRatio: 1.3,
|
||||||
),
|
),
|
||||||
itemCount: channels.length,
|
itemCount: channels.length,
|
||||||
itemBuilder: (context, index) {
|
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(
|
return ListView.separated(
|
||||||
controller: _mainScrollController,
|
controller: _mainScrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||||
itemCount: channels.length,
|
itemCount: channels.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
itemBuilder: (context, index) {
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -289,6 +289,11 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mpegts.getLoggingControl) {
|
||||||
|
mpegts.getLoggingControl().enableAll = false;
|
||||||
|
mpegts.getLoggingControl().enableError = true;
|
||||||
|
}
|
||||||
|
|
||||||
const player = mpegts.createPlayer({
|
const player = mpegts.createPlayer({
|
||||||
type: 'mpegts',
|
type: 'mpegts',
|
||||||
isLive: true,
|
isLive: true,
|
||||||
@@ -298,6 +303,10 @@
|
|||||||
enableStashBuffer: true,
|
enableStashBuffer: true,
|
||||||
stashInitialSize: 384 * 1024,
|
stashInitialSize: 384 * 1024,
|
||||||
autoCleanupSourceBuffer: true,
|
autoCleanupSourceBuffer: true,
|
||||||
|
// Make the player more resilient to timestamp gaps without spamming
|
||||||
|
fixAudioTimestampGap: true,
|
||||||
|
lazyLoadMaxDuration: 3 * 60,
|
||||||
|
seekType: 'range',
|
||||||
});
|
});
|
||||||
|
|
||||||
player.attachMediaElement(video);
|
player.attachMediaElement(video);
|
||||||
|
|||||||
Reference in new issue
Block a user