diff --git a/lib/features/iptv/services/xtream_service.dart b/lib/features/iptv/services/xtream_service.dart index d094137..22974ba 100644 --- a/lib/features/iptv/services/xtream_service.dart +++ b/lib/features/iptv/services/xtream_service.dart @@ -1,25 +1,27 @@ import 'package:dio/dio.dart'; import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; +import 'package:flutter/foundation.dart'; import '../../../core/models/playlist_config.dart'; import '../../../core/models/iptv_models.dart'; import '../models/xtream_models.dart' as xm; -import 'dart:html' as html; /// Xtream Codes API Service -/// +/// /// Handles all communication with Xtream API servers class XtreamService { late final Dio _dio; late final CacheOptions _cacheOptions; - + PlaylistConfig? _currentPlaylist; XtreamService() { - _dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 15), - ),); + _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + ), + ); // Setup caching for API responses _cacheOptions = CacheOptions( @@ -32,22 +34,21 @@ class XtreamService { _dio.interceptors.add(DioCacheInterceptor(options: _cacheOptions)); } - /// Check if we're running on HTTPS (production) - bool get _isHttps { - return html.window.location.protocol == 'https:'; + /// Get dynamic base URL for the local backend + /// On Web: use localhost (proxy handles it) + /// On Mobile: default to host machine IP for emulator + String get _backendBaseUrl { + if (kIsWeb) return 'http://localhost:8089'; + // On Android emulator, 10.0.2.2 points to host machine's localhost + return 'http://10.0.2.2:8089'; } + String get backendBaseUrl => _backendBaseUrl; + /// Wrap URL with proxy for all external IPTV URLs - /// - /// Always use proxy for HTTP URLs to: - /// 1. Handle HTTPS->HTTP mixed content - /// 2. Provide Range header support for seeking - /// 3. Normalize headers and handle redirects String _wrapWithProxy(String url) { - // Always proxy HTTP URLs for consistent streaming behavior - if (url.startsWith('http://')) { - final baseUrl = html.window.location.origin; - return '$baseUrl/api/xtream/$url'; + if (url.startsWith('http')) { + return '$_backendBaseUrl/api/xtream/$url'; } return url; } @@ -60,28 +61,19 @@ class XtreamService { /// Generate stream URL for live TV String getLiveStreamUrl(String streamId) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - - // Point to new dedicated Live API - final baseUrl = html.window.location.origin; - return '$baseUrl/api/live/$streamId.ts'; + return '$_backendBaseUrl/api/live/$streamId.ts'; } /// Generate stream URL for VOD (movies) String getVodStreamUrl(String streamId, String containerExtension) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - - // Point to new dedicated VOD API (HLS playlist) - final baseUrl = html.window.location.origin; - return '$baseUrl/api/vod/$streamId/playlist.m3u8'; + return '$_backendBaseUrl/api/vod/$streamId/playlist.m3u8'; } /// Generate stream URL for series episodes String getSeriesStreamUrl(String streamId, String containerExtension) { if (_currentPlaylist == null) throw Exception('No playlist configured'); - - // Point to new dedicated VOD API (HLS playlist) with series type - final baseUrl = html.window.location.origin; - return '$baseUrl/api/vod/$streamId/playlist.m3u8?type=series'; + return '$_backendBaseUrl/api/vod/$streamId/playlist.m3u8?type=series'; } /// Authenticate and get server info @@ -138,7 +130,7 @@ class XtreamService { } /// Get all live TV channels grouped by category - /// + /// /// Returns a Map where key is category name and value is list of channels Future>> getLiveChannels() async { if (_currentPlaylist == null) throw Exception('No playlist configured'); @@ -165,10 +157,10 @@ class XtreamService { // Get category name from our mapping using category_id final categoryId = data['category_id']?.toString() ?? ''; final categoryName = categoryMap[categoryId] ?? 'Uncategorized'; - + // Inject category_name into data before parsing data['category_name'] = categoryName; - + final channel = Channel.fromJson(data); if (!groupedChannels.containsKey(categoryName)) { @@ -326,7 +318,8 @@ class XtreamService { } /// Get movies with pagination support - Future> getMoviesPaginated({int offset = 0, int limit = 100}) async { + Future> getMoviesPaginated( + {int offset = 0, int limit = 100}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { @@ -344,13 +337,15 @@ class XtreamService { ); final List allMovies = response.data as List; - + // Apply pagination - final endIndex = (offset + limit) > allMovies.length ? allMovies.length : offset + limit; + final endIndex = (offset + limit) > allMovies.length + ? allMovies.length + : offset + limit; if (offset >= allMovies.length) return []; - + final paginatedMovies = allMovies.sublist(offset, endIndex); - + return paginatedMovies.map((movieData) { final data = movieData as Map; final categoryId = data['category_id']?.toString() ?? ''; @@ -382,7 +377,7 @@ class XtreamService { final List allMovies = response.data as List; final queryLower = query.toLowerCase(); - + // Filter by search query return allMovies .where((m) { @@ -403,7 +398,8 @@ class XtreamService { } /// Get series with pagination support (returns flat list) - Future> getSeriesPaginated({int offset = 0, int limit = 100}) async { + Future> getSeriesPaginated( + {int offset = 0, int limit = 100}) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); try { @@ -421,13 +417,15 @@ class XtreamService { ); final List allSeries = response.data as List; - + // Apply pagination - final endIndex = (offset + limit) > allSeries.length ? allSeries.length : offset + limit; + final endIndex = (offset + limit) > allSeries.length + ? allSeries.length + : offset + limit; if (offset >= allSeries.length) return []; - + final paginatedSeries = allSeries.sublist(offset, endIndex); - + return paginatedSeries.map((seriesData) { final data = seriesData as Map; final categoryId = data['category_id']?.toString() ?? ''; @@ -459,7 +457,7 @@ class XtreamService { final List allSeries = response.data as List; final queryLower = query.toLowerCase(); - + // Filter by search query return allSeries .where((s) { @@ -502,7 +500,7 @@ class XtreamService { } /// Get VOD (movie) info including duration - /// + /// /// Returns detailed movie information including duration in seconds Future getVodDuration(String vodId) async { if (_currentPlaylist == null) return null; @@ -520,18 +518,18 @@ class XtreamService { ); final data = response.data as Map; - + // Try to get duration from movie_properties or info final movieData = data['movie_data'] as Map?; final info = data['info'] as Map?; - + // Duration can be in different formats: seconds (int), "HH:MM:SS", or minutes - String? durationStr = movieData?['duration']?.toString() ?? - info?['duration']?.toString() ?? - info?['duration_secs']?.toString(); - + String? durationStr = movieData?['duration']?.toString() ?? + info?['duration']?.toString() ?? + info?['duration_secs']?.toString(); + if (durationStr == null || durationStr.isEmpty) return null; - + // Parse duration - could be "01:30:00" format or seconds if (durationStr.contains(':')) { // HH:MM:SS format @@ -548,7 +546,7 @@ class XtreamService { return minutes * 60 + seconds; } } - + // Try parsing as seconds directly return int.tryParse(durationStr); } catch (e) { @@ -558,7 +556,7 @@ class XtreamService { } /// Get short EPG for a specific stream - /// + /// /// Returns "Now" and "Next" program info Future> getShortEpg(String streamId) async { if (_currentPlaylist == null) throw Exception('No playlist configured'); @@ -585,7 +583,8 @@ class XtreamService { return []; } - final List epgData = response.data['epg_listings'] as List; + final List epgData = + response.data['epg_listings'] as List; return epgData .map((entry) => EpgEntry.fromJson(entry as Map)) .toList(); diff --git a/lib/mobile/features/iptv/screens/mobile_player_screen.dart b/lib/mobile/features/iptv/screens/mobile_player_screen.dart index 71c53e4..f2fc467 100644 --- a/lib/mobile/features/iptv/screens/mobile_player_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_player_screen.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../features/iptv/screens/player_screen.dart'; export '../../../../features/iptv/screens/player_screen.dart' show StreamType; import '../../../../core/models/playlist_config.dart'; +import '../../../../features/iptv/widgets/lite_player_view.dart'; +import '../../../../features/iptv/providers/xtream_provider.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 { +class MobilePlayerScreen extends ConsumerWidget { final String streamId; final String title; final StreamType streamType; @@ -23,15 +23,35 @@ class MobilePlayerScreen extends StatelessWidget { }); @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, + Widget build(BuildContext context, WidgetRef ref) { + final service = ref.read(xtreamServiceProvider(playlist)); + String streamUrl = ''; + + if (streamType == StreamType.live) { + streamUrl = service.getLiveStreamUrl(streamId); + } else if (streamType == StreamType.vod) { + streamUrl = service.getVodStreamUrl(streamId, containerExtension); + } else if (streamType == StreamType.series) { + streamUrl = service.getSeriesStreamUrl(streamId, containerExtension); + } + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text(title, style: const TextStyle(color: Colors.white)), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: Center( + child: LitePlayerView( + streamUrl: streamUrl, + isLive: streamType == StreamType.live, + ), + ), ); } } 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 ff89bc8..9fa8462 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -53,7 +53,7 @@ class _MobileLiveTVTabState extends ConsumerState { .where((cat) => settings.matchesLiveTvFilter(cat)) .toList(); } - categories.sort(); + // REMOVED alphabetic sort to respect server/settings order List displayedChannels = []; if (_searchQuery.isNotEmpty) { @@ -70,6 +70,12 @@ class _MobileLiveTVTabState extends ConsumerState { if (_selectedCategory == null && categories.isNotEmpty) { _selectedCategory = categories.first; } + // If the selected category is no longer available (due to filters), reset it + if (_selectedCategory != null && + !categories.contains(_selectedCategory)) { + _selectedCategory = + categories.isNotEmpty ? categories.first : null; + } if (_selectedCategory != null) { displayedChannels = groupedChannels[_selectedCategory] ?? []; } diff --git a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart index d0e9715..d803c0c 100644 --- a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart @@ -129,10 +129,12 @@ class _MobileMoviesTabState extends ConsumerState { return rating; } - String _getProxiedImageUrl(String? originalUrl) { + String _getProxiedImageUrl(String? originalUrl, WidgetRef ref) { if (originalUrl == null || originalUrl.isEmpty) return ''; - if (originalUrl.startsWith('http://')) { - return '/api/xtream/$originalUrl'; + if (originalUrl.startsWith('http')) { + final service = ref.read(xtreamServiceProvider(widget.playlist)); + // Re-use core _wrapWithProxy logic (via a new public method or direct construction) + return '${service.backendBaseUrl}/api/xtream/$originalUrl'; } return originalUrl; } @@ -176,7 +178,7 @@ class _MobileMoviesTabState extends ConsumerState { (m) => HeroItem( id: m.streamId, title: m.name, - imageUrl: _getProxiedImageUrl(m.streamIcon), + imageUrl: _getProxiedImageUrl(m.streamIcon, ref), subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, onMoreInfo: () => _playMovie(m), ), @@ -296,7 +298,7 @@ class _MobileMoviesTabState extends ConsumerState { child: movie.streamIcon != null && movie.streamIcon!.isNotEmpty ? Image.network( - _getProxiedImageUrl(movie.streamIcon), + _getProxiedImageUrl(movie.streamIcon, ref), fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(color: Colors.white10), diff --git a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart index 71be95b..4965abe 100644 --- a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart @@ -127,10 +127,11 @@ class _MobileSeriesTabState extends ConsumerState { return rating; } - String _getProxiedImageUrl(String? originalUrl) { + String _getProxiedImageUrl(String? originalUrl, WidgetRef ref) { if (originalUrl == null || originalUrl.isEmpty) return ''; - if (originalUrl.startsWith('http://')) { - return '/api/xtream/$originalUrl'; + if (originalUrl.startsWith('http')) { + final service = ref.read(xtreamServiceProvider(widget.playlist)); + return '${service.backendBaseUrl}/api/xtream/$originalUrl'; } return originalUrl; } @@ -168,7 +169,7 @@ class _MobileSeriesTabState extends ConsumerState { (s) => HeroItem( id: s.seriesId.toString(), title: s.name, - imageUrl: _getProxiedImageUrl(s.cover), + imageUrl: _getProxiedImageUrl(s.cover, ref), subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, onMoreInfo: () => _openSeries(s), ), diff --git a/task.md b/task.md index 59be5db..3f18838 100644 --- a/task.md +++ b/task.md @@ -17,6 +17,8 @@ Refonte du Guide TV pour inclure les catégories et corriger l'EPG. - [x] Suppression du tri alphabétique forcé - [x] Refonte du Guide TV (Catégories + EPG détaillé) - [x] Correction de l'endpoint EPG Backend (Fallback) +- [x] Correction du lecteur Mobile (LitePlayerView) +- [x] Alignement UI Mobile (Tri, EPG, URLs) ## Progress Log @@ -28,3 +30,5 @@ Refonte du Guide TV pour inclure les catégories et corriger l'EPG. - [x] Suppression du tri alphabétique forcé - [x] Refonte du Guide TV avec catégories - [x] Implémentation du fallback EPG Backend vers get_short_epg +- [x] Diagnostic de l'incompatibilité du lecteur mobile +- [x] Remplacement du lecteur Web par LitePlayerView sur mobile