diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index e8b241f..d993158 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -36,8 +36,20 @@ class XtreamServiceMobile { _cacheOptions = CacheOptions( store: _cacheStore, policy: CachePolicy.forceCache, - maxStale: const Duration(hours: 24), // Cache for 24h as requested + maxStale: const Duration(hours: 24), priority: CachePriority.high, + keyBuilder: (request) { + // Use the Original Host header if available to generate a stable cache key + // regardless of the resolved IP address used for the connection + if (request.headers.containsKey('Host')) { + final host = request.headers['Host'] as String; + final uri = request.uri; + // Reconstruct URI with the original hostname + final stableUri = uri.replace(host: host); + return stableUri.toString(); + } + return request.uri.toString(); + }, ); _dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions)); 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 a50b18d..3598c09 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -343,13 +343,18 @@ class _MobileLiveTVTabState extends ConsumerState with Automati ); } - return ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), + return GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6, + childAspectRatio: 0.85, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), itemCount: channels.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { final channel = channels[index]; - return _MobileChannelTile( + return _MobileChannelCard( channel: channel, playlist: widget.playlist, onTap: () => _playChannel(context, channel, channels, widget.playlist, index), @@ -398,22 +403,22 @@ class _MobileLiveTVTabState extends ConsumerState with Automati } } -class _MobileChannelTile extends ConsumerStatefulWidget { +class _MobileChannelCard extends ConsumerStatefulWidget { final Channel channel; final VoidCallback onTap; final PlaylistConfig playlist; - const _MobileChannelTile({ + const _MobileChannelCard({ required this.channel, required this.onTap, required this.playlist, }); @override - ConsumerState<_MobileChannelTile> createState() => _MobileChannelTileState(); + ConsumerState<_MobileChannelCard> createState() => _MobileChannelCardState(); } -class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> { +class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> { String? _epgTitle; @override @@ -425,7 +430,6 @@ class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> { Future _loadEpg() async { try { final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); - // Fetch current program final epg = await service.getShortEPG(widget.channel.streamId); if (mounted && epg.nowPlaying != null && epg.nowPlaying!.isNotEmpty) { setState(() { @@ -439,12 +443,10 @@ class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> { @override Widget build(BuildContext context) { - // On mobile, use direct URL (no proxy needed) final iconUrl = widget.channel.streamIcon.isNotEmpty && widget.channel.streamIcon.startsWith('http') ? widget.channel.streamIcon : null; - // Watch favorites state final favorites = ref.watch(mobileFavoritesProvider); final isFavorite = favorites.contains(widget.channel.streamId); @@ -455,71 +457,89 @@ class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> { }, borderRadius: BorderRadius.circular(12), child: Container( - padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(12), ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Icon - Container( - width: 50, - height: 50, - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(8), - ), - child: iconUrl != null - ? CachedNetworkImage( - imageUrl: iconUrl, - fit: BoxFit.contain, - errorWidget: (_, __, ___) => const Icon(Icons.tv, color: Colors.white24), - placeholder: (_, __) => const Icon(Icons.tv, color: Colors.white24), - ) - : const Icon(Icons.tv, color: Colors.white24), - ), - const SizedBox(width: 12), - // Name and EPG + // Channel Logo Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - widget.channel.name, - style: const TextStyle( - color: AppColors.textPrimary, - fontWeight: FontWeight.w500, - fontSize: 14, + flex: 3, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Stack( + children: [ + // Logo + Center( + child: iconUrl != null + ? CachedNetworkImage( + imageUrl: iconUrl, + fit: BoxFit.contain, + errorWidget: (_, __, ___) => const Icon(Icons.tv, color: Colors.white38, size: 40), + placeholder: (_, __) => const Icon(Icons.tv, color: Colors.white24, size: 40), + ) + : const Icon(Icons.tv, color: Colors.white38, size: 40), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (_epgTitle != null) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( + // Favorite badge + if (isFavorite) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.favorite, color: Colors.white, size: 12), + ), + ), + ], + ), + ), + ), + // Channel Name + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.channel.name, + style: const TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.w500, + fontSize: 11, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (_epgTitle != null) ...[ + const SizedBox(height: 2), + Text( _epgTitle!, style: const TextStyle( color: AppColors.textSecondary, - fontSize: 12, + fontSize: 9, ), + textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), - ), - ], + ], + ], + ), ), ), - // Favorite Indicator (no button, just icon) - if (isFavorite) - const Padding( - padding: EdgeInsets.only(right: 8), - child: Icon(Icons.favorite, color: Colors.red, size: 20), - ), - // Play Icon - const Icon(Icons.play_circle_outline, color: AppColors.primary), ], ), ), diff --git a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart index 5271ead..0822a18 100644 --- a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart @@ -96,7 +96,7 @@ class _MobileSettingsTabState extends ConsumerState { ), ), Text( - 'Version 1.2.0', + 'Version 1.2.3', style: GoogleFonts.inter( fontSize: 13, color: AppColors.textSecondary, diff --git a/lib/mobile/widgets/mobile_scaffold.dart b/lib/mobile/widgets/mobile_scaffold.dart index 22bab29..a7d757d 100644 --- a/lib/mobile/widgets/mobile_scaffold.dart +++ b/lib/mobile/widgets/mobile_scaffold.dart @@ -116,10 +116,9 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> { @override Widget build(BuildContext context) { - // Desktop style: selected = white bg + black text, unselected = white70 text - final bool showWhiteBg = widget.isSelected || _isFocused; - final Color textColor = showWhiteBg ? Colors.black : Colors.white70; - final Color iconColor = showWhiteBg ? Colors.black : Colors.white70; + // Selected = white bg + black text, Focused = gray border + white text, Normal = white70 text + final Color textColor = widget.isSelected ? Colors.black : (_isFocused ? Colors.white : Colors.white70); + final Color iconColor = widget.isSelected ? Colors.black : (_isFocused ? Colors.white : Colors.white70); return Focus( focusNode: _focusNode, @@ -142,16 +141,20 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), decoration: BoxDecoration( - // White pill background when selected OR focused - color: showWhiteBg ? Colors.white : Colors.transparent, + // White pill background when selected, transparent otherwise + color: widget.isSelected ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(25), - // Subtle glow when focused but not selected - boxShadow: _isFocused && !widget.isSelected + // White border when focused + border: _isFocused + ? Border.all(color: Colors.white70, width: 2) + : null, + // Gray shadow when focused (for both selected and not selected) + boxShadow: _isFocused ? [ BoxShadow( - color: Colors.white.withOpacity(0.3), - blurRadius: 8, - spreadRadius: 1, + color: Colors.white.withOpacity(0.4), + blurRadius: 12, + spreadRadius: 2, ), ] : null, @@ -165,7 +168,7 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> { widget.label, style: TextStyle( color: textColor, - fontWeight: showWhiteBg ? FontWeight.w700 : FontWeight.w400, + fontWeight: widget.isSelected ? FontWeight.w700 : FontWeight.w400, fontSize: 13, ), ),