diff --git a/assets/images/logo_xtremflow.png b/assets/images/logo_xtremflow.png index ae4b108..62d53cc 100644 Binary files a/assets/images/logo_xtremflow.png and b/assets/images/logo_xtremflow.png differ diff --git a/lib/core/services/ip_service.dart b/lib/core/services/ip_service.dart new file mode 100644 index 0000000..f7012dd --- /dev/null +++ b/lib/core/services/ip_service.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; + +class IpService { + static final IpService _instance = IpService._internal(); + + factory IpService() { + return _instance; + } + + IpService._internal(); + + String? _cachedCountry; + String? _cachedCountryCode; + + Future fetchIpDetails() async { + if (_cachedCountry != null) return; + + try { + // Using ip-api.com (free tier, HTTP) + final response = await http.get(Uri.parse('http://ip-api.com/json')); + + if (response.statusCode == 200) { + final data = json.decode(response.body); + if (data['status'] == 'success') { + _cachedCountry = data['country']; + _cachedCountryCode = data['countryCode']; + debugPrint( + 'IpService: Detected country: $_cachedCountry ($_cachedCountryCode)'); + } + } + } catch (e) { + debugPrint('IpService: Error fetching IP details: $e'); + } + } + + String? get country => _cachedCountry; + String? get countryCode => _cachedCountryCode; +} diff --git a/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart b/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart index db81998..f09d73a 100644 --- a/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_series_detail_screen.dart @@ -291,6 +291,15 @@ class _MobileSeriesDetailScreenState episode.episodeNum, ); final isWatched = watchHistory.isEpisodeWatched(episodeKey); + final resumePos = watchHistory.getResumePosition(episodeKey); + + // Calculate progress + double progress = 0.0; + if (episode.durationSecs != null && + episode.durationSecs! > 0 && + resumePos > 0) { + progress = (resumePos / episode.durationSecs!).clamp(0.0, 1.0); + } // Force dark mode colors since background is hardcoded dark const textColor = Colors.white; @@ -300,8 +309,6 @@ class _MobileSeriesDetailScreenState return InkWell( onTap: () { - // Watch progress is tracked in player at 80% completion - // Enforce Native Player for Series (VOD) Navigator.push( context, @@ -316,61 +323,78 @@ class _MobileSeriesDetailScreenState seriesId: widget.series.seriesId, season: episode.seasonNum, episodeNum: episode.episodeNum, + // Pass explicit initial position for resume + initialPosition: + resumePos > 0 ? Duration(seconds: resumePos) : null, ), ), ); }, - 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 ? primaryColor : surfaceColor, - shape: BoxShape.circle, - border: isWatched - ? null - : Border.all(color: primaryColor, width: 2), - ), - child: Icon( - isWatched ? Icons.check : Icons.play_arrow, - color: isWatched - ? Colors.black // Checkmark on white bg - : primaryColor, // Play icon - size: 20, - ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: Colors.white10)), ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'E${episode.episodeNum} - ${episode.title}', - style: TextStyle( - color: isWatched ? secondaryTextColor : textColor, - fontWeight: FontWeight.w600, - fontSize: 14, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Row( + children: [ + // Play Button / Indicator + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isWatched ? primaryColor : surfaceColor, + shape: BoxShape.circle, + border: isWatched + ? null + : Border.all(color: primaryColor, width: 2), ), - if (episode.durationSecs != null && episode.durationSecs! > 0) - Text( - _formatDuration(episode.durationSecs!), - style: TextStyle(color: secondaryTextColor, fontSize: 12), - ), - ], - ), + child: Icon( + isWatched ? Icons.check : Icons.play_arrow, + color: isWatched + ? Colors.black // Checkmark on white bg + : primaryColor, // Play icon + size: 20, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'E${episode.episodeNum} - ${episode.title}', + style: TextStyle( + color: isWatched ? secondaryTextColor : textColor, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (episode.durationSecs != null && + episode.durationSecs! > 0) + Text( + _formatDuration(episode.durationSecs!), + style: TextStyle( + color: secondaryTextColor, fontSize: 12), + ), + ], + ), + ), + ], ), - ], - ), + ), + // Progress Bar (Red like YouTube/Netflix) + if (progress > 0 && !isWatched) + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.transparent, + valueColor: const AlwaysStoppedAnimation(Colors.red), + minHeight: 2, + ), + ], ), ); } diff --git a/lib/mobile/features/iptv/screens/native_player_screen.dart b/lib/mobile/features/iptv/screens/native_player_screen.dart index cb11e23..2996ab4 100644 --- a/lib/mobile/features/iptv/screens/native_player_screen.dart +++ b/lib/mobile/features/iptv/screens/native_player_screen.dart @@ -44,11 +44,14 @@ class NativePlayerScreen extends ConsumerStatefulWidget { this.containerExtension, this.channels, this.initialIndex = 0, + this.initialPosition, this.seriesId, this.season, this.episodeNum, }); + final Duration? initialPosition; + @override ConsumerState createState() => _NativePlayerScreenState(); } @@ -646,12 +649,22 @@ class _NativePlayerScreenState extends ConsumerState // Resume from saved position (VOD/Series only) if (widget.streamType != StreamType.live) { - final resumePos = - ref.read(mobileWatchHistoryProvider).getResumePosition(_contentId); - debugPrint( - 'MediaKitPlayer: Resume check - contentId: $_contentId, resumePos: $resumePos'); - if (resumePos > 30) { - _seekToResume(resumePos); + // Priority 1: Explicit initial position passed from caller verification + if (widget.initialPosition != null && + widget.initialPosition!.inSeconds > 0) { + debugPrint( + 'MediaKitPlayer: Using explicit initial position: ${widget.initialPosition!.inSeconds}s'); + _seekToResume(widget.initialPosition!.inSeconds); + } else { + // Priority 2: Internal lookup (fallback) + final resumePos = ref + .read(mobileWatchHistoryProvider) + .getResumePosition(_contentId); + debugPrint( + 'MediaKitPlayer: Resume check - contentId: $_contentId, resumePos: $resumePos'); + if (resumePos > 30) { + _seekToResume(resumePos); + } } } } catch (e) { diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart index c97e925..7b93ab3 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -14,6 +14,7 @@ import '../../../theme/mobile_theme.dart'; import 'package:xtremflow/mobile/widgets/tv_focusable.dart'; import 'package:xtremflow/mobile/features/iptv/screens/lite_player_screen.dart'; import 'package:flutter/services.dart'; +import '../../../../core/services/ip_service.dart'; class MobileLiveTVTab extends ConsumerStatefulWidget { final PlaylistConfig playlist; @@ -41,6 +42,10 @@ class _MobileLiveTVTabState extends ConsumerState _searchQuery = _searchController.text.toLowerCase(); }); }); + // Trigger IP fetch for smart sorting + IpService().fetchIpDetails().then((_) { + if (mounted) setState(() {}); + }); } @override @@ -111,8 +116,22 @@ class _MobileLiveTVTabState extends ConsumerState categories = categories .where((cat) => settings.matchesLiveTvFilter(cat)) .toList(); + categories.sort(); + } else { + // Smart Sort (GeoIP) + final userCountry = IpService().country; + categories.sort((a, b) { + if (userCountry != null) { + final aMatches = + a.toLowerCase().contains(userCountry.toLowerCase()); + final bMatches = + b.toLowerCase().contains(userCountry.toLowerCase()); + if (aMatches && !bMatches) return -1; + if (!aMatches && bMatches) return 1; + } + return a.compareTo(b); + }); } - categories.sort(); // Prepare channels List displayedChannels = []; @@ -151,6 +170,7 @@ class _MobileLiveTVTabState extends ConsumerState children: [ // Search Bar - Wrapped in TVFocusable for remote access TVFocusable( + scale: 1.0, // Disable scaling to prevent overflow onPressed: () { setState(() => _isSearchEditing = true); WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart index 4063100..a62ca19 100644 --- a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart @@ -258,6 +258,7 @@ class _MobileSeriesTabState extends ConsumerState padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: TVFocusable( + scale: 1.0, // Disable scaling to prevent overflow onPressed: () { setState(() => _isSearchEditing = true); WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart index ebc7640..9b9bdf6 100644 --- a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart @@ -75,11 +75,13 @@ class _MobileSettingsTabState extends ConsumerState { padding: const EdgeInsets.only(bottom: 16), child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), + Container( + width: 48, + height: 48, + // No decoration for free-floating logo child: Image.asset( 'assets/images/logo_xtremflow.png', - fit: BoxFit.cover, + fit: BoxFit.contain, errorBuilder: (_, __, ___) => const Icon( Icons.play_circle_filled, color: AppColors.primary, diff --git a/lib/mobile/widgets/mobile_scaffold.dart b/lib/mobile/widgets/mobile_scaffold.dart index 5daaa15..4289e1c 100644 --- a/lib/mobile/widgets/mobile_scaffold.dart +++ b/lib/mobile/widgets/mobile_scaffold.dart @@ -1,9 +1,11 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; import '../../core/theme/app_colors.dart'; -class MobileScaffold extends ConsumerWidget { +class MobileScaffold extends ConsumerStatefulWidget { final Widget child; final int currentIndex; final Function(int) onIndexChanged; @@ -16,59 +18,130 @@ class MobileScaffold extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _MobileScaffoldState(); +} + +class _MobileScaffoldState extends ConsumerState { + late Stream _clockStream; + + @override + void initState() { + super.initState(); + _clockStream = + Stream.periodic(const Duration(seconds: 1), (_) => DateTime.now()); + } + + @override + Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.background, body: Column( children: [ - // Top Navigation Bar (Apple TV Style) + // Top Navigation Bar (Redesigned) Container( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withOpacity(0.6), - Colors.transparent, - ], - ), + padding: const EdgeInsets.symmetric( + vertical: 24, + horizontal: + 24), // Increased vertical padding for floating effect + decoration: const BoxDecoration( + color: Colors.transparent, // Transparent background ), child: SafeArea( bottom: false, child: Container( + height: 64, // Taller pill to accommodate larger logo + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A) + .withOpacity(0.9), // Slightly more opaque pill + borderRadius: BorderRadius.circular(40), + border: Border.all(color: Colors.white.withOpacity(0.1)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 10, + spreadRadius: 2, + ) + ], + ), child: Row( children: [ - // LOGO (Left side) + // --- LEFT: LOGO & BRAND --- Padding( - padding: const EdgeInsets.only(right: 16), - child: Image.asset( - 'assets/images/logo_xtremflow.png', - height: 40, - width: 40, - errorBuilder: (context, error, stackTrace) => - const SizedBox(width: 40), // Fallback if missing + padding: const EdgeInsets.only( + left: 8, right: 32), // More spacing after logo + child: Row( + children: [ + Container( + width: 52, // Larger Logo + height: 52, + // No decoration for free-floating logo + child: Image.asset( + 'assets/images/logo_xtremflow.png', + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Icon( + Icons.play_circle_filled, + color: Colors.white, + size: 40, + ), + ), + ), + const SizedBox(width: 12), + const Text( + 'XtremFlow', + style: TextStyle( + color: Colors.white, + fontSize: 20, // Slightly larger text + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + ], ), ), - // NAVIGATION PILL (Expanded) + // --- CENTER: NAVIGATION --- Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildNavItem(0, 'Live TV', Icons.tv), + const SizedBox(width: 32), // Increased spacing + _buildNavItem(1, 'Movies', Icons.movie), + const SizedBox(width: 32), + _buildNavItem(2, 'Series', Icons.video_library), + const SizedBox(width: 32), + _buildNavItem(3, 'Settings', Icons.settings), + ], + ), + ), + + // --- RIGHT: CLOCK ONLY --- + Padding( + padding: const EdgeInsets.only(left: 32, right: 16), child: Container( - height: 50, + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(30), - border: - Border.all(color: Colors.white.withOpacity(0.15)), + color: Colors.white + .withOpacity(0.08), // Subtle background + borderRadius: BorderRadius.circular(20), ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildNavItem(0, 'Live TV', Icons.tv), - _buildNavItem(1, 'Films', Icons.movie), - _buildNavItem(2, 'Séries', Icons.video_library), - _buildNavItem(3, 'Paramètres', Icons.settings), - ], + child: StreamBuilder( + stream: _clockStream, + initialData: DateTime.now(), + builder: (context, snapshot) { + final time = + DateFormat('HH:mm').format(snapshot.data!); + return Text( + time, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ); + }, ), ), ), @@ -79,20 +152,19 @@ class MobileScaffold extends ConsumerWidget { ), // Main Content - Expanded(child: child), + Expanded(child: widget.child), ], ), ); } Widget _buildNavItem(int index, String label, IconData icon) { - final isSelected = currentIndex == index; - + final isSelected = widget.currentIndex == index; return _AppleTVNavItem( isSelected: isSelected, icon: icon, label: label, - onPressed: () => onIndexChanged(index), + onPressed: () => widget.onIndexChanged(index), ); } } @@ -138,11 +210,13 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> { @override Widget build(BuildContext context) { - // Selected OR Focused = black text (white bg), Normal = white70 text - final Color textColor = - (widget.isSelected || _isFocused) ? Colors.black : Colors.white70; - final Color iconColor = - (widget.isSelected || _isFocused) ? Colors.black : Colors.white70; + // Selected: Black text on White bg + // Focused: White text on Translucent bg + // Normal: White70 text on Transparent bg + final Color textColor = widget.isSelected ? Colors.black : Colors.white; + final Color iconColor = widget.isSelected ? Colors.black : Colors.white; + final FontWeight fontWeight = + widget.isSelected ? FontWeight.w700 : FontWeight.w500; return Focus( focusNode: _focusNode, @@ -163,38 +237,30 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> { child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), decoration: BoxDecoration( - // White pill when selected OR focused color: widget.isSelected ? Colors.white : (_isFocused - ? Colors.white.withOpacity(0.9) + ? Colors.white.withOpacity(0.2) // Explicit focus highlight : Colors.transparent), - borderRadius: BorderRadius.circular(25), - // Strong glow when focused - boxShadow: _isFocused - ? [ - BoxShadow( - color: Colors.white.withOpacity(0.5), - blurRadius: 16, - spreadRadius: 4, - ), - ] + borderRadius: BorderRadius.circular(20), // Matches pill shape + border: _isFocused && !widget.isSelected + ? Border.all( + color: Colors.white + .withOpacity(0.3)) // Subtle border for focus : null, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(widget.icon, color: iconColor, size: 18), - const SizedBox(width: 6), + const SizedBox(width: 8), Text( widget.label, style: TextStyle( color: textColor, - fontWeight: - widget.isSelected ? FontWeight.w700 : FontWeight.w400, - fontSize: 13, + fontWeight: fontWeight, + fontSize: 14, ), ), ],