diff --git a/bin/server.dart b/bin/server.dart index 917c351..3ceb561 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -46,22 +46,25 @@ void main(List args) async { ..mount('/api/auth', authHandler.router) // Playlists endpoints (with auth middleware) ..mount( - '/api/playlists', - Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler(playlistsHandler.router.call)) + '/api/playlists', + const Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(playlistsHandler.router.call), + ) // Users endpoints (with auth middleware) ..mount( - '/api/users', - Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler(usersHandler.router.call)) + '/api/users', + const Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(usersHandler.router.call), + ) // Settings endpoints (with auth middleware) ..mount( - '/api/settings', - Pipeline() - .addMiddleware(authMiddleware(db)) - .addHandler(settingsHandler.router.call)); + '/api/settings', + const Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler(settingsHandler.router.call), + ); // Initialize Cleanup Service final cleanupService = CleanupService(); @@ -76,7 +79,9 @@ void main(List args) async { // Admin Routes (protected) apiRouter.mount( '/api/admin', - Pipeline().addMiddleware(authMiddleware(db)).addHandler((Request request) { + const Pipeline() + .addMiddleware(authMiddleware(db)) + .addHandler((Request request) { final router = Router(); // POST /api/admin/purge @@ -84,7 +89,8 @@ void main(List args) async { final user = req.context['user'] as User?; if (user == null || !user.isAdmin) { return Response.forbidden( - jsonEncode({'error': 'Admin access required'})); + jsonEncode({'error': 'Admin access required'}), + ); } final result = await cleanupService.runCleanup(); @@ -107,7 +113,7 @@ void main(List args) async { ); // Wrap static handler to enforce cache policies - Handler staticHandler = (Request request) async { + FutureOr staticHandler(Request request) async { final response = await baseStaticHandler(request); // Disable cache for entry points to ensure updates are seen immediately @@ -115,22 +121,27 @@ void main(List args) async { if (path.isEmpty || path == 'index.html' || path.endsWith( - '.js') || // Disable cache for ALL JS files (main.dart.js, bootstrap, etc) + '.js', + ) || // Disable cache for ALL JS files (main.dart.js, bootstrap, etc) path.endsWith('.json')) { // version.json etc - return response.change(headers: { - 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', - 'Pragma': 'no-cache', - 'Expires': '0', - }); + return response.change( + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + 'Expires': '0', + }, + ); } // Allow aggressive caching for hashed assets (images, compiled JS) // Flutter web builds usually hash main.dart.js, but let's be safe with 1 day - return response.change(headers: { - 'Cache-Control': 'public, max-age=86400', - }); - }; + return response.change( + headers: { + 'Cache-Control': 'public, max-age=86400', + }, + ); + } // Main handler with API proxy and FFmpeg streaming final handler = Cascade() @@ -142,7 +153,7 @@ void main(List args) async { .handler; // Add middleware - final pipeline = Pipeline() + final pipeline = const Pipeline() .addMiddleware(logRequests()) .addMiddleware(securityHeadersMiddleware()) // Basic Headers .addMiddleware(honeypotMiddleware()) // Trap Bots @@ -354,7 +365,7 @@ Handler _createXtreamProxyHandler() { /// Stream video file using HttpClient with Range support for seeking Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { - final maxRedirects = 5; + const maxRedirects = 5; var currentUrl = targetUrl; final cookies = {}; // Cookie Jar for redirects @@ -397,9 +408,9 @@ Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { print('Upstream Response: ${response.statusCode} (Redirect $i)'); // Collect Cookies from response - response.cookies.forEach((cookie) { + for (var cookie in response.cookies) { cookies[cookie.name] = cookie.value; - }); + } // Handle Redirects (301, 302, 303, 307, 308) if (response.statusCode >= 300 && response.statusCode < 400) { @@ -427,7 +438,8 @@ Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { // Checks for 206 vs 200 if (response.statusCode == 200 && rangeHeader != null) { print( - 'WARNING: Upstream ignored Range header and returned 200. Seeking might fail.'); + 'WARNING: Upstream ignored Range header and returned 200. Seeking might fail.', + ); } else if (response.statusCode == 206) { print('SUCCESS: Upstream returned 206 Partial Content.'); } @@ -458,7 +470,8 @@ Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { if (i == maxRedirects - 1) { return Response.internalServerError( body: jsonEncode( - {'error': 'Video streaming error', 'message': e.toString()}), + {'error': 'Video streaming error', 'message': e.toString()}, + ), headers: {'content-type': 'application/json'}, ); } @@ -471,8 +484,12 @@ Future _streamVideoFile(Uri targetUrl, String? rangeHeader) async { /// Rewrite URLs in M3U8 playlist to go through the proxy /// /// Handles both relative and absolute URLs in HLS playlists -String _rewriteM3u8Urls(String m3u8Content, String baseUrl, String originalPath, - String proxyOrigin) { +String _rewriteM3u8Urls( + String m3u8Content, + String baseUrl, + String originalPath, + String proxyOrigin, +) { final lines = m3u8Content.split('\n'); final rewrittenLines = []; @@ -517,7 +534,11 @@ String _rewriteM3u8Urls(String m3u8Content, String baseUrl, String originalPath, /// Rewrite a single URL to go through the proxy String _rewriteUrl( - String url, String baseUrl, String basePath, String proxyOrigin) { + String url, + String baseUrl, + String basePath, + String proxyOrigin, +) { String fullUrl; if (url.startsWith('http://') || url.startsWith('https://')) { @@ -731,7 +752,8 @@ Handler _createStreamHandler() { print(stackTrace); return Response.internalServerError( body: jsonEncode( - {'error': 'Stream setup error', 'message': e.toString()}), + {'error': 'Stream setup error', 'message': e.toString()}, + ), headers: {'content-type': 'application/json'}, ); } diff --git a/lib/core/api/dns_interceptor.dart b/lib/core/api/dns_interceptor.dart index b9ba185..2755afb 100644 --- a/lib/core/api/dns_interceptor.dart +++ b/lib/core/api/dns_interceptor.dart @@ -7,14 +7,15 @@ import 'package:dio/io.dart'; /// 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 { + Future onError( + DioException err, ErrorInterceptorHandler handler) async { // Basic check for connection/host lookup errors if (_isDnsError(err)) { final uri = err.requestOptions.uri; @@ -30,12 +31,12 @@ class DnsFallbackInterceptor extends Interceptor { // 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'); + print('DnsFallback: Failed to resolve $host'); } } catch (e) { print('DnsFallback: Resolution error: $e'); @@ -48,30 +49,32 @@ class DnsFallbackInterceptor extends Interceptor { // 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); + 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'); - } + 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; } @@ -79,14 +82,17 @@ class DnsFallbackInterceptor extends Interceptor { /// 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'); + 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'}); + 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; } @@ -94,26 +100,28 @@ class DnsFallbackInterceptor extends Interceptor { 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 - + ..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']; - } + if (ans['type'] == 1) { + // Type A record + return ans['data']; + } } } } diff --git a/lib/core/api/dns_resolver.dart b/lib/core/api/dns_resolver.dart index 8811870..e02dbff 100644 --- a/lib/core/api/dns_resolver.dart +++ b/lib/core/api/dns_resolver.dart @@ -23,12 +23,10 @@ class DnsResolver { ); // 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'}, - ); - } + 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'); @@ -40,7 +38,8 @@ class DnsResolver { return ip; } - static Future _queryDoH(String url, {Map? headers}) async { + static Future _queryDoH(String url, + {Map? headers}) async { try { final client = HttpClient() ..connectionTimeout = const Duration(seconds: 10) diff --git a/lib/core/database/hive_service.dart b/lib/core/database/hive_service.dart index 4d68518..34c3620 100644 --- a/lib/core/database/hive_service.dart +++ b/lib/core/database/hive_service.dart @@ -8,7 +8,7 @@ import '../models/playlist_config.dart'; class HiveService { static const String _usersBoxName = 'users'; static const String _playlistsBoxName = 'playlists'; - + static bool _initialized = false; static List? _encryptionKey; @@ -45,27 +45,27 @@ class HiveService { // Generate new 256-bit key _encryptionKey = Hive.generateSecureKey(); - + return _encryptionKey!; } /// Seed default admin user (admin/admin) static Future _seedDefaultAdmin() async { final usersBox = Hive.box(_usersBoxName); - + if (usersBox.isEmpty) { final adminId = const Uuid().v4(); final passwordHash = _hashPassword('admin'); - + final admin = AppUser( id: adminId, username: 'admin', passwordHash: passwordHash, isAdmin: true, - assignedPlaylistIds: [], + assignedPlaylistIds: const [], createdAt: DateTime.now(), ); - + await usersBox.put(adminId, admin); } } @@ -88,12 +88,12 @@ class HiveService { final legacyHash = sha256.convert(utf8.encode(password)).toString(); return legacyHash == storedHash; } - + final salt = parts[0]; final expectedHash = parts[1]; final bytes = utf8.encode(password + salt); final actualHash = sha256.convert(bytes).toString(); - + return actualHash == expectedHash; } catch (e) { return false; @@ -103,12 +103,11 @@ class HiveService { /// Public method to hash passwords (used by auth) static String hashPassword(String password) => _hashPassword(password); - /// Get users box static Box get usersBox => Hive.box(_usersBoxName); /// Get playlists box - static Box get playlistsBox => + static Box get playlistsBox => Hive.box(_playlistsBoxName); /// Close all boxes (cleanup) diff --git a/lib/core/services/playlist_api_service.dart b/lib/core/services/playlist_api_service.dart index db1dc43..c7d08a6 100644 --- a/lib/core/services/playlist_api_service.dart +++ b/lib/core/services/playlist_api_service.dart @@ -1,30 +1,18 @@ -import '../api/api_client.dart'; +import 'package:uuid/uuid.dart'; +import '../database/hive_service.dart'; import '../models/playlist_config.dart'; -/// Service for managing playlists via API +/// Service for managing playlists via local storage (Hive) class PlaylistApiService { - final ApiClient _api = ApiClient(); - /// Get all playlists for current user Future> getPlaylists() async { try { - final response = await _api.get('/api/playlists'); - final data = response.data as Map; - final playlistsData = data['playlists'] as List; - - return playlistsData.map((p) { - final playlist = p as Map; - return PlaylistConfig( - id: playlist['id'] as String, - name: playlist['name'] as String, - dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String, - username: playlist['username'] as String, - password: playlist['password'] as String, - createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(), - ); - }).toList(); + if (!HiveService.playlistsBox.isOpen) { + await HiveService.init(); + } + return HiveService.playlistsBox.values.toList(); } catch (e) { - print('Error fetching playlists: $e'); + print('Error fetching playlists from Hive: $e'); return []; } } @@ -37,30 +25,20 @@ class PlaylistApiService { required String password, }) async { try { - final response = await _api.post('/api/playlists', data: { - 'name': name, - 'serverUrl': dns, - 'username': username, - 'password': password, - 'dns': dns, - },); + final id = const Uuid().v4(); + final playlist = PlaylistConfig( + id: id, + name: name, + dns: dns, + username: username, + password: password, + createdAt: DateTime.now(), + ); - final data = response.data as Map; - - if (data['success'] == true) { - final playlist = data['playlist'] as Map; - return PlaylistConfig( - id: playlist['id'] as String, - name: playlist['name'] as String, - dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String, - username: playlist['username'] as String, - password: playlist['password'] as String, - createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(), - ); - } - return null; + await HiveService.playlistsBox.put(id, playlist); + return playlist; } catch (e) { - print('Error creating playlist: $e'); + print('Error creating playlist in Hive: $e'); return null; } } @@ -74,30 +52,26 @@ class PlaylistApiService { required String password, }) async { try { - final response = await _api.put('/api/playlists/$id', data: { - 'name': name, - 'serverUrl': dns, - 'username': username, - 'password': password, - 'dns': dns, - },); - - final data = response.data as Map; - - if (data['success'] == true) { - final playlist = data['playlist'] as Map; - return PlaylistConfig( - id: playlist['id'] as String, - name: playlist['name'] as String, - dns: playlist['serverUrl'] as String? ?? playlist['dns'] as String, - username: playlist['username'] as String, - password: playlist['password'] as String, - createdAt: DateTime.tryParse(playlist['createdAt'] as String? ?? '') ?? DateTime.now(), - ); + if (!HiveService.playlistsBox.containsKey(id)) { + return null; } - return null; + + final existing = HiveService.playlistsBox.get(id); + if (existing == null) return null; + + final updated = PlaylistConfig( + id: id, + name: name, + dns: dns, + username: username, + password: password, + createdAt: existing.createdAt, + ); + + await HiveService.playlistsBox.put(id, updated); + return updated; } catch (e) { - print('Error updating playlist: $e'); + print('Error updating playlist in Hive: $e'); return null; } } @@ -105,11 +79,10 @@ class PlaylistApiService { /// Delete a playlist Future deletePlaylist(String id) async { try { - final response = await _api.delete('/api/playlists/$id'); - final data = response.data as Map; - return data['success'] == true; + await HiveService.playlistsBox.delete(id); + return true; } catch (e) { - print('Error deleting playlist: $e'); + print('Error deleting playlist from Hive: $e'); return false; } } diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index a87a954..6353ae7 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; /// XtremFlow Apple TV Style Palette -/// +/// /// Ultra minimalist, focus-driven palette. /// - Background: Pure Black (#000000) /// - Focus/Active: Pure White (#FFFFFF) @@ -13,20 +13,20 @@ class AppColors { // ============ BACKGROUNDS ============ /// Pure black for that infinite depth look (OLED friendly) static const Color background = Color(0xFF000000); - + /// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey) static const Color surface = Color(0xFF1C1C1E); - + /// Slightly lighter grey for secondary surfaces or hover states static const Color surfaceVariant = Color(0xFF3A3A3C); - + /// Focused element background (often white in tvOS for text, or bright accent) static const Color focusColor = Color(0xFFFFFFFF); // ============ ACCENTS ============ /// Minimal white accent. In Apple TV, color is used sparingly. /// We keep a subtle blue only for specific indicators if needed. - static const Color primary = Color(0xFFFFFFFF); + static const Color primary = Color(0xFFFFFFFF); static const Color accent = Color(0xFFE5E5E5); /// Primary Gradient (Subtle silver/white glow) @@ -60,7 +60,7 @@ class AppColors { // ============ BORDERS ============ // Borders are usually hidden until focused - static const Color border = Color(0xFF1F1F1F); + static const Color border = Color(0xFF1F1F1F); static const Color focusBorder = Color(0xFFFFFFFF); // ============ GLASSMORPHISM ============ @@ -70,28 +70,27 @@ class AppColors { // ============ THEME SCHEMES ============ static ColorScheme get darkColorScheme => const ColorScheme.dark( - primary: primary, - secondary: accent, - surface: surface, - background: background, - error: error, - onPrimary: Colors.black, // White text on buttons -> Black text on White buttons - onSecondary: Colors.black, - onSurface: textPrimary, - onError: Colors.white, - brightness: Brightness.dark, - ); + primary: primary, + secondary: accent, + surface: surface, + error: error, + onPrimary: Colors + .black, // White text on buttons -> Black text on White buttons + onSecondary: Colors.black, + onSurface: textPrimary, + onError: Colors.white, + brightness: Brightness.dark, + ); static ColorScheme get lightColorScheme => const ColorScheme.light( - primary: Colors.black, - secondary: Colors.grey, - surface: Color(0xFFF2F2F7), - background: Colors.white, - error: error, - onPrimary: Colors.white, - onSecondary: Colors.white, - onSurface: Colors.black, - onError: Colors.white, - brightness: Brightness.light, - ); + primary: Colors.black, + secondary: Colors.grey, + surface: Color(0xFFF2F2F7), + error: error, + onPrimary: Colors.white, + onSecondary: Colors.white, + onSurface: Colors.black, + onError: Colors.white, + brightness: Brightness.light, + ); } diff --git a/lib/core/widgets/themed_loading_screen.dart b/lib/core/widgets/themed_loading_screen.dart index e792467..e1db1dd 100644 --- a/lib/core/widgets/themed_loading_screen.dart +++ b/lib/core/widgets/themed_loading_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import '../theme/app_colors.dart'; class ThemedLoading extends StatelessWidget { const ThemedLoading({super.key}); diff --git a/lib/features/admin/screens/admin_panel.dart b/lib/features/admin/screens/admin_panel.dart index 020ee8b..b493095 100644 --- a/lib/features/admin/screens/admin_panel.dart +++ b/lib/features/admin/screens/admin_panel.dart @@ -87,9 +87,9 @@ class _AdminContentState extends ConsumerState Text( 'Administration', style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), const Spacer(), // Custom Tab Bar Indicator @@ -111,12 +111,13 @@ class _AdminContentState extends ConsumerState ], ), ), - + // Content Expanded( child: TabBarView( controller: _tabController, - physics: const NeverScrollableScrollPhysics(), // Custom tabs handle ref + physics: + const NeverScrollableScrollPhysics(), // Custom tabs handle ref children: const [ _PlaylistsTab(), _UsersTab(), @@ -182,7 +183,11 @@ class _PlaylistsTab extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white70)), + Text('${playlistsAsync.asData?.value.length ?? 0} Playlists', + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: Colors.white70)), GradientButton( label: 'Add Playlist', icon: Icons.add, @@ -194,16 +199,21 @@ class _PlaylistsTab extends ConsumerWidget { Expanded( child: playlistsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))), + error: (error, stack) => Center( + child: Text('Error: $error', + style: const TextStyle(color: AppColors.error))), data: (playlists) { if (playlists.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.playlist_remove, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), + Icon(Icons.playlist_remove, + size: 64, + color: AppColors.textSecondary.withOpacity(0.5)), const SizedBox(height: 16), - Text('No playlists configured', style: Theme.of(context).textTheme.titleMedium), + Text('No playlists configured', + style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), TextButton( onPressed: () => _showPlaylistDialog(context, ref), @@ -215,7 +225,8 @@ class _PlaylistsTab extends ConsumerWidget { } return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 8), itemCount: playlists.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { @@ -231,16 +242,25 @@ class _PlaylistsTab extends ConsumerWidget { color: AppColors.surface, borderRadius: BorderRadius.circular(12), ), - child: const Icon(Icons.playlist_play, color: AppColors.textPrimary), + child: const Icon(Icons.playlist_play, + color: AppColors.textPrimary), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(playlist.name, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), + Text(playlist.name, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: Colors.white)), const SizedBox(height: 4), - Text(playlist.dns, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white60)), + Text(playlist.dns, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Colors.white60)), ], ), ), @@ -249,13 +269,15 @@ class _PlaylistsTab extends ConsumerWidget { IconButton( icon: const Icon(Icons.edit_outlined, size: 20), color: Colors.white70, - onPressed: () => _showPlaylistDialog(context, ref, playlist), + onPressed: () => + _showPlaylistDialog(context, ref, playlist), tooltip: 'Edit', ), IconButton( icon: const Icon(Icons.delete_outline, size: 20), color: AppColors.error, - onPressed: () => _deletePlaylist(context, ref, playlist), + onPressed: () => + _deletePlaylist(context, ref, playlist), tooltip: 'Delete', ), ], @@ -272,7 +294,8 @@ class _PlaylistsTab extends ConsumerWidget { ); } - void _showPlaylistDialog(BuildContext context, WidgetRef ref, [PlaylistConfig? playlist]) { + void _showPlaylistDialog(BuildContext context, WidgetRef ref, + [PlaylistConfig? playlist]) { final nameController = TextEditingController(text: playlist?.name); final dnsController = TextEditingController(text: playlist?.dns); final usernameController = TextEditingController(text: playlist?.username); @@ -284,40 +307,79 @@ class _PlaylistsTab extends ConsumerWidget { context: context, builder: (dialogContext) => StatefulBuilder( builder: (context, setState) => AlertDialog( - title: Text(playlist == null ? 'Ajouter une playlist' : 'Modifier la playlist'), + title: Text(playlist == null + ? 'Ajouter une playlist' + : 'Modifier la playlist'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: nameController, decoration: const InputDecoration(labelText: 'Nom')), + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: 'Nom')), const SizedBox(height: 12), - TextField(controller: dnsController, decoration: const InputDecoration(labelText: 'URL du serveur', hintText: 'http://server.com:8080')), + TextField( + controller: dnsController, + decoration: const InputDecoration( + labelText: 'URL du serveur', + hintText: 'http://server.com:8080')), const SizedBox(height: 12), - TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Utilisateur')), + TextField( + controller: usernameController, + decoration: + const InputDecoration(labelText: 'Utilisateur')), const SizedBox(height: 12), - TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe'), obscureText: true), + TextField( + controller: passwordController, + decoration: + const InputDecoration(labelText: 'Mot de passe'), + obscureText: true), ], ), ), actions: [ - TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), + TextButton( + onPressed: + isLoading ? null : () => Navigator.pop(dialogContext), + child: const Text('Annuler')), FilledButton( - onPressed: isLoading ? null : () async { - if (nameController.text.isEmpty || dnsController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis'))); - return; - } - setState(() => isLoading = true); - final dns = dnsController.text.trim().replaceAll(RegExp(r'/$'), ''); - if (playlist == null) { - await service.createPlaylist(name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); - } else { - await service.updatePlaylist(id: playlist.id, name: nameController.text.trim(), dns: dns, username: usernameController.text.trim(), password: passwordController.text); - } - if (dialogContext.mounted) Navigator.pop(dialogContext); - ref.invalidate(playlistsProvider); - }, - child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Sauvegarder'), + onPressed: isLoading + ? null + : () async { + if (nameController.text.isEmpty || + dnsController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tous les champs sont requis'))); + return; + } + setState(() => isLoading = true); + final dns = dnsController.text + .trim() + .replaceAll(RegExp(r'/$'), ''); + if (playlist == null) { + await service.createPlaylist( + name: nameController.text.trim(), + dns: dns, + username: usernameController.text.trim(), + password: passwordController.text); + } else { + await service.updatePlaylist( + id: playlist.id, + name: nameController.text.trim(), + dns: dns, + username: usernameController.text.trim(), + password: passwordController.text); + } + if (dialogContext.mounted) Navigator.pop(dialogContext); + ref.invalidate(playlistsProvider); + }, + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Sauvegarder'), ), ], ), @@ -325,14 +387,17 @@ class _PlaylistsTab extends ConsumerWidget { ); } - void _deletePlaylist(BuildContext context, WidgetRef ref, PlaylistConfig playlist) { + void _deletePlaylist( + BuildContext context, WidgetRef ref, PlaylistConfig playlist) { showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Supprimer la playlist'), content: Text('Supprimer "${playlist.name}" ?'), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Annuler')), FilledButton( onPressed: () async { Navigator.pop(dialogContext); @@ -363,7 +428,11 @@ class _UsersTab extends ConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('${usersAsync.asData?.value.length ?? 0} Users', style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Colors.white70)), + Text('${usersAsync.asData?.value.length ?? 0} Users', + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: Colors.white70)), GradientButton( label: 'Create User', icon: Icons.person_add, @@ -375,23 +444,29 @@ class _UsersTab extends ConsumerWidget { Expanded( child: usersAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('Error: $error', style: const TextStyle(color: AppColors.error))), + error: (error, stack) => Center( + child: Text('Error: $error', + style: const TextStyle(color: AppColors.error))), data: (users) { if (users.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.people_outline, size: 64, color: AppColors.textSecondary.withOpacity(0.5)), + Icon(Icons.people_outline, + size: 64, + color: AppColors.textSecondary.withOpacity(0.5)), const SizedBox(height: 16), - Text('No users found', style: Theme.of(context).textTheme.titleMedium), + Text('No users found', + style: Theme.of(context).textTheme.titleMedium), ], ), ); } return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 24, vertical: 8), itemCount: users.length, separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (context, index) { @@ -404,12 +479,18 @@ class _UsersTab extends ConsumerWidget { width: 48, height: 48, decoration: BoxDecoration( - color: user.isAdmin ? AppColors.focusColor : AppColors.surface, + color: user.isAdmin + ? AppColors.focusColor + : AppColors.surface, shape: BoxShape.circle, ), child: Icon( - user.isAdmin ? Icons.admin_panel_settings : Icons.person, - color: user.isAdmin ? Colors.black : AppColors.textPrimary, + user.isAdmin + ? Icons.admin_panel_settings + : Icons.person, + color: user.isAdmin + ? Colors.black + : AppColors.textPrimary, size: 24, ), ), @@ -420,38 +501,72 @@ class _UsersTab extends ConsumerWidget { children: [ Row( children: [ - Text(user.username, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), + Text(user.username, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: Colors.white)), if (user.isAdmin) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: AppColors.focusColor.withOpacity(0.2), + color: AppColors.focusColor + .withOpacity(0.2), borderRadius: BorderRadius.circular(50), ), - child: const Text('ADMIN', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: AppColors.focusColor)), + child: const Text('ADMIN', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: AppColors.focusColor)), ), ], ], ), const SizedBox(height: 4), - Text('ID: ${user.id}', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white60)), + Text('ID: ${user.id}', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Colors.white60)), ], ), ), PopupMenuButton( - icon: const Icon(Icons.more_vert, color: Colors.white70), + icon: const Icon(Icons.more_vert, + color: Colors.white70), color: AppColors.surface, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), onSelected: (value) { switch (value) { - case 'password': _showChangePasswordDialog(context, ref, user); break; - case 'delete': _deleteUser(context, ref, user); break; + case 'password': + _showChangePasswordDialog(context, ref, user); + break; + case 'delete': + _deleteUser(context, ref, user); + break; } }, itemBuilder: (context) => [ - const PopupMenuItem(value: 'password', child: Row(children: [Icon(Icons.lock_reset, size: 18), SizedBox(width: 8), Text('Change Password')])), - const PopupMenuItem(value: 'delete', child: Row(children: [Icon(Icons.delete, color: AppColors.error, size: 18), SizedBox(width: 8), Text('Delete', style: TextStyle(color: AppColors.error))])), + const PopupMenuItem( + value: 'password', + child: Row(children: [ + Icon(Icons.lock_reset, size: 18), + SizedBox(width: 8), + Text('Change Password') + ])), + const PopupMenuItem( + value: 'delete', + child: Row(children: [ + Icon(Icons.delete, + color: AppColors.error, size: 18), + SizedBox(width: 8), + Text('Delete', + style: TextStyle(color: AppColors.error)) + ])), ], ), ], @@ -480,9 +595,18 @@ class _UsersTab extends ConsumerWidget { content: Column( mainAxisSize: MainAxisSize.min, children: [ - TextField(controller: usernameController, decoration: const InputDecoration(labelText: 'Nom d\'utilisateur', prefixIcon: Icon(Icons.person_outline))), + TextField( + controller: usernameController, + decoration: const InputDecoration( + labelText: 'Nom d\'utilisateur', + prefixIcon: Icon(Icons.person_outline))), const SizedBox(height: 12), - TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true), + TextField( + controller: passwordController, + decoration: const InputDecoration( + labelText: 'Mot de passe', + prefixIcon: Icon(Icons.lock_outline)), + obscureText: true), const SizedBox(height: 16), SwitchListTile( title: const Text('Administrateur'), @@ -493,22 +617,40 @@ class _UsersTab extends ConsumerWidget { ], ), actions: [ - TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), + TextButton( + onPressed: + isLoading ? null : () => Navigator.pop(dialogContext), + child: const Text('Annuler')), FilledButton( - onPressed: isLoading ? null : () async { - if (usernameController.text.isEmpty || passwordController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Tous les champs sont requis'))); - return; - } - setState(() => isLoading = true); - final result = await UserApiService().createUser(username: usernameController.text.trim(), password: passwordController.text, isAdmin: isAdmin); - if (dialogContext.mounted) Navigator.pop(dialogContext); - ref.invalidate(usersProvider); - if (!result.success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); - } - }, - child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Créer'), + onPressed: isLoading + ? null + : () async { + if (usernameController.text.isEmpty || + passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Tous les champs sont requis'))); + return; + } + setState(() => isLoading = true); + final result = await UserApiService().createUser( + username: usernameController.text.trim(), + password: passwordController.text, + isAdmin: isAdmin); + if (dialogContext.mounted) Navigator.pop(dialogContext); + ref.invalidate(usersProvider); + if (!result.success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(result.error ?? 'Erreur'), + backgroundColor: AppColors.error)); + } + }, + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Créer'), ), ], ), @@ -516,7 +658,8 @@ class _UsersTab extends ConsumerWidget { ); } - void _showChangePasswordDialog(BuildContext context, WidgetRef ref, AppUser user) { + void _showChangePasswordDialog( + BuildContext context, WidgetRef ref, AppUser user) { final passwordController = TextEditingController(); bool isLoading = false; @@ -525,25 +668,49 @@ class _UsersTab extends ConsumerWidget { builder: (dialogContext) => StatefulBuilder( builder: (context, setState) => AlertDialog( title: Text('Changer le mot de passe de ${user.username}'), - content: TextField(controller: passwordController, decoration: const InputDecoration(labelText: 'Nouveau mot de passe', prefixIcon: Icon(Icons.lock_outline)), obscureText: true), + content: TextField( + controller: passwordController, + decoration: const InputDecoration( + labelText: 'Nouveau mot de passe', + prefixIcon: Icon(Icons.lock_outline)), + obscureText: true), actions: [ - TextButton(onPressed: isLoading ? null : () => Navigator.pop(dialogContext), child: const Text('Annuler')), + TextButton( + onPressed: + isLoading ? null : () => Navigator.pop(dialogContext), + child: const Text('Annuler')), FilledButton( - onPressed: isLoading ? null : () async { - if (passwordController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Mot de passe requis'))); - return; - } - setState(() => isLoading = true); - final result = await UserApiService().updatePassword(userId: user.id, newPassword: passwordController.text); - if (dialogContext.mounted) Navigator.pop(dialogContext); - if (result.success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Mot de passe modifié'), backgroundColor: AppColors.success)); - } else if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); - } - }, - child: isLoading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Text('Changer'), + onPressed: isLoading + ? null + : () async { + if (passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mot de passe requis'))); + return; + } + setState(() => isLoading = true); + final result = await UserApiService().updatePassword( + userId: user.id, + newPassword: passwordController.text); + if (dialogContext.mounted) Navigator.pop(dialogContext); + if (result.success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mot de passe modifié'), + backgroundColor: AppColors.success)); + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(result.error ?? 'Erreur'), + backgroundColor: AppColors.error)); + } + }, + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Changer'), ), ], ), @@ -556,16 +723,21 @@ class _UsersTab extends ConsumerWidget { context: context, builder: (dialogContext) => AlertDialog( title: const Text('Supprimer l\'utilisateur'), - content: Text('Supprimer "${user.username}" ? Cette action est irréversible.'), + content: Text( + 'Supprimer "${user.username}" ? Cette action est irréversible.'), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Annuler')), + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Annuler')), FilledButton( onPressed: () async { Navigator.pop(dialogContext); final result = await UserApiService().deleteUser(user.id); ref.invalidate(usersProvider); if (!result.success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(result.error ?? 'Erreur'), backgroundColor: AppColors.error)); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(result.error ?? 'Erreur'), + backgroundColor: AppColors.error)); } }, style: FilledButton.styleFrom(backgroundColor: AppColors.error), diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart index 213aeb2..17f3fc2 100644 --- a/lib/features/auth/screens/login_screen.dart +++ b/lib/features/auth/screens/login_screen.dart @@ -67,7 +67,7 @@ class _LoginScreenState extends ConsumerState { ), ), ), - + // Main content Center( child: SingleChildScrollView( @@ -132,9 +132,12 @@ class _LoginScreenState extends ConsumerState { children: [ Text( 'Welcome Back', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + ), textAlign: TextAlign.center, ), const SizedBox(height: 32), @@ -143,21 +146,26 @@ class _LoginScreenState extends ConsumerState { style: const TextStyle(color: Colors.white), decoration: InputDecoration( labelText: 'Username', - labelStyle: const TextStyle(color: Colors.white70), - prefixIcon: const Icon(Icons.person_outline, color: Colors.white70), + labelStyle: + const TextStyle(color: Colors.white70), + prefixIcon: const Icon(Icons.person_outline, + color: Colors.white70), filled: true, fillColor: Colors.white.withOpacity(0.05), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.white.withOpacity(0.1)), + borderSide: BorderSide( + color: Colors.white.withOpacity(0.1)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.white.withOpacity(0.1)), + borderSide: BorderSide( + color: Colors.white.withOpacity(0.1)), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.primary), + borderSide: const BorderSide( + color: AppColors.primary), ), ), validator: (value) { @@ -176,21 +184,26 @@ class _LoginScreenState extends ConsumerState { style: const TextStyle(color: Colors.white), decoration: InputDecoration( labelText: 'Password', - labelStyle: const TextStyle(color: Colors.white70), - prefixIcon: const Icon(Icons.lock_outline, color: Colors.white70), + labelStyle: + const TextStyle(color: Colors.white70), + prefixIcon: const Icon(Icons.lock_outline, + color: Colors.white70), filled: true, fillColor: Colors.white.withOpacity(0.05), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.white.withOpacity(0.1)), + borderSide: BorderSide( + color: Colors.white.withOpacity(0.1)), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: Colors.white.withOpacity(0.1)), + borderSide: BorderSide( + color: Colors.white.withOpacity(0.1)), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.primary), + borderSide: const BorderSide( + color: AppColors.primary), ), suffixIcon: IconButton( icon: Icon( @@ -228,7 +241,7 @@ class _LoginScreenState extends ConsumerState { ), child: Row( children: [ - Icon( + const Icon( Icons.error_outline, color: AppColors.error, size: 20, @@ -277,6 +290,5 @@ class _LoginScreenState extends ConsumerState { ], ), ); - } } diff --git a/lib/features/iptv/screens/dashboard_screen.dart b/lib/features/iptv/screens/dashboard_screen.dart index c48e0f7..b6dacfb 100644 --- a/lib/features/iptv/screens/dashboard_screen.dart +++ b/lib/features/iptv/screens/dashboard_screen.dart @@ -2,8 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../core/models/playlist_config.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_theme.dart'; import '../../../core/widgets/digital_clock.dart'; import '../widgets/live_tv_tab.dart'; import '../widgets/movies_tab.dart'; @@ -11,7 +9,7 @@ import '../widgets/series_tab.dart'; import '../widgets/settings_tab.dart'; /// Apple TV Style Dashboard -/// +/// /// Uses a floating Top Bar for navigation instead of a sidebar. /// Content flows underneath the top bar. class DashboardScreen extends ConsumerStatefulWidget { @@ -26,11 +24,17 @@ class DashboardScreen extends ConsumerStatefulWidget { ConsumerState createState() => _DashboardScreenState(); } -class _DashboardScreenState extends ConsumerState with SingleTickerProviderStateMixin { +class _DashboardScreenState extends ConsumerState + with SingleTickerProviderStateMixin { late TabController _tabController; - + final List _tabs = ['Live TV', 'Films', 'Séries', 'Réglages']; - final List _icons = [Icons.live_tv, Icons.movie, Icons.tv, Icons.settings]; + final List _icons = [ + Icons.live_tv, + Icons.movie, + Icons.tv, + Icons.settings + ]; @override void initState() { @@ -74,13 +78,14 @@ class _DashboardScreenState extends ConsumerState with SingleTi child: _buildLogo(), ), ), - + // 2. Centered Navigation Tabs (Expanded to fill space, then Centered) Expanded( child: Center( child: Container( height: 50, - constraints: const BoxConstraints(maxWidth: 600), // Prevent taking too much space + constraints: const BoxConstraints( + maxWidth: 600), // Prevent taking too much space decoration: BoxDecoration( color: Colors.white.withOpacity(0.1), borderRadius: BorderRadius.circular(30), @@ -97,8 +102,10 @@ class _DashboardScreenState extends ConsumerState with SingleTi indicatorSize: TabBarIndicatorSize.tab, indicatorPadding: const EdgeInsets.all(4), labelColor: Colors.black, - unselectedLabelColor: Colors.white70, // Fixed: Improved readability - labelStyle: GoogleFonts.inter(fontWeight: FontWeight.w700, fontSize: 13), + unselectedLabelColor: + Colors.white70, // Fixed: Improved readability + labelStyle: GoogleFonts.inter( + fontWeight: FontWeight.w700, fontSize: 13), overlayColor: WidgetStateProperty.all(Colors.transparent), tabs: List.generate(_tabs.length, (index) { return Tab( @@ -118,7 +125,7 @@ class _DashboardScreenState extends ConsumerState with SingleTi ), ), ), - + // 3. Search / Profile (Fixed Width for centering balance) SizedBox( width: 200, @@ -130,7 +137,7 @@ class _DashboardScreenState extends ConsumerState with SingleTi IconButton( icon: const Icon(Icons.search, color: Colors.white), onPressed: () { - // Global search trigger + // Global search trigger }, ), const SizedBox(width: 16), @@ -139,7 +146,8 @@ class _DashboardScreenState extends ConsumerState with SingleTi const CircleAvatar( radius: 16, backgroundColor: Colors.white24, - child: Icon(Icons.person, size: 18, color: Colors.white), + child: + Icon(Icons.person, size: 18, color: Colors.white), ), ], ), @@ -165,17 +173,25 @@ class _DashboardScreenState extends ConsumerState with SingleTi ), ), ), - + // Content TabBarView( controller: _tabController, - physics: const NeverScrollableScrollPhysics(), // Disable swipe to avoid gesture conflicts + physics: + const NeverScrollableScrollPhysics(), // Disable swipe to avoid gesture conflicts children: [ // Padding top to account for the transparent AppBar - Padding(padding: const EdgeInsets.only(top: 80), child: LiveTVTab(playlist: widget.playlist)), - Padding(padding: const EdgeInsets.only(top: 80), child: MoviesTab(playlist: widget.playlist)), - Padding(padding: const EdgeInsets.only(top: 80), child: SeriesTab(playlist: widget.playlist)), - const Padding(padding: EdgeInsets.only(top: 80), child: SettingsTab()), + Padding( + padding: const EdgeInsets.only(top: 80), + child: LiveTVTab(playlist: widget.playlist)), + Padding( + padding: const EdgeInsets.only(top: 80), + child: MoviesTab(playlist: widget.playlist)), + Padding( + padding: const EdgeInsets.only(top: 80), + child: SeriesTab(playlist: widget.playlist)), + const Padding( + padding: EdgeInsets.only(top: 80), child: SettingsTab()), ], ), ], @@ -190,7 +206,10 @@ class _DashboardScreenState extends ConsumerState with SingleTi width: 36, height: 36, decoration: BoxDecoration( - gradient: const LinearGradient(colors: [Color(0xFF43cea2), Color(0xFF185a9d)]), // Green to Blue + gradient: const LinearGradient(colors: [ + Color(0xFF43cea2), + Color(0xFF185a9d) + ]), // Green to Blue borderRadius: BorderRadius.circular(10), ), child: const Icon(Icons.play_arrow_rounded, color: Colors.white), @@ -202,13 +221,16 @@ class _DashboardScreenState extends ConsumerState with SingleTi children: [ Text( 'Xtrem', - style: GoogleFonts.inter(fontWeight: FontWeight.w800, fontSize: 16, color: Colors.white), + style: GoogleFonts.inter( + fontWeight: FontWeight.w800, + fontSize: 16, + color: Colors.white), ), Text( 'Flow', style: GoogleFonts.inter( - fontWeight: FontWeight.w300, - fontSize: 16, + fontWeight: FontWeight.w300, + fontSize: 16, color: Colors.white.withOpacity(0.8), height: 0.8, ), diff --git a/lib/features/iptv/screens/player_screen.dart b/lib/features/iptv/screens/player_screen.dart index 4062001..7842447 100644 --- a/lib/features/iptv/screens/player_screen.dart +++ b/lib/features/iptv/screens/player_screen.dart @@ -72,13 +72,11 @@ class _PlayerScreenState extends ConsumerState { String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60)); return "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds"; } - + // Premium Features State List> _audioTracks = []; String _aspectRatio = 'contain'; - - void _setupMessageListener() { // Listen for postMessage from player.html iframe _messageSubscription = html.window.onMessage.listen((event) { @@ -95,8 +93,10 @@ class _PlayerScreenState extends ConsumerState { _totalDuration = duration > 0 ? duration : 1; }); ref.read(playbackPositionsProvider.notifier).savePosition( - _contentId, currentTime, duration, - ); + _contentId, + currentTime, + duration, + ); } else if (type == 'playback_status') { final status = data['status']; setState(() { @@ -123,9 +123,9 @@ class _PlayerScreenState extends ConsumerState { debugPrint('Sending message to iframe ($_viewId): $message'); final iframe = html.document.getElementById(_viewId) as html.IFrameElement?; if (iframe == null) { - debugPrint('ERROR: Iframe with ID $_viewId not found!'); + debugPrint('ERROR: Iframe with ID $_viewId not found!'); } else { - iframe.contentWindow?.postMessage(message, '*'); + iframe.contentWindow?.postMessage(message, '*'); } } @@ -177,23 +177,26 @@ class _PlayerScreenState extends ConsumerState { setState(() { _currentPosition = value; }); - + // Check if we are using server-side transcoding for VOD/Series final settings = ref.read(iptvSettingsProvider); // Only reload for seeking if we are rigorously Transcoding (FFmpeg pipe) // If we are in Direct Proxy mode (default), we can seek natively via JS. - final bool usingTranscoding = settings.transcodingMode == TranscodingMode.forced; - + final bool usingTranscoding = + settings.transcodingMode == TranscodingMode.forced; + if (usingTranscoding) { - debugPrint('Seeking in transcoded VOD - reloading player at ${value.round()}s'); - ref.read(playbackPositionsProvider.notifier).savePosition(_contentId, value, _totalDuration); - _initializePlayer(startTimeOverride: value); - + debugPrint( + 'Seeking in transcoded VOD - reloading player at ${value.round()}s'); + ref + .read(playbackPositionsProvider.notifier) + .savePosition(_contentId, value, _totalDuration); + _initializePlayer(startTimeOverride: value); } else { - // Direct play (Proxy) or Live - standard seeking happens in the browser via Range headers - _sendMessage({'type': 'seek', 'value': value}); + // Direct play (Proxy) or Live - standard seeking happens in the browser via Range headers + _sendMessage({'type': 'seek', 'value': value}); } - + _onHover(); } @@ -234,7 +237,8 @@ class _PlayerScreenState extends ConsumerState { void _playPrevious() { if (widget.channels == null || widget.channels!.isEmpty) return; - final prevIndex = (_currentIndex - 1 + widget.channels!.length) % widget.channels!.length; + final prevIndex = + (_currentIndex - 1 + widget.channels!.length) % widget.channels!.length; _switchChannel(prevIndex); } @@ -252,7 +256,7 @@ class _PlayerScreenState extends ConsumerState { void _toggleFullscreen() { final document = html.document; final docElement = document.documentElement; - + if (_isFullscreen) { document.exitFullscreen(); setState(() { @@ -275,39 +279,41 @@ class _PlayerScreenState extends ConsumerState { }); // Validate playlist before proceeding - if (widget.playlist.dns.isEmpty || - widget.playlist.username.isEmpty || + if (widget.playlist.dns.isEmpty || + widget.playlist.username.isEmpty || widget.playlist.password.isEmpty) { throw Exception('Invalid playlist configuration: missing credentials'); } final xtreamService = ref.read(xtreamServiceProvider(widget.playlist)); xtreamService.setPlaylist(widget.playlist); - + // Determine effective Stream ID (from navigation or initial) - final currentStreamId = widget.channels != null && widget.channels!.isNotEmpty - ? widget.channels![_currentIndex].streamId - : widget.streamId; + final currentStreamId = + widget.channels != null && widget.channels!.isNotEmpty + ? widget.channels![_currentIndex].streamId + : widget.streamId; // Set content ID for resume features - _contentId = widget.streamType == StreamType.live - ? '' + _contentId = widget.streamType == StreamType.live + ? '' : '${widget.streamType.name}_$currentStreamId'; String hlsUrl; - + if (widget.streamType == StreamType.live) { // For live streams, use FFmpeg transcoding setState(() { _statusMessage = 'Starting stream transcoding...'; }); - + // Build the FFmpeg endpoint URL with streaming settings final settings = ref.read(iptvSettingsProvider); final baseUrl = html.window.location.origin; - final iptvUrl = '${widget.playlist.dns}/live/${widget.playlist.username}/${widget.playlist.password}/$currentStreamId.ts'; + final iptvUrl = + '${widget.playlist.dns}/live/${widget.playlist.username}/${widget.playlist.password}/$currentStreamId.ts'; final encodedUrl = Uri.encodeComponent(iptvUrl); - + // Map enum values to string params using if-else (avoids switch exhaustivity issues) String qualityParam = 'medium'; if (settings.streamQuality == StreamQuality.low) { @@ -315,14 +321,14 @@ class _PlayerScreenState extends ConsumerState { } else if (settings.streamQuality == StreamQuality.high) { qualityParam = 'high'; } - + String bufferParam = 'medium'; if (settings.bufferSize == BufferSize.low) { bufferParam = 'low'; } else if (settings.bufferSize == BufferSize.high) { bufferParam = 'high'; } - + String timeoutParam = 'medium'; if (settings.connectionTimeout == ConnectionTimeout.short) { timeoutParam = 'short'; @@ -330,18 +336,18 @@ class _PlayerScreenState extends ConsumerState { timeoutParam = 'long'; } final modeParam = settings.modeString; // direct, transcode, or auto - + // Construct the stream URL directly using MPEG-TS format - final streamEndpoint = '$baseUrl/api/stream/$currentStreamId?url=$encodedUrl&quality=$qualityParam&buffer=$bufferParam&timeout=$timeoutParam&mode=$modeParam&ext=.ts'; - + final streamEndpoint = + '$baseUrl/api/stream/$currentStreamId?url=$encodedUrl&quality=$qualityParam&buffer=$bufferParam&timeout=$timeoutParam&mode=$modeParam&ext=.ts'; + debugPrint('PlayerScreen: Starting Direct Stream: $streamEndpoint'); hlsUrl = streamEndpoint; - } else { // For VOD and Series - + // For VOD and Series - + // Determine play mode: Direct (Proxy) vs Transcode (FFmpeg) // Default to Direct/Proxy because it preserves duration metadata and seeking capability (via Range headers) // Only use Transcode if explicitly FORCED (e.g. for audio compatibility issues like AC3/DTS on some browsers) @@ -350,88 +356,98 @@ class _PlayerScreenState extends ConsumerState { bool useTranscode = settings.transcodingMode == TranscodingMode.forced; setState(() { - _statusMessage = useTranscode ? 'Preparing stream...' : 'Loading video...'; + _statusMessage = + useTranscode ? 'Preparing stream...' : 'Loading video...'; }); if (useTranscode) { - // Transcoding Mode (FFmpeg pipe) - final encodedUrl = Uri.encodeComponent( - widget.streamType == StreamType.vod - ? xtreamService.getVodStreamUrl(currentStreamId, widget.containerExtension) - : xtreamService.getSeriesStreamUrl(currentStreamId, widget.containerExtension) - ); - - String qualityParam = 'medium'; - if (settings.streamQuality == StreamQuality.low) qualityParam = 'low'; - else if (settings.streamQuality == StreamQuality.high) qualityParam = 'high'; - - // Calculate start time for seeking support - double startSeconds = startTimeOverride ?? 0; - if (startTimeOverride == null && _contentId.isNotEmpty) { - final positions = ref.read(playbackPositionsProvider); - final saved = positions.getPosition(_contentId); - if (saved > 0) startSeconds = saved; - } - - // We force extension to .ts for mpegts.js compatibility - hlsUrl = '$baseUrl/api/stream/$currentStreamId?url=$encodedUrl&quality=$qualityParam&start=${startSeconds.round()}&ext=.ts'; - debugPrint('PlayerScreen: Streaming VOD (Transcoded): $hlsUrl'); - + // Transcoding Mode (FFmpeg pipe) + final encodedUrl = Uri.encodeComponent( + widget.streamType == StreamType.vod + ? xtreamService.getVodStreamUrl( + currentStreamId, widget.containerExtension) + : xtreamService.getSeriesStreamUrl( + currentStreamId, widget.containerExtension), + ); + + String qualityParam = 'medium'; + if (settings.streamQuality == StreamQuality.low) { + qualityParam = 'low'; + } else if (settings.streamQuality == StreamQuality.high) + qualityParam = 'high'; + + // Calculate start time for seeking support + double startSeconds = startTimeOverride ?? 0; + if (startTimeOverride == null && _contentId.isNotEmpty) { + final positions = ref.read(playbackPositionsProvider); + final saved = positions.getPosition(_contentId); + if (saved > 0) startSeconds = saved; + } + + // We force extension to .ts for mpegts.js compatibility + hlsUrl = + '$baseUrl/api/stream/$currentStreamId?url=$encodedUrl&quality=$qualityParam&start=${startSeconds.round()}&ext=.ts'; + debugPrint('PlayerScreen: Streaming VOD (Transcoded): $hlsUrl'); } else { - // Direct/Proxy Mode (Preserves Duration) - final rawUrl = widget.streamType == StreamType.vod - ? '${widget.playlist.dns}/movie/${widget.playlist.username}/${widget.playlist.password}/$currentStreamId.${widget.containerExtension}' - : '${widget.playlist.dns}/series/${widget.playlist.username}/${widget.playlist.password}/$currentStreamId.${widget.containerExtension}'; - - // Force use of local proxy /api/xtream/ to handle CORS and Range headers correctly - final proxyPath = '/api/xtream/$rawUrl'; - hlsUrl = '$baseUrl$proxyPath'; - - // Append start time for direct play? - // HTML5 video handles seeking naturally via headers, but if we want to resume: - // We have to seek AFTER metadata is loaded via JS (_seekTo called in onMessage logic). - - debugPrint('PlayerScreen: Streaming VOD (Direct Proxy): $hlsUrl'); + // Direct/Proxy Mode (Preserves Duration) + final rawUrl = widget.streamType == StreamType.vod + ? '${widget.playlist.dns}/movie/${widget.playlist.username}/${widget.playlist.password}/$currentStreamId.${widget.containerExtension}' + : '${widget.playlist.dns}/series/${widget.playlist.username}/${widget.playlist.password}/$currentStreamId.${widget.containerExtension}'; + + // Force use of local proxy /api/xtream/ to handle CORS and Range headers correctly + final proxyPath = '/api/xtream/$rawUrl'; + hlsUrl = '$baseUrl$proxyPath'; + + // Append start time for direct play? + // HTML5 video handles seeking naturally via headers, but if we want to resume: + // We have to seek AFTER metadata is loaded via JS (_seekTo called in onMessage logic). + + debugPrint('PlayerScreen: Streaming VOD (Direct Proxy): $hlsUrl'); } } - + setState(() { _statusMessage = 'Loading player...'; }); - + // Get resume position for VOD/Series double startTime = startTimeOverride ?? 0; - if (startTime <= 0 && widget.streamType != StreamType.live && _contentId.isNotEmpty) { + if (startTime <= 0 && + widget.streamType != StreamType.live && + _contentId.isNotEmpty) { final positions = ref.read(playbackPositionsProvider); startTime = positions.getPosition(_contentId); if (startTime > 0) { - debugPrint('PlayerScreen: Resuming from ${startTime.toStringAsFixed(0)}s'); + debugPrint( + 'PlayerScreen: Resuming from ${startTime.toStringAsFixed(0)}s'); } } - + // If we are Transcoding VOD, the start time is handled by the server (burned into stream) - // So we should NOT pass startTime to player.html, otherwise it might try to seek + // So we should NOT pass startTime to player.html, otherwise it might try to seek // on a stream that already starts at X. // EXCEPTION: If player.html receives an MPEG-TS stream that has timestamps starting at X, // setting currentTime might be redundant or needed depending on player impl. // Safest: If Transcoding, don't pass startTime to player.html (server handles it). // If Direct Play, pass startTime. - + final settings = ref.read(iptvSettingsProvider); // We force transcoding/proxy usage for all VOD/Series now - final bool usingTranscoding = settings.transcodingMode == TranscodingMode.forced; - + final bool usingTranscoding = + settings.transcodingMode == TranscodingMode.forced; + final encodedHlsUrl = Uri.encodeComponent(hlsUrl); // Pass startTime to player.html if we are NOT using server-side transcoding (Direct/Proxy mode) // For Transcoding, the server handles start time via the 'start' query param in hlsUrl. final playerUrl = (!usingTranscoding && startTime > 0) ? 'player.html?url=$encodedHlsUrl&startTime=${startTime.toStringAsFixed(0)}' : 'player.html?url=$encodedHlsUrl'; - + debugPrint('PlayerScreen: Player URL: $playerUrl'); - + // Generate a new view ID for every load to force iframe recreation - _viewId = 'iptv-player-${widget.streamId}-${DateTime.now().millisecondsSinceEpoch}'; + _viewId = + 'iptv-player-${widget.streamId}-${DateTime.now().millisecondsSinceEpoch}'; // Register platform view factory // ignore: undefined_prefixed_name @@ -467,11 +483,11 @@ class _PlayerScreenState extends ConsumerState { void initState() { super.initState(); _currentIndex = widget.initialIndex; - + // Load preferred aspect ratio from settings final settings = ref.read(iptvSettingsProvider); _aspectRatio = settings.preferredAspectRatio; - + _initializePlayer(); _setupMessageListener(); } @@ -479,7 +495,7 @@ class _PlayerScreenState extends ConsumerState { void _showSettingsDialog() { // Read current settings final settingsNotifier = ref.read(iptvSettingsProvider.notifier); - // We use a local state variable for the switch in the dialog, + // We use a local state variable for the switch in the dialog, // initialized from the provider. bool showClock = ref.read(iptvSettingsProvider).showClock; @@ -540,14 +556,20 @@ class _PlayerScreenState extends ConsumerState { ), ), IconButton( - icon: const Icon(Icons.close, color: Colors.white54), + icon: const Icon(Icons.close, + color: Colors.white54), onPressed: () => Navigator.pop(context), tooltip: 'Fermer', ), ], ), const SizedBox(height: 24), - const Text('FORMAT D\'IMAGE', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), + const Text('FORMAT D\'IMAGE', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 1)), const SizedBox(height: 12), Container( padding: const EdgeInsets.all(4), @@ -557,50 +579,77 @@ class _PlayerScreenState extends ConsumerState { ), child: Row( children: [ - _buildRatioOption('Original', 'contain', Icons.crop_original, setDialogState), - _buildRatioOption('Remplir', 'cover', Icons.crop_free, setDialogState), - _buildRatioOption('Étirer', 'fill', Icons.aspect_ratio, setDialogState), + _buildRatioOption('Original', 'contain', + Icons.crop_original, setDialogState), + _buildRatioOption('Remplir', 'cover', + Icons.crop_free, setDialogState), + _buildRatioOption('Étirer', 'fill', + Icons.aspect_ratio, setDialogState), ], ), ), - if (_audioTracks.isNotEmpty) ...[ - const SizedBox(height: 32), - const Text('AUDIO', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), - const SizedBox(height: 12), - Container( - constraints: const BoxConstraints(maxHeight: 150), - decoration: BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white.withOpacity(0.05)), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: SingleChildScrollView( - child: Column( - children: _audioTracks.map((track) { - return Material( - color: Colors.transparent, - child: ListTile( - title: Text(track['label'] ?? 'Piste ${track['id']}', style: const TextStyle(color: Colors.white, fontSize: 14)), - subtitle: Text(track['lang'] ?? '', style: const TextStyle(color: Colors.white54, fontSize: 12)), - onTap: () { - _setAudioTrack(track['id']); - setDialogState(() {}); - }, - dense: true, - leading: const Icon(Icons.audiotrack, size: 18, color: Colors.white54), - hoverColor: Colors.white.withOpacity(0.1), - ), - ); - }).toList(), + if (_audioTracks.isNotEmpty) ...[ + const SizedBox(height: 32), + const Text('AUDIO', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 1)), + const SizedBox(height: 12), + Container( + constraints: + const BoxConstraints(maxHeight: 150), + decoration: BoxDecoration( + color: Colors.black26, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.white.withOpacity(0.05)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SingleChildScrollView( + child: Column( + children: _audioTracks.map((track) { + return Material( + color: Colors.transparent, + child: ListTile( + title: Text( + track['label'] ?? + 'Piste ${track['id']}', + style: const TextStyle( + color: Colors.white, + fontSize: 14)), + subtitle: Text(track['lang'] ?? '', + style: const TextStyle( + color: Colors.white54, + fontSize: 12)), + onTap: () { + _setAudioTrack(track['id']); + setDialogState(() {}); + }, + dense: true, + leading: const Icon( + Icons.audiotrack, + size: 18, + color: Colors.white54), + hoverColor: + Colors.white.withOpacity(0.1), + ), + ); + }).toList(), + ), ), ), ), - ), - ], - const SizedBox(height: 32), - const Text('AFFICHAGE', style: TextStyle(color: AppColors.textSecondary, fontSize: 12, fontWeight: FontWeight.bold, letterSpacing: 1)), + ], + const SizedBox(height: 32), + const Text('AFFICHAGE', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 1)), const SizedBox(height: 12), SwitchListTile( value: showClock, @@ -610,12 +659,15 @@ class _PlayerScreenState extends ConsumerState { }); settingsNotifier.setShowClock(val); }, - title: const Text('Afficher l\'heure', style: TextStyle(color: Colors.white, fontSize: 14)), - secondary: const Icon(Icons.access_time, color: Colors.white54), - activeColor: AppColors.primary, + title: const Text('Afficher l\'heure', + style: TextStyle( + color: Colors.white, fontSize: 14)), + secondary: const Icon(Icons.access_time, + color: Colors.white54), + activeThumbColor: AppColors.primary, contentPadding: EdgeInsets.zero, ), - ], + ], ), ), ), @@ -629,7 +681,8 @@ class _PlayerScreenState extends ConsumerState { ); } - Widget _buildRatioOption(String label, String value, IconData icon, StateSetter setDialogState) { + Widget _buildRatioOption( + String label, String value, IconData icon, StateSetter setDialogState) { final isSelected = _aspectRatio == value; return Expanded( child: GestureDetector( @@ -637,7 +690,9 @@ class _PlayerScreenState extends ConsumerState { debugPrint('Changing Aspect Ratio to: $value'); _setAspectRatio(value); // Save preference - ref.read(iptvSettingsProvider.notifier).setPreferredAspectRatio(value); + ref + .read(iptvSettingsProvider.notifier) + .setPreferredAspectRatio(value); setDialogState(() {}); // Force rebuild of the dialog content }, child: AnimatedContainer( @@ -647,11 +702,19 @@ class _PlayerScreenState extends ConsumerState { color: isSelected ? AppColors.surfaceVariant : Colors.transparent, borderRadius: BorderRadius.circular(10), border: isSelected ? Border.all(color: Colors.white12) : null, - boxShadow: isSelected ? [BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(0, 2))] : null, + boxShadow: isSelected + ? [ + const BoxShadow( + color: Colors.black12, + blurRadius: 4, + offset: Offset(0, 2)) + ] + : null, ), child: Column( children: [ - Icon(icon, color: isSelected ? Colors.white : Colors.white54, size: 20), + Icon(icon, + color: isSelected ? Colors.white : Colors.white54, size: 20), const SizedBox(height: 4), Text( label, @@ -672,331 +735,392 @@ class _PlayerScreenState extends ConsumerState { Widget build(BuildContext context) { final settings = ref.watch(iptvSettingsProvider); return Scaffold( - appBar: _showControls ? AppBar( - title: Text(widget.channels != null && widget.channels!.isNotEmpty - ? widget.channels![_currentIndex].name - : widget.title,), - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - elevation: 0, - actions: [ - PointerInterceptor( - child: IconButton( - icon: const Icon(Icons.settings), - onPressed: _showSettingsDialog, - tooltip: 'Réglages', - ), - ), - const SizedBox(width: 16), - ], - ) : null, + appBar: _showControls + ? AppBar( + title: Text( + widget.channels != null && widget.channels!.isNotEmpty + ? widget.channels![_currentIndex].name + : widget.title, + ), + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + PointerInterceptor( + child: IconButton( + icon: const Icon(Icons.settings), + onPressed: _showSettingsDialog, + tooltip: 'Réglages', + ), + ), + const SizedBox(width: 16), + ], + ) + : null, extendBodyBehindAppBar: true, backgroundColor: Colors.black, body: ClipRect( child: MouseRegion( - onHover: (_) => _onHover(), - child: _errorMessage != null - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 64, color: Colors.red), - const SizedBox(height: 16), - const Text('Failed to load stream', style: TextStyle(color: Colors.white, fontSize: 18)), - const SizedBox(height: 8), - Text(_errorMessage!, style: const TextStyle(color: Colors.white70)), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () { - setState(() { - _errorMessage = null; - _isLoading = true; - }); - _initializePlayer(); - }, - child: const Text('Retry'), - ), - ], - ), - ) - : _isLoading - ? const ThemedLoading() - : !_isInitialized - ? const ThemedLoading() - : Stack( - children: [ - // HTML5 video player via iframe - HtmlElementView(viewType: _viewId), + onHover: (_) => _onHover(), + child: _errorMessage != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, + size: 64, color: Colors.red), + const SizedBox(height: 16), + const Text('Failed to load stream', + style: TextStyle(color: Colors.white, fontSize: 18)), + const SizedBox(height: 8), + Text(_errorMessage!, + style: const TextStyle(color: Colors.white70)), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + setState(() { + _errorMessage = null; + _isLoading = true; + }); + _initializePlayer(); + }, + child: const Text('Retry'), + ), + ], + ), + ) + : _isLoading + ? const ThemedLoading() + : !_isInitialized + ? const ThemedLoading() + : Stack( + children: [ + // HTML5 video player via iframe + HtmlElementView(viewType: _viewId), - // GLOBAL INTERACTION LAYER (Fixes Click/Hover issues) - // This transparent layer sits ABOVE the iframe but BELOW the controls. - // It intercepts all pointer events, enabling Flutter to handle them. - Positioned.fill( - child: PointerInterceptor( - child: MouseRegion( - onHover: (_) => _onHover(), - child: GestureDetector( - // Toggle controls on tap - onTap: () { - if (_showControls) { - _hideControls(); - } else { - _onHover(); - } - }, - behavior: HitTestBehavior.translucent, - child: Container(color: Colors.transparent), + // GLOBAL INTERACTION LAYER (Fixes Click/Hover issues) + // This transparent layer sits ABOVE the iframe but BELOW the controls. + // It intercepts all pointer events, enabling Flutter to handle them. + Positioned.fill( + child: PointerInterceptor( + child: MouseRegion( + onHover: (_) => _onHover(), + child: GestureDetector( + // Toggle controls on tap + onTap: () { + if (_showControls) { + _hideControls(); + } else { + _onHover(); + } + }, + behavior: HitTestBehavior.translucent, + child: Container(color: Colors.transparent), + ), ), ), ), - ), - - // Hover Controls Layer - if (_showControls) ...[ - // EPG Overlay (Moved behind controls for Z-ordering) - if (widget.streamType == StreamType.live) + + // Hover Controls Layer + if (_showControls) ...[ + // EPG Overlay (Moved behind controls for Z-ordering) + if (widget.streamType == StreamType.live) + Positioned( + bottom: + 15, // Moves EPG down to avoid blocking controls + left: 0, + right: 0, + child: PointerInterceptor( + // Wrap with IgnorePointer so EPG text doesn't steal clicks from controls + child: IgnorePointer( + child: EpgOverlay( + streamId: widget.channels != null + ? widget.channels![_currentIndex] + .streamId + : widget.streamId, + playlist: widget.playlist, + ), + ), + ), + ), + + // Volume Slider (Left Side) Positioned( - bottom: 15, // Moves EPG down to avoid blocking controls - left: 0, - right: 0, - child: PointerInterceptor( - // Wrap with IgnorePointer so EPG text doesn't steal clicks from controls - child: IgnorePointer( - child: EpgOverlay( - streamId: widget.channels != null - ? widget.channels![_currentIndex].streamId - : widget.streamId, - playlist: widget.playlist, + left: 24, + top: 0, + bottom: 0, + child: Center( + child: Container( + height: 200, + width: 40, + decoration: BoxDecoration( + color: Colors.black45, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric( + vertical: 12), + child: Column( + children: [ + PointerInterceptor( + child: IconButton( + icon: Icon( + _volume == 0 + ? Icons.volume_off + : _volume < 0.5 + ? Icons.volume_down + : Icons.volume_up, + color: Colors.white, + size: 20, + ), + onPressed: _toggleMute, + tooltip: _volume == 0 + ? 'Unmute' + : 'Mute', + ), + ), + const SizedBox(height: 8), + Expanded( + child: RotatedBox( + quarterTurns: 3, + child: SliderTheme( + data: SliderThemeData( + trackHeight: 4, + thumbShape: + const RoundSliderThumbShape( + enabledThumbRadius: 6), + overlayShape: + const RoundSliderOverlayShape( + overlayRadius: 12), + activeTrackColor: Colors.white, + inactiveTrackColor: + Colors.white24, + thumbColor: AppColors.primary, + overlayColor: AppColors.primary + .withOpacity(0.2), + ), + child: Slider( + value: _volume, + onChanged: _setVolume, + ), + ), + ), + ), + ], ), ), ), ), - // Volume Slider (Left Side) - Positioned( - left: 24, - top: 0, - bottom: 0, - child: Center( - child: Container( - height: 200, - width: 40, - decoration: BoxDecoration( - color: Colors.black45, - borderRadius: BorderRadius.circular(20), - ), - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( - children: [ - PointerInterceptor( - child: IconButton( - icon: Icon( - _volume == 0 - ? Icons.volume_off - : _volume < 0.5 ? Icons.volume_down : Icons.volume_up, - color: Colors.white, - size: 20, - ), - onPressed: _toggleMute, - tooltip: _volume == 0 ? 'Unmute' : 'Mute', - ), - ), - const SizedBox(height: 8), - Expanded( - child: RotatedBox( - quarterTurns: 3, - child: SliderTheme( - data: SliderThemeData( - trackHeight: 4, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), - activeTrackColor: Colors.white, - inactiveTrackColor: Colors.white24, - thumbColor: AppColors.primary, - overlayColor: AppColors.primary.withOpacity(0.2), - ), - child: Slider( - value: _volume, - onChanged: _setVolume, - ), - ), - ), - ), - ], - ), - ), - ), - ), - - // Playback Controls (Centered Row: Previous - Play/Pause - Next - Fullscreen) - Positioned( - top: null, - bottom: 150, - left: 0, - right: 0, - child: Center( - child: PointerInterceptor( - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Previous Channel Button - if (widget.channels != null && widget.channels!.isNotEmpty) - InkWell( - onTap: _playPrevious, - borderRadius: BorderRadius.circular(50), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.skip_previous, - color: Colors.white, - size: 32, - ), - ), - ), - if (widget.channels != null && widget.channels!.isNotEmpty) - const SizedBox(width: 24), - - // Play/Pause Button - InkWell( - onTap: _togglePlayPause, - borderRadius: BorderRadius.circular(50), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - child: Icon( - _isPlaying ? Icons.pause : Icons.play_arrow, - color: Colors.white, - size: 40, - ), - ), - ), - - // Next Channel Button - if (widget.channels != null && widget.channels!.isNotEmpty) - const SizedBox(width: 24), - if (widget.channels != null && widget.channels!.isNotEmpty) - InkWell( - onTap: _playNext, - borderRadius: BorderRadius.circular(50), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.skip_next, - color: Colors.white, - size: 32, - ), - ), - ), - - // Fullscreen Button - const SizedBox(width: 32), - InkWell( - onTap: _toggleFullscreen, - borderRadius: BorderRadius.circular(50), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), - shape: BoxShape.circle, - ), - child: Icon( - _isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, - color: Colors.white, - size: 28, - ), - ), - ), - ], - ), - ), - ), - ), - - // Progress Bar (Bottom) - Only for VOD/Series - if (widget.streamType != StreamType.live) + // Playback Controls (Centered Row: Previous - Play/Pause - Next - Fullscreen) Positioned( - bottom: 80, - left: 24, - right: 24, - child: PointerInterceptor( - child: Column( - children: [ - SliderTheme( - data: SliderThemeData( - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 16), - activeTrackColor: AppColors.primary, - inactiveTrackColor: Colors.white24, - thumbColor: AppColors.primary, - overlayColor: AppColors.primary.withOpacity(0.2), - ), - child: Slider( - value: _currentPosition, - max: _totalDuration, - onChanged: (value) { - _seekTo(value); - }, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _formatDuration(Duration(seconds: _currentPosition.isFinite ? _currentPosition.toInt() : 0)), - style: const TextStyle(color: Colors.white, fontSize: 12), + top: null, + bottom: 150, + left: 0, + right: 0, + child: Center( + child: PointerInterceptor( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + // Previous Channel Button + if (widget.channels != null && + widget.channels!.isNotEmpty) + InkWell( + onTap: _playPrevious, + borderRadius: + BorderRadius.circular(50), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black + .withOpacity(0.5), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.skip_previous, + color: Colors.white, + size: 32, + ), ), - Text( - _formatDuration(Duration(seconds: _totalDuration.isFinite ? _totalDuration.toInt() : 0)), - style: const TextStyle(color: Colors.white, fontSize: 12), + ), + if (widget.channels != null && + widget.channels!.isNotEmpty) + const SizedBox(width: 24), + + // Play/Pause Button + InkWell( + onTap: _togglePlayPause, + borderRadius: + BorderRadius.circular(50), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: + Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 2), ), - ], + child: Icon( + _isPlaying + ? Icons.pause + : Icons.play_arrow, + color: Colors.white, + size: 40, + ), + ), ), - ), - ], + + // Next Channel Button + if (widget.channels != null && + widget.channels!.isNotEmpty) + const SizedBox(width: 24), + if (widget.channels != null && + widget.channels!.isNotEmpty) + InkWell( + onTap: _playNext, + borderRadius: + BorderRadius.circular(50), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black + .withOpacity(0.5), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.skip_next, + color: Colors.white, + size: 32, + ), + ), + ), + + // Fullscreen Button + const SizedBox(width: 32), + InkWell( + onTap: _toggleFullscreen, + borderRadius: + BorderRadius.circular(50), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + Colors.black.withOpacity(0.5), + shape: BoxShape.circle, + ), + child: Icon( + _isFullscreen + ? Icons.fullscreen_exit + : Icons.fullscreen, + color: Colors.white, + size: 28, + ), + ), + ), + ], + ), ), ), ), - + // Progress Bar (Bottom) - Only for VOD/Series + if (widget.streamType != StreamType.live) + Positioned( + bottom: 80, + left: 24, + right: 24, + child: PointerInterceptor( + child: Column( + children: [ + SliderTheme( + data: SliderThemeData( + thumbShape: + const RoundSliderThumbShape( + enabledThumbRadius: 8), + overlayShape: + const RoundSliderOverlayShape( + overlayRadius: 16), + activeTrackColor: AppColors.primary, + inactiveTrackColor: Colors.white24, + thumbColor: AppColors.primary, + overlayColor: AppColors.primary + .withOpacity(0.2), + ), + child: Slider( + value: _currentPosition, + max: _totalDuration, + onChanged: (value) { + _seekTo(value); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(Duration( + seconds: _currentPosition + .isFinite + ? _currentPosition + .toInt() + : 0)), + style: const TextStyle( + color: Colors.white, + fontSize: 12), + ), + Text( + _formatDuration(Duration( + seconds: _totalDuration + .isFinite + ? _totalDuration.toInt() + : 0)), + style: const TextStyle( + color: Colors.white, + fontSize: 12), + ), + ], + ), + ), + ], + ), + ), + ), + ], + // Clock Widget (Always visible if enabled, or only with controls? User asked "met l'heure dans les options") + // Positioned in top right + if (settings.showClock) + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + top: 24, // Fixed top + right: _showControls + ? 80 + : 24, // Shift left when settings visible + child: const IgnorePointer( + child: ClockWidget( + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black, blurRadius: 8) + ], + ), + ), + ), + ), ], - - // Clock Widget (Always visible if enabled, or only with controls? User asked "met l'heure dans les options") - // Positioned in top right - if (settings.showClock) - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - top: 24, // Fixed top - right: _showControls ? 80 : 24, // Shift left when settings visible - child: const IgnorePointer( - child: ClockWidget( - style: TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - shadows: [Shadow(color: Colors.black, blurRadius: 8)], - ), - ), - ), - ), - ], - ), + ), ), ), ); diff --git a/lib/features/iptv/screens/playlist_selection_screen.dart b/lib/features/iptv/screens/playlist_selection_screen.dart index 4024526..8d325eb 100644 --- a/lib/features/iptv/screens/playlist_selection_screen.dart +++ b/lib/features/iptv/screens/playlist_selection_screen.dart @@ -26,14 +26,16 @@ class PlaylistSelectionScreen extends ConsumerWidget { appBar: AppBar( title: Text( 'Select Playlist', - style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white), + style: GoogleFonts.roboto( + fontWeight: FontWeight.w600, color: Colors.white), ), backgroundColor: Colors.transparent, elevation: 0, actions: [ if (currentUser?.isAdmin ?? false) IconButton( - icon: const Icon(Icons.admin_panel_settings, color: Colors.white70), + icon: + const Icon(Icons.admin_panel_settings, color: Colors.white70), onPressed: () => context.go('/admin'), tooltip: 'Admin Panel', ), @@ -69,7 +71,9 @@ class PlaylistSelectionScreen extends ConsumerWidget { children: [ Icon(Icons.error_outline, size: 64, color: Colors.red.shade400), const SizedBox(height: 16), - Text('Error loading playlists', style: GoogleFonts.roboto(fontSize: 18, color: Colors.white70)), + Text('Error loading playlists', + style: GoogleFonts.roboto( + fontSize: 18, color: Colors.white70)), const SizedBox(height: 8), ElevatedButton( onPressed: () => ref.refresh(playlistsProvider), @@ -84,7 +88,7 @@ class PlaylistSelectionScreen extends ConsumerWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( + const Icon( Icons.playlist_remove, size: 64, color: Colors.white24, @@ -113,7 +117,8 @@ class PlaylistSelectionScreen extends ConsumerWidget { } return GridView.builder( - padding: const EdgeInsets.fromLTRB(24, 100, 24, 24), // Top padding for transparent AppBar + padding: const EdgeInsets.fromLTRB( + 24, 100, 24, 24), // Top padding for transparent AppBar gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, crossAxisSpacing: 24, @@ -165,23 +170,25 @@ class _PlaylistCardState extends State<_PlaylistCard> { child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( - color: _isHovered - ? Colors.white.withOpacity(0.15) + color: _isHovered + ? Colors.white.withOpacity(0.15) : Colors.white.withOpacity(0.05), // Glass effect borderRadius: BorderRadius.circular(16), border: Border.all( - color: _isHovered - ? Colors.white.withOpacity(0.5) + color: _isHovered + ? Colors.white.withOpacity(0.5) : Colors.white.withOpacity(0.1), width: 1.5, ), - boxShadow: _isHovered ? [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ] : [], + boxShadow: _isHovered + ? [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ] + : [], ), padding: const EdgeInsets.all(24), child: Column( @@ -204,7 +211,8 @@ class _PlaylistCardState extends State<_PlaylistCard> { const SizedBox(height: 16), Text( widget.playlist.name, - style: GoogleFonts.outfit( // Using Outfit for modern look + style: GoogleFonts.outfit( + // Using Outfit for modern look fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white, @@ -229,4 +237,3 @@ class _PlaylistCardState extends State<_PlaylistCard> { ); } } - diff --git a/lib/features/iptv/widgets/category_carousel.dart b/lib/features/iptv/widgets/category_carousel.dart index feec124..fd159c2 100644 --- a/lib/features/iptv/widgets/category_carousel.dart +++ b/lib/features/iptv/widgets/category_carousel.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; /// Horizontal carousel for displaying channels by category -/// +/// /// Netflix-style horizontal scrolling list with: /// - Category title with count /// - Smooth horizontal scrolling @@ -36,7 +36,7 @@ class CategoryCarousel extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - + if (items.isEmpty) { return const SizedBox.shrink(); } @@ -110,15 +110,15 @@ class CategoryCarousel extends StatelessWidget { ], ), ), - + const SizedBox(height: 12), - + // Horizontal scrolling list with fade edges SizedBox( height: itemHeight, child: ShaderMask( shaderCallback: (Rect bounds) { - return LinearGradient( + return const LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ @@ -127,7 +127,7 @@ class CategoryCarousel extends StatelessWidget { Colors.white, Colors.transparent, ], - stops: const [0.0, 0.02, 0.98, 1.0], + stops: [0.0, 0.02, 0.98, 1.0], ).createShader(bounds); }, blendMode: BlendMode.dstIn, @@ -173,7 +173,7 @@ class CategoryChipButton extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - + return GestureDetector( onTap: onTap, child: AnimatedContainer( @@ -181,14 +181,10 @@ class CategoryChipButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), decoration: BoxDecoration( gradient: isSelected ? AppColors.primaryGradient : null, - color: isSelected - ? null - : colorScheme.surfaceContainerHighest, + color: isSelected ? null : colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), border: Border.all( - color: isSelected - ? Colors.transparent - : colorScheme.outlineVariant, + color: isSelected ? Colors.transparent : colorScheme.outlineVariant, ), ), child: Row( @@ -209,9 +205,8 @@ class CategoryChipButton extends StatelessWidget { style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, - color: isSelected - ? colorScheme.onPrimary - : colorScheme.onSurface, + color: + isSelected ? colorScheme.onPrimary : colorScheme.onSurface, ), ), ], diff --git a/lib/features/iptv/widgets/epg_overlay.dart b/lib/features/iptv/widgets/epg_overlay.dart index a4a6ba1..1c555c1 100644 --- a/lib/features/iptv/widgets/epg_overlay.dart +++ b/lib/features/iptv/widgets/epg_overlay.dart @@ -1,10 +1,8 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/models/playlist_config.dart'; import '../providers/xtream_provider.dart'; - /// Minimal EPG overlay - shows program title and info class EpgOverlay extends ConsumerWidget { final String streamId; @@ -18,9 +16,11 @@ class EpgOverlay extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final epgAsync = ref.watch(epgByPlaylistProvider( - EpgRequestKey(playlist: playlist, streamId: streamId), - ),); + final epgAsync = ref.watch( + epgByPlaylistProvider( + EpgRequestKey(playlist: playlist, streamId: streamId), + ), + ); return epgAsync.when( data: (epgEntries) { @@ -41,67 +41,69 @@ class EpgOverlay extends ConsumerWidget { // Enhanced info display return Align( - alignment: Alignment.bottomLeft, // Align to bottom left near controls - child: Container( - margin: const EdgeInsets.fromLTRB(24, 0, 24, 24), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.5), // Semi-transparent backing for readability - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white.withOpacity(0.1)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'LIVE', - style: TextStyle( - color: Colors.white, - fontSize: 12, // Larger - fontWeight: FontWeight.bold, - ), + alignment: Alignment.bottomLeft, // Align to bottom left near controls + child: Container( + margin: const EdgeInsets.fromLTRB(24, 0, 24, 24), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.black + .withOpacity(0.5), // Semi-transparent backing for readability + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.1)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontSize: 12, // Larger + fontWeight: FontWeight.bold, ), ), - const SizedBox(width: 12), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500), // Wider - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + ), + const SizedBox(width: 12), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), // Wider + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + currentProgram.title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, // Much Larger + fontWeight: FontWeight.bold, + shadows: [Shadow(color: Colors.black, blurRadius: 4)], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (currentProgram.description.isNotEmpty) Text( - currentProgram.title, + currentProgram.description, style: const TextStyle( - color: Colors.white, - fontSize: 18, // Much Larger - fontWeight: FontWeight.bold, - shadows: [Shadow(color: Colors.black, blurRadius: 4)], + color: Colors.white70, + fontSize: 13, // Larger desc ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (currentProgram.description.isNotEmpty) - Text( - currentProgram.description, - style: const TextStyle( - color: Colors.white70, - fontSize: 13, // Larger desc - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), + ], ), - ], - ), + ), + ], ), + ), ); }, loading: () => const SizedBox.shrink(), diff --git a/lib/features/iptv/widgets/live_tv_tab.dart b/lib/features/iptv/widgets/live_tv_tab.dart index b962650..0166a97 100644 --- a/lib/features/iptv/widgets/live_tv_tab.dart +++ b/lib/features/iptv/widgets/live_tv_tab.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../providers/xtream_provider.dart'; import '../providers/settings_provider.dart'; import '../providers/favorites_provider.dart'; @@ -10,7 +9,6 @@ import '../../../core/models/playlist_config.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/utils/responsive_layout.dart'; -import '../../../core/widgets/components/ui_components.dart'; import '../../../core/widgets/themed_loading_screen.dart'; import 'channel_card.dart'; @@ -26,16 +24,15 @@ class LiveTVTab extends ConsumerStatefulWidget { class _LiveTVTabState extends ConsumerState with AutomaticKeepAliveClientMixin { - // State String? _selectedCategory; - + // UI State bool _isGridView = true; final TextEditingController _searchController = TextEditingController(); bool _showFavoritesOnly = false; String _searchQuery = ''; - + // Scroll Controllers final ScrollController _mainScrollController = ScrollController(); @@ -63,7 +60,8 @@ class _LiveTVTabState extends ConsumerState Widget build(BuildContext context) { super.build(context); - final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); + final channelsAsync = + ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final favorites = ref.watch(favoritesProvider); final settings = ref.watch(iptvSettingsProvider); @@ -73,34 +71,38 @@ class _LiveTVTabState extends ConsumerState loading: () => const ThemedLoading(), error: (e, s) => Center(child: Text('Error: $e')), data: (groupedChannels) { - // Groups - var categories = groupedChannels.keys.toList(); - if (settings.liveTvKeywords.isNotEmpty) { - categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); - } - categories.sort(); - - // Filter Logic - List displayedChannels = []; - bool showingCategoryGrid = false; - - if (_searchQuery.isNotEmpty) { - // Global Search - displayedChannels = groupedChannels.values.expand((l) => l) - .where((c) => c.name.toLowerCase().contains(_searchQuery)) - .toList(); - } else if (_showFavoritesOnly) { - // Favorites - displayedChannels = groupedChannels.values.expand((l) => l) - .where((c) => favorites.contains(c.streamId)) - .toList(); - } else if (_selectedCategory != null) { - // Selected Category View - displayedChannels = groupedChannels[_selectedCategory] ?? []; - } else { - // Category Grid View (Default) - showingCategoryGrid = true; - } + // Groups + var categories = groupedChannels.keys.toList(); + if (settings.liveTvKeywords.isNotEmpty) { + categories = categories + .where((cat) => settings.matchesLiveTvFilter(cat)) + .toList(); + } + categories.sort(); + + // Filter Logic + List displayedChannels = []; + bool showingCategoryGrid = false; + + if (_searchQuery.isNotEmpty) { + // Global Search + displayedChannels = groupedChannels.values + .expand((l) => l) + .where((c) => c.name.toLowerCase().contains(_searchQuery)) + .toList(); + } else if (_showFavoritesOnly) { + // Favorites + displayedChannels = groupedChannels.values + .expand((l) => l) + .where((c) => favorites.contains(c.streamId)) + .toList(); + } else if (_selectedCategory != null) { + // Selected Category View + displayedChannels = groupedChannels[_selectedCategory] ?? []; + } else { + // Category Grid View (Default) + showingCategoryGrid = true; + } return Column( children: [ @@ -110,9 +112,12 @@ class _LiveTVTabState extends ConsumerState child: Row( children: [ // Back Button (if in category view and not searching) - if (_selectedCategory != null && _searchQuery.isEmpty && !_showFavoritesOnly) ...[ + if (_selectedCategory != null && + _searchQuery.isEmpty && + !_showFavoritesOnly) ...[ IconButton( - icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), + icon: const Icon(Icons.arrow_back, + color: AppColors.textPrimary), onPressed: () { setState(() { _selectedCategory = null; @@ -125,34 +130,40 @@ class _LiveTVTabState extends ConsumerState ], Text( - _searchQuery.isNotEmpty ? 'Search Results' : - _showFavoritesOnly ? 'Favorites' : - _selectedCategory ?? 'Live TV', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), + _searchQuery.isNotEmpty + ? 'Search Results' + : _showFavoritesOnly + ? 'Favorites' + : _selectedCategory ?? 'Live TV', + style: + Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), const Spacer(), - + // Search Pill Container( width: 300, height: 40, decoration: BoxDecoration( color: AppColors.surface, - borderRadius: BorderRadius.circular(AppTheme.radiusFull), + borderRadius: + BorderRadius.circular(AppTheme.radiusFull), border: Border.all(color: AppColors.border), ), padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - const Icon(Icons.search, size: 20, color: AppColors.textSecondary), + const Icon(Icons.search, + size: 20, color: AppColors.textSecondary), const SizedBox(width: 8), Expanded( child: TextField( controller: _searchController, - style: const TextStyle(fontSize: 14, color: Colors.white), + style: const TextStyle( + fontSize: 14, color: Colors.white), decoration: const InputDecoration( hintText: 'Rechercher...', hintStyle: TextStyle(color: Colors.white54), @@ -163,31 +174,37 @@ class _LiveTVTabState extends ConsumerState ), ), if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () => _searchController.clear(), - child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), - ), + GestureDetector( + onTap: () => _searchController.clear(), + child: const Icon(Icons.close, + size: 16, color: AppColors.textSecondary), + ), ], ), ), const SizedBox(width: 16), - + // Favorites Toggle IconButton( icon: Icon( - _showFavoritesOnly ? Icons.favorite : Icons.favorite_border, - color: _showFavoritesOnly ? AppColors.error : AppColors.textSecondary, + _showFavoritesOnly + ? Icons.favorite + : Icons.favorite_border, + color: _showFavoritesOnly + ? AppColors.error + : AppColors.textSecondary, ), onPressed: () { - setState(() { - _showFavoritesOnly = !_showFavoritesOnly; - if (_showFavoritesOnly) _searchController.clear(); - _selectedCategory = null; // Reset selection when toggling favorites - }); + setState(() { + _showFavoritesOnly = !_showFavoritesOnly; + if (_showFavoritesOnly) _searchController.clear(); + _selectedCategory = + null; // Reset selection when toggling favorites + }); }, tooltip: 'Favorites Only', ), - + // View Toggle (Only show when not in category grid) if (!showingCategoryGrid) IconButton( @@ -195,7 +212,8 @@ class _LiveTVTabState extends ConsumerState _isGridView ? Icons.view_list : Icons.grid_view, color: AppColors.textSecondary, ), - onPressed: () => setState(() => _isGridView = !_isGridView), + onPressed: () => + setState(() => _isGridView = !_isGridView), tooltip: 'Toggle View', ), ], @@ -209,9 +227,15 @@ class _LiveTVTabState extends ConsumerState : displayedChannels.isEmpty ? Center( child: Text( - _searchQuery.isNotEmpty ? 'No channels found' : - _showFavoritesOnly ? 'No favorites' : 'No channels in this group', - style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: AppColors.textSecondary), + _searchQuery.isNotEmpty + ? 'No channels found' + : _showFavoritesOnly + ? 'No favorites' + : 'No channels in this group', + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith(color: AppColors.textSecondary), ), ) : _isGridView @@ -225,7 +249,8 @@ class _LiveTVTabState extends ConsumerState ); } - Widget _buildCategoryGrid(List categories, Map> groupedChannels) { + Widget _buildCategoryGrid( + List categories, Map> groupedChannels) { final columns = ResponsiveLayout.value( context, mobile: 2, @@ -246,11 +271,12 @@ class _LiveTVTabState extends ConsumerState itemBuilder: (context, index) { final category = categories[index]; final count = groupedChannels[category]?.length ?? 0; - + return Card( clipBehavior: Clip.antiAlias, elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: InkWell( onTap: () { setState(() { @@ -281,7 +307,8 @@ class _LiveTVTabState extends ConsumerState color: AppColors.primary.withOpacity(0.1), shape: BoxShape.circle, ), - child: const Icon(Icons.folder_open, color: AppColors.primary, size: 32), + child: const Icon(Icons.folder_open, + color: AppColors.primary, size: 32), ), const SizedBox(height: 12), Text( diff --git a/lib/features/iptv/widgets/movies_tab.dart b/lib/features/iptv/widgets/movies_tab.dart index 7261db5..a104611 100644 --- a/lib/features/iptv/widgets/movies_tab.dart +++ b/lib/features/iptv/widgets/movies_tab.dart @@ -61,7 +61,7 @@ class _MoviesTabState extends ConsumerState { void _onSearchChanged(String query) { _searchDebounce?.cancel(); - + setState(() { _searchQuery = query; if (query.isEmpty) { @@ -69,7 +69,7 @@ class _MoviesTabState extends ConsumerState { _isSearching = false; } }); - + if (query.length >= 2) { _searchDebounce = Timer(const Duration(milliseconds: 500), () { _performSearch(query); @@ -79,13 +79,13 @@ class _MoviesTabState extends ConsumerState { Future _performSearch(String query) async { if (query.isEmpty) return; - + setState(() => _isSearching = true); - + try { final service = ref.read(xtreamServiceProvider(widget.playlist)); final results = await service.searchMovies(query); - + if (mounted && _searchQuery == query) { setState(() { _searchResults = results; @@ -151,7 +151,7 @@ class _MoviesTabState extends ConsumerState { void _playMovie(Movie movie) { ref.read(watchHistoryProvider.notifier).markMovieWatched(movie.streamId); - + Navigator.push( context, MaterialPageRoute( @@ -170,32 +170,39 @@ class _MoviesTabState extends ConsumerState { Widget build(BuildContext context) { final settings = ref.watch(iptvSettingsProvider); final watchHistory = ref.watch(watchHistoryProvider); - + List displayMovies; if (_searchQuery.isNotEmpty && _searchResults != null) { displayMovies = _searchResults!; } else { displayMovies = settings.moviesKeywords.isEmpty ? _movies - : _movies.where((m) => settings.matchesMoviesFilter(m.categoryName)).toList(); + : _movies + .where((m) => settings.matchesMoviesFilter(m.categoryName)) + .toList(); } // Hero Items (Take 5 random or first 5 from filtered list) - final heroItems = displayMovies.take(5).map((m) => HeroItem( - id: m.streamId, - title: m.name, - imageUrl: _getProxiedImageUrl(m.streamIcon), - subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, - onMoreInfo: () { - _playMovie(m); - }, - )).toList(); + final heroItems = displayMovies + .take(5) + .map( + (m) => HeroItem( + id: m.streamId, + title: m.name, + imageUrl: _getProxiedImageUrl(m.streamIcon), + subtitle: m.rating != null ? '${_formatRating(m.rating)} ★' : null, + onMoreInfo: () { + _playMovie(m); + }, + ), + ) + .toList(); if (_movies.isEmpty && !_isLoading) { return const Center(child: Text('No movies available')); } - final double gridItemRatio = 0.65; + const double gridItemRatio = 0.65; final int crossAxisCount = ResponsiveLayout.value( context, mobile: 3, @@ -215,9 +222,9 @@ class _MoviesTabState extends ConsumerState { Text( 'Movies', style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), const Spacer(), Container( @@ -231,12 +238,14 @@ class _MoviesTabState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ - const Icon(Icons.search, size: 20, color: AppColors.textSecondary), + const Icon(Icons.search, + size: 20, color: AppColors.textSecondary), const SizedBox(width: 8), Expanded( child: TextField( controller: _searchController, - style: const TextStyle(fontSize: 14, color: Colors.white), + style: const TextStyle( + fontSize: 14, color: Colors.white), decoration: const InputDecoration( hintText: 'Rechercher...', hintStyle: TextStyle(color: Colors.white54), @@ -250,14 +259,18 @@ class _MoviesTabState extends ConsumerState { ), ), if (_isSearching) - const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2)), if (_searchQuery.isNotEmpty) GestureDetector( onTap: () { - _searchController.clear(); - _onSearchChanged(''); + _searchController.clear(); + _onSearchChanged(''); }, - child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), + child: const Icon(Icons.close, + size: 16, color: AppColors.textSecondary), ), ], ), @@ -275,13 +288,14 @@ class _MoviesTabState extends ConsumerState { child: HeroCarousel( items: heroItems, onTap: (item) { - final movie = _movies.firstWhere((element) => element.streamId == item.id); - _playMovie(movie); + final movie = _movies + .firstWhere((element) => element.streamId == item.id); + _playMovie(movie); }, ), ), ), - + // Grid SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), @@ -297,17 +311,20 @@ class _MoviesTabState extends ConsumerState { if (index >= displayMovies.length) return null; final movie = displayMovies[index]; final isWatched = watchHistory.isMovieWatched(movie.streamId); - + return MediaCard( title: movie.name, imageUrl: _getProxiedImageUrl(movie.streamIcon), - - subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null, + subtitle: movie.rating != null + ? '${_formatRating(movie.rating)} ★' + : null, rating: _formatRating(movie.rating), isWatched: isWatched, onTap: () => _playMovie(movie), onLongPress: () { - ref.read(watchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); + ref + .read(watchHistoryProvider.notifier) + .toggleMovieWatched(movie.streamId); }, ); }, @@ -315,7 +332,7 @@ class _MoviesTabState extends ConsumerState { ), ), ), - + // Loader if (_isLoading) const SliverToBoxAdapter( diff --git a/lib/features/iptv/widgets/series_tab.dart b/lib/features/iptv/widgets/series_tab.dart index 81dc6a5..05fdff0 100644 --- a/lib/features/iptv/widgets/series_tab.dart +++ b/lib/features/iptv/widgets/series_tab.dart @@ -61,7 +61,7 @@ class _SeriesTabState extends ConsumerState { void _onSearchChanged(String query) { _searchDebounce?.cancel(); - + setState(() { _searchQuery = query; if (query.isEmpty) { @@ -69,7 +69,7 @@ class _SeriesTabState extends ConsumerState { _isSearching = false; } }); - + if (query.length >= 2) { _searchDebounce = Timer(const Duration(milliseconds: 500), () { _performSearch(query); @@ -79,13 +79,13 @@ class _SeriesTabState extends ConsumerState { Future _performSearch(String query) async { if (query.isEmpty) return; - + setState(() => _isSearching = true); - + try { final service = ref.read(xtreamServiceProvider(widget.playlist)); final results = await service.searchSeries(query); - + if (mounted && _searchQuery == query) { setState(() { _searchResults = results; @@ -164,14 +164,16 @@ class _SeriesTabState extends ConsumerState { @override Widget build(BuildContext context) { final settings = ref.watch(iptvSettingsProvider); - + List displaySeries; if (_searchQuery.isNotEmpty && _searchResults != null) { displaySeries = _searchResults!; } else { displaySeries = settings.seriesKeywords.isEmpty ? _series - : _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList(); + : _series + .where((s) => settings.matchesSeriesFilter(s.categoryName)) + .toList(); } if (_series.isEmpty && !_isLoading) { @@ -179,15 +181,20 @@ class _SeriesTabState extends ConsumerState { } // Hero Items - final heroItems = displaySeries.take(5).map((s) => HeroItem( - id: s.seriesId.toString(), // ID logic might differ for Series - title: s.name, - imageUrl: _getProxiedImageUrl(s.cover), - subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, - onMoreInfo: () => _openSeries(s), - )).toList(); + final heroItems = displaySeries + .take(5) + .map( + (s) => HeroItem( + id: s.seriesId.toString(), // ID logic might differ for Series + title: s.name, + imageUrl: _getProxiedImageUrl(s.cover), + subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, + onMoreInfo: () => _openSeries(s), + ), + ) + .toList(); - final double gridItemRatio = 0.65; + const double gridItemRatio = 0.65; final int crossAxisCount = ResponsiveLayout.value( context, mobile: 3, @@ -207,9 +214,9 @@ class _SeriesTabState extends ConsumerState { Text( 'TV Series', style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), const Spacer(), Container( @@ -223,12 +230,14 @@ class _SeriesTabState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ - const Icon(Icons.search, size: 20, color: AppColors.textSecondary), + const Icon(Icons.search, + size: 20, color: AppColors.textSecondary), const SizedBox(width: 8), Expanded( child: TextField( controller: _searchController, - style: const TextStyle(fontSize: 14, color: Colors.white), + style: const TextStyle( + fontSize: 14, color: Colors.white), decoration: const InputDecoration( hintText: 'Rechercher...', hintStyle: TextStyle(color: Colors.white54), @@ -242,14 +251,18 @@ class _SeriesTabState extends ConsumerState { ), ), if (_isSearching) - const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), + const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2)), if (_searchQuery.isNotEmpty) GestureDetector( onTap: () { - _searchController.clear(); - _onSearchChanged(''); + _searchController.clear(); + _onSearchChanged(''); }, - child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), + child: const Icon(Icons.close, + size: 16, color: AppColors.textSecondary), ), ], ), @@ -267,13 +280,14 @@ class _SeriesTabState extends ConsumerState { child: HeroCarousel( items: heroItems, onTap: (item) { - final series = _series.firstWhere((s) => s.name == item.title); // Fallback by title if ID mismatch - _openSeries(series); + final series = _series.firstWhere((s) => + s.name == item.title); // Fallback by title if ID mismatch + _openSeries(series); }, ), ), ), - + // Grid SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), @@ -291,8 +305,9 @@ class _SeriesTabState extends ConsumerState { return MediaCard( title: serie.name, imageUrl: _getProxiedImageUrl(serie.cover), - - subtitle: serie.rating != null ? '${_formatRating(serie.rating)} ★' : null, + subtitle: serie.rating != null + ? '${_formatRating(serie.rating)} ★' + : null, rating: _formatRating(serie.rating), placeholderIcon: Icons.tv, onTap: () => _openSeries(serie), @@ -302,7 +317,7 @@ class _SeriesTabState extends ConsumerState { ), ), ), - + // Loader if (_isLoading) const SliverToBoxAdapter( diff --git a/lib/features/iptv/widgets/settings_tab.dart b/lib/features/iptv/widgets/settings_tab.dart index d24beb2..977e60b 100644 --- a/lib/features/iptv/widgets/settings_tab.dart +++ b/lib/features/iptv/widgets/settings_tab.dart @@ -18,7 +18,8 @@ class SettingsTab extends ConsumerStatefulWidget { ConsumerState createState() => _SettingsTabState(); } -class _SettingsTabState extends ConsumerState with SingleTickerProviderStateMixin { +class _SettingsTabState extends ConsumerState + with SingleTickerProviderStateMixin { late TabController _tabController; late TextEditingController _liveTvController; late TextEditingController _moviesController; @@ -45,13 +46,16 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv } void _syncControllers(IptvSettings settings) { - if (!_initialized || _liveTvController.text != settings.liveTvCategoryFilter) { + if (!_initialized || + _liveTvController.text != settings.liveTvCategoryFilter) { _liveTvController.text = settings.liveTvCategoryFilter; } - if (!_initialized || _moviesController.text != settings.moviesCategoryFilter) { + if (!_initialized || + _moviesController.text != settings.moviesCategoryFilter) { _moviesController.text = settings.moviesCategoryFilter; } - if (!_initialized || _seriesController.text != settings.seriesCategoryFilter) { + if (!_initialized || + _seriesController.text != settings.seriesCategoryFilter) { _seriesController.text = settings.seriesCategoryFilter; } _initialized = true; @@ -61,7 +65,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv Widget build(BuildContext context) { final currentUser = ref.watch(authProvider).currentUser; final settings = ref.watch(iptvSettingsProvider); - + // Sync controllers with persisted state (only on first load) WidgetsBinding.instance.addPostFrameCallback((_) { if (!_initialized) _syncControllers(settings); @@ -85,17 +89,19 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv labelColor: Colors.white, unselectedLabelColor: Colors.white54, dividerColor: Colors.transparent, - overlayColor: MaterialStateProperty.all(Colors.transparent), + overlayColor: WidgetStateProperty.all(Colors.transparent), tabs: [ const Tab(icon: Icon(Icons.filter_list), text: 'Filtres'), const Tab(icon: Icon(Icons.stream), text: 'Streaming'), const Tab(icon: Icon(Icons.palette), text: 'Apparence'), if (currentUser?.isAdmin ?? false) - const Tab(icon: Icon(Icons.admin_panel_settings), text: 'Administration'), + const Tab( + icon: Icon(Icons.admin_panel_settings), + text: 'Administration'), ], ), ), - + // TabBarView Expanded( child: TabBarView( @@ -103,16 +109,15 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv children: [ // Filters Tab _buildFiltersTab(context, currentUser, settings), - + // Streaming Tab const StreamingSettingsTab(), - + // Appearance Tab _buildAppearanceTab(context), // Admin Tab - if (currentUser?.isAdmin ?? false) - const AdminContent(), + if (currentUser?.isAdmin ?? false) const AdminContent(), ], ), ), @@ -125,7 +130,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv final themeState = ref.watch(themeProvider); final themeNotifier = ref.read(themeProvider.notifier); final isDark = Theme.of(context).brightness == Brightness.dark; - + return ListView( padding: const EdgeInsets.all(16), children: [ @@ -142,7 +147,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv children: [ Row( children: [ - Icon( + const Icon( Icons.dark_mode, color: Colors.white, ), @@ -158,7 +163,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ], ), const SizedBox(height: 16), - + // Theme options _buildThemeOption( context: context, @@ -189,9 +194,9 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ], ), ), - + const SizedBox(height: 16), - + // Quick toggle card Container( decoration: BoxDecoration( @@ -206,20 +211,21 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), title: Text( 'Mode actuel', - style: GoogleFonts.inter(fontWeight: FontWeight.w500, color: Colors.white), + style: GoogleFonts.inter( + fontWeight: FontWeight.w500, color: Colors.white), ), subtitle: Text( isDark ? 'Thème sombre actif' : 'Thème clair actif', - style: TextStyle(color: Colors.white70), + style: const TextStyle(color: Colors.white70), ), trailing: Switch( value: isDark, onChanged: (_) => themeNotifier.toggleTheme(), - activeColor: AppColors.primary, - trackColor: MaterialStateProperty.resolveWith( - (states) => states.contains(MaterialState.selected) - ? AppColors.primary.withOpacity(0.5) - : Colors.grey.shade800, + activeThumbColor: AppColors.primary, + trackColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? AppColors.primary.withOpacity(0.5) + : Colors.grey.shade800, ), ), ), @@ -238,7 +244,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv }) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - + return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), @@ -298,8 +304,8 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ); } - - Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) { + Widget _buildFiltersTab( + BuildContext context, dynamic currentUser, IptvSettings settings) { return ListView( padding: const EdgeInsets.all(16), children: [ @@ -319,18 +325,21 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), subtitle: Text( currentUser?.username ?? 'Unknown', - style: GoogleFonts.roboto(fontWeight: FontWeight.w600, color: Colors.white), + style: GoogleFonts.roboto( + fontWeight: FontWeight.w600, color: Colors.white), ), trailing: currentUser?.isAdmin ?? false ? Chip( - label: Text('Admin', style: GoogleFonts.roboto(fontSize: 11, color: Colors.black)), + label: Text('Admin', + style: GoogleFonts.roboto( + fontSize: 11, color: Colors.black)), backgroundColor: Colors.white, ) : null, ), ), const SizedBox(height: 16), - + // Category filters section Text( 'Filtres de Catégories', @@ -349,7 +358,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), ), const SizedBox(height: 12), - + // Live TV Filter _buildFilterCard( title: 'Filtre TV Live', @@ -365,7 +374,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv }, ), const SizedBox(height: 8), - + // Movies Filter _buildFilterCard( title: 'Filtre Films', @@ -381,7 +390,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv }, ), const SizedBox(height: 8), - + // Series Filter _buildFilterCard( title: 'Filtre Séries', @@ -397,9 +406,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv }, ), const SizedBox(height: 16), - - // Change playlist Container( decoration: BoxDecoration( @@ -409,13 +416,14 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), child: ListTile( leading: const Icon(Icons.playlist_play, color: Colors.white), - title: Text('Changer de Playlist', style: GoogleFonts.roboto(color: Colors.white)), + title: Text('Changer de Playlist', + style: GoogleFonts.roboto(color: Colors.white)), trailing: const Icon(Icons.chevron_right, color: Colors.white54), onTap: () => context.go('/playlists'), ), ), const SizedBox(height: 8), - + // About Container( decoration: BoxDecoration( @@ -425,7 +433,8 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), child: ListTile( leading: const Icon(Icons.info_outline, color: Colors.white), - title: Text('À propos', style: GoogleFonts.roboto(color: Colors.white)), + title: Text('À propos', + style: GoogleFonts.roboto(color: Colors.white)), subtitle: Text( 'XtremFlow IPTV v1.0.0', style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), @@ -433,7 +442,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), ), const SizedBox(height: 16), - + // Maintenance Section Container( decoration: BoxDecoration( @@ -443,50 +452,53 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), child: ListTile( leading: const Icon(Icons.cleaning_services, color: Colors.orange), - title: Text('Maintenance', style: GoogleFonts.roboto(color: Colors.white)), + title: Text('Maintenance', + style: GoogleFonts.roboto(color: Colors.white)), subtitle: Text( 'En cas de problèmes d\'affichage ou de mise à jour', style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), ), trailing: FilledButton.icon( onPressed: () { - // Show confirmation dialog - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: const Color(0xFF1E1E1E), - title: const Text('Vider le cache ?', style: TextStyle(color: Colors.white)), - content: const Text( - 'Cette action va recharger l\'application et effacer les données temporaires.\n' - 'Vos réglages et favoris sont sauvegardés sur le serveur.', - style: TextStyle(color: Colors.white70), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: Colors.orange), - onPressed: () { - Navigator.pop(context); - - // Clear Flutter Image Cache - PaintingBinding.instance.imageCache.clear(); - PaintingBinding.instance.imageCache.clearLiveImages(); - - // Clear Browser Local Storage (SharedPreferences backend for Web) - html.window.localStorage.clear(); - html.window.sessionStorage.clear(); - - // Reload application - html.window.location.reload(); - }, - child: const Text('Vider et Recharger'), - ), - ], - ) - ); + // Show confirmation dialog + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1E1E1E), + title: const Text('Vider le cache ?', + style: TextStyle(color: Colors.white)), + content: const Text( + 'Cette action va recharger l\'application et effacer les données temporaires.\n' + 'Vos réglages et favoris sont sauvegardés sur le serveur.', + style: TextStyle(color: Colors.white70), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: Colors.orange), + onPressed: () { + Navigator.pop(context); + + // Clear Flutter Image Cache + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + + // Clear Browser Local Storage (SharedPreferences backend for Web) + html.window.localStorage.clear(); + html.window.sessionStorage.clear(); + + // Reload application + html.window.location.reload(); + }, + child: const Text('Vider et Recharger'), + ), + ], + ), + ); }, icon: const Icon(Icons.refresh, size: 16), label: const Text('Vider le Cache'), @@ -498,7 +510,7 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), ), const SizedBox(height: 24), - + // Logout button FilledButton.icon( onPressed: () { @@ -553,7 +565,8 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv controller: controller, decoration: InputDecoration( hintText: 'Ex: FR,FRANCE,HD,SPORT', - hintStyle: GoogleFonts.roboto(fontSize: 12, color: Colors.white30), + hintStyle: + GoogleFonts.roboto(fontSize: 12, color: Colors.white30), filled: true, fillColor: Colors.black26, border: OutlineInputBorder( @@ -567,7 +580,8 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv ), suffixIcon: controller.text.isNotEmpty ? IconButton( - icon: const Icon(Icons.clear, size: 16, color: Colors.white54), + icon: const Icon(Icons.clear, + size: 16, color: Colors.white54), onPressed: onClear, padding: EdgeInsets.zero, constraints: const BoxConstraints(), @@ -584,15 +598,18 @@ class _SettingsTabState extends ConsumerState with SingleTickerProv runSpacing: 6, children: keywords.map((keyword) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: Colors.blueAccent.withOpacity(0.2), borderRadius: BorderRadius.circular(50), - border: Border.all(color: Colors.blueAccent.withOpacity(0.5)), + border: + Border.all(color: Colors.blueAccent.withOpacity(0.5)), ), child: Text( keyword, - style: GoogleFonts.roboto(fontSize: 11, color: Colors.white), + style: + GoogleFonts.roboto(fontSize: 11, color: Colors.white), ), ); }).toList(), diff --git a/lib/features/iptv/widgets/streaming_settings_tab.dart b/lib/features/iptv/widgets/streaming_settings_tab.dart index ccf37c5..9dd2472 100644 --- a/lib/features/iptv/widgets/streaming_settings_tab.dart +++ b/lib/features/iptv/widgets/streaming_settings_tab.dart @@ -107,7 +107,7 @@ class StreamingSettingsTab extends ConsumerWidget { style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), ), value: settings.autoReconnect, - activeColor: Colors.blueAccent, + activeThumbColor: Colors.blueAccent, onChanged: (v) => notifier.setAutoReconnect(v), ), ], @@ -143,7 +143,7 @@ class StreamingSettingsTab extends ConsumerWidget { style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54), ), value: settings.preferDirectPlay, - activeColor: Colors.blueAccent, + activeThumbColor: Colors.blueAccent, onChanged: (v) => notifier.setPreferDirectPlay(v), ), ], @@ -248,8 +248,10 @@ class StreamingSettingsTab extends ConsumerWidget { required String Function(T) labelBuilder, }) { return ListTile( - title: Text(title, style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)), - subtitle: Text(subtitle, style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)), + title: Text(title, + style: GoogleFonts.roboto(fontSize: 14, color: Colors.white)), + subtitle: Text(subtitle, + style: GoogleFonts.roboto(fontSize: 12, color: Colors.white54)), trailing: DropdownButton( value: value, underline: const SizedBox(), diff --git a/lib/main_mobile.dart b/lib/main_mobile.dart index 19cb3c3..ba84a73 100644 --- a/lib/main_mobile.dart +++ b/lib/main_mobile.dart @@ -1,5 +1,4 @@ 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'; @@ -13,25 +12,24 @@ import 'core/models/playlist_config.dart'; /// No authentication - direct access to playlist selection 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, + // 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(); + // 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'); } @@ -40,7 +38,7 @@ void main() async { if (!Hive.isAdapterRegistered(1)) { Hive.registerAdapter(PlaylistConfigAdapter()); } - + runApp(const ProviderScope(child: MobileApp())); } @@ -74,4 +72,3 @@ class MobileApp extends ConsumerWidget { ); } } - diff --git a/lib/mobile/features/auth/screens/mobile_login_screen.dart b/lib/mobile/features/auth/screens/mobile_login_screen.dart index f7f9e7e..17c434c 100644 --- a/lib/mobile/features/auth/screens/mobile_login_screen.dart +++ b/lib/mobile/features/auth/screens/mobile_login_screen.dart @@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart'; import '../../../../features/auth/providers/auth_provider.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../theme/mobile_theme.dart'; -import '../../../../core/widgets/components/ui_components.dart'; class MobileLoginScreen extends ConsumerStatefulWidget { const MobileLoginScreen({super.key}); @@ -103,8 +102,10 @@ class _MobileLoginScreenState extends ConsumerState { labelText: 'Username', prefixIcon: Icon(Icons.person_outline), ), - validator: (value) => - (value == null || value.trim().isEmpty) ? 'Required' : null, + validator: (value) => + (value == null || value.trim().isEmpty) + ? 'Required' + : null, textInputAction: TextInputAction.next, ), const SizedBox(height: 16), @@ -121,18 +122,21 @@ class _MobileLoginScreenState extends ConsumerState { ? Icons.visibility_outlined : Icons.visibility_off_outlined, ), - onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword), ), ), - validator: (value) => - (value == null || value.isEmpty) ? 'Required' : null, + validator: (value) => (value == null || value.isEmpty) + ? 'Required' + : null, onFieldSubmitted: (_) => _handleLogin(), ), if (authState.errorMessage != null) ...[ const SizedBox(height: 16), Text( authState.errorMessage!, - style: const TextStyle(color: AppColors.error, fontSize: 12), + style: const TextStyle( + color: AppColors.error, fontSize: 12), textAlign: TextAlign.center, ), ], @@ -145,7 +149,8 @@ class _MobileLoginScreenState extends ConsumerState { ? const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black), + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.black), ) : const Text('Sign In'), ), diff --git a/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart b/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart index ca697ef..97a76ae 100644 --- a/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_playlist_screen.dart @@ -8,7 +8,8 @@ import '../../../services/local_playlist_service.dart'; import 'mobile_dashboard_screen.dart'; /// Provider for local playlists -final localPlaylistsProvider = FutureProvider>((ref) async { +final localPlaylistsProvider = + FutureProvider>((ref) async { final service = LocalPlaylistService(); return service.getPlaylists(); }); @@ -18,7 +19,8 @@ class MobilePlaylistScreen extends ConsumerStatefulWidget { const MobilePlaylistScreen({super.key}); @override - ConsumerState createState() => _MobilePlaylistScreenState(); + ConsumerState createState() => + _MobilePlaylistScreenState(); } class _MobilePlaylistScreenState extends ConsumerState { @@ -28,7 +30,7 @@ class _MobilePlaylistScreenState extends ConsumerState { void initState() { super.initState(); _playlistService.init(); - + // Auto-login: if playlists exist, navigate to first one automatically WidgetsBinding.instance.addPostFrameCallback((_) { _checkAutoLogin(); @@ -64,8 +66,10 @@ class _MobilePlaylistScreenState extends ConsumerState { final isEditing = playlist != null; final nameController = TextEditingController(text: playlist?.name ?? ''); final dnsController = TextEditingController(text: playlist?.dns ?? ''); - final usernameController = TextEditingController(text: playlist?.username ?? ''); - final passwordController = TextEditingController(text: playlist?.password ?? ''); + final usernameController = + TextEditingController(text: playlist?.username ?? ''); + final passwordController = + TextEditingController(text: playlist?.password ?? ''); // Focus nodes for TV navigation final nameFocus = FocusNode(); @@ -90,7 +94,8 @@ class _MobilePlaylistScreenState extends ConsumerState { policy: OrderedTraversalPolicy(), // Ensure logical order child: Padding( padding: const EdgeInsets.all(24), - child: SingleChildScrollView( // Ensure scrolling when keyboard/focus moves + child: SingleChildScrollView( + // Ensure scrolling when keyboard/focus moves child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -107,7 +112,7 @@ class _MobilePlaylistScreenState extends ConsumerState { ), ), const SizedBox(height: 24), - + // Title Text( isEditing ? 'Modifier la playlist' : 'Ajouter une playlist', @@ -118,7 +123,7 @@ class _MobilePlaylistScreenState extends ConsumerState { ), ), const SizedBox(height: 24), - + // Form fields _buildTextField( controller: nameController, @@ -126,10 +131,11 @@ class _MobilePlaylistScreenState extends ConsumerState { hint: 'Ma Playlist IPTV', icon: Icons.label_outline, textAction: TextInputAction.next, - onSubmitted: () => FocusScope.of(context).requestFocus(dnsFocus), + onSubmitted: () => + FocusScope.of(context).requestFocus(dnsFocus), focusNode: nameFocus, ), - + const SizedBox(height: 16), _buildTextField( controller: dnsController, @@ -138,10 +144,11 @@ class _MobilePlaylistScreenState extends ConsumerState { icon: Icons.dns_outlined, keyboardType: TextInputType.url, textAction: TextInputAction.next, - onSubmitted: () => FocusScope.of(context).requestFocus(usernameFocus), + onSubmitted: () => + FocusScope.of(context).requestFocus(usernameFocus), focusNode: dnsFocus, ), - + const SizedBox(height: 16), _buildTextField( controller: usernameController, @@ -149,10 +156,11 @@ class _MobilePlaylistScreenState extends ConsumerState { hint: 'username', icon: Icons.person_outline, textAction: TextInputAction.next, - onSubmitted: () => FocusScope.of(context).requestFocus(passwordFocus), + onSubmitted: () => + FocusScope.of(context).requestFocus(passwordFocus), focusNode: usernameFocus, ), - + const SizedBox(height: 16), _buildTextField( controller: passwordController, @@ -161,12 +169,13 @@ class _MobilePlaylistScreenState extends ConsumerState { icon: Icons.lock_outline, obscureText: true, textAction: TextInputAction.done, - onSubmitted: () => FocusScope.of(context).requestFocus(buttonFocus), + onSubmitted: () => + FocusScope.of(context).requestFocus(buttonFocus), focusNode: passwordFocus, ), - + const SizedBox(height: 32), - + // Save button SizedBox( width: double.infinity, @@ -186,55 +195,58 @@ class _MobilePlaylistScreenState extends ConsumerState { return; } - // Auto-fix URL scheme - String dns = dnsController.text.trim(); - if (!dns.startsWith('http://') && !dns.startsWith('https://')) { - dns = 'http://$dns'; - } + // Auto-fix URL scheme + String dns = dnsController.text.trim(); + if (!dns.startsWith('http://') && + !dns.startsWith('https://')) { + dns = 'http://$dns'; + } - if (isEditing) { - await _playlistService.updatePlaylist( - id: playlist!.id, - name: nameController.text.trim(), - dns: dns, - username: usernameController.text.trim(), - password: passwordController.text.trim(), - ); - } else { - await _playlistService.addPlaylist( - name: nameController.text.trim(), - dns: dns, - username: usernameController.text.trim(), - password: passwordController.text.trim(), - ); - } + if (isEditing) { + await _playlistService.updatePlaylist( + id: playlist.id, + name: nameController.text.trim(), + dns: dns, + username: usernameController.text.trim(), + password: passwordController.text.trim(), + ); + } else { + await _playlistService.addPlaylist( + name: nameController.text.trim(), + dns: dns, + username: usernameController.text.trim(), + password: passwordController.text.trim(), + ); + } - if (mounted) { - Navigator.pop(context); - _refreshPlaylists(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(isEditing ? 'Playlist modifiée' : 'Playlist ajoutée'), - backgroundColor: Colors.green, - ), - ); - } - }, - icon: Icon(isEditing ? Icons.save : Icons.add), - label: Text(isEditing ? 'Enregistrer' : 'Ajouter'), - style: FilledButton.styleFrom( - backgroundColor: AppColors.primary, - padding: const EdgeInsets.symmetric(vertical: 16), + if (mounted) { + Navigator.pop(context); + _refreshPlaylists(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(isEditing + ? 'Playlist modifiée' + : 'Playlist ajoutée'), + backgroundColor: Colors.green, + ), + ); + } + }, + icon: Icon(isEditing ? Icons.save : Icons.add), + label: Text(isEditing ? 'Enregistrer' : 'Ajouter'), + style: FilledButton.styleFrom( + backgroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), ), - ), + const SizedBox(height: 16), + ], ), - const SizedBox(height: 16), - ], + ), ), ), ), - ), - ), ); } @@ -251,11 +263,11 @@ class _MobilePlaylistScreenState extends ConsumerState { }) { // Create a dedicated internal focus node for the actual TextField // We don't expose this, allowing us to control when the keyboard opens - + // Since we are inside a stateless method but need state for focus nodes if we want to create them on the fly... // Actually, to avoid State complexity, we will trust the passed 'focusNode' is for NAVIGATION. // And we will use a LayoutBuilder + Stateful wrapper or just a customized Focus widget. - + return _TVTextField( controller: controller, label: label, @@ -269,7 +281,6 @@ class _MobilePlaylistScreenState extends ConsumerState { ); } - void _showOptionsSheet(PlaylistConfig playlist) { showModalBottomSheet( context: context, @@ -297,7 +308,8 @@ class _MobilePlaylistScreenState extends ConsumerState { const SizedBox(height: 16), ListTile( leading: const Icon(Icons.edit, color: AppColors.textPrimary), - title: const Text('Modifier', style: TextStyle(color: AppColors.textPrimary)), + title: const Text('Modifier', + style: TextStyle(color: AppColors.textPrimary)), onTap: () { Navigator.pop(context); _showPlaylistDialog(playlist: playlist); @@ -305,7 +317,8 @@ class _MobilePlaylistScreenState extends ConsumerState { ), ListTile( leading: const Icon(Icons.delete, color: AppColors.error), - title: const Text('Supprimer', style: TextStyle(color: AppColors.error)), + title: const Text('Supprimer', + style: TextStyle(color: AppColors.error)), onTap: () { Navigator.pop(context); _showDeleteConfirmation(playlist); @@ -324,10 +337,14 @@ class _MobilePlaylistScreenState extends ConsumerState { context: context, builder: (context) => AlertDialog( backgroundColor: AppColors.surface, - title: const Text('Supprimer la playlist ?', - style: TextStyle(color: AppColors.textPrimary)), - content: Text('Voulez-vous supprimer "${playlist.name}" ?', - style: const TextStyle(color: AppColors.textSecondary)), + title: const Text( + 'Supprimer la playlist ?', + style: TextStyle(color: AppColors.textPrimary), + ), + content: Text( + 'Voulez-vous supprimer "${playlist.name}" ?', + style: const TextStyle(color: AppColors.textSecondary), + ), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -371,13 +388,19 @@ class _MobilePlaylistScreenState extends ConsumerState { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.error_outline, size: 48, color: AppColors.error), + const Icon(Icons.error_outline, + size: 48, color: AppColors.error), const SizedBox(height: 16), - const Text('Erreur de chargement', - style: TextStyle(color: AppColors.textPrimary)), + const Text( + 'Erreur de chargement', + style: TextStyle(color: AppColors.textPrimary), + ), const SizedBox(height: 8), - Text(error.toString(), - style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)), + Text( + error.toString(), + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 12), + ), const SizedBox(height: 16), TextButton.icon( onPressed: _refreshPlaylists, @@ -540,7 +563,8 @@ class _PlaylistCard extends StatelessWidget { color: AppColors.textTertiary, ), IconButton( - icon: const Icon(Icons.more_vert, color: AppColors.textSecondary), + icon: const Icon(Icons.more_vert, + color: AppColors.textSecondary), onPressed: onLongPress, // Re-use the options sheet logic ), ], @@ -589,7 +613,7 @@ class _TVTextFieldState extends State<_TVTextField> { super.initState(); _inputFocus = FocusNode(); _navFocus = widget.navigationFocus ?? FocusNode(); - + // When input loses focus (keyboard closed), return to nav focus _inputFocus.addListener(() { if (!_inputFocus.hasFocus && _isEditing) { @@ -622,8 +646,8 @@ class _TVTextFieldState extends State<_TVTextField> { onKeyEvent: (node, event) { if (event is KeyDownEvent) { final key = event.logicalKey; - if (key == LogicalKeyboardKey.select || - key == LogicalKeyboardKey.enter || + if (key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.space || key == LogicalKeyboardKey.gameButtonA || key == LogicalKeyboardKey.numpadEnter) { @@ -640,7 +664,9 @@ class _TVTextFieldState extends State<_TVTextField> { onTap: _activateInput, child: Container( decoration: BoxDecoration( - border: isFocused ? Border.all(color: AppColors.primary, width: 2) : null, + border: isFocused + ? Border.all(color: AppColors.primary, width: 2) + : null, borderRadius: BorderRadius.circular(4), ), // Ignore standard focus traversal to child, only allow programatic focus @@ -650,37 +676,44 @@ class _TVTextFieldState extends State<_TVTextField> { controller: widget.controller, focusNode: _inputFocus, // Prevent keyboard if somehow focused while not editing - readOnly: !_isEditing, + readOnly: !_isEditing, showCursor: _isEditing, obscureText: widget.obscureText, keyboardType: widget.keyboardType, textInputAction: widget.textAction, onSubmitted: (_) { - setState(() => _isEditing = false); - // Give focus back to nav node first so we can move to next - _navFocus.requestFocus(); - widget.onSubmitted?.call(); + setState(() => _isEditing = false); + // Give focus back to nav node first so we can move to next + _navFocus.requestFocus(); + widget.onSubmitted?.call(); }, style: const TextStyle(color: AppColors.textPrimary), decoration: InputDecoration( - labelText: widget.label, - hintText: widget.hint, - prefixIcon: Icon(widget.icon, color: isFocused ? AppColors.primary : AppColors.textSecondary), - labelStyle: TextStyle(color: isFocused ? AppColors.primary : AppColors.textSecondary), - hintStyle: const TextStyle(color: AppColors.textTertiary), - filled: true, - fillColor: AppColors.surface, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide.none, + labelText: widget.label, + hintText: widget.hint, + prefixIcon: Icon(widget.icon, + color: isFocused + ? AppColors.primary + : AppColors.textSecondary), + labelStyle: TextStyle( + color: isFocused + ? AppColors.primary + : AppColors.textSecondary), + hintStyle: const TextStyle(color: AppColors.textTertiary), + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 14), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), ), ), ), - ), ); - } + }, ), ); } 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 5ecf1e8..e227cfa 100644 --- a/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart +++ b/lib/mobile/features/iptv/screens/mobile_playlist_selection_screen.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../../../core/services/playlist_api_service.dart'; import '../../../../core/models/playlist_config.dart'; -import '../../../../features/auth/providers/auth_provider.dart'; + import '../../../../core/theme/app_colors.dart'; import '../../../theme/mobile_theme.dart'; @@ -18,10 +18,12 @@ class MobilePlaylistSelectionScreen extends ConsumerStatefulWidget { const MobilePlaylistSelectionScreen({super.key, this.manageMode = false}); @override - ConsumerState createState() => _MobilePlaylistSelectionScreenState(); + ConsumerState createState() => + _MobilePlaylistSelectionScreenState(); } -class _MobilePlaylistSelectionScreenState extends ConsumerState { +class _MobilePlaylistSelectionScreenState + extends ConsumerState { @override void initState() { super.initState(); @@ -42,9 +44,155 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState _showPlaylistDialog({PlaylistConfig? playlist}) async { + final isEditing = playlist != null; + final nameController = TextEditingController(text: playlist?.name ?? ''); + final dnsController = TextEditingController(text: playlist?.dns ?? ''); + final userController = + TextEditingController(text: playlist?.username ?? ''); + final passController = + TextEditingController(text: playlist?.password ?? ''); + + await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppColors.surface, + title: Text( + isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist', + style: GoogleFonts.inter(color: Colors.white), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Nom', + labelStyle: TextStyle(color: Colors.white70), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.white24)), + ), + ), + const SizedBox(height: 8), + TextField( + controller: dnsController, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'URL (DNS)', + labelStyle: TextStyle(color: Colors.white70), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.white24)), + ), + ), + const SizedBox(height: 8), + TextField( + controller: userController, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Utilisateur', + labelStyle: TextStyle(color: Colors.white70), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.white24)), + ), + ), + const SizedBox(height: 8), + TextField( + controller: passController, + obscureText: true, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'Mot de passe', + labelStyle: TextStyle(color: Colors.white70), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.white24)), + ), + ), + ], + ), + ), + actions: [ + if (isEditing) + TextButton( + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + title: const Text('Confirmer', + style: TextStyle(color: Colors.white)), + content: const Text('Supprimer cette playlist ?', + style: TextStyle(color: Colors.white70)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: + TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (confirm == true) { + await PlaylistApiService().deletePlaylist(playlist.id); + + if (context.mounted) { + Navigator.pop(context); + ref.invalidate(playlistsProvider); + } + } + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Supprimer'), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () async { + if (nameController.text.isEmpty || dnsController.text.isEmpty) { + return; + } + + final service = PlaylistApiService(); + if (isEditing) { + await service.updatePlaylist( + id: playlist.id, + name: nameController.text, + dns: dnsController.text, + username: userController.text, + password: passController.text, + ); + } else { + await service.createPlaylist( + name: nameController.text, + dns: dnsController.text, + username: userController.text, + password: passController.text, + ); + } + + if (context.mounted) { + Navigator.pop(context); + ref.invalidate(playlistsProvider); + } + }, + child: const Text('Enregistrer'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { - final currentUser = ref.watch(authProvider).currentUser; final playlistsAsync = ref.watch(playlistsProvider); return Theme( @@ -56,11 +204,17 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState Navigator.pop(context), - ), + if (widget.manageMode) ...[ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _showPlaylistDialog(), + tooltip: 'Ajouter une playlist', + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], ], ), body: Container( @@ -73,11 +227,13 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState ref.refresh(playlistsProvider), + onPressed: () => ref.invalidate(playlistsProvider), child: const Text('Réessayer'), ), ], @@ -85,51 +241,51 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState _showPlaylistDialog(), + icon: const Icon(Icons.add), + label: const Text('Ajouter une Playlist'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), ), - const SizedBox(height: 32), - ElevatedButton.icon( - onPressed: () { - // Logic to add playlist (usually dialog or separate screen) - // specific logic for add playlist - }, - icon: const Icon(Icons.add), - label: const Text('Ajouter une Playlist'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - ), - ], - ), - ); + ), + ], + ), + ); } return ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 100, 16, 16), // Top padding for AppBar + padding: const EdgeInsets.fromLTRB( + 16, 100, 16, 16), // Top padding for AppBar itemCount: playlists.length, - separatorBuilder: (context, index) => const SizedBox(height: 16), + separatorBuilder: (context, index) => + const SizedBox(height: 16), itemBuilder: (context, index) { final playlist = playlists[index]; return _MobilePlaylistCard( playlist: playlist, + manageMode: widget.manageMode, onTap: () { if (widget.manageMode) { - // In manage mode, maybe we want to edit or delete? - // For now, just switch to it. - context.go('/dashboard', extra: playlist); + _showPlaylistDialog(playlist: playlist); } else { - context.go('/dashboard', extra: playlist); + context.go('/dashboard', extra: playlist); } }, ); @@ -146,10 +302,12 @@ class _MobilePlaylistSelectionScreenState extends ConsumerState createState() => _MobileSeriesTabState(); } -class _MobileSeriesTabState extends ConsumerState with AutomaticKeepAliveClientMixin { +class _MobileSeriesTabState extends ConsumerState + with AutomaticKeepAliveClientMixin { final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); @@ -33,7 +34,7 @@ class _MobileSeriesTabState extends ConsumerState with Automati bool _isSearching = false; bool _hasMore = true; int _currentOffset = 0; - static const int _pageSize = 50; + static const int _pageSize = 50; Timer? _searchDebounce; @override @@ -54,9 +55,10 @@ class _MobileSeriesTabState extends ConsumerState with Automati void _onScroll() { // Only trigger load when near bottom and not already loading - if (!_isLoading && _hasMore && + if (!_isLoading && + _hasMore && _scrollController.position.pixels >= - _scrollController.position.maxScrollExtent * 0.8) { + _scrollController.position.maxScrollExtent * 0.8) { _loadMoreSeries(); } } @@ -70,7 +72,7 @@ class _MobileSeriesTabState extends ConsumerState with Automati _isSearching = false; } }); - + if (query.length >= 2) { _searchDebounce = Timer(const Duration(milliseconds: 500), () { _performSearch(query); @@ -81,11 +83,12 @@ class _MobileSeriesTabState extends ConsumerState with Automati Future _performSearch(String query) async { if (query.isEmpty) return; setState(() => _isSearching = true); - + try { - final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); + final service = + await ref.read(mobileXtreamServiceProvider(widget.playlist).future); final results = await service.searchSeries(query); - + if (mounted && _searchQuery == query) { setState(() { _searchResults = results; @@ -104,13 +107,16 @@ class _MobileSeriesTabState extends ConsumerState with Automati setState(() => _isLoading = true); try { - final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); - + final service = + await ref.read(mobileXtreamServiceProvider(widget.playlist).future); + // Add timeout to prevent infinite loading state - final newSeries = await service.getSeriesPaginated( - offset: _currentOffset, - limit: _pageSize, - ).timeout(const Duration(seconds: 15)); + final newSeries = await service + .getSeriesPaginated( + offset: _currentOffset, + limit: _pageSize, + ) + .timeout(const Duration(seconds: 15)); if (mounted) { setState(() { @@ -134,10 +140,11 @@ class _MobileSeriesTabState extends ConsumerState with Automati Future _refresh() async { // Clear service cache to force fresh data try { - final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future); + final service = + await ref.read(mobileXtreamServiceProvider(widget.playlist).future); service.clearCache(); } catch (_) {} - + setState(() { _series.clear(); _currentOffset = 0; @@ -184,159 +191,176 @@ class _MobileSeriesTabState extends ConsumerState with Automati Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin final settings = ref.watch(mobileSettingsProvider); - + List displaySeries; if (_searchQuery.isNotEmpty && _searchResults != null) { displaySeries = _searchResults!; } else { displaySeries = settings.seriesKeywords.isEmpty ? _series - : _series.where((s) => settings.matchesSeriesFilter(s.categoryName)).toList(); + : _series + .where((s) => settings.matchesSeriesFilter(s.categoryName)) + .toList(); } // Hero Items (use filtered series) - final heroItems = displaySeries.take(3).map((s) => HeroItem( - id: s.seriesId.toString(), - title: s.name, - imageUrl: _getImageUrl(s.cover), - subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, - onMoreInfo: () => _openSeries(s), - )).toList(); + final heroItems = displaySeries + .take(3) + .map( + (s) => HeroItem( + id: s.seriesId.toString(), + title: s.name, + imageUrl: _getImageUrl(s.cover), + subtitle: s.rating != null ? '${_formatRating(s.rating)} ★' : null, + onMoreInfo: () => _openSeries(s), + ), + ) + .toList(); return Container( decoration: const BoxDecoration(gradient: AppColors.appleTvGradient), child: SafeArea( - bottom: false, - child: RefreshIndicator( - onRefresh: _refresh, - color: AppColors.primary, - backgroundColor: AppColors.surface, - child: CustomScrollView( - controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - // Hero Section (reduced height, moved above search) - if (_searchQuery.isEmpty && heroItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 8, bottom: 8), - child: SizedBox( - height: 180, // Reduced from 250 - child: HeroCarousel( - items: heroItems, - onTap: (item) { - try { - final series = _series.firstWhere((s) => s.seriesId.toString() == item.id); - _openSeries(series); - } catch (e) { - // Fallback logic not critical - } - }, - ), - ), - ), - ), - - // Search Bar (moved below carousel) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: TVFocusable( - onPressed: () { - setState(() => _isSearchEditing = true); - WidgetsBinding.instance.addPostFrameCallback((_) { - _searchFocusNode.requestFocus(); - }); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - height: 40, - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - border: _isSearchEditing - ? Border.all(color: AppColors.primary) - : Border.all(color: AppColors.border), + bottom: false, + child: RefreshIndicator( + onRefresh: _refresh, + color: AppColors.primary, + backgroundColor: AppColors.surface, + child: CustomScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + // Hero Section (reduced height, moved above search) + if (_searchQuery.isEmpty && heroItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: SizedBox( + height: 180, // Reduced from 250 + child: HeroCarousel( + items: heroItems, + onTap: (item) { + try { + final series = _series.firstWhere( + (s) => s.seriesId.toString() == item.id); + _openSeries(series); + } catch (e) { + // Fallback logic not critical + } + }, + ), ), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - const Icon(Icons.search, size: 20, color: AppColors.textSecondary), - const SizedBox(width: 8), - Expanded( - child: ExcludeFocus( - excluding: !_isSearchEditing, - child: TextField( - controller: _searchController, - focusNode: _searchFocusNode, - readOnly: !_isSearchEditing, - style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), - decoration: const InputDecoration( - hintText: 'Rechercher une série...', - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.only(bottom: 11), + ), + ), + + // Search Bar (moved below carousel) + SliverToBoxAdapter( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TVFocusable( + onPressed: () { + setState(() => _isSearchEditing = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + height: 40, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: _isSearchEditing + ? Border.all(color: AppColors.primary) + : Border.all(color: AppColors.border), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + const Icon(Icons.search, + size: 20, color: AppColors.textSecondary), + const SizedBox(width: 8), + Expanded( + child: ExcludeFocus( + excluding: !_isSearchEditing, + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + readOnly: !_isSearchEditing, + style: const TextStyle( + fontSize: 14, color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: 'Rechercher une série...', + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only(bottom: 11), + ), + onChanged: _onSearchChanged, + onSubmitted: (_) => + setState(() => _isSearchEditing = false), ), - onChanged: _onSearchChanged, - onSubmitted: (_) => setState(() => _isSearchEditing = false), ), ), - ), - if (_isSearching) - const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)), - if (_searchQuery.isNotEmpty) - GestureDetector( - onTap: () { - _searchController.clear(); - _onSearchChanged(''); - }, - child: const Icon(Icons.close, size: 16, color: AppColors.textSecondary), - ), - ], + if (_isSearching) + const SizedBox( + width: 12, + height: 12, + child: + CircularProgressIndicator(strokeWidth: 2)), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { + _searchController.clear(); + _onSearchChanged(''); + }, + child: const Icon(Icons.close, + size: 16, color: AppColors.textSecondary), + ), + ], + ), ), ), ), ), + + // Grid + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, // Reduced size further (was 4) + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: 0.7, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index >= displaySeries.length) return null; + final series = displaySeries[index]; + + return MobilePosterCard( + title: series.name, + imageUrl: _getImageUrl(series.cover), + rating: _formatRating(series.rating), + placeholderIcon: Icons.tv, + onTap: () => _openSeries(series), + ); + }, + childCount: displaySeries.length, + ), + ), + ), + + if (_isLoading) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + ), + ], ), - - // Grid - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 80), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, // Reduced size further (was 4) - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 0.7, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - if (index >= displaySeries.length) return null; - final series = displaySeries[index]; - - return MobilePosterCard( - title: series.name, - imageUrl: _getImageUrl(series.cover), - rating: _formatRating(series.rating), - placeholderIcon: Icons.tv, - onTap: () => _openSeries(series), - ); - }, - childCount: displaySeries.length, - ), - ), - ), - - if (_isLoading) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator()), - ), - ), - ], - ), - ), + ), ), ); } diff --git a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart index bfbaf82..c99cb45 100644 --- a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart @@ -6,7 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import '../../../providers/mobile_settings_providers.dart'; import '../../../widgets/tv_focusable.dart'; -import '../../../../core/providers/theme_provider.dart'; + import '../../../../core/theme/app_colors.dart'; import '../screens/mobile_playlist_selection_screen.dart'; @@ -61,8 +61,6 @@ class _MobileSettingsTabState extends ConsumerState { @override Widget build(BuildContext context) { final settings = ref.watch(mobileSettingsProvider); - final themeNotifier = ref.read(themeProvider.notifier); - final isDark = Theme.of(context).brightness == Brightness.dark; WidgetsBinding.instance.addPostFrameCallback((_) { if (!_initialized) _syncControllers(settings); @@ -114,16 +112,6 @@ class _MobileSettingsTabState extends ConsumerState { const SizedBox(height: 16), - // === APPARENCE === - _buildSectionHeader('Apparence'), - - _buildSettingItem( - icon: isDark ? Icons.dark_mode : Icons.light_mode, - title: 'Mode Sombre', - value: isDark ? 'Activé' : 'Désactivé', - onTap: () => themeNotifier.toggleTheme(), - ), - _buildSettingItem( icon: Icons.access_time, title: 'Afficher l\'heure', @@ -138,16 +126,6 @@ class _MobileSettingsTabState extends ConsumerState { // === LECTURE VIDEO === _buildSectionHeader('Lecture Vidéo'), - _buildSettingItem( - icon: Icons.bug_report, - title: 'Stats pour Nerds', - subtitle: 'Affiche FPS, Buffer, Bitrate', - value: settings.showDebugStats ? 'Activé' : 'Désactivé', - onTap: () => ref - .read(mobileSettingsProvider.notifier) - .toggleShowDebugStats(!settings.showDebugStats), - ), - _buildSettingItem( icon: Icons.memory, title: 'Décodeur Vidéo', diff --git a/lib/mobile/providers/mobile_settings_providers.dart b/lib/mobile/providers/mobile_settings_providers.dart index 63cf059..6db7802 100644 --- a/lib/mobile/providers/mobile_settings_providers.dart +++ b/lib/mobile/providers/mobile_settings_providers.dart @@ -52,8 +52,9 @@ class MobileSettings { if (liveTvKeywords.isEmpty) return true; if (category == null) return true; final lowerCategory = category.toLowerCase(); - return liveTvKeywords.any((keyword) => - lowerCategory.contains(keyword.toLowerCase())); + return liveTvKeywords.any( + (keyword) => lowerCategory.contains(keyword.toLowerCase()), + ); } /// Check if category matches Movies filter @@ -61,8 +62,9 @@ class MobileSettings { if (moviesKeywords.isEmpty) return true; if (category == null) return true; final lowerCategory = category.toLowerCase(); - return moviesKeywords.any((keyword) => - lowerCategory.contains(keyword.toLowerCase())); + return moviesKeywords.any( + (keyword) => lowerCategory.contains(keyword.toLowerCase()), + ); } /// Check if category matches Series filter @@ -70,8 +72,9 @@ class MobileSettings { if (seriesKeywords.isEmpty) return true; if (category == null) return true; final lowerCategory = category.toLowerCase(); - return seriesKeywords.any((keyword) => - lowerCategory.contains(keyword.toLowerCase())); + return seriesKeywords.any( + (keyword) => lowerCategory.contains(keyword.toLowerCase()), + ); } } @@ -85,16 +88,22 @@ class MobileSettingsNotifier extends StateNotifier { Future _init() async { _box = await Hive.openBox(_boxName); - - 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 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?; - final showDebugStats = _box?.get('showDebugStats', defaultValue: false) as bool?; - final decoderMode = _box?.get('decoderMode', defaultValue: 'auto') as String?; - final playerEngine = _box?.get('playerEngine', defaultValue: 'ultra') as String?; + final showDebugStats = + _box?.get('showDebugStats', defaultValue: false) as bool?; + final decoderMode = + _box?.get('decoderMode', defaultValue: 'auto') as String?; + final playerEngine = + _box?.get('playerEngine', defaultValue: 'ultra') as String?; final buffer = _box?.get('bufferDuration', defaultValue: 0) as int?; - + state = MobileSettings( liveTvKeywords: liveTv?.cast() ?? [], moviesKeywords: movies?.cast() ?? [], @@ -148,7 +157,8 @@ class MobileSettingsNotifier extends StateNotifier { } } -final mobileSettingsProvider = StateNotifierProvider((ref) { +final mobileSettingsProvider = + StateNotifierProvider((ref) { return MobileSettingsNotifier(); }); @@ -177,8 +187,9 @@ class MobileWatchHistory { } bool isMovieWatched(String streamId) => watchedMovies.contains(streamId); - bool isEpisodeWatched(String episodeKey) => watchedEpisodes.contains(episodeKey); - + bool isEpisodeWatched(String episodeKey) => + watchedEpisodes.contains(episodeKey); + /// Get resume position in seconds (0 if none saved) int getResumePosition(String contentId) => resumePositions[contentId] ?? 0; @@ -197,11 +208,13 @@ class MobileWatchHistoryNotifier extends StateNotifier { Future _init() async { _box = await Hive.openBox(_boxName); - - final movies = _box?.get('watchedMovies', defaultValue: []) as List?; - final episodes = _box?.get('watchedEpisodes', defaultValue: []) as List?; + + final movies = + _box?.get('watchedMovies', defaultValue: []) as List?; + final episodes = + _box?.get('watchedEpisodes', defaultValue: []) as List?; final positionsRaw = _box?.get('resumePositions'); - + // Safe conversion for resumePositions map Map positions = {}; if (positionsRaw != null && positionsRaw is Map) { @@ -213,7 +226,7 @@ class MobileWatchHistoryNotifier extends StateNotifier { } }); } - + state = MobileWatchHistory( watchedMovies: movies?.cast().toSet() ?? {}, watchedEpisodes: episodes?.cast().toSet() ?? {}, @@ -247,25 +260,28 @@ class MobileWatchHistoryNotifier extends StateNotifier { // Clear resume position when marked as fully watched final positions = {...state.resumePositions}; positions.remove(episodeKey); - state = state.copyWith(watchedEpisodes: updated, resumePositions: positions); + state = + state.copyWith(watchedEpisodes: updated, resumePositions: positions); _box?.put('watchedEpisodes', updated.toList()); _box?.put('resumePositions', positions); } - + /// Save resume position for movie or episode void saveResumePosition(String contentId, int positionSeconds) { // Only save if position is meaningful (> 30 seconds) if (positionSeconds < 30) { - debugPrint('[WatchHistory] Position too short ($positionSeconds), skipping save'); + debugPrint( + '[WatchHistory] Position too short ($positionSeconds), skipping save'); return; } - - debugPrint('[WatchHistory] Saving position for $contentId: ${positionSeconds}s'); + + debugPrint( + '[WatchHistory] Saving position for $contentId: ${positionSeconds}s'); final positions = {...state.resumePositions, contentId: positionSeconds}; state = state.copyWith(resumePositions: positions); _box?.put('resumePositions', Map.from(positions)); } - + /// Clear resume position (when content finished or user wants to restart) void clearResumePosition(String contentId) { final positions = {...state.resumePositions}; @@ -275,7 +291,9 @@ class MobileWatchHistoryNotifier extends StateNotifier { } } -final mobileWatchHistoryProvider = StateNotifierProvider((ref) { +final mobileWatchHistoryProvider = + StateNotifierProvider( + (ref) { return MobileWatchHistoryNotifier(); }); @@ -308,6 +326,7 @@ class MobileFavoritesNotifier extends StateNotifier> { bool isFavorite(String streamId) => state.contains(streamId); } -final mobileFavoritesProvider = StateNotifierProvider>((ref) { +final mobileFavoritesProvider = + StateNotifierProvider>((ref) { return MobileFavoritesNotifier(); }); diff --git a/lib/mobile/widgets/tv_focusable.dart b/lib/mobile/widgets/tv_focusable.dart index 76b2237..27cfba2 100644 --- a/lib/mobile/widgets/tv_focusable.dart +++ b/lib/mobile/widgets/tv_focusable.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import '../../../../core/theme/app_colors.dart'; class TVFocusable extends StatefulWidget { final Widget child; @@ -107,7 +106,7 @@ class _TVFocusableState extends State { if (!_isKeyDown) { _isKeyDown = true; _longPressTriggered = false; - + // Start long press timer only if onLongPress is defined if (widget.onLongPress != null) { _longPressTimer?.cancel(); @@ -121,20 +120,20 @@ class _TVFocusableState extends State { } return KeyEventResult.handled; } - + if (event is KeyUpEvent && isSelectKey) { _longPressTimer?.cancel(); - + // If long press wasn't triggered, it's a normal press if (_isKeyDown && !_longPressTriggered) { _handlePress(); } - + _isKeyDown = false; _longPressTriggered = false; return KeyEventResult.handled; } - + return KeyEventResult.ignored; }, child: GestureDetector( @@ -153,15 +152,18 @@ class _TVFocusableState extends State { child: Container( decoration: _isFocused ? BoxDecoration( - borderRadius: widget.borderRadius ?? BorderRadius.circular(12), + borderRadius: + widget.borderRadius ?? BorderRadius.circular(12), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.5), // Shadow for depth + color: Colors.black + .withOpacity(0.5), // Shadow for depth blurRadius: 10, offset: const Offset(0, 4), ), BoxShadow( - color: widget.focusColor.withOpacity(0.3), // Glow effect + color: widget.focusColor + .withOpacity(0.3), // Glow effect blurRadius: 12, spreadRadius: 2, ), @@ -170,7 +172,8 @@ class _TVFocusableState extends State { : null, foregroundDecoration: _isFocused ? BoxDecoration( - borderRadius: widget.borderRadius ?? BorderRadius.circular(12), + borderRadius: + widget.borderRadius ?? BorderRadius.circular(12), border: Border.all( color: widget.focusColor, width: widget.borderWidth,