From 5dd83019d31a280e8d802d46b06eb8a9bc7fc337 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 26 Dec 2025 22:32:01 +0100 Subject: [PATCH] feat: add XtreamServiceMobile for Xtream Codes API communication with caching and DNS resolution for mobile. --- lib/core/database/hive_service.dart | 9 +- lib/core/services/playlist_api_service.dart | 7 +- .../iptv/services/xtream_service_mobile.dart | 141 +++++++++++------- lib/main_mobile.dart | 16 +- .../iptv/screens/mobile_playlist_screen.dart | 12 +- .../mobile_playlist_selection_screen.dart | 5 +- .../iptv/widgets/mobile_movies_tab.dart | 2 +- .../services/local_playlist_service.dart | 92 ------------ 8 files changed, 107 insertions(+), 177 deletions(-) delete mode 100644 lib/mobile/services/local_playlist_service.dart diff --git a/lib/core/database/hive_service.dart b/lib/core/database/hive_service.dart index 34c3620..ea332c8 100644 --- a/lib/core/database/hive_service.dart +++ b/lib/core/database/hive_service.dart @@ -20,8 +20,13 @@ class HiveService { await Hive.initFlutter(); // Register adapters - Hive.registerAdapter(AppUserAdapter()); - Hive.registerAdapter(PlaylistConfigAdapter()); + // Register adapters + if (!Hive.isAdapterRegistered(0)) { + Hive.registerAdapter(AppUserAdapter()); + } + if (!Hive.isAdapterRegistered(1)) { + Hive.registerAdapter(PlaylistConfigAdapter()); + } // Open boxes WITHOUT encryption on Web // Note: On Web, IndexedDB is already isolated by origin, diff --git a/lib/core/services/playlist_api_service.dart b/lib/core/services/playlist_api_service.dart index c7d08a6..9653ec6 100644 --- a/lib/core/services/playlist_api_service.dart +++ b/lib/core/services/playlist_api_service.dart @@ -7,9 +7,7 @@ class PlaylistApiService { /// Get all playlists for current user Future> getPlaylists() async { try { - if (!HiveService.playlistsBox.isOpen) { - await HiveService.init(); - } + await HiveService.init(); return HiveService.playlistsBox.values.toList(); } catch (e) { print('Error fetching playlists from Hive: $e'); @@ -25,6 +23,7 @@ class PlaylistApiService { required String password, }) async { try { + await HiveService.init(); final id = const Uuid().v4(); final playlist = PlaylistConfig( id: id, @@ -52,6 +51,7 @@ class PlaylistApiService { required String password, }) async { try { + await HiveService.init(); if (!HiveService.playlistsBox.containsKey(id)) { return null; } @@ -79,6 +79,7 @@ class PlaylistApiService { /// Delete a playlist Future deletePlaylist(String id) async { try { + await HiveService.init(); await HiveService.playlistsBox.delete(id); return true; } catch (e) { diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index 6a4ce92..bd9b237 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -241,20 +241,25 @@ class XtreamServiceMobile { final Map> groupedChannels = {}; for (final streamData in streams) { - final data = streamData as Map; - // Get category name from our mapping using category_id - final categoryId = data['category_id']?.toString() ?? ''; - final categoryName = categoryMap[categoryId] ?? 'Uncategorized'; + try { + final data = streamData as Map; + // 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; + // Inject category_name into data before parsing + data['category_name'] = categoryName; - final channel = Channel.fromJson(data); + final channel = Channel.fromJson(data); - if (!groupedChannels.containsKey(categoryName)) { - groupedChannels[categoryName] = []; + if (!groupedChannels.containsKey(categoryName)) { + groupedChannels[categoryName] = []; + } + groupedChannels[categoryName]!.add(channel); + } catch (e) { + // Skip malformed channel + continue; } - groupedChannels[categoryName]!.add(channel); } return groupedChannels; @@ -371,13 +376,19 @@ class XtreamServiceMobile { final paginatedMovies = allMovies.sublist(offset, endIndex); - return paginatedMovies.map((movieData) { - final data = - Map.from(movieData as Map); - final categoryId = data['category_id']?.toString() ?? ''; - data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; - return xm.Movie.fromJson(data); - }).toList(); + final List movies = []; + for (final movieData in paginatedMovies) { + try { + final data = + Map.from(movieData as Map); + final categoryId = data['category_id']?.toString() ?? ''; + data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; + movies.add(xm.Movie.fromJson(data)); + } catch (e) { + continue; + } + } + return movies; } catch (e) { throw Exception('Failed to fetch movies: $e'); } @@ -403,14 +414,20 @@ class XtreamServiceMobile { ); if (response.data is! List) return []; - final List movies = response.data as List; + final List moviesRaw = response.data as List; - return movies.map((movieData) { - final data = - Map.from(movieData as Map); - data['category_name'] = categoryName; - return xm.Movie.fromJson(data); - }).toList(); + final List movies = []; + for (final movieData in moviesRaw) { + try { + final data = + Map.from(movieData as Map); + data['category_name'] = categoryName; + movies.add(xm.Movie.fromJson(data)); + } catch (e) { + continue; + } + } + return movies; } catch (e) { // Return empty list instead of throwing to prevent UI crash return []; @@ -440,19 +457,21 @@ class XtreamServiceMobile { final queryLower = query.toLowerCase(); // Filter by search query - return allMovies - .where((m) { - final name = (m['name']?.toString() ?? '').toLowerCase(); - return name.contains(queryLower); - }) - .take(100) // Limit results - .map((movieData) { - final data = movieData as Map; - final categoryId = data['category_id']?.toString() ?? ''; - data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; - return xm.Movie.fromJson(data); - }) - .toList(); + final List results = []; + for (final movieData in allMovies.where((m) { + final name = (m['name']?.toString() ?? '').toLowerCase(); + return name.contains(queryLower); + }).take(100)) { + try { + final data = movieData as Map; + final categoryId = data['category_id']?.toString() ?? ''; + data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; + results.add(xm.Movie.fromJson(data)); + } catch (e) { + continue; + } + } + return results; } catch (e) { return []; } @@ -502,13 +521,19 @@ class XtreamServiceMobile { final paginatedSeries = allSeries.sublist(offset, endIndex); - return paginatedSeries.map((seriesData) { - final data = - Map.from(seriesData as Map); - final categoryId = data['category_id']?.toString() ?? ''; - data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; - return xm.Series.fromJson(data); - }).toList(); + final List seriesList = []; + for (final seriesData in paginatedSeries) { + try { + final data = + Map.from(seriesData as Map); + final categoryId = data['category_id']?.toString() ?? ''; + data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; + seriesList.add(xm.Series.fromJson(data)); + } catch (e) { + continue; + } + } + return seriesList; } catch (e) { throw Exception('Failed to fetch series: $e'); } @@ -537,19 +562,21 @@ class XtreamServiceMobile { final queryLower = query.toLowerCase(); // Filter by search query - return allSeries - .where((s) { - final name = (s['name']?.toString() ?? '').toLowerCase(); - return name.contains(queryLower); - }) - .take(100) // Limit results - .map((seriesData) { - final data = seriesData as Map; - final categoryId = data['category_id']?.toString() ?? ''; - data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; - return xm.Series.fromJson(data); - }) - .toList(); + final List results = []; + for (final seriesData in allSeries.where((s) { + final name = (s['name']?.toString() ?? '').toLowerCase(); + return name.contains(queryLower); + }).take(100)) { + try { + final data = seriesData as Map; + final categoryId = data['category_id']?.toString() ?? ''; + data['category_name'] = categoryMap[categoryId] ?? 'Uncategorized'; + results.add(xm.Series.fromJson(data)); + } catch (e) { + continue; + } + } + return results; } catch (e) { return []; } diff --git a/lib/main_mobile.dart b/lib/main_mobile.dart index ba84a73..7cd7db6 100644 --- a/lib/main_mobile.dart +++ b/lib/main_mobile.dart @@ -7,6 +7,7 @@ import 'core/providers/theme_provider.dart'; import 'mobile/features/iptv/screens/mobile_playlist_screen.dart'; import 'mobile/features/iptv/screens/mobile_dashboard_screen.dart'; import 'core/models/playlist_config.dart'; +import 'core/database/hive_service.dart'; /// Mobile-optimized entry point for XtremFlow IPTV /// No authentication - direct access to playlist selection @@ -16,29 +17,18 @@ void main() async { // Initialize MediaKit (required for video playback) MediaKit.ensureInitialized(); - // Initialize Hive for local storage - await Hive.initFlutter(); + // Initialize Hive (Centralized) + await HiveService.init(); // 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()); - } - runApp(const ProviderScope(child: MobileApp())); } diff --git a/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart b/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart index 1cd00b8..bfea669 100644 --- a/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart @@ -4,13 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/models/playlist_config.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../theme/mobile_theme.dart'; -import '../../../services/local_playlist_service.dart'; +import '../../../../core/services/playlist_api_service.dart'; import 'mobile_dashboard_screen.dart'; /// Provider for local playlists final localPlaylistsProvider = FutureProvider>((ref) async { - final service = LocalPlaylistService(); + final service = PlaylistApiService(); return service.getPlaylists(); }); @@ -24,12 +24,12 @@ class MobilePlaylistScreen extends ConsumerStatefulWidget { } class _MobilePlaylistScreenState extends ConsumerState { - final LocalPlaylistService _playlistService = LocalPlaylistService(); + final PlaylistApiService _playlistService = PlaylistApiService(); @override void initState() { super.initState(); - _playlistService.init(); + // _playlistService.init(); // PlaylistAPI service inits internally via HiveService // Auto-login: if playlists exist, navigate to first one automatically WidgetsBinding.instance.addPostFrameCallback((_) { @@ -40,7 +40,7 @@ class _MobilePlaylistScreenState extends ConsumerState { Future _checkAutoLogin() async { try { final playlists = await _playlistService.getPlaylists(); - if (playlists.isNotEmpty && mounted) { + if (playlists.length == 1 && mounted) { // Navigate to first playlist automatically _navigateToDashboard(playlists.first); } @@ -211,7 +211,7 @@ class _MobilePlaylistScreenState extends ConsumerState { password: passwordController.text.trim(), ); } else { - await _playlistService.addPlaylist( + await _playlistService.createPlaylist( name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), diff --git a/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart b/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart index d0111e4..202e35b 100644 --- a/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart @@ -37,9 +37,8 @@ class _MobilePlaylistSelectionScreenState Future _checkAutoLogin() async { final playlists = await ref.read(playlistsProvider.future); - if (playlists.isNotEmpty && mounted) { - // Auto-login to the first playlist (or last used) - // Ideally last used, but for now first is fine as per request "une playlist existe". + // Only auto-login if there is exactly ONE playlist + if (playlists.length == 1 && mounted) { context.go('/dashboard', extra: playlists.first); } } diff --git a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart index f401a1c..335b3ef 100644 --- a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart @@ -8,7 +8,7 @@ import '../../../../core/models/playlist_config.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../providers/mobile_settings_providers.dart'; import '../../../providers/mobile_xtream_providers.dart'; -import '../../../../features/iptv/services/xtream_service_mobile.dart'; + import '../screens/native_player_screen.dart'; class MobileMoviesTab extends ConsumerStatefulWidget { diff --git a/lib/mobile/services/local_playlist_service.dart b/lib/mobile/services/local_playlist_service.dart deleted file mode 100644 index 044324d..0000000 --- a/lib/mobile/services/local_playlist_service.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:uuid/uuid.dart'; -import '../../core/models/playlist_config.dart'; - -/// Local playlist storage service for mobile -/// Uses Hive for persistent local storage -class LocalPlaylistService { - static const String _boxName = 'playlists_mobile'; - Box? _box; - - /// Initialize the service and open the Hive box - Future init() async { - if (_box != null && _box!.isOpen) return; - - // Register adapter if not already registered - if (!Hive.isAdapterRegistered(1)) { - Hive.registerAdapter(PlaylistConfigAdapter()); - } - - _box = await Hive.openBox(_boxName); - } - - /// Get all playlists - Future> getPlaylists() async { - await init(); - return _box!.values.toList(); - } - - /// Add a new playlist - Future addPlaylist({ - required String name, - required String dns, - required String username, - required String password, - }) async { - await init(); - - final playlist = PlaylistConfig( - id: const Uuid().v4(), - name: name, - dns: dns.endsWith('/') ? dns.substring(0, dns.length - 1) : dns, - username: username, - password: password, - createdAt: DateTime.now(), - ); - - await _box!.put(playlist.id, playlist); - return playlist; - } - - /// Update an existing playlist - Future updatePlaylist({ - required String id, - required String name, - required String dns, - required String username, - required String password, - }) async { - await init(); - - final existing = _box!.get(id); - if (existing == null) return null; - - final updated = existing.copyWith( - name: name, - dns: dns.endsWith('/') ? dns.substring(0, dns.length - 1) : dns, - username: username, - password: password, - ); - - await _box!.put(id, updated); - return updated; - } - - /// Delete a playlist - Future deletePlaylist(String id) async { - await init(); - await _box!.delete(id); - return true; - } - - /// Get a single playlist by ID - Future getPlaylist(String id) async { - await init(); - return _box!.get(id); - } - - /// Close the box when done - Future dispose() async { - await _box?.close(); - } -}