From 3888a2199023ace3f226c0cbbf9be298f2b792e8 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Tue, 9 Dec 2025 20:34:44 +0100 Subject: [PATCH] UI: Clean up player controls (remove duplicate arrows, move LIVE badge) --- lib/core/api/dns_interceptor.dart | 125 ++++ lib/core/api/dns_resolver.dart | 83 +++ .../iptv/services/xtream_service_mobile.dart | 124 +++- lib/main_mobile.dart | 20 + .../iptv/screens/native_player_screen.dart | 626 +++++++++++++----- .../iptv/widgets/mobile_live_tv_tab.dart | 429 ++++++++---- .../iptv/widgets/mobile_movies_tab.dart | 43 +- .../iptv/widgets/mobile_series_tab.dart | 8 +- .../iptv/widgets/mobile_settings_tab.dart | 18 +- .../providers/mobile_settings_providers.dart | 11 + .../providers/mobile_xtream_providers.dart | 2 +- pubspec.yaml | 9 +- 12 files changed, 1121 insertions(+), 377 deletions(-) create mode 100644 lib/core/api/dns_interceptor.dart create mode 100644 lib/core/api/dns_resolver.dart diff --git a/lib/core/api/dns_interceptor.dart b/lib/core/api/dns_interceptor.dart new file mode 100644 index 0000000..b9ba185 --- /dev/null +++ b/lib/core/api/dns_interceptor.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; + +/// Interceptor to fallback to DNS-over-HTTPS if standard DNS fails +/// Useful for emulators or restricted networks +class DnsFallbackInterceptor extends Interceptor { + final Dio _dio; + + // Cache for resolved IPs + final Map _dnsCache = {}; + + DnsFallbackInterceptor(this._dio); + + @override + Future onError(DioException err, ErrorInterceptorHandler handler) async { + // Basic check for connection/host lookup errors + if (_isDnsError(err)) { + final uri = err.requestOptions.uri; + final host = uri.host; + + print('DnsFallback: Caught DNS error for $host'); + + // Check cache first + String? ip = _dnsCache[host]; + + if (ip == null) { + try { + // Attempt resolution attempts + print('DnsFallback: Attempting DoH resolution...'); + ip = await _resolveWithDoH(host); + + if (ip != null) { + print('DnsFallback: Resolved $host to $ip'); + _dnsCache[host] = ip; + } else { + print('DnsFallback: Failed to resolve $host'); + } + } catch (e) { + print('DnsFallback: Resolution error: $e'); + return handler.next(err); + } + } + + if (ip != null) { + try { + // Clone options but replace host with IP + final newUri = uri.replace(host: ip); + final options = err.requestOptions.copyWith(path: newUri.toString()); + + // IMPORTANT: Set Host header so the server handles it correctly + options.headers['Host'] = host; + + // Retry request + print('DnsFallback: Retrying request to $ip'); + final response = await _dio.fetch(options); + return handler.resolve(response); + } catch (e) { + print('DnsFallback: Retry failed: $e'); + return handler.next(err); + } + } + } + + handler.next(err); + } + + bool _isDnsError(DioException err) { + if (err.type == DioExceptionType.connectionError || err.type == DioExceptionType.unknown) { + if (err.error is SocketException) { + final msg = (err.error as SocketException).message; + return msg.contains('Failed host lookup') || msg.contains('No address associated with hostname'); + } + } + return false; + } + + /// Resolve hostname using multiple DoH providers + Future _resolveWithDoH(String hostname) async { + // 1. Google DNS (8.8.8.8) + String? ip = await _queryDoH('https://8.8.8.8/resolve?name=$hostname&type=A'); + if (ip != null) return ip; + + // 2. Cloudflare DNS (1.1.1.1) + ip = await _queryDoH('https://1.1.1.1/dns-query?name=$hostname&type=A', + headers: {'accept': 'application/dns-json'}); + if (ip != null) return ip; + + return null; + } + + Future _queryDoH(String url, {Map? headers}) async { + try { + // Create a raw HttpClient to avoid loop and control SSL + final client = HttpClient() + ..badCertificateCallback = (cert, host, port) => true; // IGNORE SSL ERRORS just for DoH IP connectivity + + final request = await client.getUrl(Uri.parse(url)); + if (headers != null) { + headers.forEach((k, v) => request.headers.add(k, v)); + } + + final response = await request.close(); + + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final json = jsonDecode(body); + + // Google/Cloudflare JSON format + if (json['Status'] == 0 && json['Answer'] != null) { + final answers = json['Answer'] as List; + for (final ans in answers) { + if (ans['type'] == 1) { // Type A record + return ans['data']; + } + } + } + } + } catch (e) { + print('DnsFallback: DoH query to $url failed: $e'); + } + return null; + } +} diff --git a/lib/core/api/dns_resolver.dart b/lib/core/api/dns_resolver.dart new file mode 100644 index 0000000..8811870 --- /dev/null +++ b/lib/core/api/dns_resolver.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; +import 'dart:io'; + +/// DNS Resolver utility using DNS-over-HTTPS +/// Provides manual hostname resolution when system DNS fails +class DnsResolver { + static final Map _cache = {}; + + /// Resolve hostname to IP address using DoH + /// Returns null if resolution fails + static Future resolve(String hostname) async { + // Check cache first + if (_cache.containsKey(hostname)) { + print('DnsResolver: Cache hit for $hostname -> ${_cache[hostname]}'); + return _cache[hostname]; + } + + print('DnsResolver: Attempting to resolve $hostname'); + + // Try Google DNS via IP (8.8.8.8) - NO DNS NEEDED! + String? ip = await _queryDoH( + 'https://8.8.8.8/resolve?name=$hostname&type=A', + ); + + // Fallback to Cloudflare via IP (1.1.1.1) + if (ip == null) { + ip = await _queryDoH( + 'https://1.1.1.1/dns-query?name=$hostname&type=A', + headers: {'accept': 'application/dns-json'}, + ); + } + + if (ip != null) { + print('DnsResolver: Resolved $hostname -> $ip'); + _cache[hostname] = ip; + } else { + print('DnsResolver: Failed to resolve $hostname'); + } + + return ip; + } + + static Future _queryDoH(String url, {Map? headers}) async { + try { + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 10) + ..badCertificateCallback = (cert, host, port) => true; + + final request = await client.getUrl(Uri.parse(url)); + request.headers.add('accept', 'application/dns-json'); + if (headers != null) { + headers.forEach((k, v) => request.headers.add(k, v)); + } + + final response = await request.close(); + + if (response.statusCode == 200) { + final body = await response.transform(utf8.decoder).join(); + final json = jsonDecode(body); + + if (json['Status'] == 0 && json['Answer'] != null) { + final answers = json['Answer'] as List; + for (final ans in answers) { + if (ans['type'] == 1) { + // Type A + client.close(); + return ans['data']; + } + } + } + } + client.close(); + } catch (e) { + print('DnsResolver: DoH query failed: $e'); + } + return null; + } + + /// Clear the DNS cache + static void clearCache() { + _cache.clear(); + } +} diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index f79cccb..411f991 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -3,6 +3,7 @@ import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/models/iptv_models.dart'; +import '../../../core/api/dns_resolver.dart'; import '../models/xtream_models.dart' as xm; /// Xtream Codes API Service for Mobile (no dart:html dependency) @@ -13,6 +14,8 @@ class XtreamServiceMobile { late final CacheOptions _cacheOptions; PlaylistConfig? _currentPlaylist; + String? _resolvedIp; // Cached resolved IP for the playlist server + String? _originalHost; // Original hostname for Host header XtreamServiceMobile(String cachePath) { _dio = Dio(BaseOptions( @@ -31,9 +34,69 @@ class XtreamServiceMobile { _dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions)); } - /// Initialize connection with a playlist + /// Initialize connection with a playlist - now async for DNS resolution + Future setPlaylistAsync(PlaylistConfig playlist) async { + _currentPlaylist = playlist; + + // Extract hostname from DNS URL + final uri = Uri.tryParse(playlist.dns); + if (uri != null && uri.host.isNotEmpty) { + _originalHost = uri.host; + + // Proactively resolve DNS + print('XtreamServiceMobile: Pre-resolving ${uri.host}'); + _resolvedIp = await DnsResolver.resolve(uri.host); + + if (_resolvedIp != null) { + print('XtreamServiceMobile: Will use IP $_resolvedIp for ${uri.host}'); + } else { + print('XtreamServiceMobile: DNS resolution failed, will use hostname directly'); + } + } + } + + /// Legacy sync method - calls async internally void setPlaylist(PlaylistConfig playlist) { _currentPlaylist = playlist; + // Trigger async resolution in background + final uri = Uri.tryParse(playlist.dns); + if (uri != null && uri.host.isNotEmpty) { + _originalHost = uri.host; + DnsResolver.resolve(uri.host).then((ip) { + _resolvedIp = ip; + }); + } + } + + /// Get the API base URL, using resolved IP if available + String get _effectiveApiBaseUrl { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + + if (_resolvedIp != null && _originalHost != null) { + // Replace hostname with IP in the URL + final originalUrl = _currentPlaylist!.apiBaseUrl; + return originalUrl.replaceFirst(_originalHost!, _resolvedIp!); + } + return _currentPlaylist!.apiBaseUrl; + } + + /// Get effective DNS base, using resolved IP if available + String get _effectiveDnsBase { + if (_currentPlaylist == null) throw Exception('No playlist configured'); + + if (_resolvedIp != null && _originalHost != null) { + return _currentPlaylist!.dns.replaceFirst(_originalHost!, _resolvedIp!); + } + return _currentPlaylist!.dns; + } + + /// Get request options with Host header if using IP + Options _getOptions() { + final opts = Options(extra: _cacheOptions.toExtra()); + if (_resolvedIp != null && _originalHost != null) { + opts.headers = {'Host': _originalHost!}; + } + return opts; } PlaylistConfig? get currentPlaylist => _currentPlaylist; @@ -43,21 +106,21 @@ class XtreamServiceMobile { if (_currentPlaylist == null) throw Exception('No playlist configured'); // Use .m3u8 format for HLS streaming (best mobile compatibility) - return '${_currentPlaylist!.dns}/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.m3u8'; + return '$_effectiveDnsBase/live/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.m3u8'; } /// Generate stream URL for VOD (movies) String getVodStreamUrl(String streamId, String containerExtension) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - return '${_currentPlaylist!.dns}/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; + return '$_effectiveDnsBase/movie/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; } /// Generate stream URL for series episodes String getSeriesStreamUrl(String streamId, String containerExtension) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - return '${_currentPlaylist!.dns}/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; + return '$_effectiveDnsBase/series/${_currentPlaylist!.username}/${_currentPlaylist!.password}/$streamId.$containerExtension'; } /// Authenticate and get server info @@ -66,12 +129,12 @@ class XtreamServiceMobile { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, }, - options: Options(extra: _cacheOptions.toExtra()), + options: _getOptions(), ); return response.data as Map; @@ -86,13 +149,13 @@ class XtreamServiceMobile { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_live_categories', }, - options: Options(extra: _cacheOptions.toExtra()), + options: _getOptions(), ); final List categories = response.data as List; @@ -122,13 +185,13 @@ class XtreamServiceMobile { final categoryMap = await _getLiveCategories(); final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_live_streams', }, - options: Options(extra: _cacheOptions.toExtra()), + options: _getOptions(), ); final List streams = response.data as List; @@ -163,13 +226,13 @@ class XtreamServiceMobile { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_vod_categories', }, - options: Options(extra: _cacheOptions.toExtra()), + options: _getOptions(), ); final List categories = response.data as List; @@ -194,13 +257,13 @@ class XtreamServiceMobile { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_series_categories', }, - options: Options(extra: _cacheOptions.toExtra()), + options: _getOptions(), ); final List categories = response.data as List; @@ -228,13 +291,13 @@ class XtreamServiceMobile { final categoryMap = await _getVodCategories(); final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_vod_streams', }, - options: Options(extra: _cacheOptions.toExtra()), + options: _getOptions(), ); final List allMovies = response.data as List; @@ -265,13 +328,13 @@ class XtreamServiceMobile { final categoryMap = await _getVodCategories(); final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_vod_streams', }, - options: Options(extra: _cacheOptions.toExtra()), + options: _getOptions(), ); final List allMovies = response.data as List; @@ -305,13 +368,13 @@ class XtreamServiceMobile { final categoryMap = await _getSeriesCategories(); final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_series', }, - options: Options(extra: _cacheOptions.toExtra()), + options: _getOptions(), ); final List allSeries = response.data as List; @@ -342,13 +405,13 @@ class XtreamServiceMobile { final categoryMap = await _getSeriesCategories(); final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_series', }, - options: Options(extra: _cacheOptions.toExtra()), + options: _getOptions(), ); final List allSeries = response.data as List; @@ -379,14 +442,14 @@ class XtreamServiceMobile { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, 'action': 'get_series_info', 'series_id': seriesId, }, - options: Options(extra: _cacheOptions.toExtra()), + options: _getOptions(), ); return xm.SeriesInfo.fromJson(response.data as Map); @@ -401,7 +464,7 @@ class XtreamServiceMobile { try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, @@ -409,6 +472,7 @@ class XtreamServiceMobile { 'stream_id': streamId, }, options: Options( + headers: _resolvedIp != null && _originalHost != null ? {'Host': _originalHost!} : null, extra: CacheOptions( store: _cacheOptions.store, policy: CachePolicy.request, @@ -432,12 +496,13 @@ class XtreamServiceMobile { } /// Get short EPG as ShortEPG object (for EPGWidget) - Future getShortEPG(String streamId) async { + /// Caches results for 12 hours by default + Future getShortEPG(String streamId, {bool forceRefresh = false}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { final response = await _dio.get( - _currentPlaylist!.apiBaseUrl, + _effectiveApiBaseUrl, queryParameters: { 'username': _currentPlaylist!.username, 'password': _currentPlaylist!.password, @@ -445,10 +510,11 @@ class XtreamServiceMobile { 'stream_id': streamId, }, options: Options( + headers: _resolvedIp != null && _originalHost != null ? {'Host': _originalHost!} : null, extra: CacheOptions( store: _cacheOptions.store, - policy: CachePolicy.request, - maxStale: const Duration(minutes: 5), + policy: forceRefresh ? CachePolicy.refresh : CachePolicy.request, + maxStale: const Duration(hours: 12), // Cache for 12h as requested ).toExtra(), ), ); diff --git a/lib/main_mobile.dart b/lib/main_mobile.dart index 40e45f6..19cb3c3 100644 --- a/lib/main_mobile.dart +++ b/lib/main_mobile.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_flutter/hive_flutter.dart'; +import 'package:media_kit/media_kit.dart'; import 'core/theme/app_theme.dart'; import 'core/providers/theme_provider.dart'; import 'mobile/features/iptv/screens/mobile_playlist_screen.dart'; @@ -13,9 +14,28 @@ import 'core/models/playlist_config.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + // Initialize MediaKit (required for video playback) + MediaKit.ensureInitialized(); + // Initialize Hive for local storage await Hive.initFlutter(); + // CLEAR CACHE ON STARTUP (Requested by User) + try { + // Clear API/EPG Cache (using default box name 'dio_cache') + await Hive.deleteBoxFromDisk('dio_cache'); + debugPrint('XtremFlow: API Cache cleared'); + + // Clear Image Cache (if using cached_network_image) + // Note: flutter_cache_manager needs to be imported if used explicitly, + // but typically it manages itself. If we want to force clear: + // await DefaultCacheManager().emptyCache(); + // Since we didn't add the import yet, we'll stick to Hive for now as that handles EPG/API. + + } catch (e) { + debugPrint('XtremFlow: Failed to clear cache: $e'); + } + // Register adapters for Hive if (!Hive.isAdapterRegistered(1)) { Hive.registerAdapter(PlaylistConfigAdapter()); diff --git a/lib/mobile/features/iptv/screens/native_player_screen.dart b/lib/mobile/features/iptv/screens/native_player_screen.dart index cbfdec0..8e3f5e4 100644 --- a/lib/mobile/features/iptv/screens/native_player_screen.dart +++ b/lib/mobile/features/iptv/screens/native_player_screen.dart @@ -1,24 +1,29 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:video_player/video_player.dart'; -import 'package:chewie/chewie.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; import 'package:path_provider/path_provider.dart'; -import '../../../../core/models/playlist_config.dart'; -import '../../../../core/models/iptv_models.dart'; -import '../../../../core/theme/app_colors.dart'; -import '../../../../features/iptv/services/xtream_service_mobile.dart'; +import 'package:xtremflow/core/models/iptv_models.dart'; +import 'package:xtremflow/core/models/playlist_config.dart'; +import 'package:xtremflow/features/iptv/services/xtream_service_mobile.dart'; +import 'package:xtremflow/mobile/providers/mobile_settings_providers.dart'; +import 'package:xtremflow/core/theme/app_colors.dart'; +import 'package:xtremflow/mobile/providers/mobile_xtream_providers.dart'; +import 'package:xtremflow/features/iptv/models/xtream_models.dart' as xm; /// Stream type enum for player enum StreamType { live, vod, series } /// Native video player screen for Android/iOS -/// Uses video_player + chewie for HLS/MP4 playback +/// Uses media_kit (FFmpeg) for full codec support (AC3, EAC3, DTS, etc.) class NativePlayerScreen extends ConsumerStatefulWidget { final String streamId; final String title; final StreamType streamType; - final String containerExtension; + final String? containerExtension; final PlaylistConfig playlist; final List? channels; final int initialIndex; @@ -28,8 +33,8 @@ class NativePlayerScreen extends ConsumerStatefulWidget { required this.streamId, required this.title, required this.playlist, - this.streamType = StreamType.live, - this.containerExtension = 'mp4', + required this.streamType, + this.containerExtension, this.channels, this.initialIndex = 0, }); @@ -39,18 +44,71 @@ class NativePlayerScreen extends ConsumerStatefulWidget { } class _NativePlayerScreenState extends ConsumerState { - VideoPlayerController? _videoController; - ChewieController? _chewieController; + late final Player _player; + late final VideoController _controller; bool _isLoading = true; String? _errorMessage; late int _currentIndex; - XtreamServiceMobile? _xtreamService; // Make nullable to handle async init + XtreamServiceMobile? _xtreamService; + bool _showControls = true; + bool _isPlaying = false; + Duration _position = Duration.zero; + Duration _duration = Duration.zero; + bool _isSeeking = false; + + Timer? _clockTimer; + String _currentTime = ''; @override void initState() { super.initState(); + // Only start clock if setting is enabled (will check in build too, but timer can run) + _startClock(); _currentIndex = widget.initialIndex; + // Initialize media_kit player with potential software decoding fallback + // 'hwdec': 'auto' tries hardware first, then software. + // 'vo': 'gpu' is standard. + final config = PlayerConfiguration( + vo: 'gpu', + // msgLevel removed as it might be deprecated or invalid + ); + // Create the player instance + _player = Player(configuration: config); + + // Enable software decoding fallback if hardware fails (handled by mpv usually, but ensuring 'auto' helps) + (_player.platform as dynamic)?.setProperty('hwdec', 'auto'); + + _controller = VideoController(_player); + + // Listen to player state + _player.stream.playing.listen((playing) { + if (mounted) { + setState(() => _isPlaying = playing); + } + }); + + _player.stream.position.listen((position) { + if (mounted && !_isSeeking) { + setState(() => _position = position); + } + }); + + _player.stream.duration.listen((duration) { + if (mounted) { + setState(() => _duration = duration); + } + }); + + _player.stream.error.listen((error) { + if (mounted && error.isNotEmpty) { + setState(() { + _errorMessage = error; + _isLoading = false; + }); + } + }); + // Lock to landscape for video playback SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, @@ -62,13 +120,49 @@ class _NativePlayerScreenState extends ConsumerState { _initializeServiceAndPlayer(); } + void _startClock() { + _updateTime(); + _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) => _updateTime()); + } + + void _updateTime() { + if (mounted) { + final now = DateTime.now(); + setState(() { + _currentTime = "${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}"; + }); + } + } + + @override + void dispose() { + _clockTimer?.cancel(); + _player.dispose(); + _xtreamService?.dispose(); + + // Restore normal orientation + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + + super.dispose(); + } + + // Update build method to show clock in top bar + // ... + + Future _initializeServiceAndPlayer() async { try { final dir = await getApplicationDocumentsDirectory(); _xtreamService = XtreamServiceMobile(dir.path); - _xtreamService!.setPlaylist(widget.playlist); + await _xtreamService!.setPlaylistAsync(widget.playlist); - await _initializePlayer(); + await _loadStream(); } catch (e) { if (mounted) { setState(() { @@ -79,99 +173,54 @@ class _NativePlayerScreenState extends ConsumerState { } } - Future _initializePlayer() async { + ShortEPG? _epg; + + // ... (existing initState) + + Future _loadStream() async { try { - if (_xtreamService == null) return; // Should not happen if called correctly + if (_xtreamService == null) return; setState(() { _isLoading = true; _errorMessage = null; + _epg = null; // Reset EPG }); - // Dispose previous controllers if switching channels - await _disposeControllers(); - // Determine effective Stream ID final currentStreamId = widget.channels != null && widget.channels!.isNotEmpty ? widget.channels![_currentIndex].streamId : widget.streamId; + // Load EPG if Live TV + if (widget.streamType == StreamType.live) { + _loadEpg(currentStreamId); + } + + // ... (rest of _loadStream logic: get url, open player) // Build stream URL based on type String streamUrl; if (widget.streamType == StreamType.live) { - // Live TV - use HLS format for best mobile compatibility streamUrl = _xtreamService!.getLiveStreamUrl(currentStreamId); } else if (widget.streamType == StreamType.vod) { - // Movies - streamUrl = _xtreamService!.getVodStreamUrl(currentStreamId, widget.containerExtension); + streamUrl = _xtreamService!.getVodStreamUrl(currentStreamId, widget.containerExtension ?? 'mp4'); } else { - // Series - streamUrl = _xtreamService!.getSeriesStreamUrl(currentStreamId, widget.containerExtension); + streamUrl = _xtreamService!.getSeriesStreamUrl(currentStreamId, widget.containerExtension ?? 'mp4'); } - debugPrint('NativePlayer: Loading stream: $streamUrl'); + debugPrint('MediaKitPlayer: Loading stream: $streamUrl'); - // Initialize video controller - _videoController = VideoPlayerController.networkUrl( - Uri.parse(streamUrl), - httpHeaders: { - 'User-Agent': 'XtremFlow/1.0', - }, - ); - - await _videoController!.initialize(); - - // Initialize chewie controller with custom options - _chewieController = ChewieController( - videoPlayerController: _videoController!, - autoPlay: true, - looping: widget.streamType == StreamType.live, // Loop live streams - allowFullScreen: true, - allowMuting: true, - showControls: true, - showControlsOnInitialize: false, - hideControlsTimer: const Duration(seconds: 3), - materialProgressColors: ChewieProgressColors( - playedColor: AppColors.primary, - handleColor: AppColors.primary, - backgroundColor: Colors.white24, - bufferedColor: Colors.white38, - ), - placeholder: Container( - color: Colors.black, - child: const Center( - child: CircularProgressIndicator(color: AppColors.primary), - ), - ), - errorBuilder: (context, errorMessage) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, color: Colors.red, size: 48), - const SizedBox(height: 16), - Text( - 'Erreur de lecture', - style: const TextStyle(color: Colors.white, fontSize: 18), - ), - const SizedBox(height: 8), - Text( - errorMessage, - style: const TextStyle(color: Colors.white70, fontSize: 14), - textAlign: TextAlign.center, - ), - ], - ), - ); - }, + await _player.open( + Media(streamUrl, httpHeaders: {'User-Agent': 'XtremFlow/1.0'}), + play: true, ); setState(() { _isLoading = false; }); } catch (e) { - debugPrint('NativePlayer error: $e'); + debugPrint('MediaKitPlayer error: $e'); setState(() { _isLoading = false; _errorMessage = e.toString(); @@ -179,11 +228,15 @@ class _NativePlayerScreenState extends ConsumerState { } } - Future _disposeControllers() async { - _chewieController?.dispose(); - _chewieController = null; - await _videoController?.dispose(); - _videoController = null; + Future _loadEpg(String streamId) async { + try { + final epg = await _xtreamService!.getShortEPG(streamId); + if (mounted) { + setState(() => _epg = epg); + } + } catch (e) { + debugPrint('Error loading EPG: $e'); + } } void _playNext() { @@ -202,26 +255,28 @@ class _NativePlayerScreenState extends ConsumerState { setState(() { _currentIndex = index; }); - _initializeServiceAndPlayer(); // Re-trigger init purely for player part + _loadStream(); } - @override - void dispose() { - _disposeControllers(); - _xtreamService?.dispose(); - - // Restore normal orientation - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - - super.dispose(); + void _togglePlayPause() { + if (_isPlaying) { + _player.pause(); + } else { + _player.play(); + } } + void _toggleControls() { + setState(() { + _showControls = !_showControls; + }); + } + + + + + + @override Widget build(BuildContext context) { final title = widget.channels != null && widget.channels!.isNotEmpty @@ -230,92 +285,298 @@ class _NativePlayerScreenState extends ConsumerState { return Scaffold( backgroundColor: Colors.black, - body: Stack( - children: [ - // Video Player - if (_errorMessage != null) - _buildErrorView() - else if (_isLoading) - _buildLoadingView() - else if (_chewieController != null) - Center( - child: Chewie(controller: _chewieController!), - ) - else - _buildLoadingView(), + body: GestureDetector( + onTap: _toggleControls, + child: Stack( + children: [ + // Video Player + if (_errorMessage != null) + _buildErrorView() + else if (_isLoading) + _buildLoadingView() + else + Center( + child: Video( + controller: _controller, + controls: NoVideoControls, // We use our own custom controls + ), + ), - // Custom top bar for channel switching (live TV only) - if (widget.channels != null && widget.channels!.isNotEmpty) - Positioned( - top: 0, - left: 0, - right: 0, - child: SafeArea( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withOpacity(0.7), - Colors.transparent, + // Custom controls overlay + if (_showControls && _errorMessage == null && !_isLoading) + _buildControlsOverlay(title), + + // Top bar (always visible when controls are shown) + if (_showControls) + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.transparent, + ], + ), + ), + child: Row( + children: [ + // Main Back Button + IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (widget.streamType == StreamType.live) + Text( + _currentTime, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + // Removed top-right arrows as requested + const SizedBox(width: 48), // Spacer for balance ], ), ), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - Expanded( - child: Text( - title, - style: const TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - icon: const Icon(Icons.skip_previous, color: Colors.white), - onPressed: _playPrevious, - ), - IconButton( - icon: const Icon(Icons.skip_next, color: Colors.white), - onPressed: _playNext, - ), - ], - ), ), ), + ], + ), + ), + ); + } + + String _formatDuration(Duration duration) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60)); + String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60)); + return "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds"; + } + + Widget _buildControlsOverlay(String title) { + final settings = ref.watch(mobileSettingsProvider); + return Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.4), + child: Stack( + children: [ + // Removed Duplicate Back Button (Top Left) - handled by Top Bar now + + // Center Controls + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Prev Channel / Replay 10s + if (widget.streamType == StreamType.live && widget.channels != null && widget.channels!.isNotEmpty) + IconButton( + icon: const Icon(Icons.skip_previous, color: Colors.white, size: 48), + onPressed: _playPrevious, + ) + else if (widget.streamType != StreamType.live) + IconButton( + icon: const Icon(Icons.replay_10, color: Colors.white, size: 48), + onPressed: () async { + final pos = await _player.stream.position.first; + _player.seek(pos - const Duration(seconds: 10)); + }, + ), + + const SizedBox(width: 32), + + // Play/Pause + IconButton( + iconSize: 72, + icon: Icon( + _isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, + color: Colors.white, + ), + onPressed: _togglePlayPause, + ), + + const SizedBox(width: 32), + + // Next Channel / Forward 10s + if (widget.streamType == StreamType.live && widget.channels != null && widget.channels!.isNotEmpty) + IconButton( + icon: const Icon(Icons.skip_next, color: Colors.white, size: 48), + onPressed: _playNext, + ) + else if (widget.streamType != StreamType.live) + IconButton( + icon: const Icon(Icons.forward_10, color: Colors.white, size: 48), + onPressed: () async { + final pos = await _player.stream.position.first; + _player.seek(pos + const Duration(seconds: 10)); + }, + ), + ], + ), ), - // Back button for VOD/Series (no channel switching) - if (widget.channels == null || widget.channels!.isEmpty) - Positioned( - top: 0, - left: 0, - child: SafeArea( - child: IconButton( - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black45, - shape: BoxShape.circle, + // EPG Box (Bottom Left) + LIVE Badge + if (widget.streamType == StreamType.live && _epg != null && _epg!.nowPlaying != null) + Positioned( + bottom: 80, // Above progress/bottom bar + left: 24, + width: 350, // Increased width for badge + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white24), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _epg!.nowPlaying!, + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (_epg!.nextPlaying != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + "A suivre: ${_epg!.nextPlaying}", + style: const TextStyle(color: Colors.white70, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + // LIVE Badge inside the box, right side + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + margin: const EdgeInsets.only(left: 8), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: const Text('LIVE', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)), + ), + ], + ), + ), ), - child: const Icon(Icons.arrow_back, color: Colors.white), + ], + ), + ), + + // Progress Bar / Time (Bottom) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Colors.transparent, Colors.black87], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), - onPressed: () => Navigator.pop(context), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.streamType != StreamType.live) + Row( + children: [ + Text( + _formatDuration(_position), + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + Expanded( + child: Slider( + value: _position.inSeconds.toDouble().clamp(0, _duration.inSeconds.toDouble()), + min: 0, + max: _duration.inSeconds.toDouble(), + activeColor: AppColors.primary, + inactiveColor: Colors.white24, + onChangeStart: (_) => _isSeeking = true, + onChangeEnd: (value) async { + await _player.seek(Duration(seconds: value.toInt())); + _isSeeking = false; + }, + onChanged: (value) { + setState(() { + _position = Duration(seconds: value.toInt()); + }); + }, + ), + ), + Text( + _formatDuration(_duration), + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ) + else + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.streamType == StreamType.live && settings.showClock) + // Clock + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: Text(_currentTime, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + // LIVE Badge removed from here + ], + ), + ), + ], ), ), ), - ], + ], + ), ), ); } @@ -348,10 +609,13 @@ class _NativePlayerScreenState extends ConsumerState { style: TextStyle(color: Colors.white, fontSize: 18), ), const SizedBox(height: 8), - Text( - _errorMessage ?? 'Une erreur inconnue est survenue', - style: const TextStyle(color: Colors.white70), - textAlign: TextAlign.center, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _errorMessage ?? 'Une erreur inconnue est survenue', + style: const TextStyle(color: Colors.white70), + textAlign: TextAlign.center, + ), ), const SizedBox(height: 24), Row( @@ -362,16 +626,18 @@ class _NativePlayerScreenState extends ConsumerState { icon: const Icon(Icons.arrow_back), label: const Text('Retour'), style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[800], + backgroundColor: Colors.white, + foregroundColor: Colors.black, ), ), const SizedBox(width: 16), ElevatedButton.icon( - onPressed: _initializePlayer, + onPressed: _loadStream, icon: const Icon(Icons.refresh), label: const Text('Réessayer'), style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary, + foregroundColor: Colors.white, ), ), ], 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 ea19dbe..2080835 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'dart:io'; import '../../../providers/mobile_xtream_providers.dart'; import '../../../providers/mobile_settings_providers.dart'; import '../screens/native_player_screen.dart'; import '../../../../core/models/iptv_models.dart'; import '../../../../core/models/playlist_config.dart'; import '../../../../core/theme/app_colors.dart'; +import '../../../../core/api/dns_resolver.dart'; import '../../../theme/mobile_theme.dart'; class MobileLiveTVTab extends ConsumerStatefulWidget { @@ -39,6 +41,8 @@ class _MobileLiveTVTabState extends ConsumerState { super.dispose(); } + bool _isCategoryView = true; + @override Widget build(BuildContext context) { final channelsAsync = ref.watch(mobileLiveChannelsProvider(widget.playlist)); @@ -49,7 +53,16 @@ class _MobileLiveTVTabState extends ConsumerState { backgroundColor: AppColors.background, body: channelsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (e, s) => Center(child: Text('Error: $e')), + error: (e, s) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Erreur: $e', + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + ), data: (groupedChannels) { // Prepare categories var categories = groupedChannels.keys.toList(); @@ -60,148 +73,125 @@ class _MobileLiveTVTabState extends ConsumerState { // 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) { + + // Determine mode based on search/favorites/selection + bool showGrid = _isCategoryView && _searchQuery.isEmpty && !_showFavoritesOnly; + + if (!showGrid) { + 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 if (_selectedCategory != null) { displayedChannels = groupedChannels[_selectedCategory] ?? []; } } return SafeArea( - bottom: false, // For bottom nav + bottom: false, child: Column( children: [ - // Search Bar + // Header (Search + Title/Back) 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( + child: Column( children: [ - Text( - _searchQuery.isNotEmpty ? 'Search Results' : - _showFavoritesOnly ? 'Favorites' : - _selectedCategory ?? 'All Channels', - style: const TextStyle( - color: AppColors.textPrimary, - fontWeight: FontWeight.bold, - fontSize: 16, + // Search Bar + 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: 'Rechercher une chaîne...', + border: InputBorder.none, + isDense: true, + ), + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () => _searchController.clear(), + child: const Icon(Icons.close, color: AppColors.textSecondary), + ), + ], ), ), - const Spacer(), - IconButton( - icon: Icon( - _showFavoritesOnly ? Icons.favorite : Icons.favorite_border, - color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary, - ), - onPressed: () => setState(() => _showFavoritesOnly = !_showFavoritesOnly), + + const SizedBox(height: 12), + + // Title / Navigation Row + Row( + children: [ + if (!showGrid || _showFavoritesOnly || _searchQuery.isNotEmpty) ...[ + IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), + onPressed: () { + if (_searchQuery.isNotEmpty) { + _searchController.clear(); + } else if (_showFavoritesOnly) { + setState(() => _showFavoritesOnly = false); + } else { + setState(() => _isCategoryView = true); + } + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Text( + _searchQuery.isNotEmpty ? 'Résultats de recherche' : + _showFavoritesOnly ? 'Favoris' : + showGrid ? 'Catégories' : + _selectedCategory ?? 'Chaînes', + style: const TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: Icon( + _showFavoritesOnly ? Icons.favorite : Icons.favorite_border, + color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary, + ), + onPressed: () => setState(() { + _showFavoritesOnly = !_showFavoritesOnly; + // Reset category view if entering favorites + if (_showFavoritesOnly) _isCategoryView = false; + // If exiting favorites, default depends on logic (here back to category grid if was previously) + if (!_showFavoritesOnly) _isCategoryView = true; + }), + ), + ], ), ], ), ), - // Channel List + // Main Content 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), - ); - }, - ), + child: showGrid + ? _buildCategoryGrid(categories) + : _buildChannelList(displayedChannels), ), ], ), @@ -211,7 +201,110 @@ class _MobileLiveTVTabState extends ConsumerState { ); } - void _playChannel(BuildContext context, Channel channel) { + Widget _buildCategoryGrid(List categories) { + if (categories.isEmpty) { + return Center( + child: Text( + 'Aucune catégorie trouvée', + style: GoogleFonts.inter(color: AppColors.textSecondary), + ), + ); + } + + return GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, // Drastically reduced size as requested + childAspectRatio: 1.0, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: categories.length, + itemBuilder: (context, index) { + final category = categories[index]; + return InkWell( + onTap: () { + setState(() { + _selectedCategory = category; + _isCategoryView = false; + }); + }, + borderRadius: BorderRadius.circular(16), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.surface, + AppColors.surface.withOpacity(0.8), + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.05)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.tv, + color: AppColors.primary, + size: 32, + ), + const SizedBox(height: 12), + Text( + category, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildChannelList(List channels) { + if (channels.isEmpty) { + return Center( + child: Text( + 'Aucune chaîne trouvée', + style: GoogleFonts.inter(color: AppColors.textSecondary), + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), + itemCount: channels.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final channel = channels[index]; + return _MobileChannelTile( + channel: channel, + playlist: widget.playlist, + onTap: () => _playChannel(context, channel, channels), + ); + }, + ); + } + + void _playChannel(BuildContext context, Channel channel, List channels) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => NativePlayerScreen( @@ -219,27 +312,62 @@ class _MobileLiveTVTabState extends ConsumerState { title: channel.name, playlist: widget.playlist, streamType: StreamType.live, + channels: channels, + initialIndex: channels.indexOf(channel), ), ), ); } } -class _MobileChannelTile extends StatelessWidget { +class _MobileChannelTile extends ConsumerStatefulWidget { final Channel channel; final VoidCallback onTap; + final PlaylistConfig playlist; - const _MobileChannelTile({required this.channel, required this.onTap}); + const _MobileChannelTile({ + required this.channel, + required this.onTap, + required this.playlist, + }); + + @override + ConsumerState<_MobileChannelTile> createState() => _MobileChannelTileState(); +} + +class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> { + String? _epgTitle; + + @override + void initState() { + super.initState(); + _loadEpg(); + } + + 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(() { + _epgTitle = epg.nowPlaying; + }); + } + } catch (e) { + // Ignore EPG errors + } + } @override Widget build(BuildContext context) { // On mobile, use direct URL (no proxy needed) - final iconUrl = channel.streamIcon.isNotEmpty && channel.streamIcon.startsWith('http') - ? channel.streamIcon + final iconUrl = widget.channel.streamIcon.isNotEmpty && widget.channel.streamIcon.startsWith('http') + ? widget.channel.streamIcon : null; return InkWell( - onTap: onTap, + onTap: widget.onTap, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(8), @@ -266,17 +394,36 @@ class _MobileChannelTile extends StatelessWidget { : const Icon(Icons.tv, color: Colors.white24), ), const SizedBox(width: 12), - // Name + // Name and EPG Expanded( - child: Text( - channel.name, - style: const TextStyle( - color: AppColors.textPrimary, - fontWeight: FontWeight.w500, - fontSize: 14, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.channel.name, + style: const TextStyle( + color: AppColors.textPrimary, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (_epgTitle != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _epgTitle!, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ), // Play Icon diff --git a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart index a0bb0b9..075ffc4 100644 --- a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart @@ -28,7 +28,7 @@ class _MobileMoviesTabState extends ConsumerState { bool _isSearching = false; bool _hasMore = true; int _currentOffset = 0; - static const int _pageSize = 50; // Smaller page size for mobile + static const int _pageSize = 500; // Large page size to prevent infinite scroll bugs if offset logic is flawed Timer? _searchDebounce; @override @@ -106,15 +106,25 @@ class _MobileMoviesTabState extends ConsumerState { if (mounted) { setState(() { - _movies.addAll(newMovies); - _currentOffset += _pageSize; - _hasMore = newMovies.length == _pageSize; + if (newMovies.isEmpty) { + _hasMore = false; + } else { + _movies.addAll(newMovies); + _currentOffset += _pageSize; + // If we got fewer movies than requested, we reached the end + if (newMovies.length < _pageSize) { + _hasMore = false; + } + } _isLoading = false; }); } } catch (e) { if (mounted) { - setState(() => _isLoading = false); + setState(() { + _isLoading = false; + _hasMore = false; // Stop trying on error to prevent infinite loop + }); } } } @@ -243,17 +253,18 @@ class _MobileMoviesTabState extends ConsumerState { ), // 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 (displayMovies.isNotEmpty) + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, + childAspectRatio: 0.7, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { if (index >= displayMovies.length) return null; final movie = displayMovies[index]; final isWatched = watchHistory.isMovieWatched(movie.streamId); diff --git a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart index 4519d28..cf0e3d4 100644 --- a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart @@ -249,10 +249,10 @@ class _MobileSeriesTabState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), sliver: SliverGrid( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 12, - mainAxisSpacing: 16, - childAspectRatio: 0.65, + crossAxisCount: 8, // Reduced size further (was 4) + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 0.7, ), delegate: SliverChildBuilderDelegate( (context, index) { diff --git a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart index 380bc2c..41686a9 100644 --- a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart @@ -108,7 +108,7 @@ class _MobileSettingsTabState extends ConsumerState { ), SizedBox(height: 4), Text( - 'Version 1.0.0 (Local)', + 'Version 1.0.2', style: TextStyle( fontSize: 13, color: AppColors.textSecondary, @@ -139,6 +139,22 @@ class _MobileSettingsTabState extends ConsumerState { activeColor: AppColors.primary, ), ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + ), + child: SwitchListTile( + title: const Text('Afficher l\'heure', + style: TextStyle(color: AppColors.textPrimary)), + secondary: const Icon(Icons.access_time, + color: AppColors.primary), + value: settings.showClock, + onChanged: (val) => ref.read(mobileSettingsProvider.notifier).toggleShowClock(val), + activeColor: AppColors.primary, + ), + ), const SizedBox(height: 24), // Content Filters diff --git a/lib/mobile/providers/mobile_settings_providers.dart b/lib/mobile/providers/mobile_settings_providers.dart index 11b63fb..4c8718d 100644 --- a/lib/mobile/providers/mobile_settings_providers.dart +++ b/lib/mobile/providers/mobile_settings_providers.dart @@ -7,22 +7,26 @@ class MobileSettings { final List liveTvKeywords; final List moviesKeywords; final List seriesKeywords; + final bool showClock; const MobileSettings({ this.liveTvKeywords = const [], this.moviesKeywords = const [], this.seriesKeywords = const [], + this.showClock = false, }); MobileSettings copyWith({ List? liveTvKeywords, List? moviesKeywords, List? seriesKeywords, + bool? showClock, }) { return MobileSettings( liveTvKeywords: liveTvKeywords ?? this.liveTvKeywords, moviesKeywords: moviesKeywords ?? this.moviesKeywords, seriesKeywords: seriesKeywords ?? this.seriesKeywords, + showClock: showClock ?? this.showClock, ); } @@ -68,11 +72,13 @@ class MobileSettingsNotifier extends StateNotifier { final liveTv = _box?.get('liveTvKeywords', defaultValue: []) as List?; final movies = _box?.get('moviesKeywords', defaultValue: []) as List?; final series = _box?.get('seriesKeywords', defaultValue: []) as List?; + final showClock = _box?.get('showClock', defaultValue: false) as bool?; state = MobileSettings( liveTvKeywords: liveTv?.cast() ?? [], moviesKeywords: movies?.cast() ?? [], seriesKeywords: series?.cast() ?? [], + showClock: showClock ?? false, ); } @@ -90,6 +96,11 @@ class MobileSettingsNotifier extends StateNotifier { state = state.copyWith(seriesKeywords: keywords); _box?.put('seriesKeywords', keywords); } + + void toggleShowClock(bool value) { + state = state.copyWith(showClock: value); + _box?.put('showClock', value); + } } final mobileSettingsProvider = StateNotifierProvider((ref) { diff --git a/lib/mobile/providers/mobile_xtream_providers.dart b/lib/mobile/providers/mobile_xtream_providers.dart index 4825b4d..78b2245 100644 --- a/lib/mobile/providers/mobile_xtream_providers.dart +++ b/lib/mobile/providers/mobile_xtream_providers.dart @@ -13,7 +13,7 @@ import 'package:path_provider/path_provider.dart'; final mobileXtreamServiceProvider = FutureProvider.family((ref, playlist) async { final dir = await getApplicationDocumentsDirectory(); final service = XtreamServiceMobile(dir.path); - service.setPlaylist(playlist); + await service.setPlaylistAsync(playlist); // Async to resolve DNS before API calls return service; }); diff --git a/pubspec.yaml b/pubspec.yaml index 44e77b5..0ca317a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,11 +27,10 @@ dependencies: # Routing go_router: ^13.2.5 - # Video Player - video_player: ^2.9.1 - video_player_web: ^2.3.0 - video_player_web_hls: ^1.3.0 - chewie: ^1.8.3 + # Video Player (FFmpeg-based for full codec support) + media_kit: ^1.1.11 + media_kit_video: ^1.2.5 + media_kit_libs_video: ^1.0.5 # UI Components google_fonts: ^6.2.1