mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-11 17:30:00 +02:00
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/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);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user