diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index fa25cd4..5c342a3 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -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((ref) { final authState = ref.watch(authProvider); @@ -43,18 +46,27 @@ final routerProvider = Provider((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((ref) { body: Center(child: CircularProgressIndicator()), ); } + + if (MediaQuery.of(context).size.width < 768) { + return MobileDashboardScreen(playlist: playlist); + } return DashboardScreen(playlist: playlist); }, ), diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index f38cc88..d10e374 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -188,8 +188,8 @@ class _PlayerScreenState extends ConsumerState { ..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; }, ); diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index 0b56c2c..b6deef8 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -81,6 +81,7 @@ class _LiveTVTabState extends ConsumerState // Filter Logic List displayedChannels = []; + bool showingCategoryGrid = false; if (_searchQuery.isNotEmpty) { // Global Search @@ -93,37 +94,47 @@ class _LiveTVTabState extends ConsumerState .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 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 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 ); } - Widget _buildGridView(List channels) { + Widget _buildCategoryGrid(List categories, Map> 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 channels) { final columns = ResponsiveLayout.value( context, mobile: 2, @@ -241,12 +317,12 @@ class _LiveTVTabState extends ConsumerState 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 ); } - Widget _buildListView(List channels) { + Widget _buildChannelList(List 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) { diff --git a/lib/mobile/features/auth/screens/mobile_login_screen.dart b/lib/mobile/features/auth/screens/mobile_login_screen.dart new file mode 100644 index 0000000..f7f9e7e --- /dev/null +++ b/lib/mobile/features/auth/screens/mobile_login_screen.dart @@ -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 createState() => _MobileLoginScreenState(); +} + +class _MobileLoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _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, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart b/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart new file mode 100644 index 0000000..9739021 --- /dev/null +++ b/lib/mobile/features/iptv/screens/mobile_dashboard_screen.dart @@ -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 createState() => _MobileDashboardScreenState(); +} + +class _MobileDashboardScreenState extends ConsumerState { + 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, + ), + ); + } +} diff --git a/lib/mobile/features/iptv/screens/mobile_player_screen.dart b/lib/mobile/features/iptv/screens/mobile_player_screen.dart new file mode 100644 index 0000000..3d3a8f9 --- /dev/null +++ b/lib/mobile/features/iptv/screens/mobile_player_screen.dart @@ -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, + ); + } +} diff --git a/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart b/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart new file mode 100644 index 0000000..d6bbc3f --- /dev/null +++ b/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart @@ -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>((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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart b/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart new file mode 100644 index 0000000..065380d --- /dev/null +++ b/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart @@ -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 createState() => _MobileSeriesDetailScreenState(); +} + +class _MobileSeriesDetailScreenState extends ConsumerState { + SeriesInfo? _seriesInfo; + bool _isLoading = true; + String? _error; + int _selectedSeason = 1; + + @override + void initState() { + super.initState(); + _loadSeriesInfo(); + } + + Future _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'; + } +} diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart new file mode 100644 index 0000000..5ce3b23 --- /dev/null +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -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 createState() => _MobileLiveTVTabState(); +} + +class _MobileLiveTVTabState extends ConsumerState { + 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 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), + ], + ), + ), + ); + } +} diff --git a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart new file mode 100644 index 0000000..6fdb324 --- /dev/null +++ b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart @@ -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 createState() => _MobileMoviesTabState(); +} + +class _MobileMoviesTabState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + final List _movies = []; + List? _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 _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 _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 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()), + ), + ), + ], + ), + ); + } +} diff --git a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart new file mode 100644 index 0000000..b7a097a --- /dev/null +++ b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart @@ -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 createState() => _MobileSeriesTabState(); +} + +class _MobileSeriesTabState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + final List _series = []; + List? _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 _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 _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 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()), + ), + ), + ], + ), + ); + } +} diff --git a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart new file mode 100644 index 0000000..6f62d57 --- /dev/null +++ b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart @@ -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 createState() => _MobileSettingsTabState(); +} + +class _MobileSettingsTabState extends ConsumerState { + 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 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), + ], + ), + ), + ); + } +} diff --git a/lib/mobile/theme/mobile_theme.dart b/lib/mobile/theme/mobile_theme.dart new file mode 100644 index 0000000..ee1204e --- /dev/null +++ b/lib/mobile/theme/mobile_theme.dart @@ -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), + ), + ), + ); + } +} diff --git a/lib/mobile/widgets/mobile_scaffold.dart b/lib/mobile/widgets/mobile_scaffold.dart new file mode 100644 index 0000000..cd0a61c --- /dev/null +++ b/lib/mobile/widgets/mobile_scaffold.dart @@ -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', + ), + ], + ), + ), + ); + } +} diff --git a/web/player.html b/web/player.html index c32b413..653cb3b 100644 --- a/web/player.html +++ b/web/player.html @@ -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);